升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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('', '描述') },
|
||||
{ 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
64
frontend/src/components/FeedSortBar.tsx
Normal file
64
frontend/src/components/FeedSortBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/PinnedIcon.tsx
Normal file
20
frontend/src/components/PinnedIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
334
frontend/src/components/PostRevisionPanel.tsx
Normal file
334
frontend/src/components/PostRevisionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
84
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal file
84
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user