diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b0da037 --- /dev/null +++ b/web/index.html @@ -0,0 +1,23 @@ + + +
+ + + + + 的文本;不注入任何 HTML
+ return (
+
+ )
+}
+
+const MarkdownViewer = memo(function MarkdownViewer({ source }: { source: string }) {
+ return (
+
+ (
+
+ ),
+ pre: CodeBlock,
+ // 任务列表 checkbox 只读
+ input: ({ node: _node, ...props }) => ,
+ }}
+ >
+ {source}
+
+
+ )
+})
+
+export default MarkdownViewer
diff --git a/web/src/features/site.ts b/web/src/features/site.ts
new file mode 100644
index 0000000..2707332
--- /dev/null
+++ b/web/src/features/site.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { api } from '../lib/api'
+import type { SiteSettings } from '../lib/api'
+
+export function useSiteTitle() {
+ return useQuery({
+ queryKey: ['site'],
+ queryFn: () => api('/api/site'),
+ staleTime: 5 * 60_000,
+ })
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..2332bc4
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,30 @@
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+
+/* 暗色主题:根元素 class 切换(§8.2) */
+@custom-variant dark (&:where(.dark, .dark *));
+
+@theme {
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+ "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+html {
+ @apply antialiased;
+}
+
+body {
+ @apply bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100;
+}
+
+/* CodeMirror 等宽 + 边框自适应暗色 */
+.cm-editor {
+ @apply h-full bg-white dark:bg-zinc-950;
+}
+.cm-editor.cm-focused {
+ outline: none !important;
+}
+.cm-gutters {
+ @apply border-r border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900;
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..1b0d392
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,111 @@
+// API 客户端:统一包络解析、错误对象、CSRF 头注入(§8.2 数据与状态)。
+// CSRF token 仅存内存(模块变量),刷新后经 /api/me 重取,禁止 localStorage。
+
+export class ApiError extends Error {
+ status: number
+ code: string
+ field?: string
+
+ constructor(status: number, code: string, message: string, field?: string) {
+ super(message)
+ this.status = status
+ this.code = code
+ this.field = field
+ }
+}
+
+// 模块级内存态 CSRF token(不持久化)
+let csrfToken: string | null = null
+
+export function setCsrfToken(token: string | null) {
+ csrfToken = token
+}
+
+export function getCsrfToken(): string | null {
+ return csrfToken
+}
+
+interface Envelope {
+ data?: T
+ error?: { code: string; message: string; field?: string }
+}
+
+export async function api(
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ const method = (options.method ?? 'GET').toUpperCase()
+ const headers = new Headers(options.headers)
+ if (options.body && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json')
+ }
+ if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
+ headers.set('X-CSRF-Token', csrfToken)
+ }
+ const res = await fetch(path, { ...options, method, headers, credentials: 'same-origin' })
+
+ let body: Envelope | null = null
+ try {
+ body = (await res.json()) as Envelope
+ } catch {
+ /* 空/非 JSON 响应 */
+ }
+
+ if (!res.ok) {
+ const err = body?.error
+ if (res.status === 401) {
+ // 登录态失效:清内存态,统一由 AuthContext 跳登录页
+ setCsrfToken(null)
+ }
+ throw new ApiError(
+ res.status,
+ err?.code ?? 'unknown',
+ err?.message ?? `请求失败(${res.status})`,
+ err?.field,
+ )
+ }
+ return body!.data as T
+}
+
+export function jsonBody(payload: unknown): RequestInit {
+ return { body: JSON.stringify(payload) }
+}
+
+// ---- 领域类型 ----
+
+export interface SiteSettings {
+ site_title: string
+ site_desc: string
+ page_size: number
+}
+
+export interface NoteItem {
+ id: number
+ slug: string
+ title: string
+ summary: string
+ status?: string
+ tags: string[]
+ pinned: boolean
+ deleted_at?: number | null
+ created_at: number
+ updated_at: number
+}
+
+export interface NoteDetail extends NoteItem {
+ content: string
+ prev: { slug: string; title: string } | null
+ next: { slug: string; title: string } | null
+}
+
+export interface NoteList {
+ items: NoteItem[]
+ page: number
+ page_size: number
+ total: number
+}
+
+export interface TagCount {
+ name: string
+ count: number
+}
diff --git a/web/src/lib/auth.tsx b/web/src/lib/auth.tsx
new file mode 100644
index 0000000..2e816ac
--- /dev/null
+++ b/web/src/lib/auth.tsx
@@ -0,0 +1,76 @@
+import { createContext, useCallback, useContext, useEffect, useState } from 'react'
+import type { ReactNode } from 'react'
+import { api, setCsrfToken } from './api'
+
+interface Me {
+ authenticated: boolean
+ csrf_token?: string
+}
+
+interface AuthState {
+ loading: boolean
+ authenticated: boolean
+ login: (password: string) => Promise
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+// AuthContext:会话态 + CSRF 内存态(§8.2)。
+// 刷新页面后由 /api/me 重取 CSRF token;明确禁止 localStorage 持久化。
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [loading, setLoading] = useState(true)
+ const [authenticated, setAuthenticated] = useState(false)
+
+ const refresh = useCallback(async () => {
+ try {
+ const me = await api('/api/me')
+ setAuthenticated(me.authenticated)
+ if (me.authenticated && me.csrf_token) {
+ setCsrfToken(me.csrf_token)
+ } else {
+ setCsrfToken(null)
+ }
+ } catch {
+ setAuthenticated(false)
+ setCsrfToken(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const login = useCallback(
+ async (password: string) => {
+ const res = await api<{ csrf_token: string }>('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ password }),
+ })
+ setCsrfToken(res.csrf_token)
+ setAuthenticated(true)
+ },
+ [],
+ )
+
+ const logout = useCallback(async () => {
+ await api('/api/auth/logout', { method: 'POST' })
+ setCsrfToken(null)
+ setAuthenticated(false)
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth(): AuthState {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用')
+ return ctx
+}
diff --git a/web/src/lib/sanitize.ts b/web/src/lib/sanitize.ts
new file mode 100644
index 0000000..bdd2495
--- /dev/null
+++ b/web/src/lib/sanitize.ts
@@ -0,0 +1,41 @@
+// rehype-sanitize 白名单 schema(§8.2 渲染管线):
+// - 默认 GitHub schema 之上扩展:任务列表 checkbox 所需属性、
+// highlight.js 纯 class 高亮所需 className 白名单
+// - 禁 script/iframe/style 属性/事件属性;链接协议白名单(javascript: 被剥除)
+// - 链接 target/rel 由 组件强制(schema 不支持条件属性)
+import { defaultSchema } from 'rehype-sanitize'
+import rehypeSanitize from 'rehype-sanitize'
+import type { Schema } from 'hast-util-sanitize'
+import type { Pluggable } from 'unified'
+
+export const sanitizeSchema: Schema = {
+ ...defaultSchema,
+ tagNames: [
+ ...(defaultSchema.tagNames ?? []),
+ // 允许任务列表所需的 input(GitHub schema 已含,显式声明以防上游变化)
+ 'input',
+ ],
+ attributes: {
+ ...defaultSchema.attributes,
+ // 任务列表 checkbox:默认 schema 已含 ['type','checkbox'] 与 ['disabled',true],
+ // 显式补 'checked'
+ input: [
+ ...(defaultSchema.attributes?.input ?? []),
+ ['checked', true],
+ ],
+ // highlight.js 输出纯 class(零内联样式,§3.2)
+ code: [
+ ...(defaultSchema.attributes?.code ?? []),
+ ['className', /^language-./, 'hljs'],
+ ],
+ span: [
+ ...(defaultSchema.attributes?.span ?? []),
+ ['className', /^hljs(-\w+)?$/],
+ ],
+ },
+ // 危险协议由默认 protocols 白名单(http/https/mailto/irc/xmpp…)剥除
+ clobberPrefix: defaultSchema.clobberPrefix ?? 'user-content-',
+}
+
+// rehypePlugins 元组:[rehypeSanitize, sanitizeSchema]
+export const sanitizePlugin: Pluggable = [rehypeSanitize, sanitizeSchema]
diff --git a/web/src/lib/theme.tsx b/web/src/lib/theme.tsx
new file mode 100644
index 0000000..b7a5389
--- /dev/null
+++ b/web/src/lib/theme.tsx
@@ -0,0 +1,40 @@
+import { createContext, useContext, useEffect, useState } from 'react'
+import type { ReactNode } from 'react'
+
+type ThemeMode = 'light' | 'dark' | 'system'
+
+const ThemeContext = createContext<{ mode: ThemeMode; setMode: (m: ThemeMode) => void } | null>(null)
+
+function apply(mode: ThemeMode) {
+ const dark =
+ mode === 'dark' ||
+ (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
+ document.documentElement.classList.toggle('dark', dark)
+}
+
+// 暗色主题:根元素 class + localStorage 持久化 + 跟随系统(§8.2)。
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [mode, setMode] = useState(() => {
+ const saved = localStorage.getItem('pn-theme')
+ return saved === 'dark' || saved === 'light' || saved === 'system' ? saved : 'system'
+ })
+
+ useEffect(() => {
+ localStorage.setItem('pn-theme', mode)
+ apply(mode)
+ if (mode === 'system') {
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
+ const onChange = () => apply('system')
+ mq.addEventListener('change', onChange)
+ return () => mq.removeEventListener('change', onChange)
+ }
+ }, [mode])
+
+ return {children}
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用')
+ return ctx
+}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 0000000..3cd155f
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,28 @@
+export function cn(...parts: Array): string {
+ return parts.filter(Boolean).join(' ')
+}
+
+export function formatDate(unix: number): string {
+ return new Date(unix * 1000).toLocaleDateString('zh-CN', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })
+}
+
+export function formatDateTime(unix: number): string {
+ return new Date(unix * 1000).toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+// 前端 slug 预览:与服务端 slugify 同规则(ASCII 部分),纯非 ASCII 时提示将自动生成
+export function slugPreview(title: string, date: Date): string {
+ const t = title.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^-+|-+$/g, '')
+ if (t === '') return `post-${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`
+ return t.slice(0, 80).replace(/-+$/g, '')
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..f6753af
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,32 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { BrowserRouter } from 'react-router'
+import './index.css'
+import App from './App'
+import { AuthProvider } from './lib/auth'
+import { ThemeProvider } from './lib/theme'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
+ },
+ },
+})
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/web/src/pages/AdminEdit.tsx b/web/src/pages/AdminEdit.tsx
new file mode 100644
index 0000000..761bb13
--- /dev/null
+++ b/web/src/pages/AdminEdit.tsx
@@ -0,0 +1,297 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
+import { api, ApiError } from '../lib/api'
+import type { NoteItem } from '../lib/api'
+import Editor from '../components/Editor'
+import MarkdownViewer from '../components/MarkdownViewer'
+import { slugPreview } from '../lib/utils'
+
+interface NoteFull extends NoteItem {
+ content: string
+ prev?: unknown
+ next?: unknown
+}
+
+type Form = {
+ title: string
+ slug: string
+ summary: string
+ content: string
+ status: 'public' | 'private'
+ tags: string
+ pinned: boolean
+}
+
+const emptyForm: Form = {
+ title: '',
+ slug: '',
+ summary: '',
+ content: '',
+ status: 'private',
+ tags: '',
+ pinned: false,
+}
+
+export default function AdminEdit() {
+ const { id } = useParams()
+ const noteId = id ? Number(id) : null
+ const isNew = noteId === null
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+
+ const { data: existing, isLoading } = useQuery({
+ queryKey: ['admin', 'note', noteId],
+ queryFn: () => api(`/api/admin/notes/${noteId}`),
+ enabled: !isNew,
+ })
+
+ const [form, setForm] = useState