feat: implement infinite scrolling for public entity lists
Add cursor-based pagination to backend list queries Introduce LoadMoreSentinel for intersection-based loading Replace manual load more buttons with infinite scroll sentinel
This commit is contained in:
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
@@ -19,6 +20,9 @@ const options = ref<Options | null>(null);
|
||||
const artifacts = ref<AncientArtifact[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreArtifacts = ref(false);
|
||||
const search = ref('');
|
||||
const categoryId = ref('');
|
||||
const tagIds = ref<string[]>([]);
|
||||
@@ -26,6 +30,8 @@ const tagIds = ref<string[]>([]);
|
||||
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
||||
const filterSkeletonWidths = ['52px', '36px'];
|
||||
const skeletonCardCount = 6;
|
||||
const listPageSize = 24;
|
||||
let loadRequestId = 0;
|
||||
|
||||
const categoryTabs = computed<TabOption[]>(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
@@ -43,10 +49,50 @@ function artifactCardImage(artifact: AncientArtifact) {
|
||||
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
||||
}
|
||||
|
||||
async function loadArtifacts() {
|
||||
loading.value = true;
|
||||
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
|
||||
loading.value = false;
|
||||
async function loadArtifacts(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMoreArtifacts.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMoreArtifacts.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.ancientArtifactsPage({
|
||||
...artifactQuery.value,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
artifacts.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(artifacts.value.map((item) => item.id));
|
||||
artifacts.value = [...artifacts.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreArtifacts.value = page.hasMore;
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreArtifacts() {
|
||||
void loadArtifacts(false);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -61,7 +107,9 @@ onMounted(async () => {
|
||||
await loadArtifacts();
|
||||
});
|
||||
|
||||
watch(artifactQuery, loadArtifacts);
|
||||
watch(artifactQuery, () => {
|
||||
void loadArtifacts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,6 +186,20 @@ watch(artifactQuery, loadArtifacts);
|
||||
compact-tooltip
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loadingMore" class="entity-grid catalog-card-grid collections-card-grid" aria-hidden="true">
|
||||
<article
|
||||
v-for="index in 2"
|
||||
:key="`artifact-more-${index}`"
|
||||
class="entity-card entity-card--skeleton entity-card--collection-compact"
|
||||
>
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
<Skeleton width="92px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="hasMoreArtifacts" :disabled="loading || loadingMore" @load="loadMoreArtifacts" />
|
||||
|
||||
<ItemEdit v-if="showEditor" />
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type DailyChecklistItem } from '../services/api';
|
||||
@@ -16,8 +17,13 @@ const stateRefreshIntervalMs = 60_000;
|
||||
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreItems = ref(false);
|
||||
const skeletonRows = 5;
|
||||
const listPageSize = 20;
|
||||
let stateRefreshTimer: number | null = null;
|
||||
let loadRequestId = 0;
|
||||
|
||||
function todayKey() {
|
||||
const today = new Date();
|
||||
@@ -85,14 +91,52 @@ function handleTaskChange(id: number, event: Event) {
|
||||
toggleTask(id, checkbox?.checked === true);
|
||||
}
|
||||
|
||||
async function loadDailyChecklist() {
|
||||
loading.value = true;
|
||||
try {
|
||||
checklistItems.value = await api.dailyChecklist();
|
||||
syncChecklistState();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
async function loadDailyChecklist(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMoreItems.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.dailyChecklistPage({
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
checklistItems.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(checklistItems.value.map((item) => item.id));
|
||||
checklistItems.value = [...checklistItems.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreItems.value = page.hasMore;
|
||||
if (!page.hasMore) {
|
||||
syncChecklistState();
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreDailyChecklist() {
|
||||
void loadDailyChecklist(false);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -135,9 +179,14 @@ onUnmounted(() => {
|
||||
<span>{{ item.title }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li v-for="index in loadingMore ? 2 : 0" :key="`checklist-more-${index}`" class="checklist-item checklist-check" aria-hidden="true">
|
||||
<Skeleton variant="box" width="34px" height="34px" />
|
||||
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
||||
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore" @load="loadMoreDailyChecklist" />
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { iconAdd, iconHabitat } from '../icons';
|
||||
@@ -19,7 +20,12 @@ const currentUser = ref<AuthUser | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreHabitats = ref(false);
|
||||
const skeletonCardCount = 6;
|
||||
const listPageSize = 24;
|
||||
let loadRequestId = 0;
|
||||
const query = computed(() => ({
|
||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
||||
}));
|
||||
@@ -35,10 +41,50 @@ function habitatCardImage(item: Habitat) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
}
|
||||
|
||||
async function loadHabitats() {
|
||||
loading.value = true;
|
||||
habitats.value = await api.habitats(query.value);
|
||||
loading.value = false;
|
||||
async function loadHabitats(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMoreHabitats.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMoreHabitats.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.habitatsPage({
|
||||
...query.value,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
habitats.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(habitats.value.map((item) => item.id));
|
||||
habitats.value = [...habitats.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreHabitats.value = page.hasMore;
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreHabitats() {
|
||||
void loadHabitats(false);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -52,7 +98,9 @@ onMounted(async () => {
|
||||
await loadHabitats();
|
||||
});
|
||||
|
||||
watch(query, loadHabitats);
|
||||
watch(query, () => {
|
||||
void loadHabitats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,6 +133,15 @@ watch(query, loadHabitats);
|
||||
:image="habitatCardImage(item)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loadingMore" class="entity-grid pokemon-list-grid" aria-hidden="true">
|
||||
<article v-for="index in 2" :key="`habitat-more-${index}`" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="hasMoreHabitats" :disabled="loading || loadingMore" @load="loadMoreHabitats" />
|
||||
|
||||
<HabitatEdit v-if="showEditor" />
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
@@ -24,6 +25,9 @@ const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreItems = ref(false);
|
||||
const ordering = ref(false);
|
||||
const search = ref('');
|
||||
const categoryId = ref('');
|
||||
@@ -71,6 +75,8 @@ const itemCreateDefaults = ref<ItemCreateDefaults>(readItemCreateDefaults());
|
||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||
const skeletonCardCount = 6;
|
||||
const listPageSize = 36;
|
||||
let loadRequestId = 0;
|
||||
const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') : t('pages.items.title')));
|
||||
const pageSubtitle = computed(() => (props.eventOnly ? t('pages.eventItems.subtitle') : t('pages.items.subtitle')));
|
||||
const pageKicker = computed(() => (props.eventOnly ? t('pages.eventItems.kicker') : t('pages.items.kicker')));
|
||||
@@ -80,7 +86,7 @@ const hasActiveFilters = computed(
|
||||
() => search.value.trim() !== '' || usageId.value !== '' || tagIds.value.length > 0 || categoryId.value !== ''
|
||||
);
|
||||
const itemSortingAllowed = computed(
|
||||
() => isAllView.value && !hasActiveFilters.value && currentUser.value?.permissions.includes('items.order') === true
|
||||
() => isAllView.value && !hasActiveFilters.value && !hasMoreItems.value && currentUser.value?.permissions.includes('items.order') === true
|
||||
);
|
||||
const itemInsertionAllowed = computed(
|
||||
() => itemSortingAllowed.value && currentUser.value?.permissions.includes('items.create') === true
|
||||
@@ -394,7 +400,7 @@ async function dropItem(targetItem: Item, event: DragEvent) {
|
||||
suppressNextItemClick.value = true;
|
||||
try {
|
||||
await api.reorderItems(nextItems.map((item) => item.id));
|
||||
items.value = await api.items(itemQuery.value);
|
||||
await loadItems();
|
||||
} catch {
|
||||
items.value = previousItems;
|
||||
} finally {
|
||||
@@ -418,10 +424,50 @@ function handleItemClick(event: MouseEvent) {
|
||||
suppressNextItemClick.value = false;
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
items.value = await api.items(itemQuery.value);
|
||||
loading.value = false;
|
||||
async function loadItems(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMoreItems.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.itemsPage({
|
||||
...itemQuery.value,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
items.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(items.value.map((item) => item.id));
|
||||
items.value = [...items.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreItems.value = page.hasMore;
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreItems() {
|
||||
void loadItems(false);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -444,7 +490,9 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onDocumentKeydown);
|
||||
});
|
||||
|
||||
watch(itemQuery, loadItems);
|
||||
watch(itemQuery, () => {
|
||||
void loadItems();
|
||||
});
|
||||
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
|
||||
watch(showEditor, () => {
|
||||
closeCreateDefaultsMenu();
|
||||
@@ -641,6 +689,20 @@ watch(itemSortingAllowed, (allowed) => {
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<div v-if="loadingMore" class="entity-grid catalog-card-grid collections-card-grid" aria-hidden="true">
|
||||
<article
|
||||
v-for="index in 2"
|
||||
:key="`item-more-${index}`"
|
||||
class="entity-card entity-card--skeleton entity-card--collection-compact item-grid-card"
|
||||
>
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
<Skeleton width="92px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore || ordering" @load="loadMoreItems" />
|
||||
|
||||
<div
|
||||
v-if="itemContextMenu"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
@@ -1279,17 +1280,7 @@ onUnmounted(() => {
|
||||
|
||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||
|
||||
<div v-if="commentsHasMore && !commentsLoading" class="life-feed__retry">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="commentsLoadingMore"
|
||||
@click="loadComments(false)"
|
||||
>
|
||||
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||
{{ commentsLoadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
||||
</button>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="commentsHasMore && !commentsLoading" :disabled="commentsLoadingMore" @load="loadComments(false)" />
|
||||
</section>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -2015,17 +2016,11 @@ onUnmounted(() => {
|
||||
|
||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||
|
||||
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="commentPage(post).loadingMore"
|
||||
@click="loadComments(post)"
|
||||
>
|
||||
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
||||
</button>
|
||||
</div>
|
||||
<LoadMoreSentinel
|
||||
:active="commentPage(post).hasMore && !commentPage(post).loading"
|
||||
:disabled="commentPage(post).loadingMore"
|
||||
@load="loadComments(post)"
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
@@ -22,6 +23,9 @@ const { t } = useI18n();
|
||||
const pokemon = ref<Pokemon[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMorePokemon = ref(false);
|
||||
const search = ref('');
|
||||
const environmentId = ref('');
|
||||
const skillIds = ref<string[]>([]);
|
||||
@@ -30,6 +34,8 @@ const favoriteThingIds = ref<string[]>([]);
|
||||
const favoriteThingMode = ref<'any' | 'all'>('any');
|
||||
const filterSkeletonWidths = ['52px', '92px', '48px', '72px'];
|
||||
const skeletonCardCount = 6;
|
||||
const listPageSize = 24;
|
||||
let loadRequestId = 0;
|
||||
|
||||
const query = computed(() => ({
|
||||
search: search.value,
|
||||
@@ -48,10 +54,50 @@ const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventPokemon.kicker
|
||||
const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
|
||||
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
|
||||
|
||||
async function loadPokemon() {
|
||||
loading.value = true;
|
||||
pokemon.value = await api.pokemon(query.value);
|
||||
loading.value = false;
|
||||
async function loadPokemon(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMorePokemon.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.pokemonPage({
|
||||
...query.value,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
pokemon.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(pokemon.value.map((item) => item.id));
|
||||
pokemon.value = [...pokemon.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMorePokemon.value = page.hasMore;
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMorePokemon() {
|
||||
void loadPokemon(false);
|
||||
}
|
||||
|
||||
function pokemonCardImage(item: Pokemon) {
|
||||
@@ -70,7 +116,9 @@ onMounted(async () => {
|
||||
await loadPokemon();
|
||||
});
|
||||
|
||||
watch(query, loadPokemon);
|
||||
watch(query, () => {
|
||||
void loadPokemon();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -158,6 +206,15 @@ watch(query, loadPokemon);
|
||||
:image="pokemonCardImage(item)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loadingMore" class="entity-grid pokemon-list-grid" aria-hidden="true">
|
||||
<article v-for="index in 2" :key="`pokemon-more-${index}`" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="hasMorePokemon" :disabled="loading || loadingMore" @load="loadMorePokemon" />
|
||||
|
||||
<PokemonEdit v-if="showEditor" />
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
@@ -19,6 +20,9 @@ const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreItems = ref(false);
|
||||
const search = ref('');
|
||||
const categoryId = ref('');
|
||||
const usageId = ref('');
|
||||
@@ -27,6 +31,8 @@ const tagIds = ref<string[]>([]);
|
||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||
const skeletonCardCount = 6;
|
||||
const listPageSize = 24;
|
||||
let loadRequestId = 0;
|
||||
|
||||
const categoryTabs = computed<TabOption[]>(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
@@ -63,10 +69,50 @@ function itemIcon(item: Item) {
|
||||
return item.noRecipe ? iconNoRecipe : iconAdd;
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
items.value = await api.items(itemQuery.value);
|
||||
loading.value = false;
|
||||
async function loadItems(reset = true) {
|
||||
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++loadRequestId;
|
||||
if (reset) {
|
||||
loading.value = true;
|
||||
loadingMore.value = false;
|
||||
nextCursor.value = null;
|
||||
hasMoreItems.value = false;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.itemsPage({
|
||||
...itemQuery.value,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
limit: listPageSize
|
||||
});
|
||||
|
||||
if (requestId !== loadRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
items.value = page.items;
|
||||
} else {
|
||||
const existingIds = new Set(items.value.map((item) => item.id));
|
||||
items.value = [...items.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreItems.value = page.hasMore;
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreItems() {
|
||||
void loadItems(false);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -81,7 +127,9 @@ onMounted(async () => {
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
watch(itemQuery, loadItems);
|
||||
watch(itemQuery, () => {
|
||||
void loadItems();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,6 +239,17 @@ watch(itemQuery, loadItems);
|
||||
</template>
|
||||
</EntityCard>
|
||||
</div>
|
||||
<div v-if="loadingMore" class="entity-grid catalog-card-grid" aria-hidden="true">
|
||||
<article v-for="index in 2" :key="`recipe-more-${index}`" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
<Skeleton variant="box" width="132px" height="36px" />
|
||||
<Skeleton width="92px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore" @load="loadMoreItems" />
|
||||
|
||||
<RecipeEdit v-if="showEditor" />
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -829,11 +830,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="feedsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="feedsLoading" @click="loadFeeds(false)">
|
||||
{{ feedsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="feedsHasMore" :disabled="feedsLoading" @load="loadFeeds(false)" />
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
@@ -969,11 +966,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="reactionsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="reactionsLoading" @click="loadReactions(false)">
|
||||
{{ reactionsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="reactionsHasMore" :disabled="reactionsLoading" @load="loadReactions(false)" />
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
@@ -1018,11 +1011,7 @@ onMounted(() => {
|
||||
<p v-if="comment.target.excerpt" class="profile-comment-excerpt">{{ comment.target.excerpt }}</p>
|
||||
</article>
|
||||
|
||||
<div v-if="commentsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="commentsLoading" @click="loadComments(false)">
|
||||
{{ commentsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
<LoadMoreSentinel :active="commentsHasMore" :disabled="commentsLoading" @load="loadComments(false)" />
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
|
||||
Reference in New Issue
Block a user