初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

35
frontend/index.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3685
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "jiang13-forum-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

45
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
import MainLayout from './layouts/MainLayout';
import ErrorBoundary from './components/ErrorBoundary';
import PageLoader from './components/PageLoader';
import { Toaster } from './components/ui/sonner';
const HomePage = lazy(() => import('./pages/HomePage'));
const PostDetailPage = lazy(() => import('./pages/PostDetailPage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
export default function App() {
return (
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/boards" element={<BoardsManagePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>
</Routes>
</BrowserRouter>
<Toaster />
</ErrorBoundary>
</AuthProvider>
</ThemeProvider>
);
}

112
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types';
const BASE = '';
async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(BASE + url, {
credentials: 'same-origin',
...opts,
headers: {
...(opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...opts.headers,
},
});
let data: Record<string, unknown>;
try {
data = await res.json();
} catch {
throw new Error('服务器响应异常');
}
if (!res.ok) throw new Error((data.error as string) || '请求失败');
return data as T;
}
export const api = {
me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'),
boards: () => request<{ boards: Board[] }>('/api/boards'),
posts: (params: Record<string, string | number>) => {
const q = new URLSearchParams(params as Record<string, string>).toString();
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
},
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`),
comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
},
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
online: () => request<OnlineStats>('/api/online'),
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
createBoard: (body: { name: string; description: string; sort_order: number }) =>
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
updateNickname: (nickname: string) => {
const fd = new FormData();
fd.append('nickname', nickname);
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} });
},
updatePassword: (oldPassword: string, newPassword: string) => {
const fd = new FormData();
fd.append('old_password', oldPassword);
fd.append('new_password', newPassword);
return request('/api/profile/password', { method: 'POST', body: fd, headers: {} });
},
uploadAvatar: (file: File) => {
const fd = new FormData();
fd.append('avatar', file);
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
},
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
const fd = new FormData();
fd.append('board_id', data.board_id);
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
return request<{ post_id: number }>('/api/posts', { method: 'POST', body: fd, headers: {} });
},
updatePost: (id: number, data: { title: string; content: string; tags?: string }) => {
const fd = new FormData();
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
},
login: (username: string, password: string) => {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
return request('/api/login', { method: 'POST', body: fd, headers: {} });
},
register: (username: string, password: string, nickname: string) => {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
fd.append('nickname', nickname);
return request('/api/register', { method: 'POST', body: fd, headers: {} });
},
logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
addComment: (postId: number, data: {
content: string;
replyTo?: number;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate?: boolean;
}) => {
const fd = new FormData();
fd.append('content', data.content);
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
if (data.guestNick) fd.append('guest_nick', data.guestNick);
if (data.guestEmail) fd.append('guest_email', data.guestEmail);
if (data.guestUrl) fd.append('guest_url', data.guestUrl);
if (data.isPrivate) fd.append('is_private', '1');
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
},
ping: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/ping', { method: 'POST' }),
};

74
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,74 @@
export interface User {
id: number;
username: string;
nickname: string;
avatar: string;
role: 'user' | 'admin';
}
export interface Board {
id: number;
name: string;
description: string;
sort_order: number;
post_count?: number;
}
export interface ForumStats {
users: number;
posts: number;
boards: number;
}
export interface PostItem {
id: number;
board_id: number;
user_id: number;
title: string;
content?: string;
tags: string;
pinned: boolean;
like_count: number;
view_count: number;
comment_count: number;
created_at: string;
board?: Board;
user?: User;
}
export interface Comment {
id: number;
post_id: number;
user_id: number;
floor: number;
content: string;
reply_to?: number;
guest_nick?: string;
guest_email?: string;
guest_url?: string;
is_private?: boolean;
content_hidden?: boolean;
created_at: string;
user?: User;
reply_target?: Comment;
}
export interface Notification {
id: number;
title: string;
type: string;
created_at: string;
}
export interface OnlineUser {
id: number;
nickname: string;
avatar: string;
}
export interface OnlineStats {
count: number;
members: number;
guests: number;
users: OnlineUser[];
}

View 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('![', '](url)', '描述') },
{ 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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,
};

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

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

View 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,
};

View 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,
};

View 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,
};

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

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

View 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',
},
}}
/>
);
}

View 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="加载中"
/>
);
}

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

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

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

View File

@@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
interface AuthCtx {
user: User | null;
loading: boolean;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthCtx>({
user: null, loading: true,
refresh: async () => {}, logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await api.me();
setUser(data.user ?? null);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
const logout = async () => {
await api.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,57 @@
import { useEffect, type RefObject } from 'react';
/** 判断元素是否可在指定方向继续滚动 */
function canElementScroll(el: HTMLElement, deltaY: number): boolean {
const { overflowY } = getComputedStyle(el);
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
return false;
}
if (el.scrollHeight <= el.clientHeight + 1) return false;
if (deltaY < 0) return el.scrollTop > 0;
return el.scrollTop + el.clientHeight < el.scrollHeight - 1;
}
/** 从目标节点向上查找首个可滚动容器 */
function findScrollable(start: HTMLElement | null, deltaY: number, boundary: HTMLElement): HTMLElement | null {
let el = start;
while (el && el !== boundary) {
if (canElementScroll(el, deltaY)) return el;
el = el.parentElement;
}
return null;
}
/**
* 文章页:鼠标在页面任意位置滚轮时,统一滚动主内容区。
* 保留 textarea、表情面板等主内容区内部可滚动元素的独立滚动。
*/
export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, enabled = true) {
useEffect(() => {
if (!enabled) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const root = scrollEl.closest('.app-shell') as HTMLElement | null;
if (!root) return;
const onWheel = (e: WheelEvent) => {
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return;
const inner = findScrollable(target, e.deltaY, root);
// 主内容区内部嵌套滚动(如 textarea、表情面板保留原生行为
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
// 鼠标已在主滚动容器上时,交给浏览器原生处理
if (inner === scrollEl) return;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += e.deltaY;
if (scrollEl.scrollTop !== prev) {
e.preventDefault();
}
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [scrollRef, enabled]);
}

View File

@@ -0,0 +1,35 @@
import { createContext, useContext, useLayoutEffect, useState, ReactNode } from 'react';
import { applyTheme, getStoredTheme, type Theme } from '../utils/theme';
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
theme: 'light', toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getStoredTheme);
useLayoutEffect(() => {
applyTheme(theme);
}, [theme]);
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
export function useMediaQuery(query: string) {
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
useLayoutEffect(() => {
const m = window.matchMedia(query);
const fn = () => setMatch(m.matches);
m.addEventListener('change', fn);
return () => m.removeEventListener('change', fn);
}, [query]);
return match;
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect, useCallback, Suspense } from 'react';
import PageLoader from '../components/PageLoader';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Moon, Sun, Search, Plus } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { openAdminDashboard } from '../utils/admin';
import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { api } from '../api/client';
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme();
const isMobile = useMediaQuery('(max-width: 768px)');
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
const [hot, setHot] = useState<PostItem[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [online, setOnline] = useState<OnlineStats | null>(null);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
const refreshBoards = useCallback(() => {
Promise.all([
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
setCachedBoards(next);
return next;
}).catch(() => [] as Board[]),
api.stats().then(next => {
setStats(next);
setCachedStats(next);
return next;
}).catch(() => null),
]).finally(() => setLayoutReady(true));
}, []);
const refreshOnline = useCallback(() => {
api.online().then(d => {
setOnline({
count: d.count ?? 0,
members: d.members ?? 0,
guests: d.guests ?? 0,
users: Array.isArray(d.users) ? d.users : [],
});
}).catch(() => {});
}, []);
useEffect(() => {
refreshBoards();
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
refreshOnline();
api.presence().catch(() => {});
const onlineTimer = setInterval(refreshOnline, 30000);
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh);
return () => {
clearInterval(onlineTimer);
clearInterval(presenceTimer);
window.removeEventListener('boards-refresh', onRefresh);
};
}, [refreshBoards, refreshOnline]);
const doSearch = () => {
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
else nav('/');
};
const userInitial = user?.nickname?.charAt(0) || '?';
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
return (
<div className="app-shell">
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
<button type="button" className="header-brand" onClick={() => nav('/')}>
<span className="header-logo-mark"></span>
{!isMobile && <span className="header-logo-text"></span>}
</button>
{!isCompose && (
<div className="header-search-wrap">
<Search className="header-search-icon" size={16} />
<input
className="header-search-input"
type="search"
placeholder="搜索帖子..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && doSearch()}
/>
{keyword && (
<button
type="button"
className="header-search-clear"
onClick={() => { setKeyword(''); nav('/'); }}
aria-label="清除搜索"
>×</button>
)}
</div>
)}
<div className="header-actions">
{!isCompose && (
<button
type="button"
className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav('/login')}
>
<Plus size={16} />
{!isMobile && <span></span>}
</button>
)}
<div className="header-action-group">
<button
type="button"
className="header-icon-btn"
onClick={toggle}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname}>
{user.avatar
? <img src={user.avatar} alt="" className="header-user-avatar" />
: <span className="header-user-initial">{userInitial}</span>}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/boards')}></DropdownMenuItem>
<DropdownMenuItem onClick={openAdminDashboard}></DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
</button>
)}
</div>
</div>
</div>
</header>
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
/>
)}
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
{isMobile && !isCompose && (
<div className="mobile-board-bar">
<span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav('/'); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
>{b.name}</span>
))}
</div>
)}
<Suspense fallback={<PageLoader />}>
<Outlet context={{
boardId,
keyword: params.get('keyword') || '',
setBoardId,
boards,
stats,
layoutReady,
refreshBoards,
isMobile,
} satisfies LayoutCtx} />
</Suspense>
</main>
{!isCompose && (
<aside className="aside-panel">
<RightPanel
hot={hot}
notifications={notifications}
online={online}
onPostClick={(id) => nav(`/post/${id}`)}
/>
</aside>
)}
</div>
</div>
</div>
</div>
);
}
export type LayoutCtx = {
boardId: number;
keyword: string;
setBoardId: (id: number) => void;
boards: Board[];
stats: ForumStats | null;
layoutReady: boolean;
refreshBoards: () => void;
isMobile: boolean;
};

View File

@@ -0,0 +1,8 @@
import { toast } from 'sonner';
/** 统一 Toast 入口,替代 Arco Message */
export const notify = {
success: (message: string) => toast.success(message),
error: (message: string) => toast.error(message),
warning: (message: string) => toast.warning(message),
};

View File

@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** 合并 Tailwind class后者覆盖前者冲突项 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

12
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App';
applyTheme(getStoredTheme());
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0),
});
type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Board | null>(null);
const [submitting, setSubmitting] = useState(false);
const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 },
});
const load = () => {
setLoading(true);
api.boards()
.then(d => setBoards(d.boards ?? []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
const openCreate = () => {
setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
setModalOpen(true);
};
const openEdit = (board: Board) => {
setEditing(board);
form.reset({
name: board.name,
description: board.description ?? '',
sort_order: board.sort_order,
});
setModalOpen(true);
};
const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true);
try {
if (editing) {
await api.updateBoard(editing.id, values);
notify.success('板块已更新');
} else {
await api.createBoard(values);
notify.success('板块已创建');
}
setModalOpen(false);
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.deleteBoard(id);
notify.success('板块已删除');
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
</div>
<Button onClick={openCreate}>
<Plus />
</Button>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{boards.map(board => (
<TableRow key={board.id}>
<TableCell>{board.id}</TableCell>
<TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell>
<TableCell><Badge variant="secondary">{board.post_count ?? 0}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(board)}></Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(board.id)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{boards.length === 0 && (
<div className="empty-state">
<p></p>
</div>
)}
</>
)}
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="如:技术交流" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea rows={3} maxLength={500} placeholder="板块说明(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="number" min={0} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button type="submit" loading={submitting}>{editing ? '保存' : '创建'}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
const editId = editIdParam ? Number(editIdParam) : null;
const isEdit = editId !== null && !Number.isNaN(editId);
const [params] = useSearchParams();
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(defaultBoard);
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (isEdit) {
setLoading(true);
Promise.all([api.boards(), api.post(editId!)])
.then(([boardsData, postData]) => {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) {
notify.error('无权编辑此帖子');
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
setTitle(post.title);
setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? ''));
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
nav('/');
})
.finally(() => setLoading(false));
return;
}
api.boards().then(d => {
const list = d.boards ?? [];
setBoards(list);
if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id));
}
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!user) return null;
if (loading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!isEdit && boards.length === 0) {
return (
<div className="compose-page compose-page--empty">
<div className="compose-empty-card">
<div className="compose-empty-icon"></div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
</button>
) : (
<button type="button" className="compose-ghost-btn" onClick={() => nav('/')}>
</button>
)}
</div>
</div>
);
}
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: markdownToHtml(content),
tags: tags.trim(),
};
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
nav(`/post/${res.post_id}`);
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
} finally {
setPublishing(false);
}
};
const currentBoard = boards.find(b => String(b.id) === boardId);
return (
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
<ArrowLeft size={16} />
<span></span>
</button>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
</button>
</div>
</header>
<div className="compose-meta">
{!isEdit ? (
<div className="compose-board-pills">
{boards.map(b => (
<button
key={b.id}
type="button"
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard && (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<div className="compose-tags-field">
<Tag className="compose-tags-icon" size={16} />
<input
type="text"
placeholder="添加标签,逗号分隔"
value={tags}
onChange={e => setTags(e.target.value)}
maxLength={128}
/>
</div>
</div>
<div className="compose-writing">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={256}
/>
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
</div>
)}
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
interface FavItem {
id: number;
post_id: number;
created_at: string;
post?: {
id: number;
title: string;
board?: { name: string };
user?: { nickname: string };
};
}
export default function FavoritesPage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
}, [user, authLoading, nav]);
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
if (!user) return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<h1 className="page-title"></h1>
<p className="page-desc"> {list.length} </p>
{list.length === 0 ? (
<div className="empty-state">
<p></p>
<Button onClick={() => nav('/')}></Button>
</div>
) : (
<div className="content-surface">
{list.map(fav => (
<div
key={fav.id}
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
<div className="post-meta">
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
<span> {formatTime(fav.created_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid';
export default function HomePage() {
const nav = useNavigate();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
setPage(p);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
if (reset) setPosts([]);
} finally {
setLoading(false);
}
}, [boardId, keyword]);
useEffect(() => {
load(1, true);
}, [boardId, keyword, load]);
useEffect(() => {
const fn = () => load(1, true);
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [load]);
const showBoardGrid = !keyword;
return (
<div className="page-wrap">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showBoardGrid && (
<BoardGrid
boards={ctx?.boards ?? []}
loading={!ctx?.layoutReady}
selectedId={boardId}
onSelect={(id) => {
ctx?.setBoardId(id);
nav(id ? `/?board=${id}` : '/');
}}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
</div>
<VirtualPostList
posts={posts}
loading={loading}
hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
/>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type FormValues = z.infer<typeof schema>;
export default function LoginPage() {
const nav = useNavigate();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { username: '', password: '' },
});
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
await api.login(values.username, values.password);
await refresh();
notify.success('登录成功');
nav('/', { replace: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<h1></h1>
<p className="subtitle"></p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="用户名" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="密码" autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<Link to="/register"></Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, Comment } from '../api/types';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
export default function PostDetailPage() {
const { id } = useParams();
const postId = Number(id);
const nav = useNavigate();
const { user, refresh } = useAuth();
const [post, setPost] = useState<PostItem | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
useGlobalWheelScroll(pageRef, !loading && !!post);
const fetchComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
return Array.isArray(comm.comments) ? comm.comments : [];
}, [postId, user]);
const load = async () => {
if (!postId) return;
setLoading(true);
try {
const [detail, commList] = await Promise.all([
api.post(postId),
fetchComments(),
]);
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setComments(commList);
await refresh();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
setReplyTo(null);
load();
}, [postId]);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
}, []);
const handleReplyTo = (comment: Comment) => {
if (replyTo?.id === comment.id) {
setReplyTo(null);
return;
}
setReplyTo(comment);
};
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
useLayoutEffect(() => {
if (!replyTo) return;
const el = document.getElementById(`reply-box-${replyTo.id}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [replyTo?.id]);
useEffect(() => {
return () => clearTimeout(highlightTimer.current);
}, []);
const handleLike = async () => {
if (!user) { nav('/login'); return; }
try {
const r = await api.like(postId);
setLiked(r.liked);
setPost(p => p ? { ...p, like_count: r.like_count } : p);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleFavorite = async () => {
if (!user) { nav('/login'); return; }
try {
const r = await api.favorite(postId);
setFavorited(r.favorited);
notify.success(r.favorited ? '已收藏' : '已取消收藏');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleSubmitComment = async (data: CommentSubmitData) => {
setSubmitting(true);
try {
const r = await api.addComment(postId, {
content: data.content,
replyTo: replyTo?.id,
guestNick: data.guestNick,
guestEmail: data.guestEmail,
guestUrl: data.guestUrl,
isPrivate: data.isPrivate,
});
if (!user) addMyCommentId(r.id);
setReplyTo(null);
setSubmitCount(c => c + 1);
notify.success('评论成功');
setComments(await fetchComments());
setTimeout(() => jumpToFloor(r.floor), 100);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '评论失败');
} finally {
setSubmitting(false);
}
};
const commentBoxProps = {
user,
submitting,
submitCount,
onSubmit: handleSubmitComment,
onCancelReply: () => setReplyTo(null),
};
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) return (
<div className="empty-state">
<p></p>
<Button variant="outline" onClick={() => nav('/')}></Button>
</div>
);
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
return (
<div className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
<div className="post-detail-nav">
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
{post.board && (
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge>
)}
</div>
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.title}
</h1>
<div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : authorInitial}
</div>
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<span className="post-detail-meta-line">
{formatTime(post.created_at)} · {post.view_count}
</span>
</div>
</div>
</div>
{tags.length > 0 && (
<div className="post-detail-tags">
{tags.map(t => <Badge key={t} variant="secondary">{t}</Badge>)}
</div>
)}
<PostContent html={post.content || ''} isLoggedIn={!!user} />
<div className="post-detail-actions">
<Button variant={liked ? 'default' : 'outline'} size="sm" onClick={handleLike}>
<ThumbsUp />
{post.like_count}
</Button>
<Button variant={favorited ? 'default' : 'outline'} size="sm" onClick={handleFavorite}>
<Star />
{favorited ? '已收藏' : '收藏'}
</Button>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
<Pencil />
</Button>
)}
</div>
</div>
<div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar">
<span className="comment-section-title"></span>
<span className="comment-section-count">{comments.length} </span>
</div>
{!replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}>
<CommentBox {...commentBoxProps} />
</div>
)}
<div className="comment-list-area">
{comments.length === 0 && !replyTo ? (
<div className="comment-empty">
<div className="comment-empty-icon">💬</div>
<p></p>
</div>
) : (
<CommentThreadList
comments={comments}
highlightFloor={highlightFloor}
replyToId={replyTo?.id ?? null}
onReply={handleReplyTo}
onCancelReply={() => setReplyTo(null)}
renderReplyBox={(c) => (
<CommentBox
key={c.id}
{...commentBoxProps}
replyTo={c}
inline
/>
)}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { openAdminDashboard } from '../utils/admin';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const pwdSchema = z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(6, '新密码至少 6 位'),
confirm_password: z.string().min(1, '请确认新密码'),
}).refine(d => d.new_password === d.confirm_password, {
message: '两次输入的新密码不一致',
path: ['confirm_password'],
});
type NickValues = z.infer<typeof nickSchema>;
type PwdValues = z.infer<typeof pwdSchema>;
export default function ProfilePage() {
const nav = useNavigate();
const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
useEffect(() => {
if (!authLoading && !user) {
nav('/login');
}
}, [authLoading, user, nav]);
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user) return null;
const onUpdateNick = async (values: NickValues) => {
setNickLoading(true);
try {
await api.updateNickname(values.nickname);
await refresh();
notify.success('昵称已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setNickLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
await api.updatePassword(values.old_password, values.new_password);
notify.success('密码已修改,请重新登录');
pwdForm.reset();
await api.logout();
nav('/login');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '修改失败');
} finally {
setPwdLoading(false);
}
};
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
notify.error('头像不能超过 2MB');
return;
}
setAvatarLoading(true);
try {
await api.uploadAvatar(file);
await refresh();
notify.success('头像已更新');
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败');
} finally {
setAvatarLoading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
return (
<div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<div className="profile-header">
<div className="profile-avatar-lg">
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
</div>
<div>
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
</div>
</div>
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/boards')}>
<Settings />
</Button>
<Button onClick={openAdminDashboard}>
<LayoutDashboard />
</Button>
</div>
</div>
)}
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={nickLoading}></Button>
</form>
</Form>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
JPGPNGGIFWebP 2MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={onAvatarChange}
/>
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
</Button>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
nickname: z.string().optional(),
password: z.string().min(6, '密码至少 6 位'),
});
type FormValues = z.infer<typeof schema>;
export default function RegisterPage() {
const nav = useNavigate();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { username: '', nickname: '', password: '' },
});
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
await api.register(values.username, values.password, values.nickname || values.username);
await refresh();
notify.success('注册成功');
nav('/', { replace: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<h1></h1>
<p className="subtitle"></p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<Link to="/login"></Link>
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
/** 服务端管理后台地址(非 SPA 路由) */
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
/** 跳转到系统管理后台 */
export function openAdminDashboard() {
window.location.href = ADMIN_DASHBOARD_URL;
}

View File

@@ -0,0 +1,10 @@
/** 板块图标背景色 */
const BOARD_COLORS = ['#2d8a55', '#3498db', '#9b59b6', '#e67e22', '#1abc9c', '#e74c3c', '#34495e'];
export function boardColor(id: number) {
return BOARD_COLORS[id % BOARD_COLORS.length];
}
export function boardInitial(name: string) {
return (name?.trim()?.[0] || '?').toUpperCase();
}

View File

@@ -0,0 +1,55 @@
import type { Comment } from '../api/types';
export interface CommentNode {
comment: Comment;
children: CommentNode[];
}
/** 评论显示昵称 */
export function commentNick(c: Comment): string {
if (c.user?.nickname) return c.user.nickname;
if (c.guest_nick) return c.guest_nick;
return '游客';
}
/** 评论头像首字 */
export function commentInitial(c: Comment): string {
return commentNick(c)[0] || '?';
}
/** 是否为游客评论 */
export function isGuestComment(c: Comment): boolean {
return !c.user_id || c.user_id === 0;
}
/** 构建嵌套评论树(按 reply_to */
export function buildCommentTree(comments: Comment[]): CommentNode[] {
const map = new Map<number, CommentNode>();
const roots: CommentNode[] = [];
for (const c of comments) {
map.set(c.id, { comment: c, children: [] });
}
for (const c of comments) {
const node = map.get(c.id)!;
if (c.reply_to && map.has(c.reply_to)) {
map.get(c.reply_to)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
/** 评论日期:当年显示 MM月DD日 */
export function formatCommentDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
if (d.getFullYear() === now.getFullYear()) {
return `${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}

View File

@@ -0,0 +1,20 @@
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// innerHTML 解析会吞掉换行,需转为 <br>
.replace(/\n/g, '<br>')
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
}
export function formatTime(iso: string) {
const d = new Date(iso);
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

View File

@@ -0,0 +1,21 @@
/** 评论表情列表OwO 面板) */
export const EMOJI_LIST = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁',
'😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩',
'🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴',
'😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇',
'🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐', '🤓', '😈',
'👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹',
'😻', '😼', '😽', '🙀', '😿', '😾', '👋', '🤚', '🖐', '✋',
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏',
'🙌', '👐', '🤲', '🤝', '🙏', '💪', '🦾', '🦿', '🦵', '🦶',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️',
'✨', '⭐', '🌟', '💫', '🔥', '💥', '💯', '✅', '❌', '❓',
'❗', '💢', '💤', '💦', '🎉', '🎊', '🎁', '🏆', '🥇', '🥈',
];

View File

@@ -0,0 +1,43 @@
const GUEST_NICK_KEY = 'j13_guest_nick';
const GUEST_EMAIL_KEY = 'j13_guest_email';
const GUEST_URL_KEY = 'j13_guest_url';
const MY_COMMENT_IDS_KEY = 'j13_my_comment_ids';
export interface GuestInfo {
nick: string;
email: string;
url: string;
}
export function loadGuestInfo(): GuestInfo {
return {
nick: localStorage.getItem(GUEST_NICK_KEY) || '',
email: localStorage.getItem(GUEST_EMAIL_KEY) || '',
url: localStorage.getItem(GUEST_URL_KEY) || '',
};
}
export function saveGuestInfo(info: GuestInfo) {
localStorage.setItem(GUEST_NICK_KEY, info.nick);
localStorage.setItem(GUEST_EMAIL_KEY, info.email);
localStorage.setItem(GUEST_URL_KEY, info.url);
}
export function loadMyCommentIds(): number[] {
try {
const raw = localStorage.getItem(MY_COMMENT_IDS_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter((n) => typeof n === 'number') : [];
} catch {
return [];
}
}
export function addMyCommentId(id: number) {
const ids = loadMyCommentIds();
if (!ids.includes(id)) {
ids.push(id);
localStorage.setItem(MY_COMMENT_IDS_KEY, JSON.stringify(ids.slice(-200)));
}
}

View File

@@ -0,0 +1,39 @@
import type { Board, ForumStats } from '../api/types';
const BOARDS_KEY = 'j13-cache-boards';
const STATS_KEY = 'j13-cache-stats';
function readJson<T>(key: string): T | null {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function writeJson(key: string, value: unknown) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// sessionStorage 不可用时忽略
}
}
/** 读取缓存的板块列表,用于刷新时立即还原布局 */
export function getCachedBoards(): Board[] {
return readJson<Board[]>(BOARDS_KEY) ?? [];
}
/** 读取缓存的论坛统计,用于刷新时立即还原 FeedHeader 高度 */
export function getCachedStats(): ForumStats | null {
return readJson<ForumStats>(STATS_KEY);
}
export function setCachedBoards(boards: Board[]) {
writeJson(BOARDS_KEY, boards);
}
export function setCachedStats(stats: ForumStats) {
writeJson(STATS_KEY, stats);
}

View File

@@ -0,0 +1,130 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
marked.setOptions({ breaks: true, gfm: true });
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
type MdPart = { type: 'text' | 'members'; content: string };
/** 拆分普通 Markdown 与 :::members 区块 */
function splitMembersBlocks(md: string): MdPart[] {
const parts: MdPart[] = [];
let lastIndex = 0;
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
let match: RegExpExecArray | null;
while ((match = re.exec(md)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
}
parts.push({ type: 'members', content: match[1] });
lastIndex = re.lastIndex;
}
if (lastIndex < md.length) {
parts.push({ type: 'text', content: md.slice(lastIndex) });
}
if (parts.length === 0) {
parts.push({ type: 'text', content: md });
}
return parts;
}
/** Markdown 转 HTML用于预览与发布 */
export function markdownToHtml(md: string): string {
const parts = splitMembersBlocks(md);
const html = parts.map(part => {
if (part.type === 'members') {
const inner = marked.parse(part.content.trim()) as string;
return `<members-only>${inner}</members-only>`;
}
return marked.parse(part.content) as string;
}).join('');
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** HTML 转 Markdown用于编辑已有帖子时回填编辑器 */
export function htmlToMarkdown(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const inner = () => Array.from(el.childNodes).map(walk).join('');
switch (tag) {
case 'members-only': {
if (el.getAttribute('data-locked') === 'true') return '';
const body = el.querySelector('.post-members-only__body');
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
return `:::members\n${content.trim()}\n:::\n\n`;
}
case 'br': return '\n';
case 'p': return inner().trimEnd() + '\n\n';
case 'h1': return `# ${inner().trim()}\n\n`;
case 'h2': return `## ${inner().trim()}\n\n`;
case 'h3': return `### ${inner().trim()}\n\n`;
case 'h4': return `#### ${inner().trim()}\n\n`;
case 'h5': return `##### ${inner().trim()}\n\n`;
case 'h6': return `###### ${inner().trim()}\n\n`;
case 'strong':
case 'b': return `**${inner()}**`;
case 'em':
case 'i': return `*${inner()}*`;
case 'code': return `\`${inner()}\``;
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
case 'img': return `![${el.getAttribute('alt') ?? ''}](${el.getAttribute('src') ?? ''})`;
case 'ul':
return Array.from(el.children)
.map(li => `- ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'ol':
return Array.from(el.children)
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'li': return inner();
case 'blockquote': {
const text = inner().trim().replace(/\n/g, '\n> ');
return `> ${text}\n\n`;
}
case 'hr': return '---\n\n';
case 'div':
if (el.classList.contains('post-members-only__badge')
|| el.classList.contains('post-members-only__gate')
|| el.classList.contains('post-members-only__gate-icon')) {
return '';
}
if (el.classList.contains('post-members-only__body')) {
return inner();
}
return inner();
default: return inner();
}
};
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
}
/** 统计正文字数(不含空白) */
export function countWords(text: string): number {
const t = text.trim();
if (!t) return 0;
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
return cjk + en;
}
/** 编辑器插入用的会员专属区块模板 */
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';

View File

@@ -0,0 +1,85 @@
import DOMPurify from 'dompurify';
/** DOMPurify 配置:允许会员专属自定义标签 */
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
ADD_TAGS: ['members-only'],
ADD_ATTR: ['data-locked', 'data-length'],
};
const VISIBLE_BADGE_HTML = `
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<span>登录可见</span>
</div>`;
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
/** 游客看到的锁定区块:模糊占位 + 登录引导 */
function buildLockedGateHtml(charLength: number): string {
const lineCount = charLength > 0
? Math.min(6, Math.max(3, Math.ceil(charLength / 42)))
: 4;
const lines = Array.from({ length: lineCount }, (_, i) => {
const mod = i % 3;
const widthClass = mod === 1 ? ' post-members-only__preview-line--medium'
: mod === 2 ? ' post-members-only__preview-line--short' : '';
return `<div class="post-members-only__preview-line${widthClass}"></div>`;
}).join('');
const lengthHint = charLength > 0
? `${charLength} 字的`
: '一段';
return `
<div class="post-members-only__locked-wrap">
<div class="post-members-only__badge post-members-only__badge--locked">
<span class="post-members-only__badge-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
<span>登录可见</span>
</div>
<div class="post-members-only__preview" aria-hidden="true">
${lines}
</div>
<div class="post-members-only__gate">
<div class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
</div>
</div>`;
}
/** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('members-only').forEach(el => {
const locked = el.getAttribute('data-locked') === 'true' || !isLoggedIn;
if (locked) {
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
el.setAttribute('data-locked', 'true');
el.className = 'post-members-only post-members-only--locked';
el.innerHTML = buildLockedGateHtml(charLength);
return;
}
const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML
?? Array.from(el.childNodes)
.filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge')))
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
.join('');
el.className = 'post-members-only post-members-only--visible';
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
});
return doc.body.innerHTML;
}

View File

@@ -0,0 +1,16 @@
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'j13-theme';
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
export function getStoredTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'dark' ? 'dark' : 'light';
}
export function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
localStorage.setItem(STORAGE_KEY, theme);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,55 @@
import type { Config } from 'tailwindcss';
import animate from 'tailwindcss-animate';
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['var(--j13-font-family)'],
},
},
},
plugins: [animate],
} satisfies Config;

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

46
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖
const apiPort = process.env.VITE_API_PORT || '3000';
const apiTarget = `http://localhost:${apiPort}`;
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
base: '/',
build: {
outDir: '../embed_static/static/spa',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('marked') || id.includes('dompurify')) return 'markdown-vendor';
if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor';
if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor';
if (
id.includes('react-dom')
|| id.includes('react-router')
|| /[/\\]node_modules[/\\]react[/\\]/.test(id)
) {
return 'react-vendor';
}
},
},
},
},
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': apiTarget,
'/uploads': apiTarget,
},
},
});