firefish/packages/client/src/components/MkPagination.vue
2024-04-26 22:39:58 +08:00

544 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<transition
:name="defaultStore.state.animation ? 'fade' : ''"
mode="out-in"
>
<MkLoading v-if="fetching" />
<MkError v-else-if="error" @retry="init()" />
<div v-else-if="empty" key="_empty_" class="empty">
<slot name="empty">
<div class="_fullinfo">
<img
src="/static-assets/badges/info.webp"
class="_ghost"
alt="Error"
/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</slot>
</div>
<div v-else ref="rootEl" class="list">
<div
v-show="pagination.reversed && more"
key="_more_"
class="cxiknjgy _gap"
>
<MkButton
v-if="!moreFetching"
class="button"
:disabled="moreFetching"
:style="{ cursor: moreFetching ? 'wait' : 'pointer' }"
primary
@click="fetchMoreAhead"
>
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading" />
</div>
<slot :items="items" :foldedItems="foldedItems"></slot>
<div
v-show="!pagination.reversed && more"
key="_more_"
class="cxiknjgy _gap"
>
<MkButton
v-if="!moreFetching"
v-appear="
defaultStore.state.enableInfiniteScroll &&
!disableAutoLoad
? fetchMore
: null
"
class="button"
:disabled="moreFetching"
:style="{ cursor: moreFetching ? 'wait' : 'pointer' }"
primary
@click="fetchMore"
>
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading" />
</div>
</div>
</transition>
</template>
<script lang="ts" setup generic="E extends PagingKey, Fold extends PagingAble">
import type { ComponentPublicInstance, ComputedRef, Ref } from "vue";
import {
computed,
isRef,
onActivated,
onDeactivated,
ref,
unref,
watch,
} from "vue";
import type { Endpoints, TypeUtils } from "firefish-js";
import * as os from "@/os";
import { isTopVisible, onScrollTop } from "@/scripts/scroll";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
/**
* ref type of MkPagination<E>
* Due to Vue's incomplete type support for generic components,
* we have to manually maintain this type instead of
* using `InstanceType<typeof MkPagination>`
*/
export type MkPaginationType<
E extends PagingKey,
Item = Endpoints[E]["res"][number],
> = ComponentPublicInstance & {
items: Item[];
queue: Item[];
backed: boolean;
reload: () => Promise<void>;
refresh: () => Promise<void>;
prepend: (item: Item) => Promise<void>;
append: (...item: Item[]) => Promise<void>;
removeItem: (finder: (item: Item) => boolean) => boolean;
updateItem: (id: string, replacer: (old: Item) => Item) => boolean;
};
export type PagingAble = {
id: string;
};
export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>;
// biome-ignore lint/suspicious/noExplicitAny: Used Intentionally
export type PagingKey = PagingKeyOf<any>;
export interface Paging<E extends PagingKey = PagingKey> {
endpoint: E;
limit: number;
secondFetchLimit?: number;
params?: Endpoints[E]["req"] | ComputedRef<Endpoints[E]["req"]>;
/**
* 検索APIのような、ページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/
noPaging?: boolean;
/**
* items 配列の中身を逆順にする(新しい方が最後)
*/
reversed?: boolean;
/**
* For not-reversed, not-offsetMode,
* Sort by id in ascending order
*/
ascending?: boolean;
offsetMode?: boolean;
}
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
type Item = Endpoints[E]["res"][number];
type Param = Endpoints[E]["req"] | Record<string, never>;
const SECOND_FETCH_LIMIT_DEFAULT = 30;
const FIRST_FETCH_LIMIT_DEFAULT = 10;
const props = withDefaults(
defineProps<{
pagination: Paging<E>;
disableAutoLoad?: boolean;
displayLimit?: number;
folder?: (i: Item[]) => Fold[];
}>(),
{
displayLimit: 30,
},
);
const slots = defineSlots<{
default(props: { items: Item[]; foldedItems: Fold[] }): unknown;
empty(props: Record<string, never>): never;
}>();
const emit = defineEmits<{
(ev: "queue", count: number): void;
(ev: "status", hasError: boolean): void;
}>();
const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]);
const foldedItems = ref([]) as Ref<Fold[]>;
// To improve performance, we do not use vues `computed` here
function calculateItems() {
function getItems<T>(folder: (ns: Item[]) => T[]) {
const res = [
folder(prepended.value.toReversed()),
...arrItems.value.map((arr) => folder(arr)),
folder(appended.value),
].flat(1);
if (props.pagination.reversed) {
res.reverse();
}
return res;
}
items.value = getItems((x) => x);
if (props.folder) foldedItems.value = getItems(props.folder);
}
const queue = ref<Item[]>([]);
/**
* The cached elements inserted front by `prepend` function
*/
const prepended = ref<Item[]>([]);
/**
* The array of "frozen" items
*/
const arrItems = ref<Item[][]>([]);
/**
* The cached elements inserted back by `append` function
*/
const appended = ref<Item[]>([]);
const idMap = new Map<string, boolean>();
const offset = ref(0);
type PagingByParam =
| {
offset: number;
}
| {
sinceId: string;
}
| {
untilId: string;
}
| Record<string, never>;
let nextPagingBy: PagingByParam = {};
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const backed = ref(false); // 遡り中か否か
const isBackTop = ref(false);
const empty = computed(() => items.value.length === 0);
const error = ref(false);
const init = async (): Promise<void> => {
queue.value = [];
fetching.value = true;
await fetch(true);
};
const reload = (): Promise<void> => {
arrItems.value = [];
appended.value = [];
prepended.value = [];
idMap.clear();
return init();
};
const refresh = async (): Promise<void> => {
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit: (items.value.length || foldedItems.value.length) + 1,
offset: 0,
})
.then(
(res: Item[]) => {
appended.value = [];
prepended.value = [];
// appended should be inserted into arrItems to fix the element position
arrItems.value = [res];
calculateItems();
},
(_err) => {
error.value = true;
fetching.value = false;
},
);
};
async function fetch(firstFetching?: boolean) {
let limit: number;
if (firstFetching) {
limit = props.pagination.noPaging
? props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT
: (props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT) + 1;
if (props.pagination.ascending) {
nextPagingBy = {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
};
}
} else {
if (
!more.value ||
fetching.value ||
moreFetching.value ||
items.value.length === 0
)
return;
moreFetching.value = true;
backed.value = true;
limit =
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1;
}
const params = props.pagination.params ? unref(props.pagination.params) : {};
await os
.api(props.pagination.endpoint, {
...params,
limit,
...nextPagingBy,
})
.then(
(res: Item[]) => {
if (!props.pagination.reversed)
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (props.pagination.reversed) {
if (i === res.length - (firstFetching ? 2 : 9))
item._shouldInsertAd_ = true;
} else {
if (i === (firstFetching ? 3 : 10)) item._shouldInsertAd_ = true;
}
}
if (!props.pagination.noPaging && res.length > limit - 1) {
res.pop();
more.value = true;
} else {
more.value = false;
}
offset.value += res.length;
error.value = false;
fetching.value = false;
moreFetching.value = false;
const lastRes = res[res.length - 1];
if (props.pagination.offsetMode) {
nextPagingBy = {
offset: offset.value,
};
} else if (props.pagination.ascending) {
nextPagingBy = {
sinceId: lastRes?.id,
};
} else {
nextPagingBy = {
untilId: lastRes?.id,
};
}
if (firstFetching && props.folder != null) {
// In this way, prepended has some initial values for folding
prepended.value = res.toReversed();
} else {
// For ascending and offset modes, append and prepend may cause item duplication
// so they need to be filtered out.
if (props.pagination.offsetMode || props.pagination.ascending) {
for (const item of appended.value) {
idMap.set(item.id, true);
}
// biome-ignore lint/style/noParameterAssign: assign it intentially
res = res.filter((item) => {
if (idMap.has(item)) return false;
idMap.set(item, true);
return true;
});
}
// appended should be inserted into arrItems to fix the element position
arrItems.value.push(appended.value);
arrItems.value.push(res);
appended.value = [];
}
calculateItems();
},
(_err) => {
error.value = true;
fetching.value = false;
moreFetching.value = false;
},
);
}
const fetchMore = async (): Promise<void> => {
await fetch();
};
const fetchMoreAhead = async (): Promise<void> => {
await fetch();
};
const prepend = (...item: Item[]): void => {
// If there are too many prepended, merge them into arrItems
if (
prepended.value.length >
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
) {
arrItems.value.unshift(prepended.value.toReversed());
prepended.value = [];
// We don't need to calculate here because it won't cause any changes in items
}
if (props.pagination.reversed) {
prepended.value.push(...item);
calculateItems();
} else {
// When displaying for the first time, just do this is OK
if (!rootEl.value) {
prepended.value.push(...item);
calculateItems();
return;
}
const isTop =
isBackTop.value ||
(document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
if (isTop) {
prepended.value.push(...item);
calculateItems();
} else {
queue.value.push(...item);
onScrollTop(rootEl.value, () => {
prepend(...queue.value);
queue.value = [];
});
}
}
};
const append = (...items: Item[]): void => {
appended.value.push(...items);
calculateItems();
};
const _removeItem = (arr: Item[], finder: (item: Item) => boolean): boolean => {
const i = arr.findIndex(finder);
if (i === -1) {
return false;
}
arr.splice(i, 1);
return true;
};
const _updateItem = (
arr: Item[],
id: Item["id"],
replacer: (old: Item) => Item,
): boolean => {
const i = arr.findIndex((item) => item.id === id);
if (i === -1) {
return false;
}
arr[i] = replacer(arr[i]);
return true;
};
const removeItem = (finder: (item: Item) => boolean): boolean => {
const res =
_removeItem(prepended.value, finder) ||
_removeItem(appended.value, finder) ||
arrItems.value.filter((arr) => _removeItem(arr, finder)).length > 0;
calculateItems();
return res;
};
const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
const res =
_updateItem(prepended.value, id, replacer) ||
_updateItem(appended.value, id, replacer) ||
arrItems.value.filter((arr) => _updateItem(arr, id, replacer)).length > 0;
calculateItems();
return res;
};
if (props.pagination.params && isRef<Param>(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
}
watch(
queue,
(a, b) => {
if (a.length === 0 && b.length === 0) return;
emit("queue", queue.value.length);
},
{ deep: true },
);
watch(error, (n, o) => {
if (n === o) return;
emit("status", n);
});
init();
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = window.scrollY === 0;
});
defineExpose({
items,
queue,
backed,
reload,
refresh,
prepend,
append,
removeItem,
updateItem,
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.cxiknjgy {
> .button {
margin-left: auto;
margin-right: auto;
}
}
.list > :deep(._button) {
margin-inline: auto;
margin-bottom: 16px;
&:last-of-type:not(:first-child) {
margin-top: 16px;
}
}
</style>