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

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>
);
}