225 lines
6.3 KiB
TypeScript
225 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import styles from "./community.module.css";
|
|
import { renderCommunityMarkdown } from "./renderMarkdown";
|
|
|
|
export type CommunityAttachment = {
|
|
url: string;
|
|
alt?: string;
|
|
};
|
|
|
|
export type CommunityComment = {
|
|
id: string;
|
|
memo_id: string;
|
|
user_id: string;
|
|
content: string;
|
|
created?: string;
|
|
updated?: string;
|
|
};
|
|
|
|
export type CommunityMemo = {
|
|
id: string;
|
|
user_id: string;
|
|
content: string;
|
|
pinned?: boolean;
|
|
visibility?: string;
|
|
state?: "NORMAL" | "ARCHIVED";
|
|
created?: string;
|
|
updated?: string;
|
|
create_time?: string;
|
|
update_time?: string;
|
|
display_time?: string;
|
|
tags?: string[];
|
|
attachments?: CommunityAttachment[];
|
|
comment_count?: number;
|
|
reaction_count?: number;
|
|
liked_by_me?: boolean;
|
|
comments?: CommunityComment[];
|
|
};
|
|
|
|
export function parseTags(content: string): string[] {
|
|
const matches = content.match(/#[\w\u4e00-\u9fa5]+/g) || [];
|
|
return [...new Set(matches.map((match) => match.slice(1)))];
|
|
}
|
|
|
|
export function formatCommunityTime(iso?: string): string {
|
|
if (!iso) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
const date = new Date(iso);
|
|
const diff = Date.now() - date.getTime();
|
|
if (diff < 60_000) {
|
|
return "刚刚";
|
|
}
|
|
if (diff < 3_600_000) {
|
|
return `${Math.floor(diff / 60_000)} 分钟前`;
|
|
}
|
|
if (diff < 86_400_000) {
|
|
return `${Math.floor(diff / 3_600_000)} 小时前`;
|
|
}
|
|
if (diff < 604_800_000) {
|
|
return `${Math.floor(diff / 86_400_000)} 天前`;
|
|
}
|
|
|
|
return date.toLocaleDateString("zh-CN", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function maskCommunityUserId(value: string): string {
|
|
if (!value) {
|
|
return "匿名成员";
|
|
}
|
|
if (/^user\d+$/i.test(value)) {
|
|
return `成员 ${value.slice(-4)}`;
|
|
}
|
|
return value.length > 12 ? `${value.slice(0, 8)}...` : value;
|
|
}
|
|
|
|
export function buildCommunityAvatarText(value: string): string {
|
|
if (!value) {
|
|
return "匿";
|
|
}
|
|
if (/^user\d+$/i.test(value)) {
|
|
return value.slice(-2);
|
|
}
|
|
return value.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
type CommunityMemoCardProps = {
|
|
memo: CommunityMemo;
|
|
isOwner?: boolean;
|
|
actionDisabled?: boolean;
|
|
hideDetailLink?: boolean;
|
|
onDelete?: (id: string) => void;
|
|
onPinToggle?: (id: string, pinned: boolean) => void;
|
|
onReactionToggle?: (id: string) => void;
|
|
onArchiveToggle?: (id: string, nextState: "NORMAL" | "ARCHIVED") => void;
|
|
onTagClick?: (tag: string) => void;
|
|
};
|
|
|
|
export function CommunityMemoCard({
|
|
memo,
|
|
isOwner,
|
|
actionDisabled = false,
|
|
hideDetailLink = false,
|
|
onDelete,
|
|
onPinToggle,
|
|
onReactionToggle,
|
|
onArchiveToggle,
|
|
onTagClick,
|
|
}: CommunityMemoCardProps) {
|
|
const tags = memo.tags?.length ? memo.tags : parseTags(memo.content);
|
|
const attachments = memo.attachments ?? [];
|
|
const nextArchiveState = memo.state === "ARCHIVED" ? "NORMAL" : "ARCHIVED";
|
|
|
|
return (
|
|
<article className={styles.memoCard}>
|
|
<div className={styles.memoHeader}>
|
|
<div className={styles.memoIdentity}>
|
|
<div className={styles.memoAvatar}>{buildCommunityAvatarText(memo.user_id)}</div>
|
|
<div className={styles.memoIdentityText}>
|
|
<span className={styles.memoAuthor}>{maskCommunityUserId(memo.user_id)}</span>
|
|
<span className={styles.memoMeta}>
|
|
{formatCommunityTime(
|
|
memo.display_time || memo.created || memo.updated || memo.create_time || memo.update_time
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.memoHeaderSide}>
|
|
{memo.pinned ? <span className={styles.pinnedBadge}>置顶</span> : null}
|
|
{memo.state === "ARCHIVED" ? <span className={styles.archivedBadge}>已归档</span> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{tags.length ? (
|
|
<div className={styles.memoTags}>
|
|
{tags.map((tag) => (
|
|
<button key={tag} type="button" className={styles.tagBtn} onClick={() => onTagClick?.(tag)}>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={styles.memoContent}
|
|
dangerouslySetInnerHTML={{
|
|
__html: renderCommunityMarkdown(memo.content),
|
|
}}
|
|
/>
|
|
|
|
<div className={styles.memoFooter}>
|
|
<div className={styles.memoFooterStats}>
|
|
<span className={styles.memoFooterMeta}>赞 {memo.reaction_count ?? 0}</span>
|
|
<span className={styles.memoFooterMeta}>评论 {memo.comment_count ?? 0}</span>
|
|
{attachments.length ? <span className={styles.memoFooterMeta}>附件 {attachments.length}</span> : null}
|
|
</div>
|
|
|
|
<div className={styles.memoFooterActions}>
|
|
{onReactionToggle ? (
|
|
<button
|
|
type="button"
|
|
className={memo.liked_by_me ? styles.footerActionBtnActive : styles.footerActionBtn}
|
|
onClick={() => onReactionToggle(memo.id)}
|
|
disabled={actionDisabled}
|
|
>
|
|
{memo.liked_by_me ? "已赞" : "点赞"}
|
|
</button>
|
|
) : null}
|
|
|
|
{!hideDetailLink ? (
|
|
<Link href={`/community/post/${memo.id}`} className={styles.detailLink}>
|
|
查看详情
|
|
</Link>
|
|
) : null}
|
|
|
|
{isOwner && onPinToggle ? (
|
|
<button
|
|
type="button"
|
|
className={memo.pinned ? styles.pinBtnActive : styles.pinBtn}
|
|
onClick={() => onPinToggle(memo.id, !memo.pinned)}
|
|
disabled={actionDisabled}
|
|
>
|
|
{memo.pinned ? "取消置顶" : "置顶"}
|
|
</button>
|
|
) : null}
|
|
|
|
{isOwner && onArchiveToggle ? (
|
|
<button
|
|
type="button"
|
|
className={styles.footerActionBtn}
|
|
onClick={() => onArchiveToggle(memo.id, nextArchiveState)}
|
|
disabled={actionDisabled}
|
|
>
|
|
{memo.state === "ARCHIVED" ? "恢复" : "归档"}
|
|
</button>
|
|
) : null}
|
|
|
|
{isOwner && onDelete ? (
|
|
<button
|
|
type="button"
|
|
className={styles.deleteBtn}
|
|
onClick={() => onDelete(memo.id)}
|
|
disabled={actionDisabled}
|
|
>
|
|
删除
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|