From 3ca62b4e05fe7111ddf5c820876f2bab8262f44a Mon Sep 17 00:00:00 2001 From: lsdragst Date: Fri, 26 Jun 2026 22:30:53 +0800 Subject: [PATCH 1/7] frontend dynamic redesign --- docs/frontend-redesign.md | 53 +++++ web/app/__tests__/landing-model.test.ts | 18 ++ web/app/bookmarks/page.tsx | 11 +- web/app/icon.svg | 4 + web/app/landing-model.ts | 26 +++ web/app/login/page.tsx | 114 +++++----- web/app/notebooks/__tests__/access.test.ts | 35 ++++ web/app/notebooks/__tests__/filter.test.ts | 14 ++ web/app/notebooks/filter.ts | 42 ++++ web/app/notebooks/page.tsx | 194 +++++++++++------- web/app/page.tsx | 72 ++++++- web/app/register/page.tsx | 117 +++++++---- web/components/KeyPointModal.tsx | 10 +- web/components/KeyPointsGrid.tsx | 11 +- web/components/Lightbox.tsx | 21 +- web/components/NavBar.tsx | 21 +- web/components/NoteWorkspace.tsx | 89 +++++++- web/components/RequireAuth.tsx | 16 +- web/components/TranscriptPanel.tsx | 1 - web/components/VlogTimeline.tsx | 11 +- web/components/__tests__/NavBar.test.tsx | 57 +++++ web/components/__tests__/RequireAuth.test.tsx | 34 +++ web/components/auth/AuthBrandStage.tsx | 118 +++++++++++ .../auth/__tests__/AuthBrandStage.test.tsx | 33 +++ web/test/note-workspace-layout.test.ts | 24 +++ 25 files changed, 943 insertions(+), 203 deletions(-) create mode 100644 web/app/__tests__/landing-model.test.ts create mode 100644 web/app/icon.svg create mode 100644 web/app/landing-model.ts create mode 100644 web/app/notebooks/__tests__/access.test.ts create mode 100644 web/app/notebooks/__tests__/filter.test.ts create mode 100644 web/app/notebooks/filter.ts create mode 100644 web/components/__tests__/NavBar.test.tsx create mode 100644 web/components/__tests__/RequireAuth.test.tsx create mode 100644 web/components/auth/AuthBrandStage.tsx create mode 100644 web/components/auth/__tests__/AuthBrandStage.test.tsx create mode 100644 web/test/note-workspace-layout.test.ts diff --git a/docs/frontend-redesign.md b/docs/frontend-redesign.md index 362c067..9741671 100644 --- a/docs/frontend-redesign.md +++ b/docs/frontend-redesign.md @@ -94,11 +94,64 @@ **P2 · 打磨** 进行中 - [x] 加载骨架和错误态已收敛到 `WorkspaceSkeleton` / `WorkspaceError` - [x] 移动端左栏抽屉已实现 +- [x] 公开导航不再暴露 `/mm-ablation` 内部实验页入口 +- [x] 移动端 `ChatPanel` 回到内容流,避免 sticky 遮挡正文 - [ ] 浏览器 smoke:视频播放、seek、分享、导出、QA 轮询、书签同步 - [ ] a11y:三栏 landmark、章节列表 `aria-current`、Lighthouse a11y ≥ 90 - [ ] 删除确认无引用的旧视觉组件和 `AskBar` - 验收:`npx tsc --noEmit` 通过;浏览器 smoke 记录写入 `docs/memory/` +## 4.1 2026-06-25 二次升级计划:电影感首页 + 品牌系统 + 沉浸式登录 + +本轮升级不推翻已经完成的 NotebookLM 式工作台,而是在“入口、品牌、登录注册、关键转场”上增加记忆点。原则是:工作台继续克制高效,品牌表达集中在首页和认证流,避免动效干扰阅读、输入和视频学习。 + +### A. 首页:电影感滚动入口 + +- 首页采用电影感首屏:深色/纸面双色基调、大标题、低速镜头感位移、产品片段分层进入。(已完成首版) +- 滚动节奏参考已确认的视频方向,但不复制房地产内容;改成“导入视频 → 时间线解析 → 笔记生成 → 可追问”的学习叙事。(已完成首版) +- 首页允许使用更明显的动效:纸页展开、时间线滑入、播放指针定位、章节卡片错位进入。 +- 首页 CTA 分清两个动作:`开始使用` 进入登录/笔记本库,`查看示例` 进入公开演示或示例笔记。 +- 移动端不做复杂 scroll takeover,改成一屏一段的线性叙事,保留核心品牌转场。 +- `prefers-reduced-motion: reduce` 下关闭镜头位移和连续动画,只保留静态层级与必要状态变化。 + +### B. Logo:折叠笔记页 + 时间播放指针 + +- Logo 采用“折叠笔记页 + 时间播放指针”的组合,形成简洁的 `N`。 +- 输出三档:完整标志、图形标志、favicon,分别用于导航栏、小尺寸图标和品牌展示。 +- 静态状态保持单色、专业、清晰,保证在导航栏和深浅主题下可读。 +- 动态状态只用于首页和关键转场:纸页展开、时间线滑入、播放指针定位,形成品牌记忆点。 +- 工作台内不高频播放 Logo 动效,避免分散注意力。 + +### C. 登录 / 注册:独立沉浸式认证界面 + +- 登录/注册页不使用普通全站导航,改成独立沉浸式界面。(已完成首版) +- 左侧构建品牌角色系统:由纸页、书签、时间戳组成,角色不是拟真人卡通,而是“笔记人格化”的视觉表达。(已完成首版) +- 邮箱输入聚焦时,角色视线跟随输入位置。(已完成) +- 密码输入时,角色遮住眼睛;显示密码时轻微张开一条缝。(已完成) +- 校验失败时,用轻微动作辅助提示,不使用夸张晃动。(已完成) +- 登录成功时,以轻碰、点头或纸页合拢完成庆祝反馈。(已完成组件状态) +- 背景使用书签和时间线结合的柔性飘带,鼠标经过时产生轻微布料波动,作为品牌化空间元素。(已完成首版) +- 注册页保留更亲切的互动反馈,强调“创建第一个笔记空间”的引导感;登录页更简洁、快速、可信。 +- 移动端保留一个主角色和核心密码反馈,取消复杂悬停交互。 +- 减少动态效果模式下,Logo、角色、飘带全部回落为静态状态。 + +### D. 工作台:延续克制优化 + +- 工作台不做电影感 scroll takeover,保持三栏效率模型。 +- 修复移动端内容优先级:视频不能长期挤占首屏,笔记内容和提问入口要更早出现。 +- 问答入口避免遮挡正文,移动端改成稳定的底部输入或内容流内卡片。 +- 章节抽屉补齐焦点管理、`aria-modal` 语义和关闭后的焦点恢复。 +- 公开导航不暴露内部实验页,`/mm-ablation` 保留为直接访问或开发入口。(已完成) +- 继续清理 lint、无效 eslint-disable、废弃组件引用和图片加载 warning。 + +### E. 分批落地顺序 + +1. **基础修复批次**:计划文档、公开导航收敛、lint 清理、可访问性低风险修复。(已完成首批) +2. **品牌批次**:完善 Logo 三档资产、favicon、导航栏品牌使用规范。(已有 Logo 组件,favicon 待单独验收) +3. **认证批次**:重做登录/注册独立页面,先实现静态布局,再加入角色状态机和密码互动。(已完成首版) +4. **首页批次**:电影感首页和滚动叙事,加入 reduced-motion 回退。(已完成首版) +5. **工作台批次**:移动端排序、问答入口、章节抽屉焦点和浏览器 smoke。 + ## 5. QA 接口(已落地,2026-06-11) GPU 串行约束下走异步队列(RQ "qa" 高优队列,插队在排队 pipeline 任务前): diff --git a/web/app/__tests__/landing-model.test.ts b/web/app/__tests__/landing-model.test.ts new file mode 100644 index 0000000..7f64e6e --- /dev/null +++ b/web/app/__tests__/landing-model.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { CINEMATIC_BEATS } from "../landing-model"; + +describe("landing cinematic model", () => { + it("tells the product story from import to grounded replay", () => { + expect(CINEMATIC_BEATS.map((beat) => beat.id)).toEqual([ + "import", + "timeline", + "notes", + "ask", + ]); + }); + + it("keeps every beat tied to a visible time cue", () => { + expect(CINEMATIC_BEATS.every((beat) => beat.timecode && beat.title && beat.copy)).toBe(true); + }); +}); diff --git a/web/app/bookmarks/page.tsx b/web/app/bookmarks/page.tsx index cc1db76..c0d75cd 100644 --- a/web/app/bookmarks/page.tsx +++ b/web/app/bookmarks/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useMemo, useState } from "react"; +import Image from "next/image"; import Link from "next/link"; import { Bookmark as BookmarkIcon, Play, X, Layers, BookOpen, Settings2, Trash2, Plus, @@ -235,9 +236,13 @@ export default function BookmarksPage() { {b.kind === "chunk" && b.keyframeRel ? ( - + ) : ( + + + diff --git a/web/app/landing-model.ts b/web/app/landing-model.ts new file mode 100644 index 0000000..a85379c --- /dev/null +++ b/web/app/landing-model.ts @@ -0,0 +1,26 @@ +export const CINEMATIC_BEATS = [ + { + id: "import", + timecode: "00:00", + title: "镜头一:导入视频", + copy: "把课程、讲座或教程拖入 NoteGen,系统先建立来源和时间轴。", + }, + { + id: "timeline", + timecode: "03:11", + title: "镜头二:时间线拆分", + copy: "长视频被切成可回看的章节,每个片段都保留原始时间点。", + }, + { + id: "notes", + timecode: "08:42", + title: "镜头三:纸页展开", + copy: "章节、重点和术语折叠成一页可复习的结构化笔记。", + }, + { + id: "ask", + timecode: "12:06", + title: "镜头四:带引用追问", + copy: "围绕视频继续提问,答案回到对应片段,而不是脱离原始内容。", + }, +] as const; diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 5e32848..c05907e 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -3,11 +3,11 @@ import { Suspense, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowRight, CheckCircle2, LogIn } from "lucide-react"; +import { ArrowRight, Eye, EyeOff, LogIn } from "lucide-react"; import { BrandMark } from "@/components/brand/BrandMark"; -import NavBar from "@/components/NavBar"; -import { Button, Card, Chip, Input } from "@/components/ui"; +import { AuthBrandStage, type AuthFocus, type AuthStatus } from "@/components/auth/AuthBrandStage"; +import { Button, Input } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; import { ApiError } from "@/lib/api"; @@ -28,15 +28,20 @@ function LoginInner() { const [err, setErr] = useState(null); const [unverified, setUnverified] = useState(false); const [busy, setBusy] = useState(false); + const [focus, setFocus] = useState("idle"); + const [showPassword, setShowPassword] = useState(false); + const [authStatus, setAuthStatus] = useState("idle"); async function submit(e: React.FormEvent) { e.preventDefault(); setErr(null); setUnverified(false); + setAuthStatus("idle"); setBusy(true); try { await login(email.trim(), password); + setAuthStatus("success"); router.push(next); } catch (e) { if (e instanceof ApiError && e.status === 403) { @@ -45,45 +50,36 @@ function LoginInner() { } else { setErr(e instanceof ApiError ? e.message : "登录失败,请确认后端服务是否已启动。"); } + setAuthStatus("error"); setBusy(false); } } const errorId = err ? "login-error" : undefined; + const stageStatus = err ? "error" : authStatus; return ( -
- -
-
- - - NoteGen Account - -

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

-

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

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

+

NoteGen Account

+

登录 -

-

进入 NoteGen 工作台。

+ +

回到视频笔记工作台,继续整理时间线、书签和问答。

@@ -98,7 +94,13 @@ function LoginInner() { autoComplete="email" placeholder="you@example.com" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + setFocus("email"); + }} + onClick={() => setFocus("email")} + onFocus={() => setFocus("email")} + onBlur={() => setFocus("idle")} aria-describedby={errorId} invalid={Boolean(err)} /> @@ -107,18 +109,38 @@ function LoginInner() { - setPassword(e.target.value)} - aria-describedby={errorId} - invalid={Boolean(err)} - /> +
+ { + setPassword(e.target.value); + setFocus("password"); + }} + onClick={() => setFocus("password")} + onFocus={() => setFocus("password")} + onBlur={() => setFocus("idle")} + aria-describedby={errorId} + invalid={Boolean(err)} + className="pr-11" + /> + +
{err ? ( - +
); diff --git a/web/app/notebooks/__tests__/access.test.ts b/web/app/notebooks/__tests__/access.test.ts new file mode 100644 index 0000000..24e0405 --- /dev/null +++ b/web/app/notebooks/__tests__/access.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + canCreateNotebook, + getNotebookHeroCopy, + shouldAllowPublicCatalog, + shouldShowProgressPanel, +} from "../filter"; + +describe("notebook access helpers", () => { + it("allows the public catalog without a signed-in user", () => { + expect(shouldAllowPublicCatalog("public")).toBe(true); + expect(shouldAllowPublicCatalog("mine")).toBe(false); + }); + + it("hides notebook creation for guests", () => { + expect(canCreateNotebook(null)).toBe(false); + expect(canCreateNotebook({ id: "u1" })).toBe(true); + }); + + it("uses public-example copy for guest catalog views", () => { + expect(getNotebookHeroCopy("public", null)).toEqual({ + eyebrow: "Public Examples", + title: "公开示例", + description: "先浏览 NoteGen 生成的视频笔记样例;登录后可以创建自己的私有笔记本。", + cta: "登录后创建", + }); + }); + + it("does not show personal progress to guest public viewers", () => { + expect(shouldShowProgressPanel("public", null)).toBe(false); + expect(shouldShowProgressPanel("public", { id: "u1" })).toBe(true); + expect(shouldShowProgressPanel("mine", null)).toBe(true); + }); +}); diff --git a/web/app/notebooks/__tests__/filter.test.ts b/web/app/notebooks/__tests__/filter.test.ts new file mode 100644 index 0000000..12a55ba --- /dev/null +++ b/web/app/notebooks/__tests__/filter.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { parseNotebookFilter } from "../filter"; + +describe("parseNotebookFilter", () => { + it("opens public examples when requested from the landing page", () => { + expect(parseNotebookFilter("public")).toBe("public"); + }); + + it("falls back to personal notes for missing or unknown values", () => { + expect(parseNotebookFilter(null)).toBe("mine"); + expect(parseNotebookFilter("debug")).toBe("mine"); + }); +}); diff --git a/web/app/notebooks/filter.ts b/web/app/notebooks/filter.ts new file mode 100644 index 0000000..49aac66 --- /dev/null +++ b/web/app/notebooks/filter.ts @@ -0,0 +1,42 @@ +export type NotebookFilter = "mine" | "all" | "public"; + +export const NOTEBOOK_FILTERS: { key: NotebookFilter; label: string }[] = [ + { key: "mine", label: "我的笔记" }, + { key: "all", label: "全部" }, + { key: "public", label: "公开示例" }, +]; + +export function parseNotebookFilter(value: string | null): NotebookFilter { + if (value === "all" || value === "public") return value; + return "mine"; +} + +export function shouldAllowPublicCatalog(filter: NotebookFilter) { + return filter === "public"; +} + +export function canCreateNotebook(user: { id: string } | null) { + return Boolean(user); +} + +export function getNotebookHeroCopy(filter: NotebookFilter, user: { id: string } | null) { + if (filter === "public" && !user) { + return { + eyebrow: "Public Examples", + title: "公开示例", + description: "先浏览 NoteGen 生成的视频笔记样例;登录后可以创建自己的私有笔记本。", + cta: "登录后创建", + }; + } + + return { + eyebrow: "Notebook Library", + title: "我的笔记", + description: "优先查看自己的视频笔记、最近生成进度和可复习材料;公开示例保留为参考内容。", + cta: "新建笔记本", + }; +} + +export function shouldShowProgressPanel(filter: NotebookFilter, user: { id: string } | null) { + return !(filter === "public" && !user); +} diff --git a/web/app/notebooks/page.tsx b/web/app/notebooks/page.tsx index 4389dd8..62e249d 100644 --- a/web/app/notebooks/page.tsx +++ b/web/app/notebooks/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { BookOpen, @@ -25,12 +26,20 @@ import type { LucideIcon } from "lucide-react"; import CreateNotePanel from "@/components/CreateNotePanel"; import NavBar from "@/components/NavBar"; import RequireAuth from "@/components/RequireAuth"; +import { useAuth } from "@/components/AuthContext"; import { Button, Card, Chip, IconButton, Input } from "@/components/ui"; import { deleteNote, fetchHistory, fetchMyNotes } from "@/lib/api"; import { fetchCatalog, formatDuration } from "@/lib/notes"; import type { CatalogItem, HistoryItem, NoteView } from "@/lib/types"; - -type Filter = "mine" | "all" | "public"; +import { + canCreateNotebook, + getNotebookHeroCopy, + NOTEBOOK_FILTERS, + parseNotebookFilter, + shouldAllowPublicCatalog, + shouldShowProgressPanel, + type NotebookFilter as Filter, +} from "./filter"; interface CardItem { id: string; @@ -44,12 +53,6 @@ interface CardItem { recentAt?: number; } -const FILTERS: { key: Filter; label: string }[] = [ - { key: "mine", label: "我的笔记" }, - { key: "all", label: "全部" }, - { key: "public", label: "公开示例" }, -]; - const DOMAIN_STYLE: Record = { 编程教学: { icon: Code2 }, 考研专业课: { icon: GraduationCap }, @@ -199,30 +202,37 @@ function EmptyState({ ); } -function NotebooksInner() { +function NotebooksInner({ initialFilter }: { initialFilter: Filter }) { + const { user } = useAuth(); const [pub, setPub] = useState(null); const [mine, setMine] = useState(null); const [history, setHistory] = useState(null); - const [filter, setFilter] = useState("mine"); + const [filter, setFilter] = useState(initialFilter); const [query, setQuery] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); const [delId, setDelId] = useState(null); const [deleteError, setDeleteError] = useState(null); + const canCreate = canCreateNotebook(user); + const guestPublicCatalog = shouldAllowPublicCatalog(filter) && !user; + const heroCopy = getNotebookHeroCopy(filter, user); + const showProgressPanel = shouldShowProgressPanel(filter, user); useEffect(() => { let alive = true; fetchCatalog() .then((d) => { if (alive) setPub(d); }) .catch(() => { if (alive) setPub([]); }); - fetchMyNotes() - .then((d) => { if (alive) setMine(d); }) - .catch(() => { if (alive) setMine([]); }); - fetchHistory() - .then((d) => { if (alive) setHistory(d); }) - .catch(() => { if (alive) setHistory([]); }); + if (!guestPublicCatalog) { + fetchMyNotes() + .then((d) => { if (alive) setMine(d); }) + .catch(() => { if (alive) setMine([]); }); + fetchHistory() + .then((d) => { if (alive) setHistory(d); }) + .catch(() => { if (alive) setHistory([]); }); + } return () => { alive = false; }; - }, []); + }, [guestPublicCatalog]); useEffect(() => { if (!createOpen && !confirmDelete) return; @@ -236,7 +246,7 @@ function NotebooksInner() { return () => window.removeEventListener("keydown", onKey); }, [createOpen, confirmDelete]); - const loading = pub === null || mine === null || history === null; + const loading = guestPublicCatalog ? pub === null : pub === null || mine === null || history === null; const recentByNote = useMemo(() => { const map = new Map(); @@ -249,7 +259,7 @@ function NotebooksInner() { }, [history]); const items: CardItem[] = useMemo(() => { - const mineCards: CardItem[] = (mine ?? []).map((n) => ({ + const mineCards: CardItem[] = (user ? (mine ?? []) : []).map((n) => ({ id: n.id, title: n.title, domain: n.domain, @@ -281,7 +291,7 @@ function NotebooksInner() { if (a.mine !== b.mine) return a.mine ? -1 : 1; return (b.recentAt ?? 0) - (a.recentAt ?? 0); }); - }, [filter, mine, pub, query, recentByNote]); + }, [filter, mine, pub, query, recentByNote, user]); const latestJob = useMemo(() => { return [...(history ?? [])].sort((a, b) => b.updated_at - a.updated_at)[0] ?? null; @@ -303,70 +313,78 @@ function NotebooksInner() { return (
- +
-
+

- 我的笔记 + {heroCopy.title}

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

- + {canCreate ? ( + + ) : ( + + {heroCopy.cta} + + )}
- -

最近进度

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

最近进度

+ {loading ? ( +
+
+
-

{latestJob.source}

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

{latestJob.source}

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

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

- )} - + ) : ( +

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

+ )} + + ) : null}
- {FILTERS.map((f) => ( + {NOTEBOOK_FILTERS.map((f) => ( + {canCreate ? ( + + ) : null} {loading ? Array.from({ length: 7 }).map((_, i) => ( @@ -516,10 +536,30 @@ function NotebooksInner() { ); } -export default function NotebooksPage() { +function NotebooksRouteFallback() { return ( - - +
+
+

加载笔记库…

+
+ ); +} + +function NotebooksPageContent() { + const searchParams = useSearchParams(); + const initialFilter = parseNotebookFilter(searchParams.get("filter")); + + return ( + + ); } + +export default function NotebooksPage() { + return ( + }> + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx index ccf6975..14e52d4 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -6,6 +6,7 @@ import { ArrowRight, BookOpenCheck, Code2, + Film, GraduationCap, MessageCircleQuestion, Play, @@ -19,6 +20,7 @@ import type { ReactNode } from "react"; import { BrandMark } from "@/components/brand/BrandMark"; import { Card, Chip } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; +import { CINEMATIC_BEATS } from "./landing-model"; const PROCESS_STEPS = [ { @@ -234,6 +236,72 @@ function UseCaseCard({ item }: { item: (typeof USE_CASES)[number] }) { ); } +function CinematicStory() { + const reduceMotion = useReducedMotion(); + + return ( +
+
+
+
+
+
+ + +

+ 像剪一支预告片一样,把学习过程展开 +

+

+ 入口页负责建立记忆点:视频进入、时间线滑入、纸页展开、播放指针回到证据。工作台仍然保持安静和高效。 +

+
+
+
+ +
+
+ Import + Replay +
+
+
+ +
+
+
+ {CINEMATIC_BEATS.map((beat, index) => ( + + + + +

{beat.timecode}

+

{beat.title}

+

{beat.copy}

+
+ ))} +
+
+
+
+
+ ); +} + export default function LandingPage() { const { user } = useAuth(); const primaryHref = user ? "/notebooks" : "/login?next=/notebooks"; @@ -267,7 +335,7 @@ export default function LandingPage() { {heroCtaLabel} - + 查看示例
@@ -277,6 +345,8 @@ export default function LandingPage() {
+ +
diff --git a/web/app/register/page.tsx b/web/app/register/page.tsx index e0d367c..3029386 100644 --- a/web/app/register/page.tsx +++ b/web/app/register/page.tsx @@ -2,11 +2,11 @@ import { useState } from "react"; import Link from "next/link"; -import { ArrowRight, CheckCircle2, UserPlus } from "lucide-react"; +import { ArrowRight, CheckCircle2, Eye, EyeOff, UserPlus } from "lucide-react"; import { BrandMark } from "@/components/brand/BrandMark"; -import NavBar from "@/components/NavBar"; -import { Button, Card, Chip, Input } from "@/components/ui"; +import { AuthBrandStage, type AuthFocus, type AuthStatus } from "@/components/auth/AuthBrandStage"; +import { Button, Input } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; import { apiRegister } from "@/lib/auth"; import { ApiError } from "@/lib/api"; @@ -19,13 +19,18 @@ export default function RegisterPage() { const [err, setErr] = useState(null); const [done, setDone] = useState(null); const [busy, setBusy] = useState(false); + const [focus, setFocus] = useState("idle"); + const [showPassword, setShowPassword] = useState(false); + const [authStatus, setAuthStatus] = useState("idle"); async function submit(e: React.FormEvent) { e.preventDefault(); setErr(null); + setAuthStatus("idle"); if (password.length < 8) { setErr("密码至少需要 8 位。"); + setAuthStatus("error"); return; } @@ -33,36 +38,35 @@ export default function RegisterPage() { try { const r = await apiRegister(email.trim(), password, displayName.trim() || email.trim()); setDone(r.message); + setAuthStatus("success"); await refresh(); } catch (e) { setErr(e instanceof ApiError ? e.message : "注册失败,请确认后端服务是否已启动。"); + setAuthStatus("error"); } finally { setBusy(false); } } const errorId = err ? "register-error" : undefined; + const stageStatus = done ? "success" : err ? "error" : authStatus; return ( -
- -
-
- - - Private Workspace - -

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

-

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

-
+
+
+
+ + + + + 已有账号 + +
+ +
+ - +
{done ? (
); diff --git a/web/components/KeyPointModal.tsx b/web/components/KeyPointModal.tsx index 31d9ccb..db22ca4 100644 --- a/web/components/KeyPointModal.tsx +++ b/web/components/KeyPointModal.tsx @@ -1,5 +1,6 @@ "use client"; import { useEffect } from "react"; +import Image from "next/image"; import { motion, AnimatePresence } from "framer-motion"; import { X, Play, Sparkles, Target, ChevronDown } from "lucide-react"; import type { Chunk, Mark } from "@/lib/types"; @@ -72,8 +73,13 @@ export default function KeyPointModal({ {/* 关键帧大图 */} {kfRel && (
- +
)} diff --git a/web/components/KeyPointsGrid.tsx b/web/components/KeyPointsGrid.tsx index a082aed..bf8f087 100644 --- a/web/components/KeyPointsGrid.tsx +++ b/web/components/KeyPointsGrid.tsx @@ -1,4 +1,5 @@ "use client"; +import Image from "next/image"; import { motion } from "framer-motion"; import { Sparkles, Target, Clock, Expand, Play } from "lucide-react"; import type { Chunk, Mark } from "@/lib/types"; @@ -65,9 +66,13 @@ export default function KeyPointsGrid({ {/* 幻灯片大图——卡片主体 */}
{kfRel ? ( - + ) : (
- e.stopPropagation()} - /> + > + {cur.headline} + {canPrev && ( @@ -142,7 +149,9 @@ export default function Lightbox({ ? "ring-2 ring-white scale-[1.06]" : "opacity-55 hover:opacity-90"}`} > - + + + ))}
diff --git a/web/components/NavBar.tsx b/web/components/NavBar.tsx index 8cce153..75b5bce 100644 --- a/web/components/NavBar.tsx +++ b/web/components/NavBar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Bookmark, Layers, WifiOff } from "lucide-react"; +import { Bookmark, WifiOff } from "lucide-react"; import { BrandMark } from "@/components/brand/BrandMark"; import { Button, Chip } from "@/components/ui"; @@ -16,7 +16,13 @@ import UserMenu from "./UserMenu"; * 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 }) { +export default function NavBar({ + children, + suppressOfflineBadge = false, +}: { + children?: React.ReactNode; + suppressOfflineBadge?: boolean; +}) { const { user, loading, offline } = useAuth(); const { lang } = useLang(); const bmCount = useBookmarksList().length; @@ -54,15 +60,6 @@ export default function NavBar({ children }: { children?: React.ReactNode }) { ) : null} - -
@@ -550,7 +553,7 @@ function NotebooksPageContent() { const initialFilter = parseNotebookFilter(searchParams.get("filter")); return ( - + ); From 574fc95399a8fc9f5f1974ddb27b3ca6f40473c6 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Fri, 26 Jun 2026 23:42:51 +0800 Subject: [PATCH 4/7] feat: add lightweight account companion --- web/app/login/page.tsx | 24 ++- web/app/register/page.tsx | 24 ++- web/components/auth/AccountCompanion.tsx | 143 ++++++++++++++++++ .../auth/__tests__/AccountCompanion.test.tsx | 26 ++++ 4 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 web/components/auth/AccountCompanion.tsx create mode 100644 web/components/auth/__tests__/AccountCompanion.test.tsx diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index c05907e..0dc180a 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -6,8 +6,8 @@ import { useRouter, useSearchParams } from "next/navigation"; import { ArrowRight, Eye, EyeOff, LogIn } from "lucide-react"; import { BrandMark } from "@/components/brand/BrandMark"; -import { AuthBrandStage, type AuthFocus, type AuthStatus } from "@/components/auth/AuthBrandStage"; -import { Button, Input } from "@/components/ui"; +import { AccountCompanion, type AccountCompanionState } from "@/components/auth/AccountCompanion"; +import { Button, IconButton, Input } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; import { ApiError } from "@/lib/api"; @@ -18,6 +18,17 @@ function getSafeNextPath(value: string | null) { return value; } +type AuthFocus = "email" | "password" | "idle"; +type AuthStatus = "idle" | "error" | "success"; + +function companionState(focus: AuthFocus, passwordVisible: boolean, status: AuthStatus): AccountCompanionState { + if (status === "success") return "success"; + if (status === "error") return "error"; + if (focus === "email") return "emailFocus"; + if (focus === "password") return passwordVisible ? "passwordReveal" : "passwordFocus"; + return "idle"; +} + function LoginInner() { const { login } = useAuth(); const router = useRouter(); @@ -71,7 +82,7 @@ function LoginInner() {
- +
@@ -129,17 +140,18 @@ function LoginInner() { invalid={Boolean(err)} className="pr-11" /> - +
{err ? ( diff --git a/web/app/register/page.tsx b/web/app/register/page.tsx index 3029386..66bc66b 100644 --- a/web/app/register/page.tsx +++ b/web/app/register/page.tsx @@ -5,12 +5,23 @@ import Link from "next/link"; import { ArrowRight, CheckCircle2, Eye, EyeOff, UserPlus } from "lucide-react"; import { BrandMark } from "@/components/brand/BrandMark"; -import { AuthBrandStage, type AuthFocus, type AuthStatus } from "@/components/auth/AuthBrandStage"; -import { Button, Input } from "@/components/ui"; +import { AccountCompanion, type AccountCompanionState } from "@/components/auth/AccountCompanion"; +import { Button, IconButton, Input } from "@/components/ui"; import { useAuth } from "@/components/AuthContext"; import { apiRegister } from "@/lib/auth"; import { ApiError } from "@/lib/api"; +type AuthFocus = "email" | "password" | "idle"; +type AuthStatus = "idle" | "error" | "success"; + +function companionState(focus: AuthFocus, passwordVisible: boolean, status: AuthStatus): AccountCompanionState { + if (status === "success") return "success"; + if (status === "error") return "error"; + if (focus === "email") return "emailFocus"; + if (focus === "password") return passwordVisible ? "passwordReveal" : "passwordFocus"; + return "idle"; +} + export default function RegisterPage() { const { refresh } = useAuth(); const [email, setEmail] = useState(""); @@ -64,7 +75,7 @@ export default function RegisterPage() {
- +
{done ? ( @@ -162,17 +173,18 @@ export default function RegisterPage() { invalid={Boolean(err)} className="pr-11" /> - +
{err ? ( diff --git a/web/components/auth/AccountCompanion.tsx b/web/components/auth/AccountCompanion.tsx new file mode 100644 index 0000000..b939fd4 --- /dev/null +++ b/web/components/auth/AccountCompanion.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { BrandMark } from "@/components/brand/BrandMark"; + +export type AccountCompanionState = + | "idle" + | "emailFocus" + | "passwordFocus" + | "passwordReveal" + | "error" + | "success"; + +interface AccountCompanionProps { + state: AccountCompanionState; + variant: "login" | "register"; +} + +function stateLabel(state: AccountCompanionState) { + if (state === "success") return "纸页角色把书签归档完成"; + if (state === "error") return "纸页角色停在错误时间戳旁"; + if (state === "passwordReveal") return "纸页角色从书签旁确认密码可见"; + if (state === "passwordFocus") return "纸页角色用书签遮住密码行"; + if (state === "emailFocus") return "纸页角色看向邮箱时间戳"; + return "纸页角色安静等待输入"; +} + +export function AccountCompanion({ state, variant }: AccountCompanionProps) { + const isRegister = variant === "register"; + const label = stateLabel(state); + + return ( +
+
+
+ +

+ {isRegister ? "Create first notebook" : "Back to your notebook"} +

+

+ {isRegister ? "给第一本视频笔记留一个位置" : "回到你的时间线和笔记页"} +

+

+ {isRegister + ? "创建账号后,视频、章节、提问和复习进度会收进同一个私有空间。" + : "登录后继续从视频时间点回到笔记、书签和问答记录。"} +

+
+ +
+
+ ); +} diff --git a/web/components/auth/__tests__/AccountCompanion.test.tsx b/web/components/auth/__tests__/AccountCompanion.test.tsx new file mode 100644 index 0000000..6712c0c --- /dev/null +++ b/web/components/auth/__tests__/AccountCompanion.test.tsx @@ -0,0 +1,26 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AccountCompanion } from "../AccountCompanion"; + +afterEach(() => cleanup()); + +describe("AccountCompanion", () => { + it("maps password reveal to the reveal state", () => { + render(); + + expect(screen.getByTestId("account-companion")).toHaveAttribute("data-state", "passwordReveal"); + expect(screen.getAllByText("纸页角色从书签旁确认密码可见").length).toBeGreaterThan(0); + }); + + it("marks error and success states distinctly", () => { + const { rerender } = render(); + + expect(screen.getByTestId("account-companion")).toHaveAttribute("data-state", "error"); + + rerender(); + + expect(screen.getByTestId("account-companion")).toHaveAttribute("data-state", "success"); + expect(screen.getAllByText("纸页角色把书签归档完成").length).toBeGreaterThan(0); + }); +}); From dee0de1b734fa0556b0e21c7eb7d2491ff790458 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Fri, 26 Jun 2026 23:43:00 +0800 Subject: [PATCH 5/7] chore: align note controls with primitives --- web/app/globals.css | 9 +++-- web/components/ChatPanel.tsx | 30 +++++++------- web/components/CreateNotePanel.tsx | 64 ++++++++++++------------------ web/components/ui/IconButton.tsx | 2 +- 4 files changed, 49 insertions(+), 56 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 973553e..ffe2fd9 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -301,16 +301,19 @@ html, body { font-size: 1rem; } -.wf-button[data-variant="primary"] { +.wf-button[data-variant="primary"], +.wf-icon-button[data-variant="primary"] { background: var(--wf-accent); color: var(--wf-on-accent); } -.wf-button[data-variant="primary"]:hover:not(:disabled) { +.wf-button[data-variant="primary"]:hover:not(:disabled), +.wf-icon-button[data-variant="primary"]:hover:not(:disabled) { background: var(--wf-accent-hover); } -.wf-button[data-variant="primary"]:active:not(:disabled) { +.wf-button[data-variant="primary"]:active:not(:disabled), +.wf-icon-button[data-variant="primary"]:active:not(:disabled) { background: var(--wf-accent-active); } diff --git a/web/components/ChatPanel.tsx b/web/components/ChatPanel.tsx index 0f38900..459f90b 100644 --- a/web/components/ChatPanel.tsx +++ b/web/components/ChatPanel.tsx @@ -7,6 +7,7 @@ import { useLang, pickByLang } from "./LangContext"; import { useAuth } from "./AuthContext"; import { postAsk, fetchQa, ApiError, type QaCitation, type QaHistoryItem } from "@/lib/api"; import { formatTime } from "@/lib/notes"; +import { Button, IconButton, Input } from "@/components/ui"; interface ChatMsg { role: "user" | "assistant"; @@ -205,17 +206,17 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { {msgs.length === 0 && !busy && suggestions.length > 0 && (
{suggestions.map(s => ( - + ))}
)} @@ -223,7 +224,7 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { {/* 输入行 */}
- setInput(e.target.value)} @@ -233,20 +234,21 @@ export default function ChatPanel({ noteId, onSeek, chapters }: Props) { placeholder={busy ? statusText : t("对这个视频提问,回答附时间戳引用…", "Ask about this video — answers cite timestamps…")} - className="min-w-0 flex-1 bg-transparent text-sm text-[var(--wf-text)] outline-none - placeholder:text-[var(--wf-text-tertiary)] disabled:opacity-60" + size="sm" + className="min-w-0 flex-1 border-0 bg-transparent px-0 focus-visible:outline-none disabled:opacity-60" /> - + +
); diff --git a/web/components/CreateNotePanel.tsx b/web/components/CreateNotePanel.tsx index d6eead6..45419a1 100644 --- a/web/components/CreateNotePanel.tsx +++ b/web/components/CreateNotePanel.tsx @@ -9,7 +9,6 @@ import { FileVideo, FolderUp, Link2, - Loader2, Lock, LockOpen, Search, @@ -25,6 +24,7 @@ import { type DownloadQuality, type ProbeResult, } from "@/lib/api"; +import { Button, IconButton, Input } from "@/components/ui"; type SubmitMode = "url" | "file"; @@ -172,7 +172,7 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string >
{!probed ? ( - + ) : ( - + )} ) : ( @@ -262,22 +252,25 @@ export default function CreateNotePanel({ next = "/notebooks" }: { next?: string {formatBytes(file.size)} - +