升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。

将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 03:05:45 +08:00
parent d0555de28e
commit 1d273066b0
72 changed files with 3576 additions and 676 deletions

View File

@@ -1,16 +1,23 @@
import {
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Strikethrough, Link, Code, Quote,
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
} from 'lucide-react';
import { markdownToHtml, countWords } from '../utils/markdown';
import { renderPostContentHtml } from '../utils/postContent';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { countWords } from '../utils/markdown';
import { MembersOnly } from './editor/MembersOnlyExtension';
export interface ArticleEditorHandle {
getHTML: () => string;
getMarkdown: () => string;
isEmpty: () => boolean;
focus: () => void;
}
@@ -21,181 +28,206 @@ interface Props {
placeholder?: string;
}
type ViewMode = 'edit' | 'preview' | 'split';
interface ToolBtn {
icon: ReactNode;
title: string;
active?: boolean;
className?: string;
action: () => void;
}
/** 去掉行首已有的 Markdown 块级前缀 */
function stripLinePrefix(line: string): string {
return line
.replace(/^\r/, '')
.replace(/^#{1,6}\s*/, '')
.replace(/^>\s*/, '')
.replace(/^[-*+]\s*/, '')
.replace(/^\d+\.\s*/, '');
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 判断编辑器内容是否为空 */
function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
if (editor.isActive('heading', { level })) {
if (level === 6) {
editor.chain().focus().setParagraph().run();
} else {
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
}
return;
}
}
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectionRef = useRef({ start: 0, end: 0 });
const [viewMode, setViewMode] = useState<ViewMode>('split');
const [previewHtml, setPreviewHtml] = useState('');
const isInternalUpdate = useRef(false);
const lastValueRef = useRef(value);
const [, setEditorTick] = useState(0);
useImperativeHandle(ref, () => ({
getHTML: () => markdownToHtml(value),
getMarkdown: () => value,
isEmpty: () => value.trim().length === 0,
focus: () => textareaRef.current?.focus(),
}));
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({ placeholder }),
MembersOnly,
],
content: sanitizeHtml(value) || '',
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
},
onSelectionUpdate: () => setEditorTick(t => t + 1),
onTransaction: () => setEditorTick(t => t + 1),
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
},
});
const saveSelection = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
}, []);
const getSelection = useCallback(() => {
const ta = textareaRef.current;
if (ta && document.activeElement === ta) {
return { start: ta.selectionStart, end: ta.selectionEnd };
}
return selectionRef.current;
}, []);
const restoreSelection = useCallback((start: number, end = start) => {
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(start, end);
selectionRef.current = { start, end };
});
}, []);
// 实时预览,短 debounce 保证流畅
// 外部 value 变更时同步到编辑器(如加载已有帖子)
useEffect(() => {
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
return () => clearTimeout(t);
}, [value]);
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
const adjustTextareaHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta || viewMode === 'preview') return;
ta.style.height = '0px';
const contentHeight = ta.scrollHeight;
const top = ta.getBoundingClientRect().top;
const minHeight = Math.max(280, window.innerHeight - top - 56);
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
}, [viewMode]);
useEffect(() => {
adjustTextareaHeight();
}, [value, viewMode, adjustTextareaHeight]);
useEffect(() => {
const onResize = () => adjustTextareaHeight();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [adjustTextareaHeight]);
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
const ta = textareaRef.current;
if (!ta) return;
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
restoreSelection(start + before.length + selected.length);
}, [value, onChange, getSelection, restoreSelection]);
const wrapLine = useCallback((prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const stripped = stripLinePrefix(line);
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
onChange(next);
restoreSelection(lineStart + prefix.length + stripped.length);
}, [value, onChange, getSelection, restoreSelection]);
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
const toggleHeading = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const normalized = line.replace(/^\r/, '');
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
let nextLine: string;
if (match) {
const level = match[1].length;
const text = match[2];
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
} else {
nextLine = `## ${stripLinePrefix(normalized)}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
onChange(next);
const cursor = lineStart + nextLine.length;
restoreSelection(cursor);
}, [value, onChange, getSelection, restoreSelection]);
/** 包裹为仅登录用户可见区块 */
const wrapMembersOnly = useCallback(() => {
const { start, end } = getSelection();
if (start !== end) {
const selected = value.slice(start, end);
const block = `\n:::members\n${selected}\n:::\n`;
const next = value.slice(0, start) + block + value.slice(end);
onChange(next);
restoreSelection(start + ':::members\n'.length + 1);
if (!editor) return;
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
const next = sanitizeHtml(value);
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor]);
const tools: ToolBtn[] = [
{ icon: <strong>H</strong>, title: '标题H2再次点击升级', action: toggleHeading },
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('![', '](url)', '描述') },
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
];
useImperativeHandle(ref, () => ({
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
focus: () => editor?.commands.focus(),
}), [editor, value]);
const displayPreviewHtml = useMemo(() => {
if (!value.trim()) {
return `<p class="article-preview-placeholder">${placeholder}</p>`;
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
if (!url.trim()) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
return renderPostContentHtml(previewHtml, true);
}, [value, previewHtml, placeholder]);
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
}, [editor]);
const words = countWords(value);
const showEdit = viewMode === 'edit' || viewMode === 'split';
const showPreview = viewMode === 'preview' || viewMode === 'split';
const setImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('图片地址');
if (!url?.trim()) return;
editor.chain().focus().setImage({ src: url.trim() }).run();
}, [editor]);
const wrapMembersOnly = useCallback(() => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapMembersOnly().run();
return;
}
editor.chain().focus().insertMembersOnly().run();
}, [editor]);
const buildTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
{
icon: <strong>H</strong>,
title: '标题H2再次点击升级',
active: editor.isActive('heading'),
action: () => cycleHeading(editor),
},
{
icon: <Bold size={15} />,
title: '加粗',
active: editor.isActive('bold'),
action: () => editor.chain().focus().toggleBold().run(),
},
{
icon: <Italic size={15} />,
title: '斜体',
active: editor.isActive('italic'),
action: () => editor.chain().focus().toggleItalic().run(),
},
{
icon: <Strikethrough size={15} />,
title: '删除线',
active: editor.isActive('strike'),
action: () => editor.chain().focus().toggleStrike().run(),
},
{
icon: <Minus size={15} />,
title: '分割线',
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: <Quote size={15} />,
title: '引用',
active: editor.isActive('blockquote'),
action: () => editor.chain().focus().toggleBlockquote().run(),
},
{
icon: <List size={15} />,
title: '无序列表',
active: editor.isActive('bulletList'),
action: () => editor.chain().focus().toggleBulletList().run(),
},
{
icon: <ListOrdered size={15} />,
title: '有序列表',
active: editor.isActive('orderedList'),
action: () => editor.chain().focus().toggleOrderedList().run(),
},
{
icon: <Code size={15} />,
title: '代码块',
active: editor.isActive('codeBlock'),
action: () => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <LinkIcon size={15} />,
title: '链接',
active: editor.isActive('link'),
action: setLink,
},
{
icon: <ImageIcon size={15} />,
title: '图片',
action: setImage,
},
{
icon: <LockKeyhole size={15} />,
title: '登录可见(选中文字后点击可包裹)',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
},
];
}, [editor, setLink, setImage, wrapMembersOnly]);
const tools = buildTools();
const words = editor ? countWords(editor.getText()) : 0;
return (
<div className="article-editor">
@@ -205,7 +237,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
<button
key={i}
type="button"
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
@@ -214,65 +246,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
</button>
))}
</div>
<div className="article-editor-modes">
<button
type="button"
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
onClick={() => setViewMode('edit')}
title="仅编辑"
>
<Pencil size={14} />
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
onClick={() => setViewMode('split')}
title="分栏预览"
>
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
onClick={() => setViewMode('preview')}
title="仅预览"
>
<Eye size={14} />
</button>
</div>
</div>
<div className={`article-editor-panes article-editor-panes--${viewMode}`}>
{showEdit && (
<div className="article-pane article-pane--edit">
<textarea
ref={textareaRef}
className="article-textarea"
value={value}
onChange={e => onChange(e.target.value)}
onSelect={saveSelection}
onKeyUp={saveSelection}
onClick={saveSelection}
onFocus={saveSelection}
placeholder={placeholder}
spellCheck={false}
/>
</div>
)}
{showPreview && (
<div className="article-pane article-pane--preview">
{viewMode === 'split' && <div className="article-pane-label"></div>}
<div
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
/>
</div>
)}
<div className="article-editor-body">
<EditorContent editor={editor} className="article-editor-content" />
</div>
<div className="article-editor-status">
<span>{words} </span>
<span>Markdown</span>
<span></span>
</div>
</div>
);

View File

@@ -1,70 +0,0 @@
import { Button } from '@/components/ui/button';
import { LayoutGrid, Folder, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boards: Board[];
loading?: boolean;
selectedId?: number;
onSelect: (id: number) => void;
}
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
const nav = useNavigate();
const { user } = useAuth();
if (loading) {
return (
<div className="board-grid board-grid--skeleton" aria-hidden>
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="board-tab board-tab--skeleton" />
))}
</div>
);
}
if (boards.length === 0) {
return (
<div className="board-grid-empty">
<p className="text-sm text-muted-foreground py-4 text-center">
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/admin/boards')}>
<Plus />
</Button>
</div>
)}
</div>
);
}
return (
<div className="board-grid">
<button
type="button"
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
onClick={() => onSelect(0)}
>
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
<span className="board-tab-name"></span>
</button>
{boards.map(b => (
<button
key={b.id}
type="button"
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
title={b.description ? `${b.name}${b.description}` : b.name}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-icon"><Folder size={16} /></span>
<span className="board-tab-name">{b.name}</span>
</button>
))}
</div>
);
}

View File

@@ -1,8 +1,5 @@
import { Button } from '@/components/ui/button';
import { Plus, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boardId: number;
@@ -14,55 +11,40 @@ interface Props {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const board = boards.find(b => b.id === boardId);
const title = keyword
? `搜索:${keyword}`
: (boardId && board ? board.name : '全部帖子');
const hint = keyword
? `找到 ${postTotal} 篇相关帖子`
: boardId && board
? (board.description || '欢迎在本板块交流讨论')
: '姜十三论坛 · 拾三一隅,自在交流';
const boardHint = boardId && board ? (board.description || '') : '';
return (
<div className="feed-banner">
<div className="feed-banner-row">
<div className="feed-banner-title">
<h2>{title}</h2>
<p>{hint}</p>
</div>
{!keyword && (
<div className="feed-actions flex flex-wrap items-center gap-2">
{authLoading ? (
<span className="feed-action-slot" aria-hidden />
) : user ? (
<Button
size="sm"
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
>
<Plus />
{boardId ? '在此发帖' : '发布帖子'}
</Button>
) : (
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>
)}
</div>
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<span className="feed-head__meta">
<strong>{stats.users}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.posts}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.boards}</strong>
</span>
)}
</div>
<div className="feed-stats">
<span> <strong>{stats?.users ?? '—'}</strong></span>
<span> <strong>{stats?.posts ?? '—'}</strong></span>
<span> <strong>{stats?.boards ?? '—'}</strong></span>
</div>
{keyword && (
<button
type="button"
className="feed-head__clear"
onClick={() => nav('/')}
>
</button>
)}
{keyword && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
}
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'reply' || raw === 'hot') return raw;
return 'latest';
}
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
return (
<div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
</div>
{postTotal != null && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Pin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 置顶图钉标识 */
export default function PinnedIcon({ className, size = 16 }: Props) {
return (
<Pin
className={cn('post-pinned-icon', className)}
size={size}
fill="currentColor"
aria-label="置顶"
role="img"
/>
);
}

View File

@@ -1,14 +1,22 @@
import { Badge } from '@/components/ui/badge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
interface Props {
post: PostItem;
sort?: FeedSort;
onClick: () => void;
}
export default function PostListItem({ post, onClick }: Props) {
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
return (
<div className="post-row" onClick={onClick}>
@@ -17,13 +25,13 @@ export default function PostListItem({ post, onClick }: Props) {
</div>
<div className="post-body">
<div className="post-title">
{post.pinned && <Badge variant="orange" className="mr-1.5"></Badge>}
{post.pinned && <PinnedIcon className="mr-1.5" />}
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
<span>{post.user?.nickname || '匿名'}</span>
<span>{formatTime(post.created_at)}</span>
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">

View File

@@ -0,0 +1,334 @@
import { useEffect, useMemo, useState } from 'react';
import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import {
type PostSnapshot,
htmlToDiffText,
summarizeChange,
diffTextLines,
diffTextWords,
countLineChanges,
} from '../utils/revisionDiff';
interface Props {
postId: number;
currentPost: PostSnapshot;
open: boolean;
onClose: () => void;
isLoggedIn: boolean;
}
type ViewMode = 'diff' | 'before' | 'after';
interface RevisionEntry {
rev: PostRevision;
after: PostSnapshot;
summary: ReturnType<typeof summarizeChange>;
index: number;
}
function DiffWords({ before, after }: { before: string; after: string }) {
const parts = diffTextWords(before, after);
if (before === after) {
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
}
return (
<span className="revision-diff-inline">
{parts.map((part, i) => {
if (part.added) return <ins key={i}>{part.value}</ins>;
if (part.removed) return <del key={i}>{part.value}</del>;
return <span key={i}>{part.value}</span>;
})}
</span>
);
}
function DiffLines({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none"></span>;
return (
<>
{summary.titleChanged && <span className="revision-badge"></span>}
{summary.contentChanged && <span className="revision-badge"></span>}
{summary.tagsChanged && <span className="revision-badge"></span>}
</>
);
}
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
const [revisions, setRevisions] = useState<PostRevision[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!open) {
setSelectedId(null);
setViewMode('diff');
setFullscreen(false);
return;
}
setLoading(true);
api.postRevisions(postId)
.then(d => {
const list = d.revisions ?? [];
setRevisions(list);
if (list.length > 0) setSelectedId(list[0].id);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
.finally(() => setLoading(false));
}, [open, postId]);
// 阻止背景滚动
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const entries: RevisionEntry[] = useMemo(() => {
return revisions.map((rev, index) => {
const after: PostSnapshot = index === 0
? currentPost
: {
title: revisions[index - 1].title,
content: revisions[index - 1].content,
tags: revisions[index - 1].tags,
};
const before: PostSnapshot = {
title: rev.title,
content: rev.content,
tags: rev.tags,
};
return {
rev,
after,
summary: summarizeChange(before, after),
index: revisions.length - index,
};
});
}, [revisions, currentPost]);
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
if (!open) return null;
const beforeSnap: PostSnapshot | null = selected
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
: null;
return (
<div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose}
>
<div
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="编辑历史"
>
<header className="post-revision-head">
<div className="post-revision-head-left">
<History size={18} />
<h3></h3>
{selected && (
<span className="post-revision-head-sub">
{selected.index}
</span>
)}
</div>
<div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist">
{([
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)}
>
<Icon size={14} />
<span className="post-revision-tab-label">{label}</span>
</button>
))}
</div>
<button
type="button"
className="post-revision-icon-btn"
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
>
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
<X size={18} />
</button>
</div>
</header>
{loading ? (
<div className="post-revision-loading"><Spinner size="lg" /></div>
) : entries.length === 0 ? (
<p className="post-revision-empty"></p>
) : (
<div className="post-revision-body">
<aside className="post-revision-sidebar">
<div className="post-revision-sidebar-label">线</div>
<ul className="post-revision-list">
{entries.map(entry => (
<li key={entry.rev.id}>
<button
type="button"
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
onClick={() => setSelectedId(entry.rev.id)}
>
<div className="post-revision-item-head">
<span className="post-revision-item-num">#{entry.index}</span>
<ChangeBadges summary={entry.summary} />
</div>
<span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta">
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
</span>
</button>
</li>
))}
</ul>
<div className="post-revision-current">
<span className="post-revision-current-label"></span>
<span className="post-revision-current-title">{currentPost.title}</span>
</div>
</aside>
<main className="post-revision-main">
{selected && beforeSnap ? (
<>
<div className="post-revision-main-head">
<div>
<span className="post-revision-main-editor">
{selected.rev.editor?.nickname ?? '未知'}
</span>
<span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)}
</span>
</div>
<ChangeBadges summary={selected.summary} />
</div>
<div className="post-revision-scroll">
{viewMode === 'diff' && (
<div className="revision-diff-view">
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords before={beforeSnap.title} after={selected.after.title} />
</div>
</section>
{(beforeSnap.tags || selected.after.tags) && (
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords
before={beforeSnap.tags || '(无)'}
after={selected.after.tags || '(无)'}
/>
</div>
</section>
)}
<section className="revision-diff-section revision-diff-section--content">
<h4></h4>
<DiffLines
before={htmlToDiffText(beforeSnap.content)}
after={htmlToDiffText(selected.after.content)}
/>
</section>
</div>
)}
{viewMode === 'before' && (
<div className="revision-full-view">
<h4>{beforeSnap.title}</h4>
{beforeSnap.tags && (
<p className="revision-full-tags">{beforeSnap.tags}</p>
)}
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
</div>
)}
{viewMode === 'after' && (
<div className="revision-full-view">
<h4>{selected.after.title}</h4>
{selected.after.tags && (
<p className="revision-full-tags">{selected.after.tags}</p>
)}
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
</div>
)}
</div>
</>
) : (
<div className="post-revision-empty"></div>
)}
</main>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import {
Home, Star, LayoutDashboard,
} from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -29,6 +30,8 @@ interface Props {
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
@@ -50,7 +53,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav>
@@ -63,7 +66,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>

View File

@@ -1,19 +1,36 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import PostListItem from './PostListItem';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
}
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
export default function VirtualPostList({
posts,
sort = 'latest',
loading,
hasMore,
onLoadMore,
onSelect,
restoreScrollTop,
onScrollTopChange,
onScrollRestored,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const virtualizer = useVirtualizer({
count: posts.length,
@@ -22,17 +39,29 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
overscan: 8,
});
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
onLoadMore();
}
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, onLoadMore]);
}, [hasMore, loading, onLoadMore, onScrollTopChange]);
return (
<div className="post-list-scroll" ref={parentRef}>
@@ -50,7 +79,7 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} onClick={() => onSelect(post.id)} />
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
</div>
);
})}

View File

@@ -0,0 +1,84 @@
import { Node, mergeAttributes } from '@tiptap/core';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole } from 'lucide-react';
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected }: NodeViewProps) {
return (
<NodeViewWrapper
as="members-only"
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
>
<div className="post-members-only__badge" contentEditable={false}>
<span className="post-members-only__badge-icon" aria-hidden="true">
<LockKeyhole size={12} />
</span>
<span></span>
</div>
<NodeViewContent className="post-members-only__body" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
membersOnly: {
insertMembersOnly: () => ReturnType;
wrapMembersOnly: () => ReturnType;
};
}
}
/** TipTap 自定义节点:登录可见内容区块 */
export const MembersOnly = Node.create({
name: 'membersOnly',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'members-only' }];
},
renderHTML({ HTMLAttributes }) {
return ['members-only', mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(MembersOnlyView);
},
addCommands() {
return {
insertMembersOnly: () => ({ chain }) => chain()
.insertContent({
type: this.name,
content: [{
type: 'paragraph',
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
}],
})
.run(),
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { from, to, empty } = state.selection;
if (empty) return false;
const slice = state.doc.slice(from, to);
if (!slice.content.size) return false;
const node = state.schema.nodes.membersOnly.create(null, slice.content);
if (dispatch) {
tr.replaceRangeWith(from, to, node);
}
return true;
},
};
},
});