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:
14
DESIGN.md
14
DESIGN.md
@@ -581,6 +581,7 @@ Pokemon 列表功能:
|
|||||||
- 满足任意条件
|
- 满足任意条件
|
||||||
- 满足全部条件
|
- 满足全部条件
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
|
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
|
||||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||||
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。
|
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。
|
||||||
@@ -663,6 +664,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 按用途筛选
|
- 按用途筛选
|
||||||
- 按标签筛选
|
- 按标签筛选
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
|
- 公开列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Items 或 Event Items。
|
||||||
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
||||||
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
||||||
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
||||||
@@ -1071,16 +1073,16 @@ API 暴露边界:
|
|||||||
- `GET /api/system-wordings`
|
- `GET /api/system-wordings`
|
||||||
- `GET /api/options`
|
- `GET /api/options`
|
||||||
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||||
- `GET /api/daily-checklist`
|
- `GET /api/daily-checklist`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端排序。
|
||||||
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器
|
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部 Pokemon 以兼容管理端和实体选择器。
|
||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。
|
||||||
- `GET /api/habitats/:id`
|
- `GET /api/habitats/:id`
|
||||||
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品
|
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
|
||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/ancient-artifacts/:id`
|
- `GET /api/ancient-artifacts/:id`
|
||||||
- `GET /api/recipes`
|
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/dish`
|
- `GET /api/dish`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Buffer } from 'node:buffer';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient, QueryResultRow } from 'pg';
|
||||||
import {
|
import {
|
||||||
requestAiModerationReview,
|
requestAiModerationReview,
|
||||||
type AiModerationStatus
|
type AiModerationStatus
|
||||||
@@ -21,6 +21,11 @@ import { createLifePostReactionNotification, createUserFollowNotification } from
|
|||||||
type QueryValue = string | string[] | undefined;
|
type QueryValue = string | string[] | undefined;
|
||||||
|
|
||||||
type QueryParams = Record<string, QueryValue>;
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
type ListPage<T> = {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
@@ -707,6 +712,67 @@ function asString(value: QueryValue): string | undefined {
|
|||||||
return Array.isArray(value) ? value[0] : value;
|
return Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultPublicListLimit = 24;
|
||||||
|
const maxPublicListLimit = 72;
|
||||||
|
|
||||||
|
function isPagedListRequest(paramsQuery: QueryParams): boolean {
|
||||||
|
return asString(paramsQuery.limit) !== undefined || asString(paramsQuery.cursor) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPublicListLimit(value: QueryValue): number {
|
||||||
|
const limit = Number(asString(value));
|
||||||
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxPublicListLimit) : defaultPublicListLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeOffsetCursor(offset: number): string {
|
||||||
|
return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOffsetCursor(value: QueryValue): number {
|
||||||
|
const rawValue = asString(value);
|
||||||
|
if (!rawValue) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(rawValue, 'base64url').toString('utf8')) as { offset?: unknown };
|
||||||
|
const offset = Number(payload.offset);
|
||||||
|
return Number.isInteger(offset) && offset >= 0 ? offset : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryMaybePaged<T extends QueryResultRow>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[],
|
||||||
|
paramsQuery: QueryParams
|
||||||
|
): Promise<T[] | ListPage<T>> {
|
||||||
|
if (!isPagedListRequest(paramsQuery)) {
|
||||||
|
return query<T>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = cleanPublicListLimit(paramsQuery.limit);
|
||||||
|
const offset = decodeOffsetCursor(paramsQuery.cursor);
|
||||||
|
const pagedParams = [...params, limit + 1, offset];
|
||||||
|
const rows = await query<T>(
|
||||||
|
`
|
||||||
|
${sql}
|
||||||
|
LIMIT $${pagedParams.length - 1}
|
||||||
|
OFFSET $${pagedParams.length}
|
||||||
|
`,
|
||||||
|
pagedParams
|
||||||
|
);
|
||||||
|
const items = rows.slice(0, limit);
|
||||||
|
const nextOffset = offset + items.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor: rows.length > limit ? encodeOffsetCursor(nextOffset) : null,
|
||||||
|
hasMore: rows.length > limit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanLocale(value: unknown): string {
|
export function cleanLocale(value: unknown): string {
|
||||||
const locale = typeof value === 'string' ? value.trim() : '';
|
const locale = typeof value === 'string' ? value.trim() : '';
|
||||||
return localePattern.test(locale) ? locale : defaultLocale;
|
return localePattern.test(locale) ? locale : defaultLocale;
|
||||||
@@ -2698,14 +2764,16 @@ function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChec
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDailyChecklistItems(locale = defaultLocale) {
|
export async function listDailyChecklistItems(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
||||||
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
||||||
return query(
|
return queryMaybePaged(
|
||||||
`
|
`
|
||||||
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
|
||||||
FROM daily_checklist_items c
|
FROM daily_checklist_items c
|
||||||
ORDER BY c.sort_order, c.id
|
ORDER BY c.sort_order, c.id
|
||||||
`
|
`,
|
||||||
|
[],
|
||||||
|
paramsQuery
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3007,7 +3075,7 @@ export async function reorderDailyChecklistItems(payload: Record<string, unknown
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return listDailyChecklistItems(locale);
|
return listDailyChecklistItems({}, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
||||||
@@ -5754,7 +5822,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params);
|
return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params, paramsQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPokemon(id: number, locale = defaultLocale) {
|
export async function getPokemon(id: number, locale = defaultLocale) {
|
||||||
@@ -6278,7 +6346,7 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau
|
|||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
return query(`
|
return queryMaybePaged(`
|
||||||
SELECT
|
SELECT
|
||||||
h.id,
|
h.id,
|
||||||
${habitatName} AS name,
|
${habitatName} AS name,
|
||||||
@@ -6314,7 +6382,7 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau
|
|||||||
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY ${orderByEntity('h')}
|
ORDER BY ${orderByEntity('h')}
|
||||||
`, params);
|
`, params, paramsQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHabitat(id: number, locale = defaultLocale) {
|
export async function getHabitat(id: number, locale = defaultLocale) {
|
||||||
@@ -6633,7 +6701,7 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
const orderClause = recipeOrder
|
const orderClause = recipeOrder
|
||||||
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
||||||
: `ORDER BY ${orderByEntity('i')}`;
|
: `ORDER BY ${orderByEntity('i')}`;
|
||||||
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
return queryMaybePaged(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params, paramsQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getItem(id: number, locale = defaultLocale) {
|
export async function getItem(id: number, locale = defaultLocale) {
|
||||||
@@ -7135,7 +7203,7 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params);
|
return queryMaybePaged(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params, paramsQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
||||||
@@ -7278,7 +7346,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
|
|||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
return query(`
|
return queryMaybePaged(`
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
${resultItemName} AS name,
|
${resultItemName} AS name,
|
||||||
@@ -7294,7 +7362,7 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
|
|||||||
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY ${orderByEntity('r')}
|
ORDER BY ${orderByEntity('r')}
|
||||||
`, params);
|
`, params, paramsQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecipe(id: number, locale = defaultLocale) {
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
||||||
|
|||||||
@@ -1198,7 +1198,9 @@ app.get('/api/project-updates', async (request) =>
|
|||||||
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) =>
|
||||||
|
listDailyChecklistItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
import Tabs, { type TabOption } from './Tabs.vue';
|
import Tabs, { type TabOption } from './Tabs.vue';
|
||||||
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
||||||
@@ -776,17 +777,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="hasMoreComments" class="life-feed__retry">
|
<LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
|
||||||
<button
|
|
||||||
class="ui-button ui-button--ghost ui-button--small"
|
|
||||||
type="button"
|
|
||||||
:disabled="loadingMore"
|
|
||||||
@click="loadDiscussion(false)"
|
|
||||||
>
|
|
||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="entity-discussion-empty">
|
<div v-else class="entity-discussion-empty">
|
||||||
|
|||||||
68
frontend/src/components/LoadMoreSentinel.vue
Normal file
68
frontend/src/components/LoadMoreSentinel.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
rootMargin?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
rootMargin: '360px 0px'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
load: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sentinel = ref<HTMLElement | null>(null);
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function disconnectObserver() {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeSentinel() {
|
||||||
|
disconnectObserver();
|
||||||
|
|
||||||
|
if (!props.active || props.disabled || !sentinel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
emit('load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
emit('load');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: props.rootMargin }
|
||||||
|
);
|
||||||
|
observer.observe(sentinel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void nextTick(observeSentinel);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(disconnectObserver);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.disabled, props.rootMargin, sentinel.value],
|
||||||
|
() => {
|
||||||
|
void nextTick(observeSentinel);
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="active" ref="sentinel" class="load-more-sentinel" aria-hidden="true"></div>
|
||||||
|
</template>
|
||||||
@@ -109,6 +109,19 @@ export interface ProjectUpdatesParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicListParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicListQueryParams = Record<string, string | number | boolean | null | undefined> & PublicListParams;
|
||||||
|
|
||||||
export interface EntityImage {
|
export interface EntityImage {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -997,11 +1010,11 @@ export interface RateLimitSettingsPayload {
|
|||||||
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
|
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== '') {
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
search.set(key, String(value));
|
search.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1279,6 +1292,13 @@ export const api = {
|
|||||||
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
|
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
dailyChecklistPage: (params: PublicListParams = {}) =>
|
||||||
|
getJson<ListPage<DailyChecklistItem>>(
|
||||||
|
`/api/daily-checklist${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
lifePosts: (params: LifePostsParams = {}) =>
|
lifePosts: (params: LifePostsParams = {}) =>
|
||||||
getJson<LifePostsPage>(
|
getJson<LifePostsPage>(
|
||||||
`/api/life-posts${buildQuery({
|
`/api/life-posts${buildQuery({
|
||||||
@@ -1395,6 +1415,14 @@ export const api = {
|
|||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
|
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
|
||||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
|
pokemonPage: (params: PublicListQueryParams) =>
|
||||||
|
getJson<ListPage<Pokemon>>(
|
||||||
|
`/api/pokemon${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||||
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
||||||
getJson<PokemonFetchOption[]>(
|
getJson<PokemonFetchOption[]>(
|
||||||
@@ -1411,6 +1439,14 @@ export const api = {
|
|||||||
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
||||||
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
||||||
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
||||||
|
habitatsPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<Habitat>>(
|
||||||
|
`/api/habitats${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||||
@@ -1419,6 +1455,14 @@ export const api = {
|
|||||||
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||||
items: (params: Record<string, string | number | boolean | undefined>) =>
|
items: (params: Record<string, string | number | boolean | undefined>) =>
|
||||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
|
itemsPage: (params: PublicListQueryParams) =>
|
||||||
|
getJson<ListPage<Item>>(
|
||||||
|
`/api/items${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||||
@@ -1426,6 +1470,14 @@ export const api = {
|
|||||||
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||||
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
|
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
|
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
|
||||||
|
ancientArtifactsPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<AncientArtifact>>(
|
||||||
|
`/api/ancient-artifacts${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
|
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
|
||||||
createAncientArtifact: (payload: AncientArtifactPayload) =>
|
createAncientArtifact: (payload: AncientArtifactPayload) =>
|
||||||
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
|
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
|
||||||
@@ -1436,6 +1488,14 @@ export const api = {
|
|||||||
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
|
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
|
||||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||||
|
recipesPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<Recipe>>(
|
||||||
|
`/api/recipes${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||||
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||||
|
|||||||
@@ -2946,6 +2946,10 @@ button:disabled,
|
|||||||
min-height: 1px;
|
min-height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-feed__retry {
|
.life-feed__retry {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
@@ -19,6 +20,9 @@ const options = ref<Options | null>(null);
|
|||||||
const artifacts = ref<AncientArtifact[]>([]);
|
const artifacts = ref<AncientArtifact[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreArtifacts = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const categoryId = ref('');
|
const categoryId = ref('');
|
||||||
const tagIds = ref<string[]>([]);
|
const tagIds = ref<string[]>([]);
|
||||||
@@ -26,6 +30,8 @@ const tagIds = ref<string[]>([]);
|
|||||||
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
||||||
const filterSkeletonWidths = ['52px', '36px'];
|
const filterSkeletonWidths = ['52px', '36px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: t('common.all') },
|
{ 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;
|
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArtifacts() {
|
async function loadArtifacts(reset = true) {
|
||||||
|
if (!reset && (loading.value || loadingMore.value || !hasMoreArtifacts.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
|
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;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreArtifacts() {
|
||||||
|
void loadArtifacts(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -61,7 +107,9 @@ onMounted(async () => {
|
|||||||
await loadArtifacts();
|
await loadArtifacts();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(artifactQuery, loadArtifacts);
|
watch(artifactQuery, () => {
|
||||||
|
void loadArtifacts();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -138,6 +186,20 @@ watch(artifactQuery, loadArtifacts);
|
|||||||
compact-tooltip
|
compact-tooltip
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<ItemEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import { api, type DailyChecklistItem } from '../services/api';
|
import { api, type DailyChecklistItem } from '../services/api';
|
||||||
@@ -16,8 +17,13 @@ const stateRefreshIntervalMs = 60_000;
|
|||||||
const checklistItems = ref<DailyChecklistItem[]>([]);
|
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||||
const checkedTaskIds = ref<Set<number>>(new Set());
|
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreItems = ref(false);
|
||||||
const skeletonRows = 5;
|
const skeletonRows = 5;
|
||||||
|
const listPageSize = 20;
|
||||||
let stateRefreshTimer: number | null = null;
|
let stateRefreshTimer: number | null = null;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
function todayKey() {
|
function todayKey() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -85,14 +91,52 @@ function handleTaskChange(id: number, event: Event) {
|
|||||||
toggleTask(id, checkbox?.checked === true);
|
toggleTask(id, checkbox?.checked === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDailyChecklist() {
|
async function loadDailyChecklist(reset = true) {
|
||||||
loading.value = true;
|
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||||
try {
|
return;
|
||||||
checklistItems.value = await api.dailyChecklist();
|
|
||||||
syncChecklistState();
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
@@ -135,9 +179,14 @@ onUnmounted(() => {
|
|||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
||||||
|
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore" @load="loadMoreDailyChecklist" />
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, onMounted, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import { iconAdd, iconHabitat } from '../icons';
|
import { iconAdd, iconHabitat } from '../icons';
|
||||||
@@ -19,7 +20,12 @@ const currentUser = ref<AuthUser | null>(null);
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreHabitats = ref(false);
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
const query = computed(() => ({
|
const query = computed(() => ({
|
||||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
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;
|
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHabitats() {
|
async function loadHabitats(reset = true) {
|
||||||
|
if (!reset && (loading.value || loadingMore.value || !hasMoreHabitats.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
habitats.value = await api.habitats(query.value);
|
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;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreHabitats() {
|
||||||
|
void loadHabitats(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -52,7 +98,9 @@ onMounted(async () => {
|
|||||||
await loadHabitats();
|
await loadHabitats();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, loadHabitats);
|
watch(query, () => {
|
||||||
|
void loadHabitats();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,6 +133,15 @@ watch(query, loadHabitats);
|
|||||||
:image="habitatCardImage(item)"
|
:image="habitatCardImage(item)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<HabitatEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
@@ -24,6 +25,9 @@ const { t } = useI18n();
|
|||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreItems = ref(false);
|
||||||
const ordering = ref(false);
|
const ordering = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const categoryId = ref('');
|
const categoryId = ref('');
|
||||||
@@ -71,6 +75,8 @@ const itemCreateDefaults = ref<ItemCreateDefaults>(readItemCreateDefaults());
|
|||||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 36;
|
||||||
|
let loadRequestId = 0;
|
||||||
const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') : t('pages.items.title')));
|
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 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')));
|
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 !== ''
|
() => search.value.trim() !== '' || usageId.value !== '' || tagIds.value.length > 0 || categoryId.value !== ''
|
||||||
);
|
);
|
||||||
const itemSortingAllowed = computed(
|
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(
|
const itemInsertionAllowed = computed(
|
||||||
() => itemSortingAllowed.value && currentUser.value?.permissions.includes('items.create') === true
|
() => itemSortingAllowed.value && currentUser.value?.permissions.includes('items.create') === true
|
||||||
@@ -394,7 +400,7 @@ async function dropItem(targetItem: Item, event: DragEvent) {
|
|||||||
suppressNextItemClick.value = true;
|
suppressNextItemClick.value = true;
|
||||||
try {
|
try {
|
||||||
await api.reorderItems(nextItems.map((item) => item.id));
|
await api.reorderItems(nextItems.map((item) => item.id));
|
||||||
items.value = await api.items(itemQuery.value);
|
await loadItems();
|
||||||
} catch {
|
} catch {
|
||||||
items.value = previousItems;
|
items.value = previousItems;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -418,10 +424,50 @@ function handleItemClick(event: MouseEvent) {
|
|||||||
suppressNextItemClick.value = false;
|
suppressNextItemClick.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems(reset = true) {
|
||||||
|
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
items.value = await api.items(itemQuery.value);
|
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;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreItems() {
|
||||||
|
void loadItems(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -444,7 +490,9 @@ onBeforeUnmount(() => {
|
|||||||
document.removeEventListener('keydown', onDocumentKeydown);
|
document.removeEventListener('keydown', onDocumentKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(itemQuery, loadItems);
|
watch(itemQuery, () => {
|
||||||
|
void loadItems();
|
||||||
|
});
|
||||||
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
|
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
|
||||||
watch(showEditor, () => {
|
watch(showEditor, () => {
|
||||||
closeCreateDefaultsMenu();
|
closeCreateDefaultsMenu();
|
||||||
@@ -641,6 +689,20 @@ watch(itemSortingAllowed, (allowed) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</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
|
<div
|
||||||
v-if="itemContextMenu"
|
v-if="itemContextMenu"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusBadge from '../components/StatusBadge.vue';
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
@@ -1279,17 +1280,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||||
|
|
||||||
<div v-if="commentsHasMore && !commentsLoading" class="life-feed__retry">
|
<LoadMoreSentinel :active="commentsHasMore && !commentsLoading" :disabled="commentsLoadingMore" @load="loadComments(false)" />
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -2015,17 +2016,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
<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">
|
<LoadMoreSentinel
|
||||||
<button
|
:active="commentPage(post).hasMore && !commentPage(post).loading"
|
||||||
class="ui-button ui-button--ghost ui-button--small"
|
|
||||||
type="button"
|
|
||||||
:disabled="commentPage(post).loadingMore"
|
:disabled="commentPage(post).loadingMore"
|
||||||
@click="loadComments(post)"
|
@load="loadComments(post)"
|
||||||
>
|
/>
|
||||||
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
@@ -22,6 +23,9 @@ const { t } = useI18n();
|
|||||||
const pokemon = ref<Pokemon[]>([]);
|
const pokemon = ref<Pokemon[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMorePokemon = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const environmentId = ref('');
|
const environmentId = ref('');
|
||||||
const skillIds = ref<string[]>([]);
|
const skillIds = ref<string[]>([]);
|
||||||
@@ -30,6 +34,8 @@ const favoriteThingIds = ref<string[]>([]);
|
|||||||
const favoriteThingMode = ref<'any' | 'all'>('any');
|
const favoriteThingMode = ref<'any' | 'all'>('any');
|
||||||
const filterSkeletonWidths = ['52px', '92px', '48px', '72px'];
|
const filterSkeletonWidths = ['52px', '92px', '48px', '72px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const query = computed(() => ({
|
const query = computed(() => ({
|
||||||
search: search.value,
|
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 newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
|
||||||
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
|
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
|
||||||
|
|
||||||
async function loadPokemon() {
|
async function loadPokemon(reset = true) {
|
||||||
|
if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
pokemon.value = await api.pokemon(query.value);
|
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;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMorePokemon() {
|
||||||
|
void loadPokemon(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pokemonCardImage(item: Pokemon) {
|
function pokemonCardImage(item: Pokemon) {
|
||||||
@@ -70,7 +116,9 @@ onMounted(async () => {
|
|||||||
await loadPokemon();
|
await loadPokemon();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, loadPokemon);
|
watch(query, () => {
|
||||||
|
void loadPokemon();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -158,6 +206,15 @@ watch(query, loadPokemon);
|
|||||||
:image="pokemonCardImage(item)"
|
:image="pokemonCardImage(item)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<PokemonEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
@@ -19,6 +20,9 @@ const { t } = useI18n();
|
|||||||
const items = ref<Item[]>([]);
|
const items = ref<Item[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreItems = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const categoryId = ref('');
|
const categoryId = ref('');
|
||||||
const usageId = ref('');
|
const usageId = ref('');
|
||||||
@@ -27,6 +31,8 @@ const tagIds = ref<string[]>([]);
|
|||||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: t('common.all') },
|
{ value: '', label: t('common.all') },
|
||||||
@@ -63,10 +69,50 @@ function itemIcon(item: Item) {
|
|||||||
return item.noRecipe ? iconNoRecipe : iconAdd;
|
return item.noRecipe ? iconNoRecipe : iconAdd;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems(reset = true) {
|
||||||
|
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
items.value = await api.items(itemQuery.value);
|
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;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreItems() {
|
||||||
|
void loadItems(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -81,7 +127,9 @@ onMounted(async () => {
|
|||||||
await loadItems();
|
await loadItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(itemQuery, loadItems);
|
watch(itemQuery, () => {
|
||||||
|
void loadItems();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -191,6 +239,17 @@ watch(itemQuery, loadItems);
|
|||||||
</template>
|
</template>
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
</div>
|
</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" />
|
<RecipeEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -829,11 +830,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="feedsHasMore" class="profile-load-more">
|
<LoadMoreSentinel :active="feedsHasMore" :disabled="feedsLoading" @load="loadFeeds(false)" />
|
||||||
<button class="ui-button ui-button--blue" type="button" :disabled="feedsLoading" @click="loadFeeds(false)">
|
|
||||||
{{ feedsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="profile-empty">
|
<div v-else class="profile-empty">
|
||||||
@@ -969,11 +966,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="reactionsHasMore" class="profile-load-more">
|
<LoadMoreSentinel :active="reactionsHasMore" :disabled="reactionsLoading" @load="loadReactions(false)" />
|
||||||
<button class="ui-button ui-button--blue" type="button" :disabled="reactionsLoading" @click="loadReactions(false)">
|
|
||||||
{{ reactionsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="profile-empty">
|
<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>
|
<p v-if="comment.target.excerpt" class="profile-comment-excerpt">{{ comment.target.excerpt }}</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="commentsHasMore" class="profile-load-more">
|
<LoadMoreSentinel :active="commentsHasMore" :disabled="commentsLoading" @load="loadComments(false)" />
|
||||||
<button class="ui-button ui-button--blue" type="button" :disabled="commentsLoading" @click="loadComments(false)">
|
|
||||||
{{ commentsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="profile-empty">
|
<div v-else class="profile-empty">
|
||||||
|
|||||||
Reference in New Issue
Block a user