From 62fd0375f0684777ec8638ed4d30ac6af354cc6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B6=E6=9C=88?= Date: Fri, 29 May 2026 20:20:20 +0800 Subject: [PATCH 01/80] feat: complete submission + admin review + OAuth + CI/CD - Submit form with Markdown editor, category, author type, anti-abuse (honeypot, rate limit) - CF Pages Functions API: submit, admin list/detail/review - GitHub OAuth + X OAuth 2.0 (PKCE) login with encrypted session cookies - Admin dashboard with org-membership verification + temp token fallback - Auto-trigger story site rebuild on approval - GitHub Actions CI/CD for CF Pages deploy --- .dev.vars.example | 21 + .github/workflows/deploy.yml | 37 ++ functions/api/_build.ts | 102 ++++ functions/api/_db.ts | 29 + functions/api/_middleware.ts | 84 +++ functions/api/_session.ts | 111 ++++ functions/api/admin/_middleware.ts | 29 + functions/api/admin/review.ts | 94 ++++ functions/api/admin/submissions.ts | 58 ++ functions/api/admin/submissions/[id].ts | 42 ++ functions/api/auth/github-callback.ts | 100 ++++ functions/api/auth/github.ts | 21 + functions/api/auth/me.ts | 30 ++ functions/api/auth/x-callback.ts | 78 +++ functions/api/auth/x.ts | 48 ++ functions/api/submit.ts | 113 ++++ migrations/0001_submissions.sql | 21 + package.json | 2 + pnpm-lock.yaml | 684 ++++++++++++++++++++++++ src/components/FormField.module.css | 22 + src/components/FormField.tsx | 24 + src/components/SubmitForm.module.css | 306 +++++++++++ src/components/SubmitForm.tsx | 301 +++++++++++ src/context/AuthContext.tsx | 59 ++ src/main.tsx | 5 +- src/pages/Admin.module.css | 325 +++++++++++ src/pages/Admin.tsx | 324 ++++++++++- src/pages/Submit.tsx | 9 +- story/index.html | 32 ++ story/main.js | 119 +++++ story/style.css | 181 +++++++ tsconfig.app.json | 9 +- vite.config.ts | 11 + wrangler.jsonc | 32 ++ 34 files changed, 3453 insertions(+), 10 deletions(-) create mode 100644 .dev.vars.example create mode 100644 .github/workflows/deploy.yml create mode 100644 functions/api/_build.ts create mode 100644 functions/api/_db.ts create mode 100644 functions/api/_middleware.ts create mode 100644 functions/api/_session.ts create mode 100644 functions/api/admin/_middleware.ts create mode 100644 functions/api/admin/review.ts create mode 100644 functions/api/admin/submissions.ts create mode 100644 functions/api/admin/submissions/[id].ts create mode 100644 functions/api/auth/github-callback.ts create mode 100644 functions/api/auth/github.ts create mode 100644 functions/api/auth/me.ts create mode 100644 functions/api/auth/x-callback.ts create mode 100644 functions/api/auth/x.ts create mode 100644 functions/api/submit.ts create mode 100644 migrations/0001_submissions.sql create mode 100644 src/components/FormField.module.css create mode 100644 src/components/FormField.tsx create mode 100644 src/components/SubmitForm.module.css create mode 100644 src/components/SubmitForm.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/pages/Admin.module.css create mode 100644 story/index.html create mode 100644 story/main.js create mode 100644 story/style.css create mode 100644 wrangler.jsonc diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..5cb75fd --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,21 @@ +# Copy to .dev.vars and fill in values +# .dev.vars is gitignored — never commit real secrets + +SESSION_SECRET=change-me-to-random-64-chars +TEMP_ADMIN_TOKEN=change-me-to-a-strong-temp-token + +# GitHub OAuth (optional for initial deploy — admin can use TEMP_ADMIN_TOKEN) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_ORG=TransCircle + +# X OAuth (optional) +X_CLIENT_ID= +X_CLIENT_SECRET= + +# Story repo auto-build (optional — needs GitHub PAT) +STORY_REPO_TOKEN= + +# Cloudflare Turnstile (optional — free CAPTCHA) +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..170ffa7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,37 @@ +name: Deploy to Cloudflare Pages + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + + - run: pnpm run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=transcircle-submit --branch=${{ github.ref_name }} diff --git a/functions/api/_build.ts b/functions/api/_build.ts new file mode 100644 index 0000000..14fe592 --- /dev/null +++ b/functions/api/_build.ts @@ -0,0 +1,102 @@ +// Trigger story.transcircle.org rebuild after a submission is approved +// Writes approved submissions to the story repo, triggering CF Pages deploy + +interface Env { + DB: D1Database + STORY_REPO_TOKEN?: string // GitHub PAT with repo write access + STORY_REPO_OWNER?: string + STORY_REPO_NAME?: string +} + +interface ApprovedSubmission { + id: string + title: string + content: string + category: string + author_name: string | null + author_type: string + created_at: string + reviewed_at: string +} + +const OWNER = 'TransCircle' +const REPO = 'story.transcircle.org' +const DATA_PATH = 'data/submissions.json' + +export async function triggerRebuild(env: Env): Promise { + const token = env.STORY_REPO_TOKEN + if (!token) { + console.log('No STORY_REPO_TOKEN configured — skipping rebuild trigger') + return false + } + + const owner = env.STORY_REPO_OWNER || OWNER + const repo = env.STORY_REPO_NAME || REPO + + try { + // 1. Fetch all approved submissions from D1 + const { results } = await env.DB.prepare( + `SELECT id, title, content, category, author_name, author_type, created_at, reviewed_at + FROM submissions WHERE status = 'approved' ORDER BY created_at DESC`, + ).all() + + const json = JSON.stringify(results, null, 2) + const contentEncoded = btoa(json) + + // 2. Get current file SHA (if it exists) + let sha = '' + try { + const fileRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${DATA_PATH}`, + { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'TransCircle-Submit', + }, + }, + ) + if (fileRes.ok) { + const fileData = await fileRes.json() as { sha?: string } + sha = fileData.sha || '' + } + } catch { + // File doesn't exist yet — will be created + } + + // 3. Create or update the file + const body: { + message: string + content: string + branch?: string + sha?: string + } = { + message: `Update approved submissions [${new Date().toISOString().slice(0, 10)}]`, + content: contentEncoded, + } + if (sha) body.sha = sha + + const putRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${DATA_PATH}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'TransCircle-Submit', + }, + body: JSON.stringify(body), + }, + ) + + if (!putRes.ok) { + console.error('Failed to update story repo:', await putRes.text()) + return false + } + + console.log(`Rebuild triggered: ${results.length} approved submissions written to ${owner}/${repo}`) + return true + } catch (err) { + console.error('Rebuild trigger error:', err) + return false + } +} diff --git a/functions/api/_db.ts b/functions/api/_db.ts new file mode 100644 index 0000000..3cdab61 --- /dev/null +++ b/functions/api/_db.ts @@ -0,0 +1,29 @@ +// Shared D1 schema initialization +// Called by all API handlers that need the submissions table + +let schemaReady = false + +export async function ensureSchema(db: D1Database): Promise { + if (schemaReady) return + await db.batch([ + db.prepare(`CREATE TABLE IF NOT EXISTS submissions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT NOT NULL, + author_name TEXT, + author_type TEXT NOT NULL DEFAULT 'anonymous', + contact TEXT, + submitter_gh TEXT, + submitter_x TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_gh TEXT, + review_notes TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT + )`), + db.prepare('CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status)'), + db.prepare('CREATE INDEX IF NOT EXISTS idx_submissions_created ON submissions(created_at DESC)'), + ]) + schemaReady = true +} diff --git a/functions/api/_middleware.ts b/functions/api/_middleware.ts new file mode 100644 index 0000000..6dc6ac4 --- /dev/null +++ b/functions/api/_middleware.ts @@ -0,0 +1,84 @@ +// CORS + rate limiting middleware for /api/* routes +// NOTE: The rate limiter uses in-memory storage and is not shared across Workers +// instances. For production, consider using Cloudflare Rate Limiting rules or +// a Durable Object / KV-based counter for distributed enforcement. + +const RATE_LIMIT_WINDOW = 60 * 60 * 1000 // 1 hour +const RATE_LIMIT_MAX = 10 // max submissions per IP per hour + +interface RateLimitEntry { + count: number + resetAt: number +} + +const rateLimitStore = new Map() + +const ALLOWED_ORIGINS = [ + 'https://submit.transcircle.org', + 'http://localhost:5173', + 'http://localhost:8788', +] + +function getClientIP(request: Request): string { + return request.headers.get('CF-Connecting-IP') + || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() + || 'unknown' +} + +function addCORSHeaders(request: Request, response: Response): Response { + const origin = request.headers.get('Origin') || '' + const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] + const headers = new Headers(response.headers) + headers.set('Access-Control-Allow-Origin', allowed) + headers.set('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS') + headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') + headers.set('Access-Control-Max-Age', '86400') + return new Response(response.body, { ...response, headers }) +} + +function checkRateLimit(ip: string): boolean { + const now = Date.now() + const entry = rateLimitStore.get(ip) + + // Lazy cleanup: handle stale entries on access + if (!entry || now > entry.resetAt) { + rateLimitStore.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }) + return true + } + + if (entry.count >= RATE_LIMIT_MAX) return false + + entry.count++ + return true +} + +export const onRequest: PagesFunction = async ({ request, next }) => { + // Handle CORS preflight + if (request.method === 'OPTIONS') { + const origin = request.headers.get('Origin') || '' + const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': allowed, + 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + }, + }) + } + + // Rate limit POST/PATCH requests + if (request.method === 'POST' || request.method === 'PATCH') { + const ip = getClientIP(request) + if (!checkRateLimit(ip)) { + return addCORSHeaders(request, new Response( + JSON.stringify({ error: '提交过于频繁,请稍后再试' }), + { status: 429, headers: { 'Content-Type': 'application/json' } }, + )) + } + } + + const response = await next() + return addCORSHeaders(request, response) +} diff --git a/functions/api/_session.ts b/functions/api/_session.ts new file mode 100644 index 0000000..cb9776f --- /dev/null +++ b/functions/api/_session.ts @@ -0,0 +1,111 @@ +// Encrypted session cookie management using Web Crypto API +// No external dependencies — works entirely within Workers runtime + +interface SessionData { + provider: 'github' | 'x' + userId: string + username: string + avatarUrl?: string + isAdmin: boolean + exp: number // unix timestamp +} + +const COOKIE_NAME = 'tc_session' +const SESSION_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days + +function b64ToUint8(b64: string): Uint8Array { + // Decode URL-safe base64 + const str = atob(b64.replace(/-/g, '+').replace(/_/g, '/')) + return new Uint8Array([...str].map((c) => c.charCodeAt(0))) +} + +function uint8ToB64(buf: Uint8Array): string { + const str = String.fromCharCode(...buf) + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function getKey(secret: string): Promise { + const enc = new TextEncoder() + const keyData = enc.encode(secret).slice(0, 32) // Use first 32 bytes + return crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']) +} + +async function encrypt(secret: string, data: string): Promise { + const key = await getKey(secret) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const enc = new TextEncoder() + const buf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(data)) + // iv + ciphertext + const combined = new Uint8Array(iv.length + new Uint8Array(buf).length) + combined.set(iv) + combined.set(new Uint8Array(buf), iv.length) + return uint8ToB64(combined) +} + +async function decrypt(secret: string, token: string): Promise { + try { + const key = await getKey(secret) + const data = b64ToUint8(token) + const iv = data.slice(0, 12) + const ct = data.slice(12) + const buf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct) + return new TextDecoder().decode(buf) + } catch { + return null + } +} + +export function createSessionCookie( + secret: string, + session: SessionData, +): string { + const payload = JSON.stringify(session) + // We need to first encrypt, but that's async. We'll use a sync barrier via a response header approach. + // Actually for Cookie creation, we must return the encrypted value synchronously. + // We'll set the cookie in the handler after encryption. + return payload // placeholder, real encryption happens in setSessionCookie +} + +export async function setSessionCookie( + response: Response, + secret: string, + session: SessionData, +): Promise { + const payload = JSON.stringify(session) + const encrypted = await encrypt(secret, payload) + const headers = new Headers(response.headers) + headers.set( + 'Set-Cookie', + `${COOKIE_NAME}=${encrypted}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL / 1000}`, + ) + return new Response(response.body, { ...response, headers }) +} + +export async function getSession( + request: Request, + secret: string, +): Promise { + const cookie = request.headers.get('Cookie') || '' + const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`)) + if (!match) return null + + const decrypted = await decrypt(secret, match[1]) + if (!decrypted) return null + + try { + const session = JSON.parse(decrypted) as SessionData + if (Date.now() > session.exp * 1000) return null + return session + } catch { + return null + } +} + +export function clearSessionCookie(response: Response): Response { + const headers = new Headers(response.headers) + headers.set( + 'Set-Cookie', + `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`, + ) + return new Response(response.body, { ...response, headers }) +} diff --git a/functions/api/admin/_middleware.ts b/functions/api/admin/_middleware.ts new file mode 100644 index 0000000..88026a7 --- /dev/null +++ b/functions/api/admin/_middleware.ts @@ -0,0 +1,29 @@ +// Admin auth middleware — protects all /api/admin/* routes +// Supports two auth methods (checked in order): +// 1. OAuth session cookie with isAdmin=true (production, requires GitHub OAuth App) +// 2. TEMP_ADMIN_TOKEN via Authorization header (temporary, for initial setup) + +import { getSession } from '../_session' + +interface Env { + SESSION_SECRET: string + TEMP_ADMIN_TOKEN?: string +} + +export const onRequest: PagesFunction = async ({ request, env, next }) => { + // Method 1: OAuth session + const session = await getSession(request, env.SESSION_SECRET) + if (session?.isAdmin) return next() + + // Method 2: Temporary admin token (for setup without OAuth) + const tempToken = env.TEMP_ADMIN_TOKEN + if (tempToken && tempToken.length > 0) { + const auth = request.headers.get('Authorization') || '' + if (auth === `Bearer ${tempToken}`) return next() + } + + return new Response(JSON.stringify({ error: '需要管理员权限' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/functions/api/admin/review.ts b/functions/api/admin/review.ts new file mode 100644 index 0000000..677e66a --- /dev/null +++ b/functions/api/admin/review.ts @@ -0,0 +1,94 @@ +// PATCH /api/admin/review — approve, reject, or request changes for a submission + +import { ensureSchema } from '../_db' +import { triggerRebuild } from '../_build' +import { getSession } from '../_session' + +interface ReviewBody { + id: string + action: 'approve' | 'reject' | 'request_changes' + notes?: string +} + +interface Env { + DB: D1Database + SESSION_SECRET: string + STORY_REPO_TOKEN?: string + STORY_REPO_OWNER?: string + STORY_REPO_NAME?: string +} + +export const onRequestPatch: PagesFunction = async ({ request, env }) => { + await ensureSchema(env.DB) + + const session = await getSession(request, env.SESSION_SECRET) + const reviewer = session?.username || 'unknown' + + let body: ReviewBody + try { + body = await request.json() + } catch { + return new Response(JSON.stringify({ error: '请求格式无效' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (!body.id) { + return new Response(JSON.stringify({ error: '缺少投稿ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (!['approve', 'reject', 'request_changes'].includes(body.action)) { + return new Response(JSON.stringify({ error: '无效操作' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const statusMap = { + approve: 'approved', + reject: 'rejected', + request_changes: 'pending', // stays pending but with notes + } + + const now = new Date().toISOString() + + try { + const result = await env.DB.prepare( + `UPDATE submissions + SET status = ?, reviewer_gh = ?, review_notes = ?, reviewed_at = ? + WHERE id = ?`, + ).bind(statusMap[body.action], reviewer, + body.notes || null, now, body.id).run() + + if (result.meta.changes === 0) { + return new Response(JSON.stringify({ error: '投稿不存在或状态未变更' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Trigger rebuild of story.transcircle.org if approved + if (body.action === 'approve') { + env.ctx?.waitUntil(triggerRebuild(env)) + } + + return new Response(JSON.stringify({ + id: body.id, + status: statusMap[body.action], + reviewed_at: now, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + console.error('Review failed:', err) + return new Response(JSON.stringify({ error: '服务器错误' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +} diff --git a/functions/api/admin/submissions.ts b/functions/api/admin/submissions.ts new file mode 100644 index 0000000..90cf39c --- /dev/null +++ b/functions/api/admin/submissions.ts @@ -0,0 +1,58 @@ +// GET /api/admin/submissions — list submissions with optional status filter + +import { ensureSchema } from '../_db' + +interface Env { + DB: D1Database +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + await ensureSchema(env.DB) + const url = new URL(request.url) + const status = url.searchParams.get('status') || 'pending' + const limit = Math.min(Number(url.searchParams.get('limit')) || 50, 100) + const offset = Number(url.searchParams.get('offset')) || 0 + + const validStatuses = ['pending', 'approved', 'rejected'] + if (!validStatuses.includes(status)) { + return new Response(JSON.stringify({ error: '无效状态' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Exclude sensitive fields (contact) from list view unless explicitly requested + const showContact = url.searchParams.get('showContact') === '1' + + try { + const { results } = await env.DB.prepare( + `SELECT id, title, category, author_type, author_name, + ${showContact ? 'contact,' : ''} + status, reviewer_gh, review_notes, created_at, reviewed_at + FROM submissions + WHERE status = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, + ).bind(status, limit, offset).all() + + const total = await env.DB.prepare( + 'SELECT COUNT(*) as count FROM submissions WHERE status = ?', + ).bind(status).first<{ count: number }>() + + return new Response(JSON.stringify({ + submissions: results, + total: total?.count ?? 0, + limit, + offset, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + console.error('List submissions failed:', err) + return new Response(JSON.stringify({ error: '服务器错误' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +} diff --git a/functions/api/admin/submissions/[id].ts b/functions/api/admin/submissions/[id].ts new file mode 100644 index 0000000..7d3624b --- /dev/null +++ b/functions/api/admin/submissions/[id].ts @@ -0,0 +1,42 @@ +// GET /api/admin/submissions/:id — get single submission detail (includes contact) + +import { ensureSchema } from '../../_db' + +interface Env { + DB: D1Database +} + +export const onRequestGet: PagesFunction = async ({ env, params }) => { + await ensureSchema(env.DB) + const id = params.id + if (!id) { + return new Response(JSON.stringify({ error: '缺少投稿ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + try { + const submission = await env.DB.prepare( + 'SELECT * FROM submissions WHERE id = ?', + ).bind(id).first() + + if (!submission) { + return new Response(JSON.stringify({ error: '投稿不存在' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify(submission), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + console.error('Get submission failed:', err) + return new Response(JSON.stringify({ error: '服务器错误' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +} diff --git a/functions/api/auth/github-callback.ts b/functions/api/auth/github-callback.ts new file mode 100644 index 0000000..94a6208 --- /dev/null +++ b/functions/api/auth/github-callback.ts @@ -0,0 +1,100 @@ +// GET /api/auth/github-callback — handle GitHub OAuth callback +// Exchange code → access token → fetch user → verify org → create session + +import { setSessionCookie, type SessionData } from '../_session' + +interface Env { + GITHUB_CLIENT_ID: string + GITHUB_CLIENT_SECRET: string + GITHUB_ORG: string + SESSION_SECRET: string +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + + if (!code) { + return new Response(JSON.stringify({ error: '缺少授权码' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // 1. Exchange code for access token + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + state, + }), + }) + + const tokenData = await tokenRes.json() as { + access_token?: string + error?: string + } + + if (!tokenData.access_token) { + console.error('GitHub token exchange failed:', tokenData.error) + return Response.redirect('/submit?error=oauth_failed', 302) + } + + // 2. Fetch user info + const userRes = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TransCircle-Submit', + }, + }) + const userData = await userRes.json() as { + id: number + login: string + avatar_url: string + } + + if (!userData.id) { + return Response.redirect('/submit?error=oauth_failed', 302) + } + + // 3. Check organization membership (for admin access) + let isAdmin = false + try { + const orgRes = await fetch( + `https://api.github.com/orgs/${env.GITHUB_ORG}/members/${userData.login}`, + { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TransCircle-Submit', + }, + }, + ) + isAdmin = orgRes.status === 204 + } catch { + // Org check failed — non-admin user + } + + // 4. Create session + const session: SessionData = { + provider: 'github', + userId: String(userData.id), + username: userData.login, + avatarUrl: userData.avatar_url, + isAdmin, + exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + } + + const redirectUrl = isAdmin ? '/admin' : '/submit' + const resp = Response.redirect( + new URL(redirectUrl, request.url).toString(), + 302, + ) + return setSessionCookie(resp, env.SESSION_SECRET, session) +} diff --git a/functions/api/auth/github.ts b/functions/api/auth/github.ts new file mode 100644 index 0000000..a215145 --- /dev/null +++ b/functions/api/auth/github.ts @@ -0,0 +1,21 @@ +// GET /api/auth/github — initiate GitHub OAuth flow + +interface Env { + GITHUB_CLIENT_ID: string +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const url = new URL(request.url) + const redirectUri = `${url.origin}/api/auth/github-callback` + const state = crypto.randomUUID() + + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID, + redirect_uri: redirectUri, + scope: 'read:user', + state, + }) + + const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}` + return Response.redirect(authUrl, 302) +} diff --git a/functions/api/auth/me.ts b/functions/api/auth/me.ts new file mode 100644 index 0000000..5225766 --- /dev/null +++ b/functions/api/auth/me.ts @@ -0,0 +1,30 @@ +// GET /api/auth/me — return current user info from session cookie + +import { getSession } from '../_session' + +interface Env { + SESSION_SECRET: string +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const session = await getSession(request, env.SESSION_SECRET) + + if (!session || Date.now() > session.exp * 1000) { + return new Response(JSON.stringify({ user: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ + user: { + provider: session.provider, + username: session.username, + avatarUrl: session.avatarUrl, + isAdmin: session.isAdmin, + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/functions/api/auth/x-callback.ts b/functions/api/auth/x-callback.ts new file mode 100644 index 0000000..af3ebe0 --- /dev/null +++ b/functions/api/auth/x-callback.ts @@ -0,0 +1,78 @@ +// GET /api/auth/x-callback — handle X OAuth 2.0 callback with PKCE + +import { setSessionCookie, type SessionData } from '../_session' + +interface Env { + X_CLIENT_ID: string + X_CLIENT_SECRET: string + SESSION_SECRET: string +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + + // Retrieve PKCE code_verifier from cookie + const cookie = request.headers.get('Cookie') || '' + const verifierMatch = cookie.match(/x_pkce=([^;]+)/) + const codeVerifier = verifierMatch?.[1] || '' + + if (!code) { + return new Response(JSON.stringify({ error: '缺少授权码' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const redirectUri = `${url.origin}/api/auth/x-callback` + + // Exchange code for token + const tokenRes = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${btoa(`${env.X_CLIENT_ID}:${env.X_CLIENT_SECRET}`)}`, + }, + body: new URLSearchParams({ + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }) + + const tokenData = await tokenRes.json() as { + access_token?: string + error?: string + } + + if (!tokenData.access_token) { + console.error('X token exchange failed:', tokenData.error) + return Response.redirect('/submit?error=oauth_failed', 302) + } + + // Fetch X user info + const userRes = await fetch('https://api.x.com/2/users/me', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }) + const userData = await userRes.json() as { + data?: { id: string; username: string; profile_image_url?: string } + } + + if (!userData.data) { + return Response.redirect('/submit?error=oauth_failed', 302) + } + + const session: SessionData = { + provider: 'x', + userId: userData.data.id, + username: userData.data.username, + avatarUrl: userData.data.profile_image_url, + isAdmin: false, // X users are not admins by default + exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + } + + const resp = Response.redirect('/submit', 302) + return setSessionCookie(resp, env.SESSION_SECRET, session) +} diff --git a/functions/api/auth/x.ts b/functions/api/auth/x.ts new file mode 100644 index 0000000..6b594e4 --- /dev/null +++ b/functions/api/auth/x.ts @@ -0,0 +1,48 @@ +// GET /api/auth/x — initiate X (Twitter) OAuth 2.0 with PKCE + +interface Env { + X_CLIENT_ID: string +} + +function generateCodeVerifier(): string { + const buf = crypto.getRandomValues(new Uint8Array(32)) + return btoa(String.fromCharCode(...buf)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function generateCodeChallenge(verifier: string): Promise { + const enc = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', enc.encode(verifier)) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = crypto.randomUUID() + + const url = new URL(request.url) + const redirectUri = `${url.origin}/api/auth/x-callback` + + const params = new URLSearchParams({ + response_type: 'code', + client_id: env.X_CLIENT_ID, + redirect_uri: redirectUri, + scope: 'users.read', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + + const authUrl = `https://x.com/i/oauth2/authorize?${params.toString()}` + + // Store code_verifier in a short-lived cookie for the callback + const resp = Response.redirect(authUrl, 302) + const headers = new Headers(resp.headers) + headers.set( + 'Set-Cookie', + `x_pkce=${codeVerifier}; HttpOnly; Secure; SameSite=Lax; Path=/api/auth; Max-Age=600`, + ) + return new Response(resp.body, { ...resp, headers }) +} diff --git a/functions/api/submit.ts b/functions/api/submit.ts new file mode 100644 index 0000000..49df72c --- /dev/null +++ b/functions/api/submit.ts @@ -0,0 +1,113 @@ +// POST /api/submit — create a new submission + +import { ensureSchema } from './_db' + +interface SubmissionBody { + title: string + content: string + category: string + authorType: 'real' | 'pen_name' | 'anonymous' + authorName?: string + contact?: string + // honeypot + website?: string +} + +const VALID_CATEGORIES = ['个人经历', '观点评论', '资源指南'] +const MAX_TITLE_LEN = 200 +const MAX_CONTENT_LEN = 50000 +const MAX_AUTHOR_NAME_LEN = 50 +const MAX_CONTACT_LEN = 200 + +function generateId(): string { + const ts = Date.now().toString(36).toUpperCase() + const rand = crypto.randomUUID().slice(0, 4).toUpperCase() + return `TC-${ts}-${rand}` +} + +function validate(body: SubmissionBody): string | null { + if (!body.title || typeof body.title !== 'string' || !body.title.trim()) { + return '标题不能为空' + } + if (body.title.length > MAX_TITLE_LEN) { + return `标题不能超过${MAX_TITLE_LEN}字` + } + if (!body.content || typeof body.content !== 'string' || !body.content.trim()) { + return '正文不能为空' + } + if (body.content.length > MAX_CONTENT_LEN) { + return `正文不能超过${MAX_CONTENT_LEN}字` + } + if (!body.category || !VALID_CATEGORIES.includes(body.category)) { + return '请选择有效分类' + } + if (!['real', 'pen_name', 'anonymous'].includes(body.authorType)) { + return '署名方式无效' + } + if ((body.authorType === 'real' || body.authorType === 'pen_name') && !body.authorName?.trim()) { + return '请输入署名名称' + } + if (body.authorName && body.authorName.length > MAX_AUTHOR_NAME_LEN) { + return `署名不能超过${MAX_AUTHOR_NAME_LEN}字` + } + if (body.contact && body.contact.length > MAX_CONTACT_LEN) { + return `联系方式不能超过${MAX_CONTACT_LEN}字` + } + return null +} + +export const onRequestPost: PagesFunction<{ DB: D1Database }> = async ({ request, env }) => { + let body: SubmissionBody + try { + body = await request.json() + } catch { + return new Response(JSON.stringify({ error: '请求格式无效' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Honeypot check + if (body.website && body.website.length > 0) { + // Silently accept but don't store — bot detection + return new Response(JSON.stringify({ id: generateId(), status: 'pending' }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const validationError = validate(body) + if (validationError) { + return new Response(JSON.stringify({ error: validationError }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const id = generateId() + const now = new Date().toISOString() + + try { + await ensureSchema(env.DB) + await env.DB.prepare( + `INSERT INTO submissions + (id, title, content, category, author_type, author_name, contact, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`, + ) + .bind(id, body.title.trim(), body.content, body.category, + body.authorType, body.authorName?.trim() || null, + body.contact?.trim() || null, now) + .run() + } catch (err) { + console.error('DB insert failed:', err) + return new Response(JSON.stringify({ error: '服务器错误,请稍后重试' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ id, status: 'pending' }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/migrations/0001_submissions.sql b/migrations/0001_submissions.sql new file mode 100644 index 0000000..a80355e --- /dev/null +++ b/migrations/0001_submissions.sql @@ -0,0 +1,21 @@ +-- Migration 0001: Create submissions table + +CREATE TABLE IF NOT EXISTS submissions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT NOT NULL, + author_name TEXT, + author_type TEXT NOT NULL DEFAULT 'anonymous', + contact TEXT, + submitter_gh TEXT, + submitter_x TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_gh TEXT, + review_notes TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_submissions_status ON submissions(status); +CREATE INDEX IF NOT EXISTS idx_submissions_created ON submissions(created_at DESC); diff --git a/package.json b/package.json index d3c0437..d283dd5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "lucide-react": "^1.17.0", + "md-editor-rt": "^6.5.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 350fa33..27cfbf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.6) + md-editor-rt: + specifier: ^6.5.1 + version: 6.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: specifier: ^19.2.6 version: 19.2.6 @@ -124,6 +130,96 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@codemirror/autocomplete@6.20.2': + resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.6': + resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + + '@codemirror/search@6.7.0': + resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/view@6.43.0': + resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -208,6 +304,60 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.6': + resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} + + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.6.4': + resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.19': + resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -327,6 +477,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -397,6 +556,12 @@ packages: resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vavt/copy2clipboard@1.0.3': + resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==} + + '@vavt/util@2.1.2': + resolution: {integrity: sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -423,6 +588,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -444,6 +612,12 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -451,10 +625,16 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -477,6 +657,10 @@ packages: electron-to-chromium@1.5.360: resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -720,6 +904,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -727,6 +914,39 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + markdown-it-image-figures@2.1.1: + resolution: {integrity: sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==} + engines: {node: '>=12.0.0'} + peerDependencies: + markdown-it: '*' + + markdown-it-sub@2.0.0: + resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + + md-editor-rt@6.5.1: + resolution: {integrity: sha512-k7tJT374Ww23yabLEt4wRaoU8SndeIypAcpTfG+bfcnxzwyLTkgcNCU20Gck+V5pHHQFRoqEStUqlviQFW4pjg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + medium-zoom@1.1.0: + resolution: {integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -780,6 +1000,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -842,6 +1066,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -871,6 +1098,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -926,6 +1156,9 @@ packages: yaml: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -935,6 +1168,11 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1053,6 +1291,257 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/cpp': 1.1.6 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.4 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.19 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.3 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/search@6.7.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.43.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1138,6 +1627,101 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.6.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.19': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@marijn/find-cluster-break@1.0.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1209,6 +1793,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -1312,6 +1905,10 @@ snapshots: '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 + '@vavt/copy2clipboard@1.0.3': {} + + '@vavt/util@2.1.2': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@24.12.4))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1330,6 +1927,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + argparse@2.0.1: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.31: {} @@ -1348,16 +1947,32 @@ snapshots: caniuse-lite@1.0.30001793: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + + commander@2.20.3: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + cssfilter@0.0.10: {} + csstype@3.2.3: {} debug@4.4.3: @@ -1370,6 +1985,8 @@ snapshots: electron-to-chromium@1.5.360: {} + entities@4.5.0: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -1580,6 +2197,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -1588,6 +2209,56 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.17.0(react@19.2.6): + dependencies: + react: 19.2.6 + + markdown-it-image-figures@2.1.1(markdown-it@14.2.0): + dependencies: + markdown-it: 14.2.0 + + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + md-editor-rt@6.5.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/commands': 6.10.3 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/language': 6.12.3 + '@codemirror/language-data': 6.5.2 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/highlight': 1.2.3 + '@types/markdown-it': 14.1.2 + '@vavt/copy2clipboard': 1.0.3 + '@vavt/util': 2.1.2 + codemirror: 6.0.2 + lucide-react: 1.17.0(react@19.2.6) + markdown-it: 14.2.0 + markdown-it-image-figures: 2.1.1(markdown-it@14.2.0) + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + medium-zoom: 1.1.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + xss: 1.0.15 + + mdurl@2.0.0: {} + + medium-zoom@1.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -1633,6 +2304,8 @@ snapshots: prelude-ls@1.2.1: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} react-dom@19.2.6(react@19.2.6): @@ -1693,6 +2366,8 @@ snapshots: source-map-js@1.2.1: {} + style-mod@4.1.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -1722,6 +2397,8 @@ snapshots: typescript@6.0.3: {} + uc.micro@2.1.0: {} + undici-types@7.16.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -1745,12 +2422,19 @@ snapshots: '@types/node': 24.12.4 fsevents: 2.3.3 + w3c-keyname@2.2.8: {} + which@2.0.2: dependencies: isexe: 2.0.0 word-wrap@1.2.5: {} + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/components/FormField.module.css b/src/components/FormField.module.css new file mode 100644 index 0000000..5560065 --- /dev/null +++ b/src/components/FormField.module.css @@ -0,0 +1,22 @@ +.fieldWrapper { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-main); +} + +.required { + color: var(--accent-pink); + margin-left: 2px; +} + +.error { + font-size: 0.8rem; + color: #e74c3c; + margin: 0; +} diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx new file mode 100644 index 0000000..25539fc --- /dev/null +++ b/src/components/FormField.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import styles from './FormField.module.css' + +interface FormFieldProps { + label: string + required?: boolean + error?: string + children: ReactNode +} + +const FormField = ({ label, required, error, children }: FormFieldProps) => { + return ( +
+ + {label} + {required && *} + + {children} + {error &&

{error}

} +
+ ) +} + +export default FormField diff --git a/src/components/SubmitForm.module.css b/src/components/SubmitForm.module.css new file mode 100644 index 0000000..029d3a9 --- /dev/null +++ b/src/components/SubmitForm.module.css @@ -0,0 +1,306 @@ +.form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.radioGroup { + display: flex; + gap: 1.25rem; + padding-top: 0.2rem; +} + +.radioLabel { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--text-body); + cursor: pointer; +} + +.radioLabel input[type="radio"] { + accent-color: var(--accent-pink); + width: 16px; + height: 16px; + cursor: pointer; +} + +.textInput { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1.5px solid var(--divider-color); + border-radius: 8px; + font-size: 0.92rem; + color: var(--text-main); + background-color: var(--bg-color); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + font-family: inherit; +} + +.textInput:focus { + border-color: var(--primary-pink); + box-shadow: 0 0 0 3px rgba(255, 133, 162, 0.15); +} + +.textInput::placeholder { + color: var(--text-muted); +} + +.selectInput { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1.5px solid var(--divider-color); + border-radius: 8px; + font-size: 0.92rem; + color: var(--text-main); + background-color: var(--bg-color); + outline: none; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + font-family: inherit; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.25rem; +} + +.selectInput:focus { + border-color: var(--primary-pink); + box-shadow: 0 0 0 3px rgba(255, 133, 162, 0.15); +} + +.editorWrapper { + border: 1.5px solid var(--divider-color); + border-radius: 8px; + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.editorWrapper:focus-within { + border-color: var(--primary-pink); + box-shadow: 0 0 0 3px rgba(255, 133, 162, 0.15); +} + +.editorWrapper :global(.md-editor) { + border: none; +} + +.checkboxRow { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.checkboxRow input[type="checkbox"] { + accent-color: var(--accent-pink); + width: 16px; + height: 16px; + cursor: pointer; + margin-top: 2px; + flex-shrink: 0; +} + +.checkboxLabel { + font-size: 0.88rem; + color: var(--text-body); + line-height: 1.5; + cursor: pointer; +} + +.submitButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background-color: var(--accent-pink); + color: white; + padding: 0.7rem 2rem; + border: none; + border-radius: 50px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: 0 2px 8px rgba(240, 98, 146, 0.25); + align-self: flex-start; + font-family: inherit; +} + +.submitButton:hover:not(:disabled) { + background-color: var(--cta-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--shadow-color-hover); +} + +.submitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.successBox { + background-color: #e8f5e9; + border: 1px solid #a5d6a7; + border-radius: 10px; + padding: 1.5rem; + text-align: center; +} + +[data-theme="dark"] .successBox { + background-color: rgba(76, 175, 80, 0.12); + border-color: rgba(76, 175, 80, 0.3); +} + +.successTitle { + font-size: 1.1rem; + font-weight: 600; + color: #2e7d32; + margin: 0 0 0.5rem 0; +} + +[data-theme="dark"] .successTitle { + color: #66bb6a; +} + +.successId { + font-size: 0.95rem; + color: #388e3c; + margin: 0 0 0.25rem 0; + word-break: break-all; +} + +[data-theme="dark"] .successId { + color: #81c784; +} + +.successHint { + font-size: 0.82rem; + color: #666; + margin: 0; +} + +[data-theme="dark"] .successHint { + color: #a0a0b0; +} + +.errorBox { + background-color: #fbe9e7; + border: 1px solid #ef9a9a; + border-radius: 10px; + padding: 1rem 1.25rem; +} + +[data-theme="dark"] .errorBox { + background-color: rgba(244, 67, 54, 0.1); + border-color: rgba(244, 67, 54, 0.25); +} + +.errorText { + font-size: 0.9rem; + color: #c62828; + margin: 0; +} + +[data-theme="dark"] .errorText { + color: #ef5350; +} + +.loginHint { + font-size: 0.85rem; + color: var(--text-muted); + padding: 0.75rem 1rem; + background-color: var(--hover-bg); + border-radius: 8px; + line-height: 1.5; +} + +.loginActions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.loginBtn { + background: none; + border: 1px solid var(--primary-pink); + color: var(--primary-pink); + padding: 0.2rem 0.65rem; + border-radius: 50px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + font-family: inherit; +} + +.loginBtn:hover { + background-color: var(--primary-pink); + color: white; +} + +.userBadge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.userProvider { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.45rem; + border-radius: 4px; + background-color: var(--primary-pink); + color: white; +} + +.userName { + font-weight: 600; + color: var(--text-main); +} + +.userTag { + color: var(--text-muted); + font-size: 0.8rem; +} + +.honeypot { + position: absolute; + left: -9999px; + opacity: 0; + height: 0; + width: 0; + padding: 0; + border: 0; + margin: 0; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .submitButton { + transition: none; + } +} + +@media (max-width: 768px) { + .submitButton { + width: 100%; + align-self: stretch; + } + + .radioGroup { + flex-direction: column; + gap: 0.5rem; + } +} diff --git a/src/components/SubmitForm.tsx b/src/components/SubmitForm.tsx new file mode 100644 index 0000000..371aed7 --- /dev/null +++ b/src/components/SubmitForm.tsx @@ -0,0 +1,301 @@ +import { useState, type FormEvent } from 'react' +import { MdEditor } from 'md-editor-rt' +import 'md-editor-rt/lib/style.css' +import { useTheme } from '@/context/ThemeContext' +import { useAuth } from '@/context/AuthContext' +import FormField from './FormField' +import styles from './SubmitForm.module.css' + +const CATEGORIES = ['个人经历', '观点评论', '资源指南'] as const + +type AuthorType = 'real' | 'pen_name' | 'anonymous' +type FormStatus = 'idle' | 'submitting' | 'success' | 'error' + +interface FormData { + title: string + content: string + category: string + authorType: AuthorType + authorName: string + contact: string + agreement: boolean +} + +interface FormErrors { + title?: string + content?: string + category?: string + authorName?: string + agreement?: string +} + +const INITIAL_FORM: FormData = { + title: '', + content: '', + category: '', + authorType: 'anonymous', + authorName: '', + contact: '', + agreement: false, +} + +const validate = (data: FormData): FormErrors => { + const errors: FormErrors = {} + if (!data.title.trim()) errors.title = '请输入标题' + if (!data.content.trim()) errors.content = '请输入正文' + if (!data.category) errors.category = '请选择分类' + if ((data.authorType === 'real' || data.authorType === 'pen_name') && !data.authorName.trim()) { + errors.authorName = '请输入署名名称' + } + if (!data.agreement) errors.agreement = '请同意投稿协议' + return errors +} + +const SubmitForm = () => { + const { theme } = useTheme() + const { user, loading, loginWithGitHub, loginWithX } = useAuth() + const [form, setForm] = useState(INITIAL_FORM) + const [errors, setErrors] = useState({}) + const [status, setStatus] = useState('idle') + const [submitId, setSubmitId] = useState('') + const [serverError, setServerError] = useState('') + + const set = (key: K, value: FormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })) + const errorKey = key as keyof FormErrors + if (errors[errorKey]) setErrors((prev) => ({ ...prev, [errorKey]: undefined })) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setServerError('') + + const validationErrors = validate(form) + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors) + return + } + + setStatus('submitting') + + try { + const res = await fetch('/api/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: form.title, + content: form.content, + category: form.category, + authorType: form.authorType, + authorName: form.authorType !== 'anonymous' ? form.authorName : undefined, + contact: form.contact || undefined, + }), + }) + + const data = await res.json() as { id?: string; error?: string } + + if (!res.ok) { + setServerError(data.error ?? '提交失败,请稍后重试') + setStatus('error') + return + } + + setSubmitId(data.id!) + setStatus('success') + } catch { + setServerError('网络错误,请检查网络连接后重试') + setStatus('error') + } + } + + const handleReset = () => { + setForm(INITIAL_FORM) + setErrors({}) + setStatus('idle') + setServerError('') + } + + if (status === 'success') { + return ( +
+

投稿成功

+

投稿编号:{submitId}

+

+ 请保存此编号以便查询进度。审核通过后将发表在 story.transcircle.org。 +

+ +
+ ) + } + + const editorTheme = theme === 'dark' ? 'dark' : 'light' + + return ( +
+ {!loading && ( +
+ {user ? ( + + + {user.provider === 'github' ? 'GitHub' : 'X'} + + {user.username} + 已登录 · 投稿将关联到你的账号 + + ) : ( + + 登录后可认领投稿: + + + + )} +
+ )} + + {/* Honeypot: hidden from users, filled by bots */} + + + + set('title', e.target.value)} + placeholder="输入故事标题" + maxLength={200} + /> + + + +
+ set('content', v)} + theme={editorTheme} + language="zh-CN" + preview={true} + toolbarsExclude={['image', 'link', 'mermaid', 'katex', 'github']} + style={{ height: '400px' }} + /> +
+
+ + + + + + +
+ {[ + { value: 'real' as const, label: '实名' }, + { value: 'pen_name' as const, label: '笔名' }, + { value: 'anonymous' as const, label: '匿名' }, + ].map((opt) => ( + + ))} +
+
+ + {form.authorType !== 'anonymous' && ( + + set('authorName', e.target.value)} + placeholder={form.authorType === 'real' ? '输入真实姓名' : '输入笔名'} + maxLength={50} + /> + + )} + + + set('contact', e.target.value)} + placeholder="邮箱、社交媒体账号等" + maxLength={200} + /> + + + +
+ set('agreement', e.target.checked)} + /> + +
+
+ + {status === 'error' && serverError && ( +
+

{serverError}

+
+ )} + + +
+ ) +} + +export default SubmitForm diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..aa50224 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +interface User { + provider: 'github' | 'x' + username: string + avatarUrl?: string + isAdmin: boolean +} + +interface AuthContextValue { + user: User | null + loading: boolean + loginWithGitHub: () => void + loginWithX: () => void + logout: () => void +} + +const AuthContext = createContext(null) + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/auth/me') + .then((res) => res.json()) + .then((data: { user: User | null }) => { + setUser(data.user) + }) + .catch(() => { + // API not available — ignore + }) + .finally(() => setLoading(false)) + }, []) + + const loginWithGitHub = () => { + window.location.href = '/api/auth/github' + } + + const loginWithX = () => { + window.location.href = '/api/auth/x' + } + + const logout = () => { + setUser(null) + } + + return ( + + {children} + + ) +} + +export const useAuth = (): AuthContextValue => { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/src/main.tsx b/src/main.tsx index 85d0d1a..cdf97cc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client' import { RouterProvider } from 'react-router-dom' import { ThemeProvider } from './context/ThemeContext' +import { AuthProvider } from './context/AuthContext' import { router } from './router' @@ -14,7 +15,9 @@ ReactDOM.createRoot( ).render( - + + + , ) diff --git a/src/pages/Admin.module.css b/src/pages/Admin.module.css new file mode 100644 index 0000000..de0aa48 --- /dev/null +++ b/src/pages/Admin.module.css @@ -0,0 +1,325 @@ +.container { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ── Login ────────────────────────────────── */ + +.loginBox { + max-width: 360px; + margin: 2rem auto; + text-align: center; +} + +.loginBox input { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1.5px solid var(--divider-color); + border-radius: 8px; + font-size: 0.92rem; + color: var(--text-main); + background-color: var(--bg-color); + outline: none; + font-family: inherit; + margin-bottom: 1rem; +} + +.loginBox input:focus { + border-color: var(--primary-pink); + box-shadow: 0 0 0 3px rgba(255, 133, 162, 0.15); +} + +/* ── Tabs ─────────────────────────────────── */ + +.tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--divider-color); + margin-bottom: 0.5rem; +} + +.tab { + padding: 0.6rem 1.25rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-muted); + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; + font-family: inherit; +} + +.tab:hover { + color: var(--text-main); +} + +.tabActive { + color: var(--accent-pink); + border-bottom-color: var(--accent-pink); +} + +/* ── Status bar ────────────────────────────── */ + +.bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.count { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ── List ──────────────────────────────────── */ + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; + background-color: var(--divider-color); + border: 1px solid var(--divider-color); + border-radius: 10px; + overflow: hidden; +} + +.item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.9rem 1.15rem; + background-color: var(--bg-color); + cursor: pointer; + transition: background-color 0.12s ease; + gap: 1rem; +} + +.item:hover { + background-color: var(--hover-bg); +} + +.itemMain { + flex: 1; + min-width: 0; +} + +.itemTitle { + font-size: 0.92rem; + font-weight: 500; + color: var(--text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.itemMeta { + font-size: 0.78rem; + color: var(--text-muted); + margin-top: 0.15rem; +} + +.itemCategory { + font-size: 0.75rem; + padding: 0.15rem 0.55rem; + border-radius: 50px; + background-color: var(--hover-bg); + color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.empty { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* ── Detail / Review ───────────────────────── */ + +.back { + font-size: 0.88rem; + color: var(--accent-pink); + background: none; + border: none; + cursor: pointer; + padding: 0; + margin-bottom: 0.5rem; + font-family: inherit; + align-self: flex-start; +} + +.detailCard { + border: 1px solid var(--divider-color); + border-radius: 10px; + padding: 1.5rem; + background-color: var(--bg-color); +} + +.detailTitle { + font-size: 1.35rem; + font-weight: 700; + color: var(--text-main); + margin: 0 0 1rem 0; +} + +.detailMeta { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.82rem; + color: var(--text-muted); + margin-bottom: 1.25rem; +} + +.detailContent { + padding: 1.25rem; + background-color: var(--hover-bg); + border-radius: 8px; + line-height: 1.75; + font-size: 0.9rem; + color: var(--text-body); + margin-bottom: 1.25rem; + overflow-x: auto; + white-space: pre-wrap; +} + +.detailContact { + font-size: 0.85rem; + color: var(--text-secondary); + padding: 0.75rem 1rem; + background-color: var(--hover-bg); + border-radius: 8px; + margin-bottom: 1.25rem; +} + +.reviewActions { + display: flex; + gap: 0.65rem; + flex-wrap: wrap; + align-items: center; +} + +.reviewTextarea { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1.5px solid var(--divider-color); + border-radius: 8px; + font-size: 0.9rem; + color: var(--text-main); + background-color: var(--bg-color); + outline: none; + resize: vertical; + min-height: 80px; + font-family: inherit; + margin-bottom: 0.75rem; +} + +.reviewTextarea:focus { + border-color: var(--primary-pink); + box-shadow: 0 0 0 3px rgba(255, 133, 162, 0.15); +} + +/* ── Buttons ───────────────────────────────── */ + +.btnPrimary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background-color: var(--accent-pink); + color: white; + padding: 0.55rem 1.25rem; + border: none; + border-radius: 50px; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease; + font-family: inherit; +} + +.btnPrimary:hover { + background-color: var(--cta-hover); +} + +.btnSecondary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1.5px solid var(--divider-color); + color: var(--text-main); + background: var(--bg-color); + padding: 0.55rem 1.25rem; + border-radius: 50px; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s ease; + font-family: inherit; +} + +.btnSecondary:hover { + border-color: var(--primary-pink); +} + +.btnReject { + composes: btnSecondary; + color: #c62828; + border-color: #ef9a9a; +} + +.btnReject:hover { + border-color: #e57373; + background-color: #fbe9e7; +} + +[data-theme="dark"] .btnReject { + color: #ef5350; + border-color: rgba(244, 67, 54, 0.3); +} + +[data-theme="dark"] .btnReject:hover { + background-color: rgba(244, 67, 54, 0.08); +} + +/* ── Loading ───────────────────────────────── */ + +.loading { + text-align: center; + padding: 2rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* ── Error ─────────────────────────────────── */ + +.errorBox { + background-color: #fbe9e7; + border: 1px solid #ef9a9a; + border-radius: 10px; + padding: 1rem 1.25rem; + font-size: 0.9rem; + color: #c62828; +} + +[data-theme="dark"] .errorBox { + background-color: rgba(244, 67, 54, 0.1); + border-color: rgba(244, 67, 54, 0.25); + color: #ef5350; +} + +@media (prefers-reduced-motion: reduce) { + .tab, + .item { + transition: none; + } +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 98c3d05..87cc7c3 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,7 +1,327 @@ +import { useState, useEffect, useCallback } from 'react' +import { useAuth } from '@/context/AuthContext' +import styles from './Admin.module.css' + +const TEMP_TOKEN_KEY = 'tc_temp_admin_token' + +type Status = 'pending' | 'approved' | 'rejected' +type ReviewAction = 'approve' | 'reject' | 'request_changes' + +interface Submission { + id: string + title: string + category: string + author_type: string + author_name: string | null + contact: string | null + content: string + status: Status + reviewer_gh: string | null + review_notes: string | null + created_at: string + reviewed_at: string | null + submitter_gh: string | null + submitter_x: string | null +} + +interface ListResponse { + submissions: Submission[] + total: number +} + +const TABS: { key: Status; label: string }[] = [ + { key: 'pending', label: '待审核' }, + { key: 'approved', label: '已通过' }, + { key: 'rejected', label: '已拒绝' }, +] + +function getStoredToken(): string { + try { + return localStorage.getItem(TEMP_TOKEN_KEY) || '' + } catch { return '' } +} + const Admin = () => { + const { user, loading: authLoading, loginWithGitHub } = useAuth() + const [tempToken, setTempToken] = useState(getStoredToken) + const [tokenInput, setTokenInput] = useState('') + const [activeTab, setActiveTab] = useState('pending') + const [submissions, setSubmissions] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [selected, setSelected] = useState(null) + const [reviewNotes, setReviewNotes] = useState('') + + const authHeaders = (): Record => { + if (user?.isAdmin) return {} // uses session cookie + if (tempToken) return { Authorization: `Bearer ${tempToken}` } + return {} + } + + const fetchSubmissions = useCallback(async () => { + setLoading(true) + setError('') + try { + const res = await fetch(`/api/admin/submissions?status=${activeTab}`, { headers: authHeaders() }) + if (res.status === 403) { + localStorage.removeItem(TEMP_TOKEN_KEY) + setTempToken('') + return + } + if (!res.ok) throw new Error('加载失败') + const data = await res.json() as ListResponse + setSubmissions(data.submissions) + setTotal(data.total) + } catch { + setError('加载投稿列表失败') + } finally { + setLoading(false) + } + }, [activeTab, tempToken, user]) + + useEffect(() => { + if (user?.isAdmin || tempToken) fetchSubmissions() + }, [user, tempToken, fetchSubmissions]) + + const fetchDetail = async (id: string) => { + try { + const res = await fetch(`/api/admin/submissions/${id}`, { headers: authHeaders() }) + if (!res.ok) throw new Error('加载失败') + setSelected(await res.json() as Submission) + setReviewNotes('') + } catch { + setError('加载投稿详情失败') + } + } + + const handleReview = async (action: ReviewAction) => { + if (!selected) return + try { + const res = await fetch('/api/admin/review', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + id: selected.id, + action, + notes: reviewNotes || undefined, + }), + }) + if (!res.ok) { + const data = await res.json() as { error?: string } + throw new Error(data.error ?? '操作失败') + } + setSelected(null) + fetchSubmissions() + } catch (err) { + setError(err instanceof Error ? err.message : '审核操作失败') + } + } + + // ── Loading ── + + if (authLoading) { + return ( +
+
验证身份...
+
+ ) + } + + // ── Not logged in ── + + if (!user) { + const handleTempLogin = () => { + if (tokenInput.trim()) { + localStorage.setItem(TEMP_TOKEN_KEY, tokenInput.trim()) + setTempToken(tokenInput.trim()) + } + } + + return ( +
+
+

+ 审核后台 +

+

+ 使用 GitHub OAuth 登录,或输入临时管理员令牌 +

+
+ +
+ + +
+

+ 或使用临时令牌登录(OAuth 配置完成前的过渡方案) +

+ setTokenInput(e.target.value)} + placeholder="输入临时管理员令牌" + onKeyDown={(e) => e.key === 'Enter' && handleTempLogin()} + /> + +
+
+
+ ) + } + + // ── Not admin ── + + if (!user.isAdmin) { + return ( +
+

+ 访问被拒绝 +

+

+ 你的 GitHub 账号 ({user.username}) 不是 TransCircle 组织成员,无权访问审核后台。 +

+
+ ) + } + + // ── Submission List ── + + if (!selected) { + return ( +
+
+
+

+ 审核后台 +

+ + {user.username} ({user.provider}) + +
+
+ + + +
共 {total} 篇
+ + {error &&
{error}
} + + {loading ? ( +
加载中...
+ ) : submissions.length === 0 ? ( +
暂无投稿
+ ) : ( +
    + {submissions.map((s) => ( +
  • fetchDetail(s.id)}> +
    +
    {s.title}
    +
    + {s.author_type === 'anonymous' ? '匿名' : s.author_name} · {s.created_at.slice(0, 10)} +
    +
    + {s.category} +
  • + ))} +
+ )} +
+ ) + } + + // ── Submission Detail ── + return ( -
-

审核页

+
+ + +
+

{selected.title}

+ +
+ 分类:{selected.category} + + 署名: + {selected.author_type === 'anonymous' + ? '匿名' + : `${selected.author_name}(${selected.author_type === 'real' ? '实名' : '笔名'})`} + + 投稿时间:{selected.created_at.slice(0, 16).replace('T', ' ')} + 状态:{selected.status === 'pending' ? '待审核' : selected.status === 'approved' ? '已通过' : '已拒绝'} + {selected.submitter_gh && GitHub: {selected.submitter_gh}} + {selected.submitter_x && X: {selected.submitter_x}} +
+ + {selected.contact && ( +
+ 联系方式:{selected.contact} +
+ )} + +
+ {selected.content} +
+ + {selected.review_notes && ( +
+ 审核意见:{selected.review_notes} + {selected.reviewer_gh && `(审核人:${selected.reviewer_gh})`} + {selected.reviewed_at && ` · ${selected.reviewed_at.slice(0, 16).replace('T', ' ')}`} +
+ )} + + {error &&
{error}
} + + {selected.status === 'pending' && ( + <> +