Files
gitlab-instance-0a899031_no…/app/community/components/CommunityTimeline.tsx
2026-03-15 04:25:45 -05:00

415 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommunityMemoCard,
parseTags,
type CommunityMemo,
} from "./CommunityMemoCard";
import { COMMUNITY_CHANGED_EVENT, emitCommunityChanged } from "./communityEvents";
import styles from "./community.module.css";
const CACHE_KEY_PREFIX = "community_memos_cache";
type CommunityTimelineProps = {
userId: string;
onVipRequired?: () => void;
refreshKey?: number | string;
prependedMemos?: CommunityMemo[];
state?: "NORMAL" | "ARCHIVED";
query?: string;
emptyText?: string;
};
function getCacheKey(state: string, query: string) {
return `${CACHE_KEY_PREFIX}_${state}_${query}`;
}
function loadCachedMemos(userId: string, state: string, query: string): CommunityMemo[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = sessionStorage.getItem(getCacheKey(state, query));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as { user?: string; items?: CommunityMemo[] };
if (parsed?.user === userId && Array.isArray(parsed.items)) {
return parsed.items;
}
} catch {
return [];
}
return [];
}
function saveCachedMemos(userId: string, state: string, query: string, items: CommunityMemo[]) {
if (typeof window === "undefined") {
return;
}
try {
sessionStorage.setItem(getCacheKey(state, query), JSON.stringify({ user: userId, items }));
} catch {
// ignore cache failure
}
}
export function CommunityTimeline({
userId,
onVipRequired,
refreshKey,
prependedMemos = [],
state = "NORMAL",
query = "",
emptyText = "还没有内容,先发第一条动态吧。",
}: CommunityTimelineProps) {
const normalizedQuery = query.trim().toLowerCase();
const [items, setItems] = useState<CommunityMemo[]>(() => loadCachedMemos(userId, state, normalizedQuery));
const [loading, setLoading] = useState(items.length === 0);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const updateItems = useCallback(
(updater: (current: CommunityMemo[]) => CommunityMemo[]) => {
setItems((current) => {
const next = updater(current);
saveCachedMemos(userId, state, normalizedQuery, next);
return next;
});
},
[normalizedQuery, state, userId]
);
const fetchMemos = useCallback(
async (pageNum: number, append: boolean) => {
if (!userId) {
return;
}
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
try {
const params = new URLSearchParams({
user_id: userId,
page: String(pageNum),
per_page: "20",
state,
});
if (normalizedQuery) {
params.set("query", normalizedQuery);
}
const response = await fetch(`/api/community/memos?${params.toString()}`, { cache: "no-store" });
const data = await response.json().catch(() => null);
if (response.status === 403) {
onVipRequired?.();
setItems([]);
setError("仅会员可访问私密社群。");
return;
}
if (!response.ok) {
setError(data?.error || "加载失败,请稍后重试。");
return;
}
const nextItems = Array.isArray(data?.items) ? (data.items as CommunityMemo[]) : [];
updateItems((current) => {
if (!append) {
return nextItems;
}
const seen = new Set(current.map((item) => item.id));
return [...current, ...nextItems.filter((item) => !seen.has(item.id))];
});
setHasMore(Boolean(data?.next_page_token));
setPage(pageNum);
} catch {
setError("网络异常,请稍后重试。");
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[normalizedQuery, onVipRequired, state, updateItems, userId]
);
useEffect(() => {
setTagFilter(null);
void fetchMemos(1, false);
}, [fetchMemos, refreshKey]);
useEffect(() => {
const handler = () => {
void fetchMemos(1, false);
};
window.addEventListener(COMMUNITY_CHANGED_EVENT, handler);
return () => window.removeEventListener(COMMUNITY_CHANGED_EVENT, handler);
}, [fetchMemos]);
const handleLoadMore = useCallback(() => {
if (!hasMore || loadingMore) {
return;
}
void fetchMemos(page + 1, true);
}, [fetchMemos, hasMore, loadingMore, page]);
const handlePinToggle = useCallback(
async (memoId: string, pinned: boolean) => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, pinned }),
});
if (!response.ok) {
return;
}
updateItems((current) =>
current.map((item) => (item.id === memoId ? { ...item, pinned } : item))
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const handleReactionToggle = useCallback(
async (memoId: string) => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
return;
}
const reaction = data?.reaction;
updateItems((current) =>
current.map((item) =>
item.id === memoId
? {
...item,
liked_by_me: Boolean(reaction?.liked),
reaction_count: Number(reaction?.reaction_count ?? item.reaction_count ?? 0),
}
: item
)
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const handleArchiveToggle = useCallback(
async (memoId: string, nextState: "NORMAL" | "ARCHIVED") => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, state: nextState }),
});
if (!response.ok) {
return;
}
updateItems((current) =>
current.flatMap((item) => {
if (item.id !== memoId) {
return [item];
}
if (nextState !== state) {
return [];
}
return [{ ...item, state: nextState }];
})
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, state, updateItems, userId]
);
const handleDelete = useCallback(
async (memoId: string) => {
if (!userId || mutatingId || !window.confirm("确定删除这条动态吗?")) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(
`/api/community/memos/${memoId}?user_id=${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
if (!response.ok) {
return;
}
updateItems((current) => current.filter((item) => item.id !== memoId));
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const displayItems = useMemo(() => {
const seen = new Set<string>();
return [...prependedMemos, ...items].filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}, [items, prependedMemos]);
const allTags = useMemo(() => {
const tagSet = new Set<string>();
displayItems.forEach((item) => {
const tags = item.tags?.length ? item.tags : parseTags(item.content);
tags.forEach((tag) => tagSet.add(tag.toLowerCase()));
});
return [...tagSet].sort();
}, [displayItems]);
const filteredItems = useMemo(() => {
if (!tagFilter) {
return displayItems;
}
return displayItems.filter((item) => {
const tags = item.tags?.length ? item.tags : parseTags(item.content);
return tags.some((tag) => tag.toLowerCase() === tagFilter);
});
}, [displayItems, tagFilter]);
if (loading && displayItems.length === 0) {
return (
<div className={styles.timelineLoading}>
<div className={styles.spinner} />
<span>...</span>
</div>
);
}
if (error) {
return (
<div className={styles.timelineError}>
<p>{error}</p>
</div>
);
}
if (displayItems.length === 0) {
return (
<div className={styles.timelineEmpty}>
<p>{emptyText}</p>
</div>
);
}
return (
<div className={styles.timeline}>
{allTags.length ? (
<div className={styles.tagFilter}>
<button
type="button"
className={!tagFilter ? styles.tagActive : styles.tagChip}
onClick={() => setTagFilter(null)}
>
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
className={tagFilter === tag ? styles.tagActive : styles.tagChip}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
#{tag}
</button>
))}
</div>
) : null}
{filteredItems.length ? (
filteredItems.map((memo) => (
<CommunityMemoCard
key={memo.id}
memo={memo}
isOwner={memo.user_id === userId}
actionDisabled={mutatingId === memo.id}
onDelete={handleDelete}
onPinToggle={handlePinToggle}
onReactionToggle={handleReactionToggle}
onArchiveToggle={handleArchiveToggle}
onTagClick={(tag) => setTagFilter(tag.toLowerCase())}
/>
))
) : (
<div className={styles.timelineEmpty}>
<p></p>
</div>
)}
{hasMore && !tagFilter ? (
<button
type="button"
className={styles.loadMoreBtn}
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? "加载中..." : "加载更多"}
</button>
) : null}
</div>
);
}