feat(life): add infinite scroll pagination to feed
Implement cursor-based pagination in backend API Add IntersectionObserver to frontend for automatic loading on scroll
This commit is contained in:
@@ -377,7 +377,8 @@ Life Post 可配置:
|
|||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
- 当前没有图片上传、转发、分页、置顶或单独审核流程。
|
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||||
|
- 当前没有图片上传、转发、置顶或单独审核流程。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
|
|
||||||
API 暴露边界:
|
API 暴露边界:
|
||||||
@@ -385,6 +386,7 @@ API 暴露边界:
|
|||||||
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
|
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
||||||
- 非作者不能编辑或删除其他用户的 Life Post。
|
- 非作者不能编辑或删除其他用户的 Life Post。
|
||||||
- 非作者不能删除其他用户的 Life Comment。
|
- 非作者不能删除其他用户的 Life Comment。
|
||||||
@@ -426,7 +428,7 @@ API 暴露边界:
|
|||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/life-posts`
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取。
|
||||||
|
|
||||||
认证 API:
|
认证 API:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||||
import { pool, query, queryOne } from './db.ts';
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
|
|
||||||
type QueryValue = string | string[] | undefined;
|
type QueryValue = string | string[] | undefined;
|
||||||
@@ -134,17 +135,29 @@ type LifePostRow = {
|
|||||||
id: number;
|
id: number;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
createdAtCursor: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
author: { id: number; displayName: string } | null;
|
author: { id: number; displayName: string } | null;
|
||||||
updatedBy: { id: number; displayName: string } | null;
|
updatedBy: { id: number; displayName: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LifePost = LifePostRow & {
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
||||||
comments: LifeComment[];
|
comments: LifeComment[];
|
||||||
reactionCounts: LifeReactionCounts;
|
reactionCounts: LifeReactionCounts;
|
||||||
myReaction: LifeReactionType | null;
|
myReaction: LifeReactionType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LifePostCursor = {
|
||||||
|
createdAt: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LifePostsPage = {
|
||||||
|
items: LifePost[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type HabitatPayload = {
|
type HabitatPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
translations: TranslationInput;
|
translations: TranslationInput;
|
||||||
@@ -215,6 +228,8 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const defaultLocale = 'en';
|
const defaultLocale = 'en';
|
||||||
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||||
|
const defaultLifePostLimit = 20;
|
||||||
|
const maxLifePostLimit = 50;
|
||||||
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
||||||
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
||||||
{ key: 'hp', label: 'HP' },
|
{ key: 'hp', label: 'HP' },
|
||||||
@@ -1255,6 +1270,7 @@ function lifePostProjection(): string {
|
|||||||
lp.id,
|
lp.id,
|
||||||
lp.body,
|
lp.body,
|
||||||
lp.created_at AS "createdAt",
|
lp.created_at AS "createdAt",
|
||||||
|
lp.created_at::text AS "createdAtCursor",
|
||||||
lp.updated_at AS "updatedAt",
|
lp.updated_at AS "updatedAt",
|
||||||
CASE
|
CASE
|
||||||
WHEN created_user.id IS NULL THEN NULL
|
WHEN created_user.id IS NULL THEN NULL
|
||||||
@@ -1270,6 +1286,63 @@ function lifePostProjection(): string {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanLifePostLimit(value: QueryValue): number {
|
||||||
|
const rawLimit = asString(value);
|
||||||
|
if (rawLimit === undefined || rawLimit === '') {
|
||||||
|
return defaultLifePostLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number(rawLimit);
|
||||||
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
||||||
|
const rawCursor = asString(value);
|
||||||
|
if (!rawCursor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
|
||||||
|
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
||||||
|
const id = Number(cursor.id);
|
||||||
|
|
||||||
|
if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) {
|
||||||
|
throw validationError('Cursor is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createdAt, id };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && 'statusCode' in error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw validationError('Cursor is invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeLifePostCursor(post: LifePostRow): string {
|
||||||
|
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateLifePost(
|
||||||
|
post: LifePostRow,
|
||||||
|
commentsByPost: Map<number, LifeComment[]>,
|
||||||
|
countsByPost: Map<number, LifeReactionCounts>,
|
||||||
|
myReactionsByPost: Map<number, LifeReactionType>
|
||||||
|
): LifePost {
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
body: post.body,
|
||||||
|
createdAt: post.createdAt,
|
||||||
|
updatedAt: post.updatedAt,
|
||||||
|
author: post.author,
|
||||||
|
updatedBy: post.updatedBy,
|
||||||
|
comments: commentsByPost.get(post.id) ?? [],
|
||||||
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
||||||
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function lifeCommentProjection(whereClause: string): string {
|
function lifeCommentProjection(whereClause: string): string {
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1406,22 +1479,41 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
|||||||
return row ? { ...row, replies: [] } : null;
|
return row ? { ...row, replies: [] } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLifePosts(userId: number | null = null): Promise<LifePost[]> {
|
export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise<LifePostsPage> {
|
||||||
const posts = await query<LifePostRow>(`
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||||
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let cursorClause = '';
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
params.push(cursor.createdAt, cursor.id);
|
||||||
|
cursorClause = `
|
||||||
|
WHERE (lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit + 1);
|
||||||
|
const rows = await query<LifePostRow>(
|
||||||
|
`
|
||||||
${lifePostProjection()}
|
${lifePostProjection()}
|
||||||
|
${cursorClause}
|
||||||
ORDER BY lp.created_at DESC, lp.id DESC
|
ORDER BY lp.created_at DESC, lp.id DESC
|
||||||
`);
|
LIMIT $${params.length}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const posts = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
|
||||||
const postIds = posts.map((post) => post.id);
|
const postIds = posts.map((post) => post.id);
|
||||||
const commentsByPost = await lifeCommentsForPosts(postIds);
|
const commentsByPost = await lifeCommentsForPosts(postIds);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
||||||
|
|
||||||
return posts.map((post) => ({
|
return {
|
||||||
...post,
|
items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)),
|
||||||
comments: commentsByPost.get(post.id) ?? [],
|
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
||||||
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
hasMore
|
||||||
myReaction: myReactionsByPost.get(post.id) ?? null
|
};
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
|
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
|
||||||
@@ -1439,12 +1531,7 @@ async function getLifePostById(id: number, userId: number | null = null): Promis
|
|||||||
|
|
||||||
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
||||||
return {
|
return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost);
|
||||||
...post,
|
|
||||||
comments: commentsByPost.get(post.id) ?? [],
|
|
||||||
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
|
||||||
myReaction: myReactionsByPost.get(post.id) ?? null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
|
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
|
|||||||
|
|
||||||
app.get('/api/life-posts', async (request) => {
|
app.get('/api/life-posts', async (request) => {
|
||||||
const user = await optionalUser(request);
|
const user = await optionalUser(request);
|
||||||
return listLifePosts(user?.id ?? null);
|
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/life-posts', async (request, reply) => {
|
app.post('/api/life-posts', async (request, reply) => {
|
||||||
|
|||||||
@@ -188,6 +188,17 @@ export interface LifePost {
|
|||||||
myReaction: LifeReactionType | null;
|
myReaction: LifeReactionType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LifePostsPage {
|
||||||
|
items: LifePost[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifePostsParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LifeComment {
|
export interface LifeComment {
|
||||||
id: number;
|
id: number;
|
||||||
postId: number;
|
postId: number;
|
||||||
@@ -452,7 +463,10 @@ export const api = {
|
|||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
lifePosts: () => getJson<LifePost[]>('/api/life-posts'),
|
lifePosts: (params: LifePostsParams = {}) =>
|
||||||
|
getJson<LifePostsPage>(
|
||||||
|
`/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit })}`
|
||||||
|
),
|
||||||
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||||
|
|||||||
@@ -1219,6 +1219,10 @@ button:disabled,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-feed__sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-form__counter {
|
.life-form__counter {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -33,6 +33,7 @@ const { locale, t } = useI18n();
|
|||||||
const posts = ref<LifePost[]>([]);
|
const posts = ref<LifePost[]>([]);
|
||||||
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 authReady = ref(false);
|
const authReady = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const body = ref('');
|
const body = ref('');
|
||||||
@@ -49,8 +50,16 @@ const reactionPickerPostId = ref<number | null>(null);
|
|||||||
const reactionBusyPostId = ref<number | null>(null);
|
const reactionBusyPostId = ref<number | null>(null);
|
||||||
const reactionErrors = ref<Record<number, string>>({});
|
const reactionErrors = ref<Record<number, string>>({});
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
|
const lifePostPageSize = 20;
|
||||||
const skeletonPostCount = 3;
|
const skeletonPostCount = 3;
|
||||||
|
const loadingMoreSkeletonCount = 2;
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
let feedObserver: IntersectionObserver | null = null;
|
||||||
|
let postsRequestId = 0;
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMorePosts = ref(false);
|
||||||
|
const loadMorePaused = ref(false);
|
||||||
|
|
||||||
const reactionOptions = [
|
const reactionOptions = [
|
||||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||||
@@ -88,15 +97,66 @@ async function loadCurrentUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPosts() {
|
async function loadPosts() {
|
||||||
|
const requestId = ++postsRequestId;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMorePosts.value = false;
|
||||||
|
loadMorePaused.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.lifePosts({ limit: lifePostPageSize });
|
||||||
|
if (requestId !== postsRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posts.value = page.items;
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMorePosts.value = page.hasMore;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === postsRequestId) {
|
||||||
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === postsRequestId) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMorePosts() {
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = nextCursor.value;
|
||||||
|
if (!cursor) {
|
||||||
|
hasMorePosts.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = postsRequestId;
|
||||||
|
loadingMore.value = true;
|
||||||
|
loadError.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
posts.value = await api.lifePosts();
|
const page = await api.lifePosts({ cursor, limit: lifePostPageSize });
|
||||||
|
if (requestId !== postsRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(posts.value.map((post) => post.id));
|
||||||
|
posts.value = [...posts.value, ...page.items.filter((post) => !existingIds.has(post.id))];
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMorePosts.value = page.hasMore;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId === postsRequestId) {
|
||||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
loadMorePaused.value = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (requestId === postsRequestId) {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +494,36 @@ function authorInitial(post: LifePost) {
|
|||||||
return name.slice(0, 1).toUpperCase();
|
return name.slice(0, 1).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectFeedObserver() {
|
||||||
|
feedObserver?.disconnect();
|
||||||
|
feedObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeLoadMore() {
|
||||||
|
disconnectFeedObserver();
|
||||||
|
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value || !loadMoreSentinel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
void loadMorePosts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
void loadMorePosts();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '360px 0px' }
|
||||||
|
);
|
||||||
|
feedObserver.observe(loadMoreSentinel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
@@ -444,6 +534,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
disconnectFeedObserver();
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -503,7 +594,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="life-feed" :aria-busy="loading" :aria-label="t('pages.life.kicker')">
|
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
||||||
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
|
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
|
||||||
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
|
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
|
||||||
<div class="life-post__header">
|
<div class="life-post__header">
|
||||||
@@ -777,6 +868,21 @@ 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>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article v-for="index in loadingMore ? loadingMoreSkeletonCount : 0" :key="`life-more-${index}`" class="life-post life-post--skeleton">
|
||||||
|
<div class="life-post__header">
|
||||||
|
<Skeleton variant="box" width="46px" height="46px" />
|
||||||
|
<div class="life-post__byline">
|
||||||
|
<Skeleton width="138px" />
|
||||||
|
<Skeleton width="96px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton width="94%" />
|
||||||
|
<Skeleton width="76%" />
|
||||||
|
<Skeleton width="52%" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-if="hasMorePosts" ref="loadMoreSentinel" class="life-feed__sentinel" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="status">{{ t('pages.life.empty') }}</p>
|
<p v-else class="status">{{ t('pages.life.empty') }}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user