415 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|