diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fafcf00..28f2b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,9 @@ jobs: - name: Type check run: npx tsc --noEmit + + - name: Validate Warm Fold assets + run: npm run validate:warm-fold + + - name: Next.js build + run: npm run build diff --git a/web/app/globals.css b/web/app/globals.css index 6175309..973553e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -259,3 +259,280 @@ html, body { } } /* === Warm Fold foundation tokens: END === */ + +/* === Warm Fold button primitives: START === */ +.wf-button, +.wf-icon-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + font-family: var(--wf-font-sans); + font-weight: 600; + cursor: pointer; + transition: + background var(--wf-motion-fast) var(--wf-ease), + border-color var(--wf-motion-fast) var(--wf-ease), + color var(--wf-motion-fast) var(--wf-ease), + transform var(--wf-motion-fast) var(--wf-ease); +} + +.wf-button { + gap: 0.5rem; + border-radius: var(--wf-radius-sm); +} + +.wf-button[data-size="sm"] { + min-height: 36px; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; +} + +.wf-button[data-size="md"] { + min-height: 44px; + padding: 0.625rem 1rem; + font-size: 0.9375rem; +} + +.wf-button[data-size="lg"] { + min-height: 48px; + padding: 0.75rem 1.25rem; + font-size: 1rem; +} + +.wf-button[data-variant="primary"] { + background: var(--wf-accent); + color: var(--wf-on-accent); +} + +.wf-button[data-variant="primary"]:hover:not(:disabled) { + background: var(--wf-accent-hover); +} + +.wf-button[data-variant="primary"]:active:not(:disabled) { + background: var(--wf-accent-active); +} + +.wf-button[data-variant="secondary"], +.wf-icon-button[data-variant="secondary"] { + background: var(--wf-surface); + border-color: var(--wf-border); + color: var(--wf-text); +} + +.wf-button[data-variant="ghost"], +.wf-icon-button[data-variant="ghost"] { + background: transparent; + color: var(--wf-text-secondary); +} + +.wf-button[data-variant="secondary"]:hover:not(:disabled), +.wf-icon-button[data-variant="secondary"]:hover:not(:disabled) { + background: var(--wf-surface-muted); + border-color: var(--wf-border-strong); +} + +.wf-button[data-variant="ghost"]:hover:not(:disabled), +.wf-icon-button[data-variant="ghost"]:hover:not(:disabled) { + background: var(--wf-surface-muted); + color: var(--wf-text); +} + +.wf-button[data-variant="danger"], +.wf-icon-button[data-variant="danger"] { + background: var(--wf-danger); + color: var(--wf-on-danger); +} + +.wf-button[data-variant="danger"]:hover:not(:disabled), +.wf-icon-button[data-variant="danger"]:hover:not(:disabled) { + background: var(--wf-danger-hover); +} + +.wf-button:focus-visible, +.wf-icon-button:focus-visible { + outline: 2px solid var(--wf-focus); + outline-offset: 3px; +} + +.wf-button:disabled, +.wf-icon-button:disabled { + background: var(--wf-disabled-bg); + border-color: var(--wf-disabled-border); + color: var(--wf-disabled-fg); + cursor: not-allowed; + box-shadow: none; +} + +.wf-button[data-loading="true"], +.wf-icon-button[data-loading="true"] { + cursor: progress; +} + +.wf-button[data-loading="true"] .wf-button__content, +.wf-icon-button[data-loading="true"] .wf-icon-button__content { + opacity: 0; +} + +.wf-button__spinner, +.wf-icon-button__spinner { + position: absolute; + width: 1em; + height: 1em; + animation: wf-spin 800ms linear infinite; +} + +.wf-icon-button { + width: 44px; + height: 44px; + padding: 0; + border-radius: var(--wf-radius-sm); +} + +.wf-icon-button[data-size="lg"] { + width: 48px; + height: 48px; +} + +@keyframes wf-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .wf-button__spinner, + .wf-icon-button__spinner { + animation: none; + } +} +/* === Warm Fold button primitives: END === */ + +/* === Warm Fold surface primitives: START === */ +.wf-card { + color: var(--wf-text); + border-radius: var(--wf-radius-md); + font-family: var(--wf-font-sans); +} + +.wf-card[data-variant="surface"] { + background: var(--wf-surface); + border: 1px solid var(--wf-border); + box-shadow: var(--wf-shadow-sm); +} + +.wf-card[data-variant="muted"] { + background: var(--wf-surface-muted); + border: 1px solid transparent; +} + +.wf-card[data-variant="outlined"] { + background: transparent; + border: 1px solid var(--wf-border-strong); +} + +.wf-card[data-padding="none"] { + padding: 0; +} + +.wf-card[data-padding="sm"] { + padding: 0.75rem; +} + +.wf-card[data-padding="md"] { + padding: 1rem; +} + +.wf-card[data-padding="lg"] { + padding: 1.5rem; +} + +.wf-input { + width: 100%; + border: 1px solid var(--wf-border); + border-radius: var(--wf-radius-sm); + background: var(--wf-surface); + color: var(--wf-text); + font-family: var(--wf-font-sans); + transition: + border-color var(--wf-motion-fast) var(--wf-ease), + background var(--wf-motion-fast) var(--wf-ease); +} + +.wf-input::placeholder { + color: var(--wf-text-tertiary); +} + +.wf-input[data-size="sm"] { + min-height: 36px; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; +} + +.wf-input[data-size="md"] { + min-height: 44px; + padding: 0.625rem 0.75rem; + font-size: 0.9375rem; +} + +.wf-input[data-size="lg"] { + min-height: 48px; + padding: 0.75rem 0.875rem; + font-size: 1rem; +} + +.wf-input:focus-visible { + outline: 2px solid var(--wf-focus); + outline-offset: 3px; + border-color: var(--wf-accent); +} + +.wf-input[aria-invalid="true"] { + border-color: var(--wf-danger); + background: var(--wf-danger-surface); +} + +.wf-input:disabled { + background: var(--wf-disabled-bg); + border-color: var(--wf-disabled-border); + color: var(--wf-disabled-fg); + cursor: not-allowed; +} + +.wf-chip { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: var(--wf-radius-full); + font-family: var(--wf-font-sans); + font-weight: 600; +} + +.wf-chip[data-size="sm"] { + min-height: 24px; + padding: 0.1875rem 0.5rem; + font-size: 0.75rem; +} + +.wf-chip[data-size="md"] { + min-height: 28px; + padding: 0.25rem 0.625rem; + font-size: 0.8125rem; +} + +.wf-chip[data-variant="neutral"] { + background: var(--wf-surface-muted); + color: var(--wf-text-secondary); +} + +.wf-chip[data-variant="accent"] { + background: color-mix(in srgb, var(--wf-brand-coral) 14%, var(--wf-surface)); + color: var(--wf-accent); +} + +.wf-chip[data-variant="danger"] { + background: var(--wf-danger-surface); + color: var(--wf-danger); +} +/* === Warm Fold surface primitives: END === */ diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 28d4f3d..5e32848 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -3,16 +3,26 @@ import { Suspense, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowRight, CheckCircle2, Loader2, LogIn, Sparkles } from "lucide-react"; +import { ArrowRight, CheckCircle2, LogIn } from "lucide-react"; + +import { BrandMark } from "@/components/brand/BrandMark"; import NavBar from "@/components/NavBar"; +import { Button, Card, Chip, Input } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; import { ApiError } from "@/lib/api"; +function getSafeNextPath(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/notebooks"; + } + return value; +} + function LoginInner() { const { login } = useAuth(); const router = useRouter(); const search = useSearchParams(); - const next = search.get("next") || "/notebooks"; + const next = getSafeNextPath(search.get("next")); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [err, setErr] = useState(null); @@ -24,6 +34,7 @@ function LoginInner() { setErr(null); setUnverified(false); setBusy(true); + try { await login(email.trim(), password); router.push(next); @@ -32,81 +43,104 @@ function LoginInner() { setUnverified(true); setErr(e.message); } else { - setErr(e instanceof ApiError ? e.message : "登录失败,后端是否启动?"); + setErr(e instanceof ApiError ? e.message : "登录失败,请确认后端服务是否已启动。"); } setBusy(false); } } + const errorId = err ? "login-error" : undefined; + return ( -
+
-
- + + NoteGen Account -
-

- 回到你的
视频笔记工作台。 + +

+ 回到你的 +
+ 视频笔记工作台

-

- 登录后可以生成私有笔记、查看任务进度,并把每个视频沉淀成自己的复习资料。 +

+ 登录后可以继续生成私有笔记、查看任务进度,并把每个视频沉淀成自己的复习资料。

-
- {["私有笔记库", "提交历史与诊断", "支持链接和本地视频"].map(item => ( +
+ {["私有笔记库", "提交历史与任务诊断", "支持链接和本地视频"].map((item) => (
- - {item} +
))}
-
+
-

登录

-

进入 NoteGen 工作台。

+

+ 登录 +

+

进入 NoteGen 工作台。

-
- setEmail(e.target.value)} - className="h-11 w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg)] px-3 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" - /> - setPassword(e.target.value)} - className="h-11 w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg)] px-3 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" - /> - {err && ( -

+ +

+ + setEmail(e.target.value)} + aria-describedby={errorId} + invalid={Boolean(err)} + /> +
+
+ + setPassword(e.target.value)} + aria-describedby={errorId} + invalid={Boolean(err)} + /> +
+ {err ? ( + - )} - +
-

+

还没有账号? - - 注册 + + 注册

-
+
); @@ -114,7 +148,7 @@ function LoginInner() { export default function LoginPage() { return ( - }> + }> ); diff --git a/web/app/notebooks/page.tsx b/web/app/notebooks/page.tsx index a33468f..4389dd8 100644 --- a/web/app/notebooks/page.tsx +++ b/web/app/notebooks/page.tsx @@ -2,25 +2,35 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { - BookOpen, Clapperboard, Clock, Code2, FlaskConical, - GraduationCap, Layers, Loader2, MessagesSquare, Plus, Smartphone, - Trash2, Wrench, X, + BookOpen, + Clock, + Code2, + FileVideo, + FlaskConical, + GraduationCap, + Layers, + MessageSquareText, + Plus, + Search, + Sparkles, + Trash2, + Video, + Wrench, + X, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import CreateNotePanel from "@/components/CreateNotePanel"; import NavBar from "@/components/NavBar"; import RequireAuth from "@/components/RequireAuth"; -import CreateNotePanel from "@/components/CreateNotePanel"; +import { Button, Card, Chip, IconButton, Input } from "@/components/ui"; +import { deleteNote, fetchHistory, fetchMyNotes } from "@/lib/api"; import { fetchCatalog, formatDuration } from "@/lib/notes"; -import { deleteNote, fetchMyNotes } from "@/lib/api"; -import type { CatalogItem, NoteView } from "@/lib/types"; +import type { CatalogItem, HistoryItem, NoteView } from "@/lib/types"; -/** - * 笔记本库(登录后主页,NotebookLM 应用首页的对应物)。 - * 营销 landing 在 / ;个人笔记、书签等登录后才可见(RequireAuth 包裹)。 - */ - -type Filter = "all" | "mine" | "public"; +type Filter = "mine" | "all" | "public"; interface CardItem { id: string; @@ -31,221 +41,412 @@ interface CardItem { chapters: number; uploader: string; mine: boolean; + recentAt?: number; } -/** domain 文案 → 图标 + 中间调色(中间调在明暗两套底色上都可读) */ -const DOMAIN_STYLE: Record = { - "编程教学": { icon: Code2, color: "#4285f4" }, - "考研专业课": { icon: GraduationCap, color: "#9a6bff" }, - "工具教程": { icon: Wrench, color: "#12b886" }, - "科普": { icon: FlaskConical, color: "#12b886" }, - "Vlog": { icon: Clapperboard, color: "#f59f00" }, - "时评": { icon: MessagesSquare, color: "#e64980" }, - "数码评测": { icon: Smartphone, color: "#22b8cf" }, +const FILTERS: { key: Filter; label: string }[] = [ + { key: "mine", label: "我的笔记" }, + { key: "all", label: "全部" }, + { key: "public", label: "公开示例" }, +]; + +const DOMAIN_STYLE: Record = { + 编程教学: { icon: Code2 }, + 考研专业课: { icon: GraduationCap }, + 工具教程: { icon: Wrench }, + 科普: { icon: FlaskConical }, + Vlog: { icon: Video }, + 时评: { icon: MessageSquareText }, + 数码评测: { icon: FileVideo }, }; function domainStyle(domain: string) { - return DOMAIN_STYLE[domain] ?? { icon: BookOpen, color: "#4285f4" }; + return DOMAIN_STYLE[domain] ?? { icon: BookOpen }; } -const FILTERS: { key: Filter; label: string }[] = [ - { key: "all", label: "全部" }, - { key: "mine", label: "我的" }, - { key: "public", label: "公开示例" }, -]; +function normalizeTime(value?: number | null) { + if (!value) return 0; + return value > 1_000_000_000_000 ? value : value * 1000; +} + +function formatRelativeTime(value?: number) { + if (!value) return "暂无进度"; + const diff = Date.now() - normalizeTime(value); + const minutes = Math.max(1, Math.floor(diff / 60_000)); + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days} 天前`; + return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(new Date(normalizeTime(value))); +} + +function jobStatusLabel(status: HistoryItem["status"]) { + if (status === "queued") return "排队中"; + if (status === "running") return "生成中"; + if (status === "done") return "已完成"; + if (status === "failed") return "失败"; + if (status === "interrupted") return "已中断"; + return status; +} + +function matchesSearch(item: CardItem, query: string) { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [item.title, item.domain, item.uploader].some((value) => value.toLowerCase().includes(q)); +} + +function NoteCard({ + deleting, + item, + onDelete, +}: { + deleting: boolean; + item: CardItem; + onDelete: (item: CardItem) => void; +}) { + const reduceMotion = useReducedMotion(); + const { icon: Icon } = domainStyle(item.domain); + + return ( + + + {item.mine ? ( +
+ onDelete(item)} + size="sm" + variant="danger" + > + +
+ ) : null} + + +
+ + + + {item.mine ? "我的" : "示例"} + +
+

+ {item.title} +

+

{item.uploader || "未知来源"}

+
+ + + + + {item.chunks} 段 +
+
+ + {item.domain} + + {formatRelativeTime(item.recentAt)} +
+ +
+
+ ); +} + +function EmptyState({ + filter, + onCreate, + query, +}: { + filter: Filter; + onCreate: () => void; + query: string; +}) { + const hasQuery = query.trim().length > 0; + const title = hasQuery ? "没有找到匹配的笔记" : filter === "mine" ? "还没有自己的笔记" : "暂无可显示的笔记"; + const desc = hasQuery + ? "换一个关键词,或清空搜索后再看全部内容。" + : filter === "mine" + ? "从一个课程、讲座或教程视频开始,生成第一本可复习的笔记。" + : "当前筛选下没有内容。"; + + return ( + + + ); +} function NotebooksInner() { const [pub, setPub] = useState(null); const [mine, setMine] = useState(null); - const [filter, setFilter] = useState("all"); + const [history, setHistory] = useState(null); + const [filter, setFilter] = useState("mine"); + const [query, setQuery] = useState(""); const [createOpen, setCreateOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); const [delId, setDelId] = useState(null); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { let alive = true; fetchCatalog() - .then(d => { if (alive) setPub(d); }) + .then((d) => { if (alive) setPub(d); }) .catch(() => { if (alive) setPub([]); }); fetchMyNotes() - .then(d => { if (alive) setMine(d); }) + .then((d) => { if (alive) setMine(d); }) .catch(() => { if (alive) setMine([]); }); + fetchHistory() + .then((d) => { if (alive) setHistory(d); }) + .catch(() => { if (alive) setHistory([]); }); return () => { alive = false; }; }, []); - // Esc 关闭新建面板 useEffect(() => { - if (!createOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setCreateOpen(false); }; + if (!createOpen && !confirmDelete) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setCreateOpen(false); + setConfirmDelete(null); + } + }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [createOpen]); + }, [createOpen, confirmDelete]); - const loading = pub === null || mine === null; + const loading = pub === null || mine === null || history === null; + + const recentByNote = useMemo(() => { + const map = new Map(); + for (const item of history ?? []) { + if (!item.note_id) continue; + const current = map.get(item.note_id) ?? 0; + map.set(item.note_id, Math.max(current, item.updated_at, item.finished_at ?? 0, item.created_at)); + } + return map; + }, [history]); const items: CardItem[] = useMemo(() => { - const mineCards: CardItem[] = (mine ?? []).map(n => ({ - id: n.id, title: n.title, domain: n.domain, duration_sec: n.duration_sec, - chunks: n.chunks, chapters: n.chapters, uploader: n.uploader, mine: true, + const mineCards: CardItem[] = (mine ?? []).map((n) => ({ + id: n.id, + title: n.title, + domain: n.domain, + duration_sec: n.duration_sec, + chunks: n.chunks, + chapters: n.chapters, + uploader: n.uploader, + mine: true, + recentAt: recentByNote.get(n.id), })); - const mineIds = new Set(mineCards.map(c => c.id)); + const mineIds = new Set(mineCards.map((c) => c.id)); const pubCards: CardItem[] = (pub ?? []) - .filter(p => !mineIds.has(p.id)) - .map(p => ({ - id: p.id, title: p.title, domain: p.domain, duration_sec: p.duration_sec, - chunks: p.chunks, chapters: p.chapters, uploader: p.uploader, mine: false, + .filter((p) => !mineIds.has(p.id)) + .map((p) => ({ + id: p.id, + title: p.title, + domain: p.domain, + duration_sec: p.duration_sec, + chunks: p.chunks, + chapters: p.chapters, + uploader: p.uploader, + mine: false, })); - if (filter === "mine") return mineCards; - if (filter === "public") return pubCards; - return [...mineCards, ...pubCards]; - }, [pub, mine, filter]); - - async function remove(id: string) { - if (!confirm("删除这篇笔记?产物文件会一并清除,不可恢复。")) return; - setDelId(id); + + const source = filter === "mine" ? mineCards : filter === "public" ? pubCards : [...mineCards, ...pubCards]; + return source + .filter((item) => matchesSearch(item, query)) + .sort((a, b) => { + if (a.mine !== b.mine) return a.mine ? -1 : 1; + return (b.recentAt ?? 0) - (a.recentAt ?? 0); + }); + }, [filter, mine, pub, query, recentByNote]); + + const latestJob = useMemo(() => { + return [...(history ?? [])].sort((a, b) => b.updated_at - a.updated_at)[0] ?? null; + }, [history]); + + async function remove(item: CardItem) { + setDelId(item.id); + setDeleteError(null); try { - await deleteNote(id); - setMine(m => (m ?? []).filter(x => x.id !== id)); + await deleteNote(item.id); + setMine((value) => (value ?? []).filter((x) => x.id !== item.id)); + setConfirmDelete(null); } catch (e) { - alert(`删除失败:${String(e)}`); + setDeleteError(e instanceof Error ? e.message : String(e)); } finally { setDelId(null); } } return ( -
+
-
- {/* 标题行 + filter chips */} -
-

我的笔记本

-
- {FILTERS.map(f => { - const active = filter === f.key; - return ( - - ); - })} +
+
+ +
+
+ + +

+ 我的笔记 +

+

+ 优先查看自己的视频笔记、最近生成进度和可复习材料;公开示例保留为参考内容。 +

+
+ +
+
+ + +

最近进度

+ {loading ? ( +
+
+
+
+ ) : latestJob ? ( +
+
+ + {jobStatusLabel(latestJob.status)} + + + {formatRelativeTime(latestJob.updated_at)} + +
+

{latestJob.source}

+
+
+
+ + 查看任务历史 + +
+ ) : ( +

+ 还没有生成任务。创建一本笔记后,这里会显示最近处理进度。 +

+ )} + +
+ +
+
+ {FILTERS.map((f) => ( + + ))}
- - {loading ? "" : `${items.length} 个笔记本`} + + + {loading ? "加载中" : `${items.length} 个笔记本`}
- {/* 卡片 grid */} -
- {/* 新建卡 */} +
{loading ? Array.from({ length: 7 }).map((_, i) => ( -
-
-
-
-
-
+ +
+
+
+
+ )) - : items.map(item => { - const { icon: Icon, color } = domainStyle(item.domain); - return ( - - {item.mine && ( - - )} - - - - -

- {item.title} -

-
- - {formatDuration(item.duration_sec)} - - - {item.chapters} 章 - - - - {item.domain} - - {item.mine && ( - - 我的 - - )} - -
- -
- ); - })} -
+ : items.map((item) => ( + { + setDeleteError(null); + setConfirmDelete(value); + }} + /> + ))} - {!loading && items.length === 0 && ( -
- {filter === "mine" - ? "还没有私有笔记。点「新建笔记本」提交一个视频试试。" - : "暂时没有可显示的笔记。"} -
- )} + {!loading && items.length === 0 ? ( + setCreateOpen(true)} query={query} /> + ) : null} +
- {/* 新建笔记本弹层 */} - {createOpen && ( + {createOpen ? ( setCreateOpen(false)} role="dialog" aria-modal="true" @@ -257,29 +458,68 @@ function NotebooksInner() { exit={{ opacity: 0, y: 16, scale: 0.98 }} transition={{ type: "spring", stiffness: 300, damping: 30 }} className="w-full max-w-xl" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >
新建笔记本 - +
- )} + ) : null} + + {confirmDelete ? ( + setConfirmDelete(null)} + role="dialog" + aria-modal="true" + aria-labelledby="delete-note-title" + > + e.stopPropagation()}> +

+ 删除这本笔记? +

+

+ “{confirmDelete.title}” 会从你的笔记库中移除,相关产物文件也会被清理。此操作不可撤销。 +

+ {deleteError ? ( +

+ 删除失败:{deleteError} +

+ ) : null} +
+ + +
+
+
+ ) : null}
); } export default function NotebooksPage() { - return ; + return ( + + + + ); } diff --git a/web/app/page.tsx b/web/app/page.tsx index 892557c..ccf6975 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,165 +1,343 @@ "use client"; import Link from "next/link"; -import { motion } from "framer-motion"; +import { motion, useReducedMotion } from "framer-motion"; import { - ArrowRight, BookOpenCheck, Captions, GraduationCap, Lightbulb, - ListTree, UploadCloud, + ArrowRight, + BookOpenCheck, + Code2, + GraduationCap, + MessageCircleQuestion, + Play, + Presentation, + Search, + Sparkles, + UploadCloud, } from "lucide-react"; -import NavBar from "@/components/NavBar"; +import type { ReactNode } from "react"; + +import { BrandMark } from "@/components/brand/BrandMark"; +import { Card, Chip } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; -import { - BilingualQuizTimelineVisual, - ChaptersTimelineVisual, - UploadTimelineVisual, -} from "@/components/LandingTimelineVisuals"; -/** - * 营销 landing(对标 notebooklm.google 官网首页):纯介绍,不展示任何 - * 个人笔记/书签。登录后的应用首页在 /notebooks。 - */ +const PROCESS_STEPS = [ + { + icon: UploadCloud, + title: "导入视频", + desc: "上传课程、教程、讲座或长视频,先把分散内容放进一个学习入口。", + meta: "Video source", + }, + { + icon: BookOpenCheck, + title: "生成章节笔记", + desc: "自动拆分章节,提炼重点,把视频整理成可以复习的学习路径。", + meta: "Structured notes", + }, + { + icon: MessageCircleQuestion, + title: "围绕内容问答", + desc: "基于原视频片段继续追问、复习,并定位回对应时间点。", + meta: "Grounded Q&A", + }, +] as const; -const HOW_PEOPLE_USE = [ +const USE_CASES = [ { icon: GraduationCap, - title: "高效助学", - desc: "把网课、讲座录像交给 NoteGen,用章节和知识点速览快速建立全局,再按时间戳精读难点。", - tagline: "加速学习进程,深化理解层次。", + title: "在线课程", + desc: "整理课程章节、关键知识点和复习路径。", }, { - icon: BookOpenCheck, - title: "考研复习", - desc: "专业课长视频自动拆成章节卡片,配术语表和章末小测,复习时直接当提纲用。", - tagline: "把 3 小时网课变成 10 分钟提纲。", + icon: Code2, + title: "技术教程", + desc: "记录操作步骤、关键代码片段和时间点。", + }, + { + icon: Presentation, + title: "讲座访谈", + desc: "提取观点、人物关系和内容时间线。", }, { - icon: Lightbulb, - title: "快速回顾", - desc: "看过的教程想找某个细节?打开笔记搜关键词,点时间戳跳回原片对应位置。", - tagline: "再也不用拖进度条找内容。", + icon: Search, + title: "复习备考", + desc: "把长视频变成可搜索、可回看的复习材料。", }, -]; +] as const; -export default function LandingPage() { - const { user } = useAuth(); - const primaryHref = user ? "/notebooks" : "/login?next=/notebooks"; +function LandingLinkButton({ + children, + href, + variant = "primary", +}: { + children: ReactNode; + href: string; + variant?: "primary" | "secondary" | "ghost"; +}) { + return ( + + {children} + + ); +} +function LandingNav({ primaryHref, primaryLabel }: { primaryHref: string; primaryLabel: string }) { return ( -
- - - {/* Hero:居中大标题 + 单 CTA(NotebookLM 式) */} -
- -

- 看懂 - - 任何视频 - -

-

- NoteGen 把 B 站、YouTube 和本地的长视频整理成结构化笔记—— - 章节、摘要、术语表、小测,每条内容都能跳回原片对应时间点。 -

+
+
+ + {primaryLabel} + + + + + ); +} - {/* 特性区标题 */} -

- AI 赋能的学习伙伴 -

+function ProductPreview() { + const reduceMotion = useReducedMotion(); - {/* 特性 1:上传来源(左文右图) */} -
-
+ return ( +
+
+ +
- -

上传来源

-

- 粘贴 B 站 / YouTube 链接,或直接拖入本地视频文件。NoteGen 自动完成 - 语音识别、术语修正和内容理解,支持选择下载画质,长视频也能稳定处理。 +

+ Product preview

+

线性代数公开课

- + +
-
- {/* 特性 2:章节与知识点(右文左图) */} -
-
-
- +
+
+ 03:11 + 16:42
-
- -

章节与知识点

-

- 大模型按主题边界把视频拆成章节,配秒级时间戳。知识点卡片带关键帧截图、 - 重难点标记和一句话摘要,点哪条就跳回视频哪一秒。 -

+
+
+ +
+
+ {[ + ["00:00", "课程目标与学习路线"], + ["03:11", "矩阵变换的直觉"], + ["08:42", "特征值如何定位重点"], + ].map(([time, title], index) => ( + +

{time}

+

{title}

+
+ ))} +
+ +
+
+ + 笔记摘要 + +

+ 矩阵可以被理解为对空间的变换。复习时先看几何直觉,再回到公式推导。 +

+
+
+

问答

+

+ “为什么特征向量方向不变?” 已定位到 08:42 附近的讲解片段。 +

+
+
+
+ +
+ ); +} + +function ProcessCard({ + index, + step, +}: { + index: number; + step: (typeof PROCESS_STEPS)[number]; +}) { + const Icon = step.icon; + + return ( + +
+ + + + 0{index + 1} + +
+

{step.title}

+

{step.desc}

+
+
+
+

{step.meta}

+ + ); +} + +function UseCaseCard({ item }: { item: (typeof USE_CASES)[number] }) { + const Icon = item.icon; + + return ( +
+
+ ); +} + +export default function LandingPage() { + const { user } = useAuth(); + const primaryHref = user ? "/notebooks" : "/login?next=/notebooks"; + const primaryLabel = user ? "进入笔记本库" : "开始使用"; + const heroCtaLabel = user ? "进入笔记本库" : "开始记录"; + const finalCtaLabel = user ? "进入笔记本库" : "开始创建笔记"; + + return ( +
+ + +
+
+
+ + + +

+ 把视频折叠成可回看的学习笔记 +

+

+ 上传或导入视频后,NoteGen 会自动整理章节、提炼重点,并支持围绕内容继续提问。适合课程、讲座、教程和长视频学习。 +

+
+ + {heroCtaLabel} + + 查看示例 + +
+
+ + +
- {/* 特性 3:双语 + 小测(左文右图) */} -
-
-
- -

双语笔记与自测

-

- 全部笔记内容中英双语一键切换;每章末自动生成小测题和跨段术语表, - 学完即测,复习有抓手。 +

+
+
+

How It Works

+

+ 从一段视频,到一套可以复习的笔记 +

+

+ 首页只展示静态模拟流程,重点让用户理解视频、章节、笔记和问答之间的关系。

- +
+ {PROCESS_STEPS.map((step, index) => ( + + ))} +
- {/* 大家这样用 */} -
-

- 大家这样用 NoteGen -

-
- {HOW_PEOPLE_USE.map(({ icon: Icon, title, desc, tagline }) => ( -
- -

{title}

-

{desc}

-

{tagline}

+
+
+
+
+

Use Cases

+

+ 适合所有需要“看完还要记住”的内容 +

+

+ 不把首页变成工作台,只用清晰场景帮助用户判断 NoteGen 是否适合自己的学习材料。 +

+
+
+ {USE_CASES.map((item) => ( + + ))} +
+
+ + +
+ +

+ 准备把你的第一个视频变成笔记吗? +

+

+ 无需复杂设置,从一个视频开始。 +

+
+ + {finalCtaLabel} +
- ))} +
- {/* 底部 CTA + footer */} -
-

开始你的第一篇视频笔记

- - {user ? "进入我的笔记本" : "试用 NoteGen"} - -
-
- NoteGen +
+ NoteGen · - 教学视频结构化笔记 + 视频学习笔记生成工具
); diff --git a/web/app/register/page.tsx b/web/app/register/page.tsx index 05827cf..e0d367c 100644 --- a/web/app/register/page.tsx +++ b/web/app/register/page.tsx @@ -2,11 +2,14 @@ import { useState } from "react"; import Link from "next/link"; -import { ArrowRight, CheckCircle2, Loader2, Sparkles, UserPlus } from "lucide-react"; +import { ArrowRight, CheckCircle2, UserPlus } from "lucide-react"; + +import { BrandMark } from "@/components/brand/BrandMark"; import NavBar from "@/components/NavBar"; +import { Button, Card, Chip, Input } from "@/components/ui"; +import { useAuth } from "@/components/AuthContext"; import { apiRegister } from "@/lib/auth"; import { ApiError } from "@/lib/api"; -import { useAuth } from "@/components/AuthContext"; export default function RegisterPage() { const { refresh } = useAuth(); @@ -20,95 +23,143 @@ export default function RegisterPage() { async function submit(e: React.FormEvent) { e.preventDefault(); setErr(null); - if (password.length < 8) { setErr("密码至少 8 位"); return; } + + if (password.length < 8) { + setErr("密码至少需要 8 位。"); + return; + } + setBusy(true); try { const r = await apiRegister(email.trim(), password, displayName.trim() || email.trim()); setDone(r.message); await refresh(); } catch (e) { - setErr(e instanceof ApiError ? e.message : "注册失败,后端是否启动?"); + setErr(e instanceof ApiError ? e.message : "注册失败,请确认后端服务是否已启动。"); } finally { setBusy(false); } } + const errorId = err ? "register-error" : undefined; + return ( -
+
-
- + + Private Workspace -
-

- 为你的学习视频
建一个私有空间。 + +

+ 为你的学习视频 +
+ 建一个私有空间

-

- 注册后,每次提交的视频、生成的笔记和运行历史都会归到你的账号下。 +

+ 注册后,每次提交的视频、生成的笔记和运行历史都会归到你的账号下,方便持续复习和追踪进度。

-
+ {done ? (
- -

注册成功

-

{done}

-

- 开发环境不发真邮件。验证链接会打印在 api 进程控制台,打开后即可登录。 +

+ 注册成功 +

+

{done}

+

+ 开发环境不发送真实邮件。验证链接会打印在 api 进程控制台,打开后即可登录。

- - 去登录 + + 去登录
) : ( <>
-

注册

-

创建 NoteGen 账号。

+

+ 注册 +

+

创建 NoteGen 账号。

-
- setEmail(e.target.value)} - className="h-11 w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg)] px-3 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" - /> - setDisplayName(e.target.value)} - className="h-11 w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg)] px-3 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" - /> - setPassword(e.target.value)} - className="h-11 w-full rounded-[8px] border border-[var(--border)] bg-[var(--bg)] px-3 text-sm outline-none transition-colors placeholder:text-[var(--fg-tertiary)] focus:border-[var(--accent)]" - /> - {err &&

{err}

} - +
-

+

已有账号? - - 登录 + + 登录

)} -
+
); diff --git a/web/components/NavBar.tsx b/web/components/NavBar.tsx index a6dbac1..8cce153 100644 --- a/web/components/NavBar.tsx +++ b/web/components/NavBar.tsx @@ -1,85 +1,99 @@ "use client"; + import Link from "next/link"; -import { Sparkles, Layers, WifiOff, Bookmark } from "lucide-react"; -import ThemeToggle from "./ThemeToggle"; -import LangToggle from "./LangToggle"; +import { Bookmark, Layers, WifiOff } from "lucide-react"; + +import { BrandMark } from "@/components/brand/BrandMark"; +import { Button, Chip } from "@/components/ui"; +import { useBookmarksList } from "@/lib/bookmarks"; import { useAuth } from "./AuthContext"; -import UserMenu from "./UserMenu"; +import LangToggle from "./LangToggle"; import { useLang } from "./LangContext"; -import { useBookmarksList } from "@/lib/bookmarks"; +import ThemeToggle from "./ThemeToggle"; +import UserMenu from "./UserMenu"; /** - * 全站共享导航栏。粘性 glass 风格,左侧 logo + brand,右侧 ThemeToggle。 - * children 是可选的中间内容(如详情页传当前笔记标题)。 + * Shared app navigation. Uses the Warm Fold brand mark and tokens, + * while keeping route behavior unchanged for existing pages. */ export default function NavBar({ children }: { children?: React.ReactNode }) { const { user, loading, offline } = useAuth(); const { lang } = useLang(); const bmCount = useBookmarksList().length; + return ( -
-
- - - - - NoteGen +
+
+ + + {children ? ( -
{children}
+
{children}
) : (
)} - {user && - - {lang === "en" ? "Bookmarks" : "书签"} - {bmCount > 0 && ( - - {bmCount} - - )} - } + + {user ? ( + +
); diff --git a/web/components/NoteWorkspace.tsx b/web/components/NoteWorkspace.tsx index 771f364..9b63244 100644 --- a/web/components/NoteWorkspace.tsx +++ b/web/components/NoteWorkspace.tsx @@ -24,6 +24,8 @@ import { useChapterProgress } from "@/lib/progress"; import { useLang, pickByLang } from "@/components/LangContext"; import { useAuth } from "@/components/AuthContext"; +type RailTab = "chapters" | "transcript"; + interface Props { noteId: string; bundle: NoteBundle; @@ -92,6 +94,111 @@ export function WorkspaceSkeleton({ backHref }: { backHref: string }) { ); } +function WorkspaceRail({ + bundle, + chaptersDone, + currentChapter, + currentTime, + lang, + onSeek, + railTab, + setRailTab, + shared, + sourceLabel, + sourceUrl, + title, + toggleChapterDone, +}: { + bundle: NoteBundle; + chaptersDone: number[]; + currentChapter: number; + currentTime: number; + lang: "zh" | "en"; + onSeek: (sec: number) => void; + railTab: RailTab; + setRailTab: (tab: RailTab) => void; + shared: boolean; + sourceLabel: string; + sourceUrl: string; + title: string; + toggleChapterDone: (idx: number) => void; +}) { + return ( + <> +

+ {lang === "en" ? "Source" : "来源"} +

+ {sourceUrl ? ( + + + + {sourceLabel} + + {bundle.meta?.uploader || title} + + + + ) : ( +
+ + + {sourceLabel} + + {bundle.meta?.uploader || title} + + +
+ )} +
+ {([ + ["chapters", lang === "en" ? "Chapters" : "章节"], + ["transcript", lang === "en" ? "Transcript" : "逐字稿"], + ] as const).map(([key, label]) => { + const active = railTab === key; + return ( + + ); + })} +
+ {railTab === "chapters" ? ( + bundle.chapters.length > 0 ? ( + + ) : ( +

+ {lang === "en" ? "No chapters." : "这篇笔记没有章节数据。"} +

+ ) + ) : ( + + )} + + ); +} + /** * 三栏笔记工作台(docs/frontend-redesign.md §3.2)。 * /notes/[id] 与 /s/[token] 共用;后者以 shared 模式渲染。 @@ -112,7 +219,7 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false const [mainInView, setMainInView] = useState(true); const [miniDismissed, setMiniDismissed] = useState(false); const [railOpen, setRailOpen] = useState(false); // ("chapters"); + const [railTab, setRailTab] = useState("chapters"); const [shareToken, setShareToken] = useState(null); const [shareCopied, setShareCopied] = useState(false); const [shareHint, setShareHint] = useState(null); @@ -266,82 +373,8 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false : (lang === "en" ? "Web link" : "网页链接")) : (lang === "en" ? "Local file" : "本地上传"); - // 左栏内容:桌面 aside 与 void) => ( - <> -

- {lang === "en" ? "Source" : "来源"} -

- {sourceUrl ? ( - - - - {sourceLabel} - - {bundle.meta?.uploader || title} - - - - ) : ( -
- - - {sourceLabel} - - {bundle.meta?.uploader || title} - - -
- )} - {/* 章节 | 逐字稿 tab 切换 */} -
- {([["chapters", lang === "en" ? "Chapters" : "章节"], - ["transcript", lang === "en" ? "Transcript" : "逐字稿"]] as const).map(([key, label]) => { - const active = railTab === key; - return ( - - ); - })} -
- {railTab === "chapters" ? ( - bundle.chapters.length > 0 ? ( - - ) : ( -

- {lang === "en" ? "No chapters." : "这篇笔记没有章节数据。"} -

- ) - ) : ( - - )} - - ); - return ( -
+
+
{/* 左栏:来源 + 垂直章节(仅 lg; - {renderRail(seek)} + {/* 中栏:笔记内容 + 问答 */} @@ -418,9 +465,9 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false {/* 右栏:视频 + 当前章 + 工具组( -
+
- {renderRail(seekAndCloseRail)} + )} diff --git a/web/components/brand/BrandMark.tsx b/web/components/brand/BrandMark.tsx new file mode 100644 index 0000000..990210d --- /dev/null +++ b/web/components/brand/BrandMark.tsx @@ -0,0 +1,74 @@ +import type { SVGAttributes } from "react"; + +import { + FOLD_PATHS, + INK_PATHS, + LOGO_VIEWBOX, + LOGO_WIDTH, + POINTER_BAND, + POINTER_TRIANGLE, + WORDMARK_PATH, +} from "./brandPaths"; + +type BrandMarkVariant = "mark" | "full"; +type BrandMarkSize = "sm" | "md" | "lg" | number; + +export interface BrandMarkProps extends Omit, "aria-label"> { + variant?: BrandMarkVariant; + size?: BrandMarkSize; + label?: string; +} + +const MARK_SIZE = { + sm: 16, + md: 24, + lg: 32, +} as const; + +const LOGO_SIZE = { + sm: 120, + md: 148, + lg: 176, +} as const; + +function resolveSize(size: BrandMarkSize, variant: BrandMarkVariant) { + if (typeof size === "number") return size; + return variant === "full" ? LOGO_SIZE[size] : MARK_SIZE[size]; +} + +export function BrandMark({ + className, + label, + size = "md", + variant = "mark", + ...props +}: BrandMarkProps) { + const resolvedSize = resolveSize(size, variant); + const isFull = variant === "full"; + const aspectRatio = isFull ? LOGO_WIDTH / 64 : 1; + const width = resolvedSize; + const height = Math.round(resolvedSize / aspectRatio); + const accessibilityProps = label + ? ({ role: "img", "aria-label": label } as const) + : ({ "aria-hidden": true } as const); + + return ( + + + + + + {isFull ? : null} + + ); +} diff --git a/web/components/brand/__tests__/BrandMark.test.tsx b/web/components/brand/__tests__/BrandMark.test.tsx new file mode 100644 index 0000000..0495d8d --- /dev/null +++ b/web/components/brand/__tests__/BrandMark.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { BrandMark } from "../BrandMark"; + +describe("BrandMark", () => { + it("is decorative without a label", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveAttribute("aria-hidden", "true"); + expect(svg).toHaveClass("brand"); + }); + + it("exposes an accessible name when labeled", () => { + render(); + + expect(screen.getByRole("img", { name: "NoteGen home" })).toBeInTheDocument(); + }); + + it("uses currentColor for ink and the brand token for the pointer", () => { + const { container } = render(); + + expect(container.querySelector('[data-part="ink"]')).toHaveAttribute("fill", "currentColor"); + expect(container.querySelector('[data-part="pointer"]')).toHaveAttribute( + "fill", + "var(--wf-brand-coral, #B65C3A)", + ); + }); +}); diff --git a/web/components/brand/brandPaths.ts b/web/components/brand/brandPaths.ts new file mode 100644 index 0000000..d6c9f9f --- /dev/null +++ b/web/components/brand/brandPaths.ts @@ -0,0 +1,8 @@ +// Generated by scripts/build-brand-assets.mjs. +export const INK_PATHS = ["M8 6L25 14V40L8 58Z","M39 26L56 18V48L39 58Z"] as const; +export const FOLD_PATHS = ["M8 6L25 14L20 20Z","M39 26L56 18L47 31Z"] as const; +export const POINTER_BAND = "M23 19L42 36V46L23 29Z"; +export const POINTER_TRIANGLE = "M43 44L51 49L43 54Z"; +export const WORDMARK_PATH = "M82.602 47L75.610 47L75.610 46.540L77.128 46.080Q77.956 45.850 78.324 45.183Q78.692 44.516 78.692 43.596L78.692 21.470Q78.186 20.366 77.887 19.814Q77.588 19.262 77.220 18.871Q76.852 18.480 76.208 17.882L75.426 17.100L75.426 16.640L83.568 16.640L98.840 37.478L98.840 20.136Q98.840 19.216 98.541 18.480Q98.242 17.744 97.322 17.514L95.804 17.100L95.804 16.640L102.382 16.640L102.382 17.100L101.140 17.514Q100.312 17.790 100.128 18.480Q99.944 19.170 99.944 20.090L99.944 47.138L97.368 47.138L79.796 22.988L79.796 43.596Q79.796 44.516 80.072 45.160Q80.348 45.804 81.222 46.080L82.602 46.540L82.602 47M116.596 47.690Q113.100 47.690 110.524 46.241Q107.948 44.792 106.545 42.124Q105.142 39.456 105.142 35.868Q105.142 32.280 106.637 29.658Q108.132 27.036 110.731 25.610Q113.330 24.184 116.596 24.184Q119.862 24.184 122.438 25.587Q125.014 26.990 126.509 29.612Q128.004 32.234 128.004 35.868Q128.004 39.502 126.601 42.147Q125.198 44.792 122.645 46.241Q120.092 47.690 116.596 47.690M116.596 46.770Q118.206 46.770 119.172 45.850Q120.138 44.930 120.575 42.584Q121.012 40.238 121.012 35.960Q121.012 31.636 120.575 29.290Q120.138 26.944 119.172 26.024Q118.206 25.104 116.596 25.104Q114.986 25.104 113.997 26.024Q113.008 26.944 112.571 29.290Q112.134 31.636 112.134 35.960Q112.134 40.238 112.571 42.584Q113.008 44.930 113.997 45.850Q114.986 46.770 116.596 46.770M138.768 47.690Q135.824 47.690 134.191 46.218Q132.558 44.746 132.558 41.526L132.558 25.794L129.384 25.794L129.384 25.334L130.488 25.150Q131.914 24.828 132.949 24.230Q133.984 23.632 134.996 22.620L138.768 18.434L139.228 18.434L139.090 24.874L144.242 24.874L144.242 25.794L139.044 25.794L139.044 42.354Q139.044 43.964 139.757 44.792Q140.470 45.620 141.574 45.620Q142.540 45.620 143.276 45.137Q144.012 44.654 144.748 43.826L145.208 44.286Q144.288 45.850 142.678 46.770Q141.068 47.690 138.768 47.690M158.180 24.184Q161.170 24.184 163.286 25.403Q165.402 26.622 166.529 28.715Q167.656 30.808 167.656 33.476Q167.656 33.982 167.610 34.511Q167.564 35.040 167.426 35.454L153.718 35.454Q153.764 40.422 155.489 42.607Q157.214 44.792 160.618 44.792Q162.918 44.792 164.344 44.056Q165.770 43.320 167.012 41.894L167.472 42.308Q166.046 44.838 163.677 46.264Q161.308 47.690 157.996 47.690Q154.730 47.690 152.200 46.287Q149.670 44.884 148.244 42.262Q146.818 39.640 146.818 36.006Q146.818 32.234 148.474 29.589Q150.130 26.944 152.729 25.564Q155.328 24.184 158.180 24.184M158.042 25.104Q156.708 25.104 155.765 25.909Q154.822 26.714 154.293 28.761Q153.764 30.808 153.718 34.534L161.584 34.534Q162.044 29.520 161.216 27.312Q160.388 25.104 158.042 25.104M185.780 47.828Q181.088 47.828 177.523 45.873Q173.958 43.918 171.957 40.307Q169.956 36.696 169.956 31.774Q169.956 26.990 171.934 23.402Q173.912 19.814 177.661 17.813Q181.410 15.812 186.746 15.812Q189.184 15.812 191.277 16.364Q193.370 16.916 195.256 17.974L195.440 24.414L194.980 24.414L192.404 19.630Q191.806 18.480 191.300 17.974Q190.794 17.468 190.012 17.192Q189.276 16.962 188.655 16.847Q188.034 16.732 187.022 16.732Q184.170 16.732 181.985 18.250Q179.800 19.768 178.604 23.080Q177.408 26.392 177.408 31.774Q177.408 37.110 178.535 40.468Q179.662 43.826 181.732 45.367Q183.802 46.908 186.516 46.908Q186.930 46.908 187.321 46.839Q187.712 46.770 188.080 46.678Q188.954 46.540 189.230 45.942Q189.506 45.344 189.506 44.516L189.506 35.408Q189.506 33.614 187.666 33.200L185.550 32.648L185.550 32.188L198.522 32.188L198.522 32.648L197.878 32.832Q196.130 33.384 196.130 35.132L196.130 45.114Q193.738 46.448 191.277 47.138Q188.816 47.828 185.780 47.828M211.678 24.184Q214.668 24.184 216.784 25.403Q218.900 26.622 220.027 28.715Q221.154 30.808 221.154 33.476Q221.154 33.982 221.108 34.511Q221.062 35.040 220.924 35.454L207.216 35.454Q207.262 40.422 208.987 42.607Q210.712 44.792 214.116 44.792Q216.416 44.792 217.842 44.056Q219.268 43.320 220.510 41.894L220.970 42.308Q219.544 44.838 217.175 46.264Q214.806 47.690 211.494 47.690Q208.228 47.690 205.698 46.287Q203.168 44.884 201.742 42.262Q200.316 39.640 200.316 36.006Q200.316 32.234 201.972 29.589Q203.628 26.944 206.227 25.564Q208.826 24.184 211.678 24.184M211.540 25.104Q210.206 25.104 209.263 25.909Q208.320 26.714 207.791 28.761Q207.262 30.808 207.216 34.534L215.082 34.534Q215.542 29.520 214.714 27.312Q213.886 25.104 211.540 25.104M234.264 47L223.454 47L223.454 46.540L224.098 46.356Q225.708 45.896 225.708 44.102L225.708 29.704Q225.708 28.692 225.386 28.209Q225.064 27.726 224.144 27.542L223.454 27.358L223.454 26.898L231.320 24.230L231.780 24.690L232.148 27.450Q233.758 26.024 235.782 25.104Q237.806 24.184 239.784 24.184Q242.820 24.184 244.453 25.840Q246.086 27.496 246.086 30.900L246.086 44.148Q246.086 45.942 247.834 46.402L248.248 46.540L248.248 47L237.392 47L237.392 46.540L237.990 46.356Q239.600 45.850 239.600 44.102L239.600 29.428Q239.600 26.484 236.656 26.484Q234.632 26.484 232.240 28.508L232.240 44.148Q232.240 45.942 233.850 46.402L234.264 46.540"; +export const LOGO_WIDTH = 257; +export const LOGO_VIEWBOX = "0 0 257 64"; diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx new file mode 100644 index 0000000..f32300e --- /dev/null +++ b/web/components/ui/Button.tsx @@ -0,0 +1,42 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { LoaderCircle } from "lucide-react"; +import clsx from "clsx"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost" | "danger"; + size?: "sm" | "md" | "lg"; + loading?: boolean; +} + +export const Button = forwardRef(function Button( + { + className, + children, + disabled, + loading = false, + size = "md", + type = "button", + variant = "primary", + ...props + }, + ref, +) { + return ( + + ); +}); + +export default Button; diff --git a/web/components/ui/Card.tsx b/web/components/ui/Card.tsx new file mode 100644 index 0000000..8d9600e --- /dev/null +++ b/web/components/ui/Card.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface CardProps extends HTMLAttributes { + variant?: "surface" | "muted" | "outlined"; + padding?: "none" | "sm" | "md" | "lg"; +} + +export const Card = forwardRef(function Card( + { className, variant = "surface", padding = "md", ...props }, + ref, +) { + return ( +
+ ); +}); + +export default Card; diff --git a/web/components/ui/Chip.tsx b/web/components/ui/Chip.tsx new file mode 100644 index 0000000..9b4a283 --- /dev/null +++ b/web/components/ui/Chip.tsx @@ -0,0 +1,16 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface ChipProps extends HTMLAttributes { + variant?: "neutral" | "accent" | "danger"; + size?: "sm" | "md"; +} + +export const Chip = forwardRef(function Chip( + { className, variant = "neutral", size = "md", ...props }, + ref, +) { + return ; +}); + +export default Chip; diff --git a/web/components/ui/IconButton.tsx b/web/components/ui/IconButton.tsx new file mode 100644 index 0000000..e60ecf7 --- /dev/null +++ b/web/components/ui/IconButton.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { LoaderCircle } from "lucide-react"; +import clsx from "clsx"; + +type NativeIconButtonProps = Omit, "aria-label">; + +export interface IconButtonProps extends NativeIconButtonProps { + "aria-label": string; + variant?: "ghost" | "secondary" | "danger"; + size?: "sm" | "md" | "lg"; + loading?: boolean; +} + +export const IconButton = forwardRef(function IconButton( + { + className, + children, + disabled, + loading = false, + size = "md", + type = "button", + variant = "ghost", + ...props + }, + ref, +) { + return ( + + ); +}); + +export default IconButton; diff --git a/web/components/ui/Input.tsx b/web/components/ui/Input.tsx new file mode 100644 index 0000000..2711aeb --- /dev/null +++ b/web/components/ui/Input.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface InputProps extends Omit, "size"> { + size?: "sm" | "md" | "lg"; + invalid?: boolean; +} + +export const Input = forwardRef(function Input( + { "aria-invalid": ariaInvalid, className, invalid = false, size = "md", ...props }, + ref, +) { + return ( + + ); +}); + +export default Input; diff --git a/web/components/ui/__tests__/Button.test.tsx b/web/components/ui/__tests__/Button.test.tsx new file mode 100644 index 0000000..65f7746 --- /dev/null +++ b/web/components/ui/__tests__/Button.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Button } from "../Button"; + +describe("Button", () => { + it("defaults to a non-submit button", () => { + render(); + + expect(screen.getByRole("button", { name: "Start" })).toHaveAttribute("type", "button"); + }); + + it("allows submit type when explicitly requested", () => { + render(); + + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "submit"); + }); + + it("keeps text semantics while loading", () => { + render(); + const button = screen.getByRole("button", { name: "Saving" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button.querySelector(".wf-button__spinner")).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/web/components/ui/__tests__/IconButton.test.tsx b/web/components/ui/__tests__/IconButton.test.tsx new file mode 100644 index 0000000..9b61877 --- /dev/null +++ b/web/components/ui/__tests__/IconButton.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { IconButton } from "../IconButton"; + +describe("IconButton", () => { + it("requires and exposes an accessible label", () => { + render(x); + + expect(screen.getByRole("button", { name: "Close" })).toHaveAttribute("type", "button"); + }); + + it("keeps the label while loading", () => { + render( + + R + , + ); + const button = screen.getByRole("button", { name: "Refresh" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button.querySelector(".wf-icon-button__spinner")).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/web/components/ui/__tests__/surface-primitives.test.tsx b/web/components/ui/__tests__/surface-primitives.test.tsx new file mode 100644 index 0000000..379e240 --- /dev/null +++ b/web/components/ui/__tests__/surface-primitives.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Card } from "../Card"; +import { Chip } from "../Chip"; +import { Input } from "../Input"; + +describe("surface primitives", () => { + it("keeps Card semantically neutral", () => { + const { container } = render(Notes); + + expect(container.firstChild).toHaveAttribute("data-variant", "outlined"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("keeps Chip presentational", () => { + render(Course); + + expect(screen.getByText("Course").tagName).toBe("SPAN"); + }); + + it("forwards native Input relationships and exposes invalid state", () => { + render( + , + ); + const input = screen.getByRole("textbox"); + + expect(input).toHaveAttribute("name", "email"); + expect(input).toHaveAttribute("aria-describedby", "email-help"); + expect(input).toHaveAttribute("aria-errormessage", "email-error"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).not.toHaveAttribute("size"); + expect(input).toHaveAttribute("data-size", "lg"); + }); +}); diff --git a/web/components/ui/index.ts b/web/components/ui/index.ts new file mode 100644 index 0000000..f852619 --- /dev/null +++ b/web/components/ui/index.ts @@ -0,0 +1,5 @@ +export { Button, type ButtonProps } from "./Button"; +export { Card, type CardProps } from "./Card"; +export { Chip, type ChipProps } from "./Chip"; +export { IconButton, type IconButtonProps } from "./IconButton"; +export { Input, type InputProps } from "./Input"; diff --git a/web/public/brand/brand-logo.svg b/web/public/brand/brand-logo.svg new file mode 100644 index 0000000..9ef8856 --- /dev/null +++ b/web/public/brand/brand-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/brand/brand-mark.svg b/web/public/brand/brand-mark.svg new file mode 100644 index 0000000..06cd29d --- /dev/null +++ b/web/public/brand/brand-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/brand/favicon.svg b/web/public/brand/favicon.svg new file mode 100644 index 0000000..e90f575 --- /dev/null +++ b/web/public/brand/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/scripts/build-brand-assets.mjs b/web/scripts/build-brand-assets.mjs new file mode 100644 index 0000000..9910b8e --- /dev/null +++ b/web/scripts/build-brand-assets.mjs @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import opentype from "opentype.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = path.join(root, "public", "brand"); +const componentDir = path.join(root, "components", "brand"); +const fontFile = path.join( + root, + "node_modules", + "@fontsource", + "dm-serif-display", + "files", + "dm-serif-display-latin-400-normal.woff", +); + +fs.mkdirSync(outDir, { recursive: true }); +fs.mkdirSync(componentDir, { recursive: true }); + +const inkPaths = ["M8 6L25 14V40L8 58Z", "M39 26L56 18V48L39 58Z"]; +const foldPaths = ["M8 6L25 14L20 20Z", "M39 26L56 18L47 31Z"]; +const pointerBand = "M23 19L42 36V46L23 29Z"; +const pointerTriangle = "M43 44L51 49L43 54Z"; + +const font = opentype.parse(fs.readFileSync(fontFile).buffer); +const wordmark = font.getPath("NoteGen", 74, 47, 46); +const wordmarkPath = wordmark.toPathData(3); +const wordmarkBounds = wordmark.getBoundingBox(); +const logoWidth = Math.ceil(wordmarkBounds.x2 + 8); + +const markPaths = ` + + + + `; + +const svg = (viewBox, body, attrs = "") => + `${body}\n\n`; + +fs.writeFileSync(path.join(outDir, "brand-mark.svg"), svg("0 0 64 64", markPaths, 'data-min-size="16"')); +fs.writeFileSync( + path.join(outDir, "brand-logo.svg"), + svg( + `0 0 ${logoWidth} 64`, + `${markPaths}\n `, + 'data-min-width="120"', + ), +); +fs.writeFileSync( + path.join(outDir, "favicon.svg"), + svg( + "0 0 64 64", + `\n \n `, + 'data-min-size="16"', + ), +); + +const ts = + "// Generated by scripts/build-brand-assets.mjs.\n" + + `export const INK_PATHS = ${JSON.stringify(inkPaths)} as const;\n` + + `export const FOLD_PATHS = ${JSON.stringify(foldPaths)} as const;\n` + + `export const POINTER_BAND = ${JSON.stringify(pointerBand)};\n` + + `export const POINTER_TRIANGLE = ${JSON.stringify(pointerTriangle)};\n` + + `export const WORDMARK_PATH = ${JSON.stringify(wordmarkPath)};\n` + + `export const LOGO_WIDTH = ${logoWidth};\n` + + `export const LOGO_VIEWBOX = ${JSON.stringify(`0 0 ${logoWidth} 64`)};\n`; + +fs.writeFileSync(path.join(componentDir, "brandPaths.ts"), ts); diff --git a/web/scripts/validate-warm-fold.mjs b/web/scripts/validate-warm-fold.mjs new file mode 100644 index 0000000..4b16401 --- /dev/null +++ b/web/scripts/validate-warm-fold.mjs @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const css = fs.readFileSync(path.join(root, "app", "globals.css"), "utf8"); +const assets = [ + ["brand-mark.svg", 4096, 8], + ["brand-logo.svg", 18432, 80], + ["favicon.svg", 4096, 8], +]; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function selectorBlock(selector) { + const start = css.indexOf(selector); + assert(start >= 0, `Missing selector ${selector}`); + const open = css.indexOf("{", start); + let depth = 0; + + for (let index = open; index < css.length; index += 1) { + if (css[index] === "{") depth += 1; + if (css[index] === "}" && --depth === 0) return css.slice(start, index + 1); + } + + throw new Error(`Unclosed selector ${selector}`); +} + +for (const [name, maxBytes, maxPaths] of assets) { + const file = path.join(root, "public", "brand", name); + const source = fs.readFileSync(file, "utf8"); + const document = new JSDOM(source, { contentType: "image/svg+xml" }).window.document; + + assert(document.documentElement.tagName.toLowerCase() === "svg", `${name} is not SVG`); + assert(document.documentElement.hasAttribute("viewBox"), `${name} lacks viewBox`); + assert(!source.includes("