初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
281
frontend/src/components/ArticleEditor.tsx
Normal file
281
frontend/src/components/ArticleEditor.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Link, Code, Quote,
|
||||
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
|
||||
} from 'lucide-react';
|
||||
import { markdownToHtml, countWords } from '../utils/markdown';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
getMarkdown: () => string;
|
||||
isEmpty: () => boolean;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
/** 去掉行首已有的 Markdown 块级前缀 */
|
||||
function stripLinePrefix(line: string): string {
|
||||
return line
|
||||
.replace(/^\r/, '')
|
||||
.replace(/^#{1,6}\s*/, '')
|
||||
.replace(/^>\s*/, '')
|
||||
.replace(/^[-*+]\s*/, '')
|
||||
.replace(/^\d+\.\s*/, '');
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => markdownToHtml(value),
|
||||
getMarkdown: () => value,
|
||||
isEmpty: () => value.trim().length === 0,
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
}));
|
||||
|
||||
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 保证流畅
|
||||
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);
|
||||
return;
|
||||
}
|
||||
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
|
||||
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
const displayPreviewHtml = useMemo(() => {
|
||||
if (!value.trim()) {
|
||||
return `<p class="article-preview-placeholder">${placeholder}</p>`;
|
||||
}
|
||||
return renderPostContentHtml(previewHtml, true);
|
||||
}, [value, previewHtml, placeholder]);
|
||||
|
||||
const words = countWords(value);
|
||||
const showEdit = viewMode === 'edit' || viewMode === 'split';
|
||||
const showPreview = viewMode === 'preview' || viewMode === 'split';
|
||||
|
||||
return (
|
||||
<div className="article-editor">
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{tools.map((t, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
|
||||
title={t.title}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</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>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<span>{words} 字</span>
|
||||
<span>Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticleEditor;
|
||||
70
frontend/src/components/BoardGrid.tsx
Normal file
70
frontend/src/components/BoardGrid.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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('/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>
|
||||
);
|
||||
}
|
||||
219
frontend/src/components/CommentBox.tsx
Normal file
219
frontend/src/components/CommentBox.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { User, Comment } from '../api/types';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
|
||||
import { commentNick } from '../utils/comment';
|
||||
|
||||
export interface CommentSubmitData {
|
||||
content: string;
|
||||
guestNick?: string;
|
||||
guestEmail?: string;
|
||||
guestUrl?: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User | null;
|
||||
replyTo?: Comment | null;
|
||||
inline?: boolean;
|
||||
submitting?: boolean;
|
||||
submitCount?: number;
|
||||
onSubmit: (data: CommentSubmitData) => void;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
|
||||
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
|
||||
const saved = loadGuestInfo();
|
||||
const [content, setContent] = useState('');
|
||||
const [guestNick, setGuestNick] = useState(saved.nick);
|
||||
const [guestEmail, setGuestEmail] = useState(saved.email);
|
||||
const [guestUrl, setGuestUrl] = useState(saved.url);
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inline && replyTo) {
|
||||
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [replyTo?.id, inline]);
|
||||
|
||||
useEffect(() => {
|
||||
setContent('');
|
||||
setShowEmoji(false);
|
||||
setIsPrivate(false);
|
||||
}, [submitCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEmoji) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||
setShowEmoji(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showEmoji]);
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const start = el.selectionStart ?? content.length;
|
||||
const end = el.selectionEnd ?? content.length;
|
||||
const next = content.slice(0, start) + emoji + content.slice(end);
|
||||
setContent(next);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = start + emoji.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else {
|
||||
setContent((prev) => prev + emoji);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
if (!user && !guestNick.trim()) return;
|
||||
|
||||
if (!user) {
|
||||
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
content: text,
|
||||
guestNick: user ? undefined : guestNick.trim(),
|
||||
guestEmail: user ? undefined : guestEmail.trim(),
|
||||
guestUrl: user ? undefined : guestUrl.trim(),
|
||||
isPrivate,
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
|
||||
|
||||
return (
|
||||
<div className="comment-box" ref={boxRef}>
|
||||
<div className="comment-box-avatar">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="comment-box-avatar-img" />
|
||||
) : (
|
||||
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
|
||||
{user ? avatarInitial : (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="comment-box-main">
|
||||
{replyTo && !inline && (
|
||||
<div className="comment-box-reply-hint">
|
||||
<span>回复 #{replyTo.floor} {commentNick(replyTo)}</span>
|
||||
{onCancelReply && (
|
||||
<button type="button" className="comment-box-reply-cancel" onClick={onCancelReply}>取消</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="comment-box-textarea"
|
||||
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-box-send"
|
||||
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
|
||||
onClick={handleSubmit}
|
||||
title="发送"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<div className="comment-box-guest-fields">
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
昵称
|
||||
<em className="comment-box-guest-required">必填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="怎么称呼你"
|
||||
autoComplete="nickname"
|
||||
value={guestNick}
|
||||
onChange={(e) => setGuestNick(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
邮箱
|
||||
<em className="comment-box-guest-optional">选填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
网址
|
||||
<em className="comment-box-guest-optional">选填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
autoComplete="url"
|
||||
value={guestUrl}
|
||||
onChange={(e) => setGuestUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="comment-box-guest-hint">邮箱不会公开展示,仅用于站内记录。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="comment-box-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
>
|
||||
OwO
|
||||
</button>
|
||||
<label className="comment-box-private">
|
||||
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
|
||||
<span>隐私评论</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/CommentContent.tsx
Normal file
19
frontend/src/components/CommentContent.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { highlightMentions } from '../utils/content';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onMentionClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||
return (
|
||||
<div className="floor-body">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/components/CommentThreadList.tsx
Normal file
158
frontend/src/components/CommentThreadList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Clock, MessageSquare, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment } from '../api/types';
|
||||
import CommentContent from './CommentContent';
|
||||
import {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
formatCommentDate,
|
||||
isGuestComment,
|
||||
buildCommentTree,
|
||||
type CommentNode,
|
||||
} from '../utils/comment';
|
||||
|
||||
interface ItemProps {
|
||||
node: CommentNode;
|
||||
nested?: boolean;
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框) */
|
||||
function CommentItem({
|
||||
node,
|
||||
nested,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const c = node.comment;
|
||||
const nick = commentNick(c);
|
||||
const guest = isGuestComment(c);
|
||||
const isHighlighted = highlightFloor === c.floor;
|
||||
const hidden = !!c.content_hidden;
|
||||
const isReplying = replyToId === c.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`floor-${c.floor}`}
|
||||
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
|
||||
>
|
||||
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
|
||||
{c.user?.avatar ? (
|
||||
<img src={c.user.avatar} alt="" />
|
||||
) : (
|
||||
commentInitial(c)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="waline-comment-main">
|
||||
<div className="waline-comment-head">
|
||||
{c.guest_url ? (
|
||||
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
|
||||
{nick}
|
||||
</a>
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hidden ? (
|
||||
<div className="waline-comment-private-mask">
|
||||
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||
</div>
|
||||
) : (
|
||||
<div className="waline-comment-bubble">
|
||||
{c.reply_target && (
|
||||
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
|
||||
)}
|
||||
<CommentContent content={c.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="waline-comment-meta">
|
||||
<span className="waline-comment-date">
|
||||
<Clock size={14} />
|
||||
{formatCommentDate(c.created_at)}
|
||||
</span>
|
||||
{isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||
<MessageSquare size={14} />
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReplying && renderReplyBox && (
|
||||
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||
{renderReplyBox(c)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.children.length > 0 && (
|
||||
<div className="waline-replies">
|
||||
{node.children.map((child) => (
|
||||
<CommentItem
|
||||
key={child.comment.id}
|
||||
node={child}
|
||||
nested
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comments: Comment[];
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
/** Waline 嵌套楼层评论列表 */
|
||||
export default function CommentThreadList({
|
||||
comments,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
|
||||
return (
|
||||
<div className="comment-thread-list">
|
||||
{tree.map((node) => (
|
||||
<CommentItem
|
||||
key={node.comment.id}
|
||||
node={node}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/EmojiPicker.tsx
Normal file
23
frontend/src/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { EMOJI_LIST } from '../utils/emojis';
|
||||
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
/** OwO 表情选择面板 */
|
||||
export default function EmojiPicker({ onSelect }: Props) {
|
||||
return (
|
||||
<div className="emoji-picker">
|
||||
{EMOJI_LIST.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="emoji-picker-item"
|
||||
onClick={() => onSelect(e)}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/ErrorBoundary.tsx
Normal file
33
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props { children: ReactNode }
|
||||
interface State { error: Error | null }
|
||||
|
||||
/** 捕获渲染异常,避免整页白屏 */
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[Jiang13Forum]', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<h3>页面渲染出错</h3>
|
||||
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p>
|
||||
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||
刷新页面
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
68
frontend/src/components/FeedHeader.tsx
Normal file
68
frontend/src/components/FeedHeader.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
keyword: string;
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
postTotal: number;
|
||||
}
|
||||
|
||||
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 || '欢迎在本板块交流讨论')
|
||||
: '姜十三论坛 · 拾三一隅,自在交流';
|
||||
|
||||
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('/boards')}>
|
||||
<Settings />
|
||||
管理板块
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="feed-stats">
|
||||
<span>会员 <strong>{stats?.users ?? '—'}</strong></span>
|
||||
<span>帖子 <strong>{stats?.posts ?? '—'}</strong></span>
|
||||
<span>板块 <strong>{stats?.boards ?? '—'}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/PageLoader.tsx
Normal file
9
frontend/src/components/PageLoader.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
|
||||
export default function PageLoader() {
|
||||
return (
|
||||
<div className="page-loader" role="status" aria-live="polite">
|
||||
<span className="page-loader__dot" />
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/PostContent.tsx
Normal file
40
frontend/src/components/PostContent.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
isLoggedIn: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 帖子正文渲染(含会员专属区块) */
|
||||
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const rendered = useMemo(
|
||||
() => renderPostContentHtml(html, isLoggedIn),
|
||||
[html, isLoggedIn],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-members-login]')) {
|
||||
e.preventDefault();
|
||||
nav('/login');
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-register]')) {
|
||||
e.preventDefault();
|
||||
nav('/register');
|
||||
}
|
||||
}, [nav]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/PostListItem.tsx
Normal file
35
frontend/src/components/PostListItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { PostItem } from '../api/types';
|
||||
import { formatTime } from '../utils/content';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post, onClick }: Props) {
|
||||
const initial = post.user?.nickname?.[0] || '?';
|
||||
|
||||
return (
|
||||
<div className="post-row" onClick={onClick}>
|
||||
<div className="post-avatar">
|
||||
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial}
|
||||
</div>
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
{post.pinned && <Badge variant="orange" className="mr-1.5">置顶</Badge>}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<span>💬 {post.comment_count ?? 0}</span>
|
||||
<span>👍 {post.like_count ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/RightPanel.tsx
Normal file
77
frontend/src/components/RightPanel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
hot: PostItem[];
|
||||
notifications: Notification[];
|
||||
online: OnlineStats | null;
|
||||
onPostClick: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">🔥 热门帖子</div>
|
||||
<div className="widget-card-body">
|
||||
{hotList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">📢 最新动态</div>
|
||||
<div className="widget-card-body">
|
||||
{noticeList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">👀 当前浏览 {online?.count ?? '—'} 人</div>
|
||||
<div className="widget-card-body">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{members.map(u => (
|
||||
<span key={u.id} title={u.nickname} style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)',
|
||||
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{u.nickname?.[0] || '?'}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>暂无会员在线</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card widget-card--about">
|
||||
<div className="widget-card-body">
|
||||
<p className="widget-about-text">
|
||||
<strong>姜十三论坛</strong>
|
||||
拾三一隅,自在交流。轻量社区,专为小圈子打造。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/Sidebar.tsx
Normal file
87
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Home, Settings, Star, LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
|
||||
export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/boards')) return 'boards';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
boards: Board[];
|
||||
activeBoard: number;
|
||||
onSelectBoard: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
|
||||
|
||||
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === key && 'active')}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
|
||||
</nav>
|
||||
|
||||
{boards.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
{boards.map(b => (
|
||||
<button
|
||||
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}`); }}
|
||||
>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>管理</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))}
|
||||
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/VirtualPostList.tsx
Normal file
68
frontend/src/components/VirtualPostList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import PostListItem from './PostListItem';
|
||||
import type { PostItem } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
posts: PostItem[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [hasMore, loading, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} onClick={() => onSelect(post.id)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasMore && posts.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 6, fontSize: 12, color: 'var(--color-text-3)' }}>— 已加载全部 —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/ui/alert-dialog.tsx
Normal file
101
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
32
frontend/src/components/ui/badge.tsx
Normal file
32
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
|
||||
outline: 'text-foreground',
|
||||
green: 'border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]',
|
||||
orange: 'border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : null}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
95
frontend/src/components/ui/dialog.tsx
Normal file
95
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
171
frontend/src/components/ui/dropdown-menu.tsx
Normal file
171
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
136
frontend/src/components/ui/form.tsx
Normal file
136
frontend/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = { id: string };
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
return (
|
||||
<Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
return (
|
||||
<p ref={ref} id={formDescriptionId} className={cn('text-[0.8rem] text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
if (!body) return null;
|
||||
return (
|
||||
<p ref={ref} id={formMessageId} className={cn('text-[0.8rem] font-medium text-destructive', className)} {...props}>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
18
frontend/src/components/ui/label.tsx
Normal file
18
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
20
frontend/src/components/ui/sonner.tsx
Normal file
20
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
/** 全局 Toast,替代 Arco Message */
|
||||
export function Toaster() {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
position="top-center"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'font-sans',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/spinner.tsx
Normal file
19
frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeMap = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-8 w-8' };
|
||||
|
||||
/** 加载指示器,替代 Arco Spin */
|
||||
export function Spinner({ className, size = 'md' }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn('animate-spin text-[var(--j13-green)]', sizeMap[size], className)}
|
||||
aria-label="加载中"
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ui/switch.tsx
Normal file
26
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--j13-green)] data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
57
frontend/src/components/ui/table.tsx
Normal file
57
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
|
||||
);
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
|
||||
20
frontend/src/components/ui/textarea.tsx
Normal file
20
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user