diff --git "a/PRD-001-YouTube\345\255\246\344\271\240\345\267\245\344\275\234\345\217\260.md" "b/PRD-001-YouTube\345\255\246\344\271\240\345\267\245\344\275\234\345\217\260.md" new file mode 100644 index 00000000..f2a3942b --- /dev/null +++ "b/PRD-001-YouTube\345\255\246\344\271\240\345\267\245\344\275\234\345\217\260.md" @@ -0,0 +1,1407 @@ +# PRD-001: YouTube 学习工作台 + +**版本**: v1.0 +**创建日期**: 2026-01-29 +**产品负责人**: [待填写] +**状态**: 待评审 + +--- + +## 📋 文档概览 + +### 产品愿景 + +将现有的"YouTube视频分析工具"升级为一个**沉浸式学习工作台**,专为YouTube长视频深度学习场景设计。解决学习者在观看教育/技术视频时的核心痛点:不知道视频讲了什么、无法快速定位内容、语言障碍、无法边学边记、缺乏即时答疑。 + +### 核心价值主张 + +- 🎯 **智能章节导航** - AI自动分段+可视化时间轴,一眼看清视频结构 +- 🌐 **多语言字幕实时翻译** - 支持4种翻译引擎,逐句对照 +- 📝 **边看边记笔记** - 关联时间轴的笔记系统,支持视频截图 +- 🤖 **AI学习助手** - 基于视频内容的智能问答,随时解惑 + +### 目标用户 + +- **在线学习者** - 通过YouTube学习技术/语言/职业技能 +- **研究人员** - 观看学术讲座、会议视频并记录要点 +- **内容创作者** - 分析竞品视频、提取创意灵感 +- **跨语言学习者** - 需要字幕翻译辅助理解 + +--- + +## 🗺️ 用户旅程地图 + +整个产品按用户使用流程分为 **3个核心阶段**: + +```mermaid +graph LR + A[阶段1: 视频选择与进入] --> B[阶段2: 沉浸式学习] + B --> C[阶段3: 复习与管理] + + A1[输入YouTube链接] --> A2[AI自动分析] + A2 --> A3[进入学习工作台] + + B1[观看视频+智能章节跳转] --> B2[实时字幕翻译] + B2 --> B3[记笔记] + B3 --> B4[AI助手答疑] + + C1[查看学习历史] --> C2[回顾笔记] + C2 --> C3[导出学习资料] + + style A fill:#e3f2fd + style B fill:#fff3e0 + style C fill:#f1f8e9 +``` + +--- + +## 📖 用户故事集 + +### 阶段1: 视频选择与进入学习 + +#### US-01: 主页新增"开始学习"入口 + +**作为** 学习者 +**我希望** 在主页分析完视频后,看到"开始学习"按钮 +**以便于** 快速进入学习工作台 + +**交互流程** + +```mermaid +stateDiagram-v2 + [*] --> 输入链接 + 输入链接 --> 开始分析 + 开始分析 --> 分析中_loading + 分析中_loading --> 分析完成 + 分析完成 --> 显示"开始分析"和"开始学习"按钮 + 显示"开始分析"和"开始学习"按钮 --> [*] +``` + +**UI线框图** + +``` +┌─────────────────────────────────────────────────────┐ +│ YouTube 视频分析工具 │ +│ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ 请输入YouTube视频链接 │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ +│ [ 开始分析 ] [ 开始学习 ] ← 新增按钮 │ +│ │ +│ ┌─ 分析详情 ───────────────────────────────────┐ │ +│ │ 视频标题: ... │ │ +│ │ 综合评分: 8.5/10 │ │ +│ │ ... │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**验收标准** + +- [ ] "开始学习"按钮位于"开始分析"按钮右侧,同一行 +- [ ] 点击"开始学习"按钮,在**新标签页**打开 `/learning/[videoId]` 页面 +- [ ] 原标签页保留在主页,不关闭 +- [ ] 未分析视频时,"开始学习"按钮置灰不可点击 +- [ ] Hover"开始学习"按钮显示tooltip:"进入沉浸式学习模式" + +**注意事项** + +- 新标签页打开使用 `window.open()` 或 `target="_blank"` +- 需要将当前videoId作为路由参数传递 + +--- + +#### US-02: 学习历史记录侧边栏 + +**作为** 学习者 +**我希望** 在学习页面左侧看到我的学习历史 +**以便于** 快速切换到之前学习过的视频 + +**UI线框图** + +``` +┌─学习历史──┬────────────────────────────────────────┐ +│ │ YouTube 学习工作台 │ +│ 视频1 ├────────────────────────────────────────┤ +│ ┌─────┐ │ │ +│ │缩略图│ │ │ +│ └─────┘ │ [主要内容区] │ +│ 标题... │ │ +│ 观看:2h │ │ +│ 进度:70% │ │ +│ 2026-1-28 │ │ +│ │ │ +│ 视频2 │ │ +│ ┌─────┐ │ │ +│ │缩略图│ │ │ +│ └─────┘ │ │ +│ 标题... │ │ +│ 观看:30m │ │ +│ 进度:100% │ │ +│ 2026-1-27 │ │ +│ │ │ +│ ───────── │ │ +│ │ │ +│ [按日期▼] │ │ +└───────────┴────────────────────────────────────────┘ +``` + +**业务规则** + +- **历史记录来源**: 本地IndexedDB存储,每次打开学习页面自动记录 +- **排序逻辑**: 默认按日期倒序(最近学习的在最上方) +- **分组规则**: + - 今天 + - 昨天 + - 本周 + - 更早 +- **学习进度计算**: `观看时长 / 视频总时长 * 100%` +- **观看时间累计**: 每次播放器时间变化时更新 + +**验收标准** + +- [ ] 侧边栏宽度250px,可折叠(点击边缘箭头) +- [ ] 每条历史记录显示:缩略图(120x68px)、视频标题(最多2行省略)、观看时长、学习进度条、最后观看日期 +- [ ] 默认按日期分组展示(可切换为"全部"不分组) +- [ ] 点击任意历史记录,在**新标签页**打开对应学习页面 +- [ ] 当前正在学习的视频,在列表中高亮显示(蓝色边框) +- [ ] 移动端:侧边栏默认收起,显示汉堡菜单按钮 + +**数据结构** + +```typescript +interface LearningHistory { + videoId: string; + title: string; + thumbnail: string; + duration: number; // 视频总时长(秒) + watchedTime: number; // 观看时长(秒) + progress: number; // 学习进度 0-100 + lastWatchedAt: timestamp; + createdAt: timestamp; +} +``` + +--- + +### 阶段2: 沉浸式学习核心功能 + +#### US-03: YouTube播放器集成 + +**作为** 学习者 +**我希望** 在学习页面看到嵌入的YouTube播放器 +**以便于** 直接播放视频无需跳转 + +**UI线框图** + +``` +┌──────────────────────────────────────────────────┐ +│ [ ← 返回主页 ] YouTube学习工作台 - 视频标题 │ +├──────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ YouTube 播放器 (16:9) │ │ +│ │ [可拖拽调整大小] │ │ +│ │ │ │ +│ │ ▶────────○────────── [⚙️ 设置] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +**功能需求** + +- **播放器比例**: 默认16:9,但支持拖拽调整大小 +- **拖拽功能**: 播放器右下角显示拖拽手柄,可调整宽度/高度 +- **最小尺寸**: 宽度不低于480px,高度不低于270px +- **最大尺寸**: 不超过容器宽度的80% +- **播放控制**: + - 倍速播放: 0.25x, 0.5x, 0.75x, 1x, 1.25x, 1.5x, 1.75x, 2x + - 画中画功能(PiP) + - 全屏功能 + - 音量控制 +- **进度保存**: 播放时间每10秒自动保存到IndexedDB + +**技术方案** + +```typescript +// 使用 react-youtube +import YouTube from 'react-youtube'; + +const opts = { + height: '390', + width: '640', + playerVars: { + autoplay: 0, + controls: 1, + modestbranding: 1, + rel: 0, + }, +}; + + +``` + +**验收标准** + +- [ ] 播放器加载时显示loading动画 +- [ ] 视频加载失败时显示错误提示:"视频加载失败,请检查链接" +- [ ] 拖拽调整大小时保持16:9比例 +- [ ] 播放器设置菜单支持倍速选择(0.25x-2x) +- [ ] 画中画按钮点击后,视频浮窗在页面右下角 +- [ ] 全屏模式下,退出全屏返回原位置 + +--- + +#### US-04: AI智能章节进度条 + +**作为** 学习者 +**我希望** 在播放器下方看到AI自动分段的章节时间轴 +**以便于** 快速了解视频结构并跳转到感兴趣的部分 + +**UI线框图(参考用户提供的图2)** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ [🔖 引言] [📖 核心概念] [💡 实战案例] [✅ 总结] │ +│ ●─────────────●──────────────●──────────────●────────────── │ +│ 0:00 2:30 8:15 15:40 │ +│ │ +│ 当前: 15:20 / 总计: 20:10 ▶ Play All │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**悬停详情弹窗** + +``` +┌────────────────────────────────────────┐ +│ 📖 核心概念 (2:30 - 8:15) │ +│ ──────────────────────────────────── │ +│ 本段详细讲解了React Hooks的核心原理, │ +│ 包括useState、useEffect的底层实现机制, │ +│ 以及自定义Hook的最佳实践。 │ +│ 关键词: Hooks, useState, useEffect │ +└────────────────────────────────────────┘ +``` + +**业务规则** + +**AI分段策略**: + +1. 获取视频完整字幕 +2. 调用DeepSeek API分析字幕内容 +3. 基于主题变化自动切分段落(每段2-8分钟) +4. 为每段生成: + - **简略标签**: 3-6字概括(如"引言与背景") + - **详细说明**: 50-100字段落摘要 + - **章节类型**: 引言/正文/案例/总结(用于配色) + +**章节颜色规则**: + +- 🔖 引言: `#64B5F6` (蓝色) +- 📖 正文: `#81C784` (绿色) +- 💡 案例: `#FFB74D` (橙色) +- ✅ 总结: `#E57373` (红色) + +**AI Prompt模板**: + +``` +你是视频内容分析专家。请分析以下YouTube视频的字幕,将内容分段: + +视频字幕: +[字幕内容] + +要求: +1. 根据主题变化自动切分段落,每段建议2-8分钟 +2. 为每段生成: + - start_time: 起始时间(秒) + - end_time: 结束时间(秒) + - type: 引言|正文|案例|总结 + - short_title: 3-6字标题 + - description: 50-100字详细说明 + - keywords: 3-5个关键词 + +返回JSON格式: +{ + "chapters": [ + { + "start_time": 0, + "end_time": 150, + "type": "引言", + "short_title": "课程介绍", + "description": "...", + "keywords": ["React", "Hooks"] + } + ] +} +``` + +**交互行为** + +```mermaid +sequenceDiagram + participant User + participant UI + participant YouTube + participant Subtitle + + User->>UI: 点击章节胶囊 + UI->>YouTube: 跳转到章节起始时间 + UI->>Subtitle: 滚动字幕区到对应位置 + YouTube->>UI: 开始播放 + Subtitle->>UI: 高亮当前句 +``` + +**验收标准** + +- [ ] 打开学习页面时自动触发AI分段分析(显示loading:"AI分析中...") +- [ ] 章节时间轴显示在YouTube播放器正下方 +- [ ] 每个章节胶囊显示:图标+简略标签+时长 +- [ ] 鼠标悬停章节胶囊500ms后,弹出详细说明弹窗 +- [ ] 点击章节胶囊,YouTube播放器跳转到对应时间点 +- [ ] 点击章节胶囊,字幕区自动滚动到对应段落并高亮 +- [ ] 当前播放时间点所在的章节,胶囊显示激活态(加粗边框) +- [ ] 分析失败时显示错误:"AI分段失败,请重试",并提供"重新分析"按钮 + +**性能要求** + +- AI分析时间控制在30秒内 +- 分析过的视频结果缓存在IndexedDB(7天有效期) +- 缓存命中时,直接加载章节数据(<1秒) + +--- + +#### US-05: 实时字幕翻译区 + +**作为** 学习者 +**我希望** 在视频右侧看到实时同步的字幕和翻译 +**以便于** 像读文章一样理解视频内容 + +**UI线框图** + +``` +┌─────────────────────────────────────────────────────┐ +│ 📺 实时字幕翻译 │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ [语言: 英文 ▼] [翻译: 中文 ▼] [👁️] [🔍] [📥] │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 00:15 │ │ +│ │ This is the first sentence of the video. │ │ +│ │ 这是视频的第一句话。 │ │ +│ │ ────────────────────────────────────────────── │ │ +│ │ 00:23 ← 当前播放高亮 │ │ +│ │ ► Let's dive into the core concepts. │ │ +│ │ 让我们深入了解核心概念。 │ │ +│ │ ────────────────────────────────────────────── │ │ +│ │ 00:35 │ │ +│ │ First, we need to understand... │ │ +│ │ 首先,我们需要理解... │ │ +│ │ ────────────────────────────────────────────── │ │ +│ │ ... │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**功能清单** + +**1. 语言选择下拉框** + +- 切换原文字幕语言(如果YouTube提供多语言字幕) +- 选项:中文、英文、日文(根据YouTube API返回的可用字幕) +- 优先级:优先使用YouTube官方字幕 > AI翻译字幕 + +**2. 翻译引擎下拉框** + +``` +┌──────────────────────┐ +│ 翻译引擎 │ +├──────────────────────┤ +│ ✓ DeepSeek (默认) │ +│ Google Translate │ +│ DeepL │ +│ GLM-4 │ +├──────────────────────┤ +│ ⚙️ API配置 │ +└──────────────────────┘ +``` + +- 支持4种翻译引擎切换 +- 默认使用DeepSeek(使用你的API Key) +- 点击"API配置"打开设置弹窗,允许用户输入自己的API Key + +**3. 翻译目标语言** + +- 支持:中文、英文、日文、韩文 +- 翻译方式:逐句翻译(每句原文 + 译文成对显示) + +**4. 👁️ 显示/隐藏翻译** + +- 点击眼睛图标,切换翻译文本的显示/隐藏 +- 隐藏时只显示原文 +- 状态记忆(刷新页面保持) + +**5. 🔍 搜索定位** + +``` +┌─────────────────────────────────────┐ +│ 🔍 搜索字幕... │ +│ ┌─────────────────────────────────┐ │ +│ │ React Hooks │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ 找到 3 处匹配: │ +│ ┌─────────────────────────────────┐ │ +│ │ 00:23 - ...dive into React... │ │ +│ │ 05:12 - ...Hooks API... │ │ +│ │ 12:45 - ...custom Hooks... │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +- 实时搜索原文和译文 +- 点击搜索结果,字幕区滚动到对应位置 + YouTube跳转 + +**6. 📥 下载字幕** + +``` +┌──────────────────────────┐ +│ 下载字幕 │ +├──────────────────────────┤ +│ □ 纯原文 (EN) │ +│ ✓ 双语对照 (EN-CN) │ +├──────────────────────────┤ +│ 格式: │ +│ ○ SRT (带时间戳) │ +│ ● TXT (纯文本) │ +│ ○ Word (.docx) │ +│ ○ Markdown (.md) │ +├──────────────────────────┤ +│ [ 下载 ] │ +└──────────────────────────┘ +``` + +**业务规则** + +**实时同步逻辑**: + +```typescript +// 监听YouTube播放器时间 +onPlayerStateChange = (currentTime) => { + // 查找当前时间对应的字幕 + const currentSubtitle = subtitles.find( + (sub) => currentTime >= sub.start && currentTime <= sub.end + ); + + // 滚动到当前字幕(底部对齐) + scrollToSubtitle(currentSubtitle.id, { block: 'end' }); + + // 高亮当前字幕 + highlightSubtitle(currentSubtitle.id); +}; +``` + +**分页加载**: + +- 初始加载前100条字幕 +- 滚动到底部时加载下一批100条 +- 使用虚拟滚动优化性能(推荐 `react-window`) + +**翻译缓存**: + +```typescript +interface TranslationCache { + videoId: string; + engine: 'deepseek' | 'google' | 'deepl' | 'glm'; + sourceLang: string; + targetLang: string; + translations: Array<{ + index: number; + original: string; + translated: string; + }>; + cachedAt: timestamp; +} +``` + +- 缓存周期:7天 +- 缓存键:`${videoId}_${engine}_${sourceLang}_${targetLang}` + +**验收标准** + +- [ ] 字幕区高度占右侧区域的60% +- [ ] 当前播放字幕始终保持在可视区域底部20%处 +- [ ] 高亮当前字幕使用背景色 `#FFF9C4`(浅黄色) +- [ ] 切换翻译引擎后,自动重新翻译并更新字幕 +- [ ] 搜索功能支持高亮所有匹配项 +- [ ] 下载SRT格式时,时间戳格式符合标准:`00:00:15,000 --> 00:00:18,500` +- [ ] 移动端:字幕区在播放器下方,全宽显示 + +--- + +#### US-06: 随手笔记功能 + +**作为** 学习者 +**我希望** 在学习视频的同时记录笔记 +**以便于** 保存关键要点和个人思考 + +**UI线框图** + +``` +┌─────────────────────────────────────────────────────┐ +│ 📝 随手笔记 [+ 新增笔记] │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 🔍 搜索笔记... │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 📌 React Hooks原理 [编辑] [删除] │ │ +│ │ ⏱️ 00:05:23 📅 2026-01-29 10:30 │ │ +│ │ ────────────────────────────────────────────── │ │ +│ │ useState内部使用了Fiber架构的memoizedState... │ │ +│ │ 每次调用都会... │ │ +│ │ │ │ +│ │ 🖼️ [视频截图缩略图] │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ 📌 自定义Hook最佳实践 [编辑] [删除] │ │ +│ │ ⏱️ 00:12:45 📅 2026-01-29 10:45 │ │ +│ │ ────────────────────────────────────────────── │ │ +│ │ Q: 什么是自定义Hook? │ │ +│ │ A: 自定义 Hook 是一个 JavaScript 函数... │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**编辑笔记弹窗** + +``` +┌──────────────────────────────────────────────────┐ +│ ✏️ 编辑笔记 [×] │ +├──────────────────────────────────────────────────┤ +│ 标题: │ +│ ┌────────────────────────────────────────────┐ │ +│ │ React Hooks原理 │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ 内容: (支持Markdown) │ +│ ┌────────────────────────────────────────────┐ │ +│ │ ## 核心要点 │ │ +│ │ - useState内部使用Fiber架构 │ │ +│ │ - 每次渲染都会... │ │ +│ │ │ │ +│ │ │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ 📸 视频截图: [添加当前画面] │ +│ ⏱️ 时间戳: 00:05:23 (自动记录) │ +│ │ +│ [ 取消 ] [ 保存笔记 ] │ +└──────────────────────────────────────────────────┘ +``` + +**功能需求** + +**1. 新增笔记** + +- 点击"+ 新增笔记"按钮,打开编辑弹窗 +- 自动记录: + - 当前视频时间戳 + - 创建时间 + - 关联videoId +- 可选:添加当前视频画面截图 + +**2. 编辑笔记** + +- 支持Markdown语法(使用 `react-markdown` 渲染预览) +- 笔记内容实时保存(防止丢失) +- 显示最后修改时间 + +**3. 删除笔记** + +- 删除前弹出确认弹窗:"确定要删除这条笔记吗?" +- 支持批量删除(多选模式) + +**4. 搜索笔记** + +- 搜索范围:所有视频的笔记(全局搜索) +- 搜索字段:标题 + 内容 +- 搜索结果显示:笔记所属视频标题 + 匹配片段 + +**5. 笔记卡片交互** + +- 点击笔记卡片,展开/收起完整内容 +- 点击时间戳,视频跳转到对应时间点 +- 点击视频截图,全屏预览 + +**数据结构** + +```typescript +interface Note { + id: string; + videoId: string; + videoTitle: string; // 冗余存储,方便搜索时显示 + title: string; + content: string; // Markdown格式 + timestamp: number; // 视频时间点(秒) + screenshot?: string; // Base64 or URL + createdAt: timestamp; + updatedAt: timestamp; +} +``` + +**验收标准** + +- [ ] 笔记区高度占右侧区域的40% +- [ ] 新增笔记时,编辑器获得焦点 +- [ ] 笔记列表按创建时间倒序排列 +- [ ] Markdown预览支持:标题、列表、粗体、斜体、代码块 +- [ ] 视频截图使用 `` 捕获当前YouTube画面 +- [ ] 点击笔记时间戳,YouTube播放器跳转并开始播放 +- [ ] 全局搜索结果按视频分组展示 +- [ ] 删除笔记后,立即从列表移除(带淡出动画) +- [ ] 数据存储在IndexedDB,支持离线访问 + +--- + +#### US-07: AI学习助手浮窗 + +**作为** 学习者 +**我希望** 在观看视频时随时向AI提问 +**以便于** 即时解答学习中的疑惑 + +**UI线框图 - 浮窗收起状态** + +``` +┌─────────────────────────────────┐ +│ │ +│ │ +│ │ +│ ┌────────┐ │ +│ │ 💬 │ │ +│ │ Chat │ │ +│ └────────┘ │ +└─────────────────────────────────┘ +``` + +**UI线框图 - 浮窗展开状态** + +``` +┌──────────────────────────────────────────┐ +│ 🤖 AI学习助手 [📌] [−] [×] │ +├──────────────────────────────────────────┤ +│ 💬 对话历史 │ +│ ┌────────────────────────────────────┐ │ +│ │ 👤 你: 这个Hook是什么意思? │ │ +│ │ 🤖 AI: Hook是React 16.8引入的新特性...│ │ +│ │ 它允许你在不编写类的情况下... │ │ +│ │ [💾 保存为笔记] │ │ +│ │ ────────────────────────────────── │ │ +│ │ 👤 你: 能举个例子吗? │ │ +│ │ 🤖 AI: 当然! 比如useState... │ │ +│ │ [💾 保存为笔记] │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ 🔍 快捷提问: │ +│ [总结这一段] [解释这个概念] [关键要点] │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ 输入问题... │ │ +│ └────────────────────────────────────┘ │ +│ [发送 ➤] │ +└──────────────────────────────────────────┘ +``` + +**功能需求** + +**1. 浮窗状态管理** + +- **收起状态**: 右下角显示圆形按钮(60x60px),图标💬 +- **展开状态**: 弹出对话窗口(宽400px,高600px) +- **固定模式**: 点击📌按钮,窗口固定在右侧(不可拖动) +- **拖动功能**: 未固定时,可拖动到任意位置(使用 `react-draggable`) + +**2. AI上下文配置** + +- AI知道的信息: + - 完整视频字幕 + - 用户之前创建的所有笔记 + - 当前播放时间点附近的字幕(±2分钟) +- 系统提示词模板: + +``` +你是一个YouTube学习助手。当前用户正在观看以下视频: + +视频标题: [title] +当前时间: [currentTime] +当前字幕: [currentSubtitle] + +完整字幕: +[全部字幕] + +用户笔记: +[用户的笔记列表] + +请基于以上内容回答用户的问题,提供准确、简洁的解答。 +``` + +**3. 快捷提问模板** + +- **总结这一段**: "请总结当前播放段落(±2分钟)的核心内容" +- **解释这个概念**: "请详细解释字幕中提到的[用户选中的文本]" +- **关键要点**: "请列出这段内容的3-5个关键要点" +- 点击后自动填充到输入框,用户可编辑后发送 + +**4. 对话历史** + +- 关闭窗口时清空对话记录 +- 但提供"继续上次对话"功能: + - 对话记录存储在sessionStorage + - 刷新页面后可恢复 + - 关闭标签页后清空 + +**5. 保存回答为笔记** + +- 每条AI回答右下角显示"💾 保存为笔记"按钮 +- 点击后自动创建笔记: + ``` + 标题: AI回答 - [用户问题前20字] + 内容: + Q: [用户问题] + A: [AI回答] + ``` +- 自动关联当前视频时间戳 + +**技术方案** + +**DeepSeek API集成** + +```typescript +const chatWithAI = async (userMessage: string, context: Context) => { + const response = await fetch('https://api.deepseek.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${DEEPSEEK_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-chat', + messages: [ + { + role: 'system', + content: buildSystemPrompt(context), // 包含字幕+笔记 + }, + ...conversationHistory, + { + role: 'user', + content: userMessage, + }, + ], + temperature: 0.7, + max_tokens: 1000, + }), + }); + + return response.json(); +}; +``` + +**验收标准** + +- [ ] 浮窗默认显示在右下角,距离边缘20px +- [ ] 拖动时显示半透明状态,释放后恢复正常 +- [ ] 固定模式下,窗口吸附在右侧,占据右侧30%宽度 +- [ ] AI回答支持Markdown渲染(代码高亮、列表等) +- [ ] AI回答流式输出(逐字显示,提升体验) +- [ ] 快捷提问点击后,输入框自动聚焦 +- [ ] 对话区域自动滚动到最新消息 +- [ ] 保存为笔记后,显示Toast提示:"已保存到笔记" +- [ ] API调用失败时显示错误:"AI服务暂时不可用,请稍后重试" + +--- + +### 阶段3: 导出与管理 + +#### US-08: 返回主页按钮 + +**作为** 学习者 +**我希望** 在学习页面顶部看到"返回主页"按钮 +**以便于** 快速回到分析工具主页 + +**UI线框图** + +``` +┌─────────────────────────────────────────────────────┐ +│ [ ← 返回主页 ] YouTube学习工作台 - [视频标题] │ +├─────────────────────────────────────────────────────┤ +│ ... │ +└─────────────────────────────────────────────────────┘ +``` + +**验收标准** + +- [ ] 按钮位于页面左上角 +- [ ] Hover显示tooltip: "返回视频分析主页" +- [ ] 点击后在**当前标签页**跳转到主页 `/` +- [ ] 移动端:显示为"← 主页"(缩短文字) + +--- + +## 🎨 通用设计规范 + +### 响应式布局 + +**桌面端(>1280px)** + +``` +┌─历史─┬──────播放器区──────┬──────字幕区──────┐ +│ │ │ │ +│ 250px│ 60% │ 40% │ +│ │ │ │ +│ ├────────────────────┤ ─────────────── │ +│ │ 智能章节进度条 │ 笔记区 │ +│ │ │ │ +└──────┴────────────────────┴──────────────────┘ +``` + +**平板端(768px-1280px)** + +``` +┌──────────播放器区──────────┐ +│ │ +│ 100% 宽度 │ +│ │ +├────────────────────────────┤ +│ 智能章节进度条 │ +├────────────────────────────┤ +│ 字幕翻译区 │ +│ │ +├────────────────────────────┤ +│ 笔记区 │ +└────────────────────────────┘ +``` + +**移动端(<768px)** + +- 历史记录:汉堡菜单收起 +- 播放器:全宽,高度自适应 +- 字幕/笔记:可切换Tab显示 + +### 色彩系统 + +```css +/* 主题色 */ +--primary: #2196f3; +--secondary: #ff9800; + +/* 功能色 */ +--success: #4caf50; +--warning: #ffc107; +--error: #f44336; +--info: #00bcd4; + +/* 中性色 */ +--bg-primary: #ffffff; +--bg-secondary: #f5f5f5; +--text-primary: #212121; +--text-secondary: #757575; +--border: #e0e0e0; + +/* 章节类型色 */ +--chapter-intro: #64b5f6; +--chapter-content: #81c784; +--chapter-case: #ffb74d; +--chapter-summary: #e57373; +``` + +### 动画规范 + +- 页面切换:淡入淡出 300ms +- 按钮hover:缩放 1.05 @ 200ms +- 弹窗打开:从下到上滑入 250ms +- 列表加载:骨架屏 → 内容淡入 200ms + +--- + +## 🔧 技术架构 + +### 技术栈 + +**前端框架** + +- Next.js 14 (App Router) +- TypeScript +- Tailwind CSS + +**UI组件库** + +- Radix UI (现有) +- Framer Motion (动画) +- react-markdown (Markdown渲染) +- react-youtube (YouTube播放器) +- react-draggable (拖拽) +- react-window (虚拟滚动) + +**数据存储** + +- IndexedDB (Dexie.js封装) + - 学习历史 + - 笔记 + - 翻译缓存 + - AI分段缓存 + +**外部API** + +- YouTube Data API v3 (现有) +- RapidAPI YouTube Transcript (现有) +- DeepSeek API (AI对话 + 翻译) +- Google Translate API (可选) +- DeepL API (可选) +- GLM-4 API (可选) + +### 项目结构 + +``` +app/ + page.tsx # 主页(现有) + learning/ + [videoId]/ + page.tsx # 学习工作台主入口 + layout.tsx # 学习页面布局 + components/ + Header.tsx # 顶部导航(返回主页) + HistorySidebar.tsx # 学习历史侧边栏 + VideoPlayer.tsx # YouTube播放器 + ChapterTimeline.tsx # 智能章节进度条 + SubtitlePanel/ + index.tsx + SubtitleList.tsx # 字幕列表 + TranslationControls.tsx # 翻译控制栏 + SubtitleSearch.tsx # 字幕搜索 + SubtitleDownload.tsx # 字幕下载 + NotePanel/ + index.tsx + NoteList.tsx # 笔记列表 + NoteEditor.tsx # 笔记编辑器 + NoteSearch.tsx # 笔记搜索 + AIAssistant/ + index.tsx + ChatWindow.tsx # 对话窗口 + FloatingButton.tsx # 浮动按钮 + QuickActions.tsx # 快捷提问 + +lib/ + ai/ + deepseek.ts # DeepSeek API客户端 + translators/ + index.ts # 翻译引擎抽象接口 + deepseek.ts + google.ts + deepl.ts + glm.ts + segmentation.ts # AI章节分段 + + subtitle/ + parser.ts # 字幕解析 + formatter.ts # 字幕格式化(SRT/TXT/DOCX/MD) + + storage/ + db.ts # Dexie.js配置 + history.ts # 学习历史CRUD + notes.ts # 笔记CRUD + cache.ts # 缓存管理 + + utils/ + youtube.ts # YouTube相关工具 + time.ts # 时间格式化 + screenshot.ts # 视频截图 + +components/ + ui/ # 通用UI组件(现有Radix组件) + +types/ + learning.d.ts # 学习相关类型定义 +``` + +### 数据库设计(IndexedDB) + +```typescript +// Dexie.js Schema +class LearningDB extends Dexie { + history: Dexie.Table; + notes: Dexie.Table; + translationCache: Dexie.Table; + chapterCache: Dexie.Table; + + constructor() { + super('LearningDB'); + this.version(1).stores({ + history: 'videoId, lastWatchedAt', + notes: 'id, videoId, createdAt, updatedAt', + translationCache: '[videoId+engine+sourceLang+targetLang], cachedAt', + chapterCache: 'videoId, cachedAt', + }); + } +} +``` + +--- + +## 📅 开发计划 + +### Sprint 1: 基础框架搭建(预计5天) + +**Day 1-2: 路由与布局** + +- [ ] 创建 `/learning/[videoId]` 路由 +- [ ] 实现响应式布局(左侧+中间+右侧) +- [ ] 添加"返回主页"按钮 +- [ ] 主页增加"开始学习"按钮 + +**Day 3-4: 数据层** + +- [ ] 配置IndexedDB(Dexie.js) +- [ ] 实现学习历史CRUD +- [ ] 实现笔记CRUD +- [ ] 缓存系统基础架构 + +**Day 5: 历史记录侧边栏** + +- [ ] 学习历史列表组件 +- [ ] 按日期分组逻辑 +- [ ] 缩略图+进度条展示 + +### Sprint 2: 视频播放与章节(预计7天) + +**Day 6-7: YouTube播放器** + +- [ ] 集成 react-youtube +- [ ] 实现拖拽调整大小 +- [ ] 倍速、画中画、全屏功能 +- [ ] 播放进度自动保存 + +**Day 8-10: AI章节分段** + +- [ ] 字幕获取与解析 +- [ ] DeepSeek API分段调用 +- [ ] 章节数据缓存 +- [ ] 章节时间轴UI组件 + +**Day 11-12: 章节交互** + +- [ ] 章节点击跳转 +- [ ] 悬停详情弹窗 +- [ ] 当前章节高亮 +- [ ] 章节颜色分类 + +### Sprint 3: 字幕翻译(预计6天) + +**Day 13-14: 字幕基础功能** + +- [ ] 字幕列表组件 +- [ ] 实时同步滚动 +- [ ] 当前字幕高亮 +- [ ] 分页加载(虚拟滚动) + +**Day 15-17: 翻译引擎** + +- [ ] 翻译引擎抽象层 +- [ ] DeepSeek翻译集成 +- [ ] Google/DeepL/GLM-4集成 +- [ ] 翻译缓存机制 +- [ ] API配置管理 + +**Day 18: 字幕工具** + +- [ ] 语言切换 +- [ ] 显示/隐藏翻译 +- [ ] 字幕搜索 +- [ ] 字幕下载(SRT/TXT/DOCX/MD) + +### Sprint 4: 笔记与AI助手(预计7天) + +**Day 19-21: 笔记系统** + +- [ ] 笔记列表组件 +- [ ] 笔记编辑器(Markdown支持) +- [ ] 时间戳关联 +- [ ] 视频截图功能 +- [ ] 笔记搜索(全局) + +**Day 22-25: AI助手** + +- [ ] 浮窗UI组件(收起/展开) +- [ ] 拖拽功能 +- [ ] DeepSeek对话API +- [ ] 上下文构建(字幕+笔记) +- [ ] 快捷提问模板 +- [ ] 保存回答为笔记 +- [ ] 对话历史管理 + +### Sprint 5: 优化与测试(预计5天) + +**Day 26-27: 性能优化** + +- [ ] 虚拟滚动优化 +- [ ] 图片懒加载 +- [ ] API请求防抖/节流 +- [ ] IndexedDB查询优化 + +**Day 28-29: 移动端适配** + +- [ ] 平板布局调整 +- [ ] 移动端上下堆叠 +- [ ] 触摸手势支持 + +**Day 30: 测试与修复** + +- [ ] 边界情况测试 +- [ ] 错误处理完善 +- [ ] 用户体验优化 + +--- + +## 🚨 风险与挑战 + +### 技术风险 + +**1. YouTube嵌入限制** + +- 风险:部分视频禁止嵌入(设置) +- 应对:检测嵌入权限,提示用户在YouTube打开 + +**2. 字幕获取失败** + +- 风险:视频无字幕或字幕API限流 +- 应对: + - 优先使用YouTube官方字幕 + - 备用:RapidAPI + - 最终:提示用户手动上传SRT + +**3. AI分段质量** + +- 风险:AI切分不准确或响应慢 +- 应对: + - 提供手动调整章节功能 + - 缓存结果避免重复调用 + - 超时后降级为等时长切分 + +**4. 翻译成本** + +- 风险:大量字幕翻译导致API费用高 +- 应对: + - 翻译结果强制缓存 + - 允许用户使用自己的API Key + - 提供"仅翻译可视区域"选项 + +### 性能风险 + +**1. 长视频字幕过多** + +- 风险:3小时视频可能有1000+条字幕 +- 应对: + - 虚拟滚动(react-window) + - 分页加载 + - 懒加载翻译 + +**2. IndexedDB容量限制** + +- 风险:笔记+缓存占用过大 +- 应对: + - 定期清理过期缓存 + - 限制单个笔记大小(<500KB) + - 提供数据导出功能 + +### 产品风险 + +**1. 用户学习成本** + +- 风险:功能太多,不知道怎么用 +- 应对: + - 首次使用引导(Tour) + - 功能区域清晰标注 + - 提供使用示例视频 + +**2. 移动端体验** + +- 风险:小屏幕上功能难以操作 +- 应对: + - Tab切换代替多栏布局 + - 优先级排序(核心功能优先) + - 手势操作支持 + +--- + +## 📊 成功指标 + +### 核心指标 + +- **学习时长**: 用户平均单次学习时长 > 30分钟 +- **笔记创建率**: 每个视频平均笔记数 > 3条 +- **AI使用率**: 使用AI助手的会话占比 > 40% +- **字幕翻译率**: 开启翻译功能的会话占比 > 60% + +### 功能使用率 + +- 章节跳转点击率 > 70% +- 字幕搜索使用率 > 30% +- 笔记导出使用率 > 20% +- AI回答保存为笔记 > 50% + +### 性能指标 + +- 页面首次加载 < 2s +- AI分段响应 < 30s +- 翻译速度 < 3s/100条 +- 浏览器兼容性 > 95%(Chrome/Edge/Safari/Firefox) + +--- + +## 🔄 未来规划(V2.0) + +### 第二期功能 + +- [ ] 多视频播放列表(连续学习模式) +- [ ] 笔记云端同步(用户登录系统) +- [ ] 笔记分享(生成链接/导出PPT) +- [ ] AI自动生成思维导图 +- [ ] 语音提问功能(语音转文字) +- [ ] 学习进度统计仪表盘 +- [ ] 自定义章节标注(手动调整) +- [ ] 多人协作笔记(团队学习) + +### 技术升级 + +- [ ] WebAssembly优化字幕解析 +- [ ] PWA支持(离线使用) +- [ ] WebRTC实时字幕同步(多设备) + +--- + +## 📎 附录 + +### API密钥配置界面设计 + +``` +┌────────────────────────────────────────────────┐ +│ ⚙️ API配置 [×] │ +├────────────────────────────────────────────────┤ +│ │ +│ 🔑 翻译引擎API密钥 │ +│ ──────────────────────────────────────────── │ +│ │ +│ DeepSeek API Key: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ sk-xxxxxxxxxxxxxxxxxxxxxxxx │ │ +│ └──────────────────────────────────────────┘ │ +│ ☑ 使用默认密钥 │ +│ │ +│ Google Translate API Key: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ (可选) │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ DeepL API Key: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ (可选) │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ GLM-4 API Key: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ (可选) │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ──────────────────────────────────────────── │ +│ │ +│ 💡 提示: 使用自己的API Key可获得更快的响应速度 │ +│ 和更高的请求额度。 │ +│ │ +│ [ 取消 ] [ 保存 ] │ +└────────────────────────────────────────────────┘ +``` + +### 错误状态处理 + +| 场景 | 错误提示 | 降级方案 | +| --------------- | ------------------------------------ | ------------ | +| YouTube嵌入失败 | "此视频不支持嵌入,请在YouTube观看" | 提供跳转链接 | +| 字幕获取失败 | "无法获取字幕,请稍后重试或手动上传" | 支持SRT上传 | +| AI分段超时 | "AI分析超时,使用默认等时长切分" | 每5分钟一段 | +| 翻译API失败 | "翻译服务暂时不可用,显示原文" | 仅显示原文 | +| 笔记保存失败 | "保存失败,请检查浏览器存储权限" | 提示导出 | +| AI助手无响应 | "AI助手暂时不可用,请稍后重试" | 隐藏浮窗 | + +--- + +## ✅ 验收清单 + +### 功能完整性 + +- [ ] 所有8个用户故事验收标准通过 +- [ ] 移动端/平板/桌面三端适配完成 +- [ ] 错误处理覆盖所有异常场景 + +### 性能达标 + +- [ ] 首屏加载时间 < 2s +- [ ] 长视频(3小时)流畅滚动无卡顿 +- [ ] 翻译响应速度 < 3s +- [ ] IndexedDB读写延迟 < 100ms + +### 用户体验 + +- [ ] 首次使用引导流程完整 +- [ ] 所有交互提供视觉反馈(loading/成功/失败) +- [ ] 键盘快捷键支持(空格暂停、左右箭头快进/后退) +- [ ] 无障碍支持(ARIA标签) + +### 兼容性 + +- [ ] Chrome 90+ +- [ ] Edge 90+ +- [ ] Safari 14+ +- [ ] Firefox 88+ + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-01-29 +**下次评审**: 待团队讨论后确定 + +--- + +**附:PRD总集更新行** +待确认后添加到 `docs/PRD_REGISTRY.md`: + +```markdown +| PRD-001 | YouTube学习工作台 | 将YouTube视频分析工具升级为沉浸式学习工作台。核心功能包括:AI智能章节分段(基于字幕自动切分+可视化时间轴+点击跳转),多引擎字幕翻译(DeepSeek/Google/DeepL/GLM-4 + 逐句对照+搜索定位+多格式下载),边看边记笔记系统(Markdown支持+时间戳关联+视频截图+全局搜索),AI学习助手浮窗(DeepSeek对话+上下文感知字幕与笔记+快捷提问+回答保存为笔记)。解决长视频学习的内容定位难、语言障碍、缺乏记录、无即时答疑等痛点。支持桌面/平板/移动端响应式布局,数据存储于IndexedDB。边界约束:依赖YouTube嵌入权限、字幕API可用性、翻译成本控制。非目标:不支持直播视频、不提供视频下载、暂不支持云端同步。 | [docs/prd/PRD-001-YouTube学习工作台.md](docs/prd/PRD-001-YouTube学习工作台.md) | +``` + +--- + +## 📎 补充章节(评审完善) + +> 根据团队评审意见,以下章节已作为补充文档添加: +> +> **📄 [PRD-001-补充章节.md](./PRD-001-补充章节.md)** +> +> 包含内容: +> +> - 🎯 **目标与非目标** - 明确MVP边界 +> - 📊 **MVP定义与优先级** - P0/P1/P2功能分层 +> - 🔐 **合规约束与法律边界** - YouTube使用条款约束 +> - 📡 **实时字幕同步机制** - 同步策略与异常处理 +> - 🌐 **语言切换逻辑澄清** - sourceLang vs targetLang +> - 📉 **降级策略表** - 故障场景与降级方案 +> - 📏 **事件埋点与数据口径** - 核心埋点事件定义 +> - 🔒 **隐私与数据管理** - 数据存储与隐私提示 +> - 📁 **下载文件命名规范** - 字幕文件格式规范 +> - ❓ **开放问题清单** - 待确认事项 + +--- + +**文档编码说明**: 本文档使用 UTF-8 编码保存。如看到乱码,请检查编辑器编码设置。 diff --git a/README.md b/README.md index 5613c87c..18b7e725 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# LongCut +# Little universe -LongCut turns long-form YouTube videos into a structured learning workspace. Paste a URL and the app generates highlight reels, timestamped AI answers, and a place to capture your own notes so you can absorb an hour-long video in minutes. +Little universe turns long-form YouTube videos into a structured learning workspace. Paste a URL and the app generates highlight reels, timestamped AI answers, and a place to capture your own notes so you can absorb an hour-long video in minutes. ## Overview diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 00000000..ffa310a2 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,897 @@ +# LongCut 完整排错清单与优化方法 + +> 版本: 1.0 +> 更新: 2025-01-31 +> 基于: 三位审核者的架构审查意见 + +--- + +## 目录 + +1. [问题清单(按优先级)](#一问题清单按优先级) +2. [功能排查清单(按场景)](#二功能排查清单按场景) +3. [数据完整性验证](#三数据完整性验证) +4. [环境一致性检查](#四环境一致性检查) +5. [功能走查测试方案](#五功能走查测试方案) +6. [前端稳定性验证](#六前端稳定性验证) +7. [日志与监控建议](#七日志与监控建议) +8. [快速诊断命令](#八快速诊断命令) + +--- + +## 一、问题清单(按优先级) + +### P0 - 高优先级(安全/功能漏洞) + +#### 1. 空数组永久缓存问题 +**文件**: `app/api/video-analysis/route.ts:130` + +```typescript +// 当前逻辑(有问题) +const isCachedAnalysis = Boolean(cachedVideo?.topics); + +if (!forceRegenerate && cachedVideo && cachedVideo.topics) { + return cachedVideo; // ❌ 空数组 [] 也会通过 +} +``` + +**问题**: 如果历史上因异常/中断保存了 `topics: []`,用户将永远得到空结果,无法恢复 + +**排查 SQL**: +```sql +SELECT youtube_id, title, created_at +FROM video_analyses +WHERE topics = '[]' OR topics IS NULL; +``` + +**解决方法**: +```typescript +// 方案 1: 将空数组视为无效缓存 +const isValidCache = cachedVideo?.topics && cachedVideo.topics.length > 0; + +// 方案 2: 空缓存触发再生成并覆盖 +if (cachedVideo?.topics?.length === 0) { + forceRegenerate = true; +} +``` + +--- + +#### 2. CORS 环境变量错误 +**文件**: `lib/security-middleware.ts:169` + +```typescript +// 当前(错误) +const allowedOrigins = [ + process.env.NEXT_PUBLIC_BASE_URL, // ❌ + 'http://localhost:3000', +]; + +// 应为 +const allowedOrigins = [ + process.env.NEXT_PUBLIC_APP_URL, // ✓ + 'http://localhost:3000', +]; +``` + +**排查**: 检查 `.env.local` 中 `NEXT_PUBLIC_APP_URL` 是否正确设置 + +**影响**: 跨域请求可能失败,CORS 配置不生效 + +--- + +#### 3. Signout 端点缺少 CSRF 保护 +**文件**: `app/api/auth/signout/route.ts` + +**问题**: 允许跨站登出攻击(CSRF) + +**解决方法**: +```typescript +import { withSecurity } from '@/lib/security-middleware'; + +async function handler(req: NextRequest) { + // 现有逻辑 +} + +export const POST = withSecurity(handler, { + requireAuth: true, + csrfProtection: true, + allowedMethods: ['POST'] +}); +``` + +--- + +#### 4. 速率限制使用完整 URL 作为 key +**文件**: `lib/rate-limiter.ts:72` + +**问题**: `/api/check-video-cache?videoId=abc` 和 `?videoId=xyz` 有独立限制 + +**当前逻辑**: +```typescript +const rateLimitKey = `ratelimit:${key}:${identifier}`; +// key 来自 req.url,包含查询字符串 +``` + +**解决方法**: +```typescript +// 只使用路径作为 key +const url = new URL(req.url); +const pathKey = `${url.pathname}:${identifier}`; +``` + +--- + +### P1 - 中优先级(稳定性/性能) + +#### 5. Provider Preference 端点缺少速率限制 +**文件**: `app/api/ai/provider/route.ts` + +**问题**: GET/POST 端点无速率限制,可能被滥用 + +**解决方法**: +```typescript +import { withSecurity, SECURITY_PRESETS } from '@/lib/security-middleware'; + +export const GET = withSecurity(handler, SECURITY_PRESETS.READ_ONLY); + +export const POST = withSecurity(postHandler, { + ...SECURITY_PRESETS.PUBLIC, + rateLimit: { windowMs: 60000, maxRequests: 10 } +}); +``` + +--- + +#### 6. 前端状态机竞态条件 +**文件**: `app/analyze/[videoId]/page.tsx` (2,582 行) + +**问题表现**: +- 主题切换触发并发请求 +- 语言切换与 AI 生成同时进行导致状态错位 +- 多次快速点击视频 URL 导致重复分析 + +**排查方法**: +1. 打开浏览器 Network 面板 +2. 快速切换主题/语言 +3. 观察是否有多个 `/api/video-analysis` 请求同时进行 +4. 检查最终状态是否与最后一次请求一致 + +**临时缓解**: +- 前端添加请求防抖/节流 +- 使用 AbortManager 取消进行中的请求 + +**根本解决**: 拆分大组件,独立状态管理 + +--- + +#### 7. AI Provider Fallback 不完整 +**文件**: `lib/ai-providers/registry.ts:81-93` + +**当前逻辑**: +```typescript +function isRetryableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes('service unavailable') || + lowerMessage.includes('503') || + lowerMessage.includes('502') || + lowerMessage.includes('504') || + lowerMessage.includes('timeout') || + lowerMessage.includes('overload') + ); +} +``` + +**问题**: +- 配置缺失(API Key 未设置)不会触发 fallback +- JSON 解析失败可能不被识别为可重试 + +**排查**: +- 测试各 provider API Key 失效时的行为 +- 测试结构化输出返回无效 JSON 时的行为 + +--- + +#### 8. 非原子速率限制清理 +**文件**: `lib/rate-limiter.ts:122-125` + +**问题**: 每次请求执行全表 `DELETE`,高并发时性能问题 + +**当前逻辑**: +```typescript +// 每次请求都执行全表清理 +await supabase + .from('rate_limits') + .delete() + .lt('timestamp', new Date(windowStart).toISOString()); +``` + +**解决方法**: 只清理当前 key 的旧记录 + +--- + +### P2 - 低优先级(可维护性) + +#### 9. 大组件文件 +**文件**: `app/analyze/[videoId]/page.tsx` (2,582 行) + +**影响**: 可维护性降低 + +**建议拆分**: +``` +hooks/ + ├── useVideoAnalysis.ts # 分析状态管理 + ├── usePlaybackCommand.ts # 播放命令逻辑 + └── useThemeTopics.ts # 主题话题管理 + +components/analyze/ + ├── AnalysisWorkspace.tsx # 主布局 + ├── LeftPanel.tsx # 左侧面板 + ├── RightPanel.tsx # 右侧面板 + └── LoadingStages.tsx # 加载阶段展示 +``` + +--- + +#### 10. 缺少关键流程日志 +**影响**: 无法快速定位问题来源 + +**需要添加日志的关键点**: + +| 流程 | 当前状态 | 建议 | +|------|----------|------| +| 转录获取 | 基础日志 | 添加单位判定结果 | +| AI 生成 | Provider 日志 | 添加 fallback 路径 | +| 保存到数据库 | 重试日志 | 添加 video_id 关联 | +| 计费消费 | 错误日志 | 添加用户/视频关联 | +| 限流拦截 | 当前状态 | 添加 key/remaining/retryAfter | +| 前端状态转换 | 无日志 | 添加状态变更/耗时 | + +--- + +## 二、功能排查清单(按场景) + +### 场景 1: 视频分析失败/卡住 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| 环境变量完整 | `.env.local` | 确认 XAI/Gemini/SUPADATA/Supabase/CSRF 变量 | +| API Key 有效 | 各服务提供商控制台 | 验证密钥未过期 | +| 转录获取成功 | `app/api/transcript/route.ts:85` | 检查单位判定逻辑 | +| 速率限制触发 | `lib/rate-limiter.ts` | 清理 `rate_limits` 表旧记录 | +| CSRF Token 有效 | 浏览器开发者工具 | 检查 `csrf-token` cookie | +| AI Provider 可用 | `lib/ai-providers/registry.ts` | 确认至少一个 provider 配置正确 | + +--- + +### 场景 2: 点击高亮不跳转/跳转偏移 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| 时间戳单位 | `app/api/transcript/route.ts:85` | 确认毫秒/秒转换正确 | +| 引用匹配 | `lib/quote-matcher.ts` | 验证 Boyer-Moore 搜索结果 | +| 段落索引 | Topic.segments | 检查 startSegmentIdx/endSegmentIdx | +| 字符偏移 | Topic.segments | 检查 startCharOffset/endCharOffset | + +--- + +### 场景 3: 收到 401/403/429 错误 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| 认证状态 | `lib/security-middleware.ts:50` | 确认用户登录有效 | +| CSRF Token | `lib/csrf-protection.ts:70` | 重新获取 token | +| 速率限制 | `lib/rate-limiter.ts:140` | 检查 remaining/retryAfter | +| CORS 配置 | `lib/security-middleware.ts:167` | 验证 origin 是否在允许列表 | +| 调用方式 | 前端代码 | 确认使用 `csrfFetch` 而非原生 `fetch` | + +--- + +### 场景 4: 缓存内容未更新/显示旧数据 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| 缓存命中 | `app/api/video-analysis/route.ts:120` | 检查 cachedVideo | +| 空数组陷阱 | `app/api/video-analysis/route.ts:130` | 检查 topics 是否为空数组 | +| 强制再生成 | 前端调用 | 传入 `forceRegenerate: true` | +| 数据库记录 | Supabase `video_analyses` | 手动删除旧记录 | + +--- + +### 场景 5: CSP 阻止请求 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| CSP 规则 | `middleware.ts:24` | 检查 connect-src | +| Supabase 域名 | `.env.local` | 确认 `NEXT_PUBLIC_SUPABASE_URL` 正确 | +| 控制台错误 | 浏览器开发者工具 | 查找 CSP 违规报告 | + +--- + +### 场景 6: Provider Fallback 未生效 + +| 检查项 | 位置 | 操作 | +|--------|------|------| +| 配置检查 | `.env.local` | 确认至少两个 provider 配置了 API Key | +| 错误识别 | `lib/ai-providers/registry.ts:81` | 检查 `isRetryableError` 逻辑 | +| 控制台日志 | 浏览器控制台 | 查找 provider 切换日志 | + +--- + +## 三、数据完整性验证 + +### 检查 1: 空缓存数据 +```sql +-- 查找问题缓存(空数组) +SELECT + youtube_id, + title, + jsonb_array_length(topics) as topic_count, + created_at +FROM video_analyses +WHERE topics = '[]' + OR topics IS NULL + OR jsonb_array_length(topics) = 0 +ORDER BY created_at DESC; +``` + +--- + +### 检查 2: 转录格式兼容性 +```sql +-- 检查是否需要迁移(合并格式 vs 分离格式) +SELECT + id, + youtube_id, + CASE + WHEN jsonb_typeof(transcript->'0') = 'object' THEN 'merged' + WHEN jsonb_typeof(transcript->'0') = 'array' THEN 'split' + ELSE 'unknown' + END as transcript_format, + created_at +FROM video_analyses +ORDER BY created_at DESC +LIMIT 20; +``` + +--- + +### 检查 3: 重复计费 +```sql +-- 检查同一视频是否被重复计费(最近30天) +SELECT + user_id, + youtube_id, + COUNT(*) as charge_count, + SUM(counted_toward_limit::int) as counted_count, + MIN(created_at) as first_charge, + MAX(created_at) as last_charge +FROM video_generations +WHERE created_at > NOW() - INTERVAL '30 days' +GROUP BY user_id, youtube_id +HAVING COUNT(*) > 1 +ORDER BY charge_count DESC; +``` + +--- + +### 检查 4: 限流记录健康度 +```sql +-- 检查是否有异常清理或堆积 +SELECT + key, + COUNT(*) as record_count, + COUNT(DISTINCT identifier) as unique_users, + MIN(timestamp) as oldest_record, + MAX(timestamp) as newest_record +FROM rate_limits +WHERE timestamp > NOW() - INTERVAL '7 days' +GROUP BY key +ORDER BY record_count DESC +LIMIT 20; +``` + +--- + +### 检查 5: AI 生成成功率 +```sql +-- 检查 AI 生成失败率 +SELECT + model_used, + COUNT(*) as total, + COUNT(*) FILTER (WHERE topics IS NULL OR jsonb_array_length(topics) = 0) as failed, + ROUND(100.0 * COUNT(*) FILTER (WHERE topics IS NULL OR jsonb_array_length(topics) = 0) / COUNT(*), 2) as failure_rate +FROM video_analyses +WHERE created_at > NOW() - INTERVAL '30 days' + AND model_used IS NOT NULL +GROUP BY model_used +ORDER BY failure_rate DESC; +``` + +--- + +## 四、环境一致性检查 + +### 检查清单 1: URL 环境变量 + +**`.env.local` 中检查**: +```bash +# 必需且正确 +NEXT_PUBLIC_APP_URL=https://your-domain.com + +# 应该删除或与 APP_URL 一致(避免混淆) +# NEXT_PUBLIC_BASE_URL= + +# Supabase 配置 +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc... + +# AI Provider(至少配置一个) +XAI_API_KEY=sk-xxx # Grok (xAI) +GEMINI_API_KEY=AIzaxxx # Gemini +DEEPSEEK_API_KEY=sk-xxx # DeepSeek + +# 其他必需 +SUPADATA_API_KEY=xxx +CSRF_SALT=random_string_here +``` + +--- + +### 检查清单 2: Supabase 配置 + +登录 [Supabase Dashboard](https://supabase.com/dashboard) 检查: + +**Authentication → URL Configuration**: +- [ ] Site URL 与 `NEXT_PUBLIC_APP_URL` 一致 +- [ ] Redirect URLs 包含你的域名 + +**API → URL**: +- [ ] URL 与 `NEXT_PUBLIC_SUPABASE_URL` 一致 + +**Database → Tables**: +- [ ] `video_analyses` 表存在 +- [ ] `video_generations` 表存在 +- [ ] `rate_limits` 表存在 +- [ ] `profiles` 表存在 +- [ ] `stripe_events` 表存在 + +--- + +### 检查清单 3: Provider 配置验证 + +```bash +# 本地验证(运行以下命令) +# 检查环境变量是否加载 +echo "APP_URL: $NEXT_PUBLIC_APP_URL" +echo "SUPABASE_URL: $NEXT_PUBLIC_SUPABASE_URL" +echo "XAI_KEY: ${XAI_API_KEY:+SET}" +echo "GEMINI_KEY: ${GEMINI_API_KEY:+SET}" +echo "DEEPSEEK_KEY: ${DEEPSEEK_API_KEY:+SET}" +``` + +--- + +## 五、功能走查测试方案 + +### 测试用例 1: 完整链路(正常流程) + +| 步骤 | 操作 | 预期结果 | 失败指标 | +|------|------|----------|----------| +| 1 | 输入新 YouTube URL | 进入分析页面,显示加载中 | 卡在首页 | +| 2 | 等待转录获取 | 进入"理解中"阶段 | 报错"转录失败" | +| 3 | 等待 AI 生成 | 进入"生成中"阶段 | 报错"AI 失败" | +| 4 | 生成完成 | 显示 5 个话题 + 主题选择器 | 显示空结果 | +| 5 | 选择一个主题 | 生成 5 个新话题 | 无响应 | +| 6 | 点击话题播放 | 视频跳转到正确位置 | 跳转位置错误 | +| 7 | 刷新页面 | 瞬间加载缓存数据 | 重新生成 | + +--- + +### 测试用例 2: 异常恢复 + +| 步骤 | 操作 | 预期结果 | 失败指标 | +|------|------|----------|----------| +| 1 | 在生成过程中刷新页面 | 重新进入生成流程 | 页面崩溃 | +| 2 | 生成一半断开网络 | 显示友好的错误信息 | 无限加载 | +| 3 | 网络恢复后重试 | 能够重新生成 | 无法操作 | +| 4 | AI 生成失败(模拟) | Fallback 到其他 provider 或报错 | 沉默失败 | + +--- + +### 测试用例 3: 边界条件 + +| 步骤 | 操作 | 预期结果 | 失败指标 | +|------|------|----------|----------| +| 1 | 使用无效 API Key | Fallback 到其他 provider | 整体失败 | +| 2 | 所有 API Key 无效 | 清晰的错误提示 | 技术错误堆栈 | +| 3 | 极短视频(<1分钟) | 正常处理或提示 | 崩溃 | +| 4 | 无字幕视频 | 提示字幕不可用 | 沉默失败 | +| 5 | 超长视频(>4小时) | 正常处理或提示 | 超时 | + +--- + +### 测试用例 4: 认证与限流 + +| 步骤 | 操作 | 预期结果 | 失败指标 | +|------|------|----------|----------| +| 1 | 匿名用户生成第1个视频 | 成功 | 被拒绝 | +| 2 | 匿名用户生成第2个视频 | 提示登录 | 仍然成功 | +| 3 | 登录后生成视频 | 扣除额度 | 不扣额度 | +| 4 | 达到免费额度 | 提示升级 | 仍然生成 | +| 5 | 退出登录 | 清除 cookies | 仍然登录 | + +--- + +## 六、前端稳定性验证 + +### 测试 1: 竞态条件检测 + +**在浏览器控制台运行**: +```javascript +// 快速触发多次主题切换 +const themes = ['技术', '商业', '教育']; +for (let i = 0; i < 10; i++) { + setTimeout(() => { + // 模拟点击主题(根据实际调整选择器) + document.querySelector(`[data-theme="${themes[i % 3]}"]`)?.click(); + }, i * 100); +} + +// 观察: +// 1. 网络请求数量(应该是 10 个或被节流减少) +// 2. 最终显示的主题是否与最后一次点击一致 +// 3. 是否有未处理的 Promise +``` + +--- + +### 测试 2: 重渲染检测 + +**使用 React DevTools**: +1. 安装 React DevTools 浏览器扩展 +2. 打开 Components 面板 +3. 勾选 "Highlight updates when components render" +4. 执行常见操作: + - 切换主题 + - 播放视频 + - 发送聊天消息 + - 切换标签页 +5. 观察: + - 是否有不必要的组件重渲染 + - 重渲染频率是否过高 + +--- + +### 测试 3: 内存泄漏检测 + +**在浏览器控制台运行**: +```javascript +// 记录初始内存 +const initialMemory = performance.memory.usedJSHeapSize; + +// 执行多次操作 +for (let i = 0; i < 10; i++) { + // 模拟切换视频 + window.location.href = `/analyze/test_video_${i}`; + await new Promise(r => setTimeout(r, 2000)); + // 返回首页 + window.location.href = '/'; + await new Promise(r => setTimeout(r, 1000)); +} + +// 检查内存增长(需要多次刷新页面观察趋势) +console.log('内存使用:', performance.memory.usedJSHeapSize - initialMemory); +``` + +--- + +### 测试 4: 状态一致性 + +**手动测试步骤**: +1. 打开分析页面 +2. 快速依次执行: + - 选择主题 A + - 切换到摘要标签 + - 选择主题 B + - 切换到聊天标签 + - 发送消息 + - 切换回话题标签 +3. 检查: + - 显示的是哪个主题的话题? + - 聊天历史是否完整? + - 视频播放状态是否正确? + +--- + +## 七、日志与监控建议 + +### 统一请求追踪 + +**为每个请求生成唯一 ID**: +```typescript +// 在 API 路由开始时 +const requestId = crypto.randomUUID(); + +// 所有日志都包含此 ID +console.log(`[${requestId}] Starting video analysis`, { + videoId, + userId, + timestamp: new Date().toISOString() +}); +console.log(`[${requestId}] AI provider: ${provider}`); +console.log(`[${requestId}] Generated ${topics.length} topics`); +console.log(`[${requestId}] Saved to database:`, { + videoAnalysisId: result.videoId +}); +``` + +--- + +### 日志分级 + +```typescript +enum LogLevel { + DEBUG = 'debug', // 详细调试信息 + INFO = 'info', // 正常流程节点 + WARN = 'warn', // 可恢复的异常 + ERROR = 'error', // 需要关注的错误 +} + +// 使用示例 +logger.info('Video analysis started', { videoId, userId }); +logger.warn('Provider fallback triggered', { + from: 'grok', + to: 'gemini' +}); +logger.error('AI generation failed', { + error, + provider, + retryCount +}); +``` + +--- + +### 错误分类 + +```typescript +enum ErrorCategory { + USER_ERROR = 'user', // 用户输入问题(无效URL等) + PROVIDER_ERROR = 'provider', // AI/转录服务问题 + SYSTEM_ERROR = 'system', // 内部系统问题(数据库等) + NETWORK_ERROR = 'network', // 网络问题 +} + +// 使用示例 +logger.error('Analysis failed', { + category: ErrorCategory.PROVIDER_ERROR, + provider: 'grok', + error: error.message, + userId, + videoId +}); +``` + +--- + +### 关键流程日志点 + +| 流程 | 日志点 | 记录内容 | +|------|--------|----------| +| 转录获取 | 开始/成功/失败 | URL、单位判定结果、耗时 | +| AI 生成 | 开始/fallback/成功/失败 | Provider、模型、话题数量、耗时 | +| 保存数据库 | 开始/重试/成功/失败 | video_id、重试次数 | +| 计费消费 | 开始/成功/失败 | 用户ID、视频ID、剩余额度 | +| 限流拦截 | 触发时 | key、remaining、retryAfter | +| 前端状态 | 状态变更 | 从状态、到状态、触发原因 | + +--- + +### 日志查询示例 + +```sql +-- 查找特定视频的所有日志 +SELECT * FROM audit_logs +WHERE resource_id = 'video_id_here' +ORDER BY created_at DESC; + +-- 查找特定用户的所有错误 +SELECT * FROM audit_logs +WHERE user_id = 'user_id_here' + AND action IN ('ERROR', 'FATAL') +ORDER BY created_at DESC; + +-- 查找 Provider 失败统计 +SELECT + details->>'provider' as provider, + COUNT(*) as fail_count, + MIN(created_at) as first_fail, + MAX(created_at) as last_fail +FROM audit_logs +WHERE action = 'AI_GENERATION_FAILED' +GROUP BY details->>'provider' +ORDER BY fail_count DESC; +``` + +--- + +## 八、快速诊断命令 + +### 本地环境检查 + +```bash +# 1. 检查环境变量(必需项) +grep -E "^(NEXT_PUBLIC_APP_URL|NEXT_PUBLIC_SUPABASE_URL|XAI_API_KEY|GEMINI_API_KEY|SUPADATA_API_KEY|CSRF_SALT)" .env.local + +# 2. 检查依赖安装 +npm list --depth=0 | grep -E "(next|@supabase|zod)" + +# 3. 运行类型检查 +npm run build # 或 npx tsc --noEmit + +# 4. 运行 linter +npm run lint +``` + +--- + +### 数据库快速查询 + +```sql +-- 1. 检查空缓存数量 +SELECT COUNT(*) as empty_cache_count +FROM video_analyses +WHERE topics = '[]' OR topics IS NULL; + +-- 2. 检查最近的失败生成 +SELECT youtube_id, model_used, created_at +FROM video_analyses +WHERE (topics IS NULL OR jsonb_array_length(topics) = 0) + AND created_at > NOW() - INTERVAL '7 days' +ORDER BY created_at DESC +LIMIT 10; + +-- 3. 检查限流堆积 +SELECT key, COUNT(*) as count +FROM rate_limits +GROUP BY key +ORDER BY count DESC +LIMIT 10; + +-- 4. 检查重复计费 +SELECT user_id, youtube_id, COUNT(*) as charge_count +FROM video_generations +WHERE created_at > NOW() - INTERVAL '30 days' +GROUP BY user_id, youtube_id +HAVING COUNT(*) > 1; + +-- 5. 检查 AI Provider 使用分布 +SELECT model_used, COUNT(*) as count +FROM video_analyses +WHERE created_at > NOW() - INTERVAL '30 days' +GROUP BY model_used +ORDER BY count DESC; +``` + +--- + +### 修复空缓存的 SQL + +```sql +-- 标记空缓存以便重新生成(添加标记字段) +UPDATE video_analyses +SET needs_regeneration = true +WHERE topics = '[]' OR topics IS NULL; + +-- 或直接删除空缓存(触发重新生成) +DELETE FROM video_analyses +WHERE topics = '[]' OR topics IS NULL; +``` + +--- + +### 清理限流记录的 SQL + +```sql +-- 清理 7 天前的限流记录 +DELETE FROM rate_limits +WHERE timestamp < NOW() - INTERVAL '7 days'; + +-- 清理特定 key 的旧记录 +DELETE FROM rate_limits +WHERE key = 'ratelimit:video-analysis:*' + AND timestamp < NOW() - INTERVAL '1 day'; +``` + +--- + +## 九、问题优先级总结 + +| 优先级 | 问题 | 影响 | 类型 | 文件位置 | +|--------|------|------|------|----------| +| P0 | 空数组永久缓存 | 功能不可恢复 | 功能 | `app/api/video-analysis/route.ts:130` | +| P0 | CORS 环境变量 | 跨域请求失败 | 安全 | `lib/security-middleware.ts:169` | +| P0 | Signout 无 CSRF | 安全漏洞 | 安全 | `app/api/auth/signout/route.ts` | +| P1 | 速率限制 key | 限流不准确 | 功能 | `lib/rate-limiter.ts:72` | +| P1 | Provider 端点无限制 | 可被滥用 | 安全 | `app/api/ai/provider/route.ts` | +| P1 | 前端状态机竞态 | 用户体验 | 稳定性 | `app/analyze/[videoId]/page.tsx` | +| P1 | Provider Fallback | 生成成功率 | 稳定性 | `lib/ai-providers/registry.ts:81` | +| P2 | 大组件文件 | 可维护性 | 架构 | `app/analyze/[videoId]/page.tsx` | +| P2 | 全表清理限流 | 性能 | 性能 | `lib/rate-limiter.ts:122` | +| P2 | 关键日志缺失 | 可追溯性 | 可维护性 | 多处 | + +--- + +## 十、快速参考 + +### 关键文件位置 + +| 功能 | 文件 | +|------|------| +| 主分析页面 | `app/analyze/[videoId]/page.tsx` | +| 视频分析 API | `app/api/video-analysis/route.ts` | +| 转录获取 API | `app/api/transcript/route.ts` | +| 安全中间件 | `lib/security-middleware.ts` | +| 速率限制 | `lib/rate-limiter.ts` | +| CSRF 保护 | `lib/csrf-protection.ts` | +| AI 提供方注册表 | `lib/ai-providers/registry.ts` | +| 引用匹配 | `lib/quote-matcher.ts` | +| 全局 CSP/会话 | `middleware.ts` | + +--- + +### 环境变量模板 + +```bash +# === 应用配置 === +NEXT_PUBLIC_APP_URL=https://your-domain.com + +# === Supabase === +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc... + +# === AI Provider(至少配置一个)=== +XAI_API_KEY=sk-xxx +GEMINI_API_KEY=AIzaxxx +DEEPSEEK_API_KEY=sk-xxx + +# === 外部服务 === +SUPADATA_API_KEY=xxx + +# === 安全 === +CSRF_SALT=random_string_here + +# === 可选 === +AI_DEFAULT_MODEL=grok-4-1-fast-non-reasoning +UNLIMITED_VIDEO_USERS=user@email.com +``` + +--- + +### 常用诊断流程 + +``` +问题报告 + ↓ +1. 检查环境变量 → 确认配置完整 + ↓ +2. 查看数据库 → 运行诊断 SQL + ↓ +3. 检查日志 → 查找相关 requestId + ↓ +4. 复现问题 → 按测试用例执行 + ↓ +5. 定位根因 → 对照问题清单 + ↓ +6. 实施修复 → 按解决方法执行 + ↓ +7. 验证修复 → 重新测试 +``` + +--- + +> **文档维护**: 本文档应随着代码更新和问题修复同步更新。 +> **问题报告**: 发现新问题请按"错误分类"记录到审计日志。 diff --git a/app/analyze/[videoId]/page.tsx b/app/analyze/[videoId]/page.tsx index 9b089d3f..8703080b 100644 --- a/app/analyze/[videoId]/page.tsx +++ b/app/analyze/[videoId]/page.tsx @@ -1,7 +1,10 @@ "use client"; import { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { RightColumnTabs, type RightColumnTabsHandle } from "@/components/right-column-tabs"; +import { + RightColumnPanel, + type RightColumnPanelHandle, +} from "@/components/right-column-panel"; import { YouTubePlayer } from "@/components/youtube-player"; import { HighlightsPanel } from "@/components/highlights-panel"; import { ThemeSelector } from "@/components/theme-selector"; @@ -10,20 +13,53 @@ import { LoadingTips } from "@/components/loading-tips"; import { VideoSkeleton } from "@/components/video-skeleton"; import Link from "next/link"; import { useParams, useSearchParams, useRouter } from "next/navigation"; -import { Topic, TranscriptSegment, VideoInfo, Citation, PlaybackCommand, Note, NoteSource, NoteMetadata, TopicCandidate, TopicGenerationMode, TranslationRequestHandler } from "@/lib/types"; +import { + Topic, + TranscriptSegment, + VideoInfo, + Citation, + PlaybackCommand, + Note, + NoteSource, + NoteMetadata, + TopicCandidate, + TopicGenerationMode, + TranslationRequestHandler, +} from "@/lib/types"; import { normalizeWhitespace } from "@/lib/quote-matcher"; -import { hydrateTopicsWithTranscript, normalizeTranscript } from "@/lib/topic-utils"; -import { SelectionActionPayload, EXPLAIN_SELECTION_EVENT } from "@/components/selection-actions"; -import { fetchNotes, saveNote } from "@/lib/notes-client"; +import { + hydrateTopicsWithTranscript, + normalizeTranscript, +} from "@/lib/topic-utils"; +import { + SelectionActionPayload, + EXPLAIN_SELECTION_EVENT, +} from "@/components/selection-actions"; +import { saveNote } from "@/lib/notes-client"; import { EditingNote } from "@/components/notes-panel"; import { useModePreference } from "@/lib/hooks/use-mode-preference"; import { useTranslation } from "@/lib/hooks/use-translation"; import { useSubscription } from "@/lib/hooks/use-subscription"; import { useTranscriptExport } from "@/lib/hooks/use-transcript-export"; +import { HistorySidebar, useWatchHistory } from "@/components/history-sidebar"; +import { AIAssistantFloating } from "@/components/ai-assistant-floating"; +import { + useLocalNotes, + getYouTubeThumbnail, +} from "@/lib/hooks/use-local-notes"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; // Page state for better UX -type PageState = 'IDLE' | 'ANALYZING_NEW' | 'LOADING_CACHED'; -type AuthModalTrigger = 'generation-limit' | 'save-video' | 'manual' | 'save-note'; +type PageState = "IDLE" | "ANALYZING_NEW" | "LOADING_CACHED"; +type AuthModalTrigger = + | "generation-limit" + | "save-video" + | "manual" + | "save-note"; import { buildVideoSlug, extractVideoId } from "@/lib/utils"; import { getLanguageName } from "@/lib/language-utils"; import { NO_CREDITS_USED_MESSAGE } from "@/lib/no-credits-message"; @@ -40,14 +76,16 @@ import { toast } from "sonner"; import { hasSpeakerMetadata } from "@/lib/transcript-export"; import { buildSuggestedQuestionFallbacks } from "@/lib/suggested-question-fallback"; -const GUEST_LIMIT_MESSAGE = "You've used your free preview. Create a free account for 3 videos/month."; -const AUTH_LIMIT_MESSAGE = "You've used all 3 free videos this month. Upgrade to Pro for 100 videos/month."; +const GUEST_LIMIT_MESSAGE = + "You've used your free preview. Create a free account for 3 videos/month."; +const AUTH_LIMIT_MESSAGE = + "You've used all 3 free videos this month. Upgrade to Pro for 100 videos/month."; const DEFAULT_CLIENT_ERROR = "Something went wrong. Please try again."; type LimitCheckResponse = { canGenerate: boolean; isAuthenticated: boolean; - tier?: 'free' | 'pro' | 'anonymous'; + tier?: "free" | "pro" | "anonymous"; reason?: string | null; requiresTopup?: boolean; requiresAuth?: boolean; @@ -66,26 +104,30 @@ type LimitCheckResponse = { } | null; }; - -function buildLimitExceededMessage(limitData?: LimitCheckResponse | null): string { +function buildLimitExceededMessage( + limitData?: LimitCheckResponse | null, +): string { if (!limitData) { return AUTH_LIMIT_MESSAGE; } - if (limitData.reason === 'SUBSCRIPTION_INACTIVE') { - return 'Your subscription is not active. Visit the billing portal to reactivate and continue generating videos.'; + if (limitData.reason === "SUBSCRIPTION_INACTIVE") { + return "Your subscription is not active. Visit the billing portal to reactivate and continue generating videos."; } - if (limitData.tier === 'pro') { + if (limitData.tier === "pro") { return limitData.requiresTopup - ? 'You have used all Pro videos this period. Purchase a Top-Up (+20 videos for $2.99) or wait for your next billing cycle.' - : 'You have used your Pro allowance. Wait for your next billing cycle to reset.'; + ? "You have used all Pro videos this period. Purchase a Top-Up (+20 videos for $2.99) or wait for your next billing cycle." + : "You have used your Pro allowance. Wait for your next billing cycle to reset."; } return AUTH_LIMIT_MESSAGE; } -function normalizeErrorMessage(message: string | undefined, fallback: string = DEFAULT_CLIENT_ERROR): string { +function normalizeErrorMessage( + message: string | undefined, + fallback: string = DEFAULT_CLIENT_ERROR, +): string { const trimmed = typeof message === "string" ? message.trim() : ""; const baseMessage = trimmed.length > 0 ? trimmed : fallback; const normalizedSource = `${trimmed} ${baseMessage}`.toLowerCase(); @@ -121,7 +163,8 @@ function buildApiErrorMessage(errorData: unknown, fallback: string): string { const baseMessage = normalizeErrorMessage(combinedMessage, fallback); const creditsMessage = - typeof record.creditsMessage === "string" && record.creditsMessage.trim().length > 0 + typeof record.creditsMessage === "string" && + record.creditsMessage.trim().length > 0 ? record.creditsMessage.trim() : record.noCreditsUsed ? NO_CREDITS_USED_MESSAGE @@ -131,7 +174,9 @@ function buildApiErrorMessage(errorData: unknown, fallback: string): string { return baseMessage; } - const alreadyIncludes = baseMessage.toLowerCase().includes(creditsMessage.toLowerCase()); + const alreadyIncludes = baseMessage + .toLowerCase() + .includes(creditsMessage.toLowerCase()); return alreadyIncludes ? baseMessage : `${baseMessage}\n${creditsMessage}`; } @@ -159,12 +204,15 @@ function isFlattenedTranscript( const numericDuration = parseDurationSeconds(videoInfo?.duration); const referenceDuration = - numericDuration && numericDuration > 0 ? numericDuration : transcriptDuration; + numericDuration && numericDuration > 0 + ? numericDuration + : transcriptDuration; const totalWords = transcript.reduce((sum, seg) => { - const words = typeof seg.text === "string" - ? seg.text.trim().split(/\s+/).filter(Boolean).length - : 0; + const words = + typeof seg.text === "string" + ? seg.text.trim().split(/\s+/).filter(Boolean).length + : 0; return sum + words; }, 0); @@ -179,24 +227,31 @@ function isFlattenedTranscript( export default function AnalyzePage() { const params = useParams<{ videoId: string }>(); - const routeVideoId = Array.isArray(params?.videoId) ? params.videoId[0] : params?.videoId; + const routeVideoId = Array.isArray(params?.videoId) + ? params.videoId[0] + : params?.videoId; const searchParams = useSearchParams(); const router = useRouter(); - const urlParam = searchParams?.get('url'); - const cachedParam = searchParams?.get('cached'); + const urlParam = searchParams?.get("url"); + const cachedParam = searchParams?.get("cached"); const cachedParamValue = cachedParam?.toLowerCase(); - const isCachedQuery = cachedParamValue === 'true' || cachedParamValue === '1'; - const regenParam = searchParams?.get('regen'); - const forceRegenerate = (regenParam?.toLowerCase() === '1' || regenParam?.toLowerCase() === 'true'); - const authErrorParam = searchParams?.get('auth_error'); - const slugParam = searchParams?.get('slug') ?? null; + const isCachedQuery = cachedParamValue === "true" || cachedParamValue === "1"; + const regenParam = searchParams?.get("regen"); + const forceRegenerate = + regenParam?.toLowerCase() === "1" || regenParam?.toLowerCase() === "true"; + const authErrorParam = searchParams?.get("auth_error"); + const slugParam = searchParams?.get("slug") ?? null; const [pageState, setPageState] = useState(() => - (routeVideoId || urlParam) - ? (isCachedQuery ? 'LOADING_CACHED' : 'ANALYZING_NEW') - : 'IDLE' + routeVideoId || urlParam + ? isCachedQuery + ? "LOADING_CACHED" + : "ANALYZING_NEW" + : "IDLE", ); const hasAttemptedLinking = useRef(false); - const [loadingStage, setLoadingStage] = useState<'fetching' | 'understanding' | 'generating' | 'processing' | null>(null); + const [loadingStage, setLoadingStage] = useState< + "fetching" | "understanding" | "generating" | "processing" | null + >(null); const { mode, isLoading: isModeLoading } = useModePreference(); const [error, setError] = useState(""); const [isRateLimitError, setIsRateLimitError] = useState(false); @@ -208,33 +263,48 @@ export default function AnalyzePage() { const [baseTopics, setBaseTopics] = useState([]); const [themes, setThemes] = useState([]); const [selectedTheme, setSelectedTheme] = useState(null); - const [themeTopicsMap, setThemeTopicsMap] = useState>({}); - const [, setThemeCandidateMap] = useState>({}); + const [themeTopicsMap, setThemeTopicsMap] = useState>( + {}, + ); + const [, setThemeCandidateMap] = useState>( + {}, + ); const [usedTopicKeys, setUsedTopicKeys] = useState>(new Set()); const baseTopicKeySet = useMemo(() => { const keys = new Set(); baseTopics.forEach((topic) => { if (topic.quote?.timestamp && topic.quote.text) { - keys.add(`${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`); + keys.add( + `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`, + ); } }); return keys; }, [baseTopics]); const [isLoadingThemeTopics, setIsLoadingThemeTopics] = useState(false); const [themeError, setThemeError] = useState(null); - const [switchingToLanguage, setSwitchingToLanguage] = useState(null); + const [switchingToLanguage, setSwitchingToLanguage] = useState( + null, + ); const [selectedTopic, setSelectedTopic] = useState(null); const [currentTime, setCurrentTime] = useState(0); const [videoDuration, setVideoDuration] = useState(0); const [isShareReady, setIsShareReady] = useState(false); // Centralized playback control state - const [playbackCommand, setPlaybackCommand] = useState(null); + const [playbackCommand, setPlaybackCommand] = + useState(null); const [transcriptHeight, setTranscriptHeight] = useState("auto"); - const [citationHighlight, setCitationHighlight] = useState(null); - const [generationStartTime, setGenerationStartTime] = useState(null); - const [processingStartTime, setProcessingStartTime] = useState(null); - const rightColumnTabsRef = useRef(null); + const [citationHighlight, setCitationHighlight] = useState( + null, + ); + const [generationStartTime, setGenerationStartTime] = useState( + null, + ); + const [processingStartTime, setProcessingStartTime] = useState( + null, + ); + const rightColumnTabsRef = useRef(null); const abortManager = useRef(new AbortManager()); const selectedThemeRef = useRef(null); const seoPathRef = useRef(null); @@ -247,9 +317,12 @@ export default function AnalyzePage() { const [playAllIndex, setPlayAllIndex] = useState(0); // Memoized setters for Play All state - const memoizedSetPlayAllIndex = useCallback((value: number | ((prev: number) => number)) => { - setPlayAllIndex(value); - }, []); + const memoizedSetPlayAllIndex = useCallback( + (value: number | ((prev: number) => number)) => { + setPlayAllIndex(value); + }, + [], + ); const memoizedSetIsPlayingAll = useCallback((value: boolean) => { setIsPlayingAll(value); @@ -260,9 +333,12 @@ export default function AnalyzePage() { const [, setIsGeneratingTakeaways] = useState(false); const [, setTakeawaysError] = useState(""); const [showChatTab, setShowChatTab] = useState(false); + const [isAIChatOpen, setIsAIChatOpen] = useState(false); // Cached suggested questions - const [cachedSuggestedQuestions, setCachedSuggestedQuestions] = useState(null); + const [cachedSuggestedQuestions, setCachedSuggestedQuestions] = useState< + string[] | null + >(null); // Use custom hooks for translation const { @@ -276,13 +352,19 @@ export default function AnalyzePage() { // Create unified translation handler with videoInfo context const translateWithContext: TranslationRequestHandler = useCallback( (text: string, cacheKey: string, scenario?, targetLanguage?) => { - return handleRequestTranslation(text, cacheKey, scenario, videoInfo, targetLanguage); + return handleRequestTranslation( + text, + cacheKey, + scenario, + videoInfo, + targetLanguage, + ); }, - [handleRequestTranslation, videoInfo] + [handleRequestTranslation, videoInfo], ); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return; } @@ -296,7 +378,8 @@ export default function AnalyzePage() { } const normalizedSlugParam = slugParam?.trim() || null; - const fallbackTitle = videoInfo?.title || `YouTube Video ${effectiveVideoId}`; + const fallbackTitle = + videoInfo?.title || `YouTube Video ${effectiveVideoId}`; const derivedSlug = normalizedSlugParam ? normalizedSlugParam : buildVideoSlug(fallbackTitle, effectiveVideoId); @@ -307,13 +390,16 @@ export default function AnalyzePage() { const targetPath = `/v/${derivedSlug}`; - if (seoPathRef.current === targetPath || window.location.pathname === targetPath) { + if ( + seoPathRef.current === targetPath || + window.location.pathname === targetPath + ) { seoPathRef.current = targetPath; return; } const newUrl = `${targetPath}${window.location.search}`; - window.history.replaceState(window.history.state, '', newUrl); + window.history.replaceState(window.history.state, "", newUrl); seoPathRef.current = targetPath; }, [isShareReady, routeVideoId, videoId, videoInfo?.title, slugParam]); @@ -324,23 +410,27 @@ export default function AnalyzePage() { // Auth and generation limit state const { user } = useAuth(); const [authModalOpen, setAuthModalOpen] = useState(false); - const [authModalTrigger, setAuthModalTrigger] = useState('generation-limit'); + const [authModalTrigger, setAuthModalTrigger] = + useState("generation-limit"); // Store current video data in sessionStorage before auth - const storeCurrentVideoForAuth = useCallback((id?: string) => { - const targetVideoId = id ?? videoId; - if (targetVideoId && !user) { - try { - sessionStorage.setItem('pendingVideoId', targetVideoId); - } catch (error) { - console.error('Failed to persist pending video ID:', error); + const storeCurrentVideoForAuth = useCallback( + (id?: string) => { + const targetVideoId = id ?? videoId; + if (targetVideoId && !user) { + try { + sessionStorage.setItem("pendingVideoId", targetVideoId); + } catch (error) { + console.error("Failed to persist pending video ID:", error); + } } - } - }, [user, videoId]); + }, + [user, videoId], + ); const handleAuthRequired = useCallback(() => { storeCurrentVideoForAuth(); - setAuthModalTrigger('manual'); + setAuthModalTrigger("manual"); setAuthModalOpen(true); }, [storeCurrentVideoForAuth]); @@ -358,14 +448,22 @@ export default function AnalyzePage() { useEffect(() => { if (user && !subscriptionStatus && !isCheckingSubscription) { fetchSubscriptionStatus().catch((err) => { - console.error('Failed to prefetch subscription status:', err); + console.error("Failed to prefetch subscription status:", err); }); } - }, [user, subscriptionStatus, isCheckingSubscription, fetchSubscriptionStatus]); + }, [ + user, + subscriptionStatus, + isCheckingSubscription, + fetchSubscriptionStatus, + ]); // Translation is available to all authenticated users (Free + Pro) - const hasSpeakerData = useMemo(() => hasSpeakerMetadata(transcript), [transcript]); + const hasSpeakerData = useMemo( + () => hasSpeakerMetadata(transcript), + [transcript], + ); // Use custom hook for transcript export const { @@ -398,9 +496,7 @@ export default function AnalyzePage() { videoInfo, user, hasSpeakerData, - subscriptionStatus, - isCheckingSubscription, - fetchSubscriptionStatus, + // MVP: Subscription props removed onAuthRequired: handleAuthRequired, onRequestTranslation: translateWithContext, onBulkTranslation: handleBulkTranslation, @@ -416,11 +512,12 @@ export default function AnalyzePage() { // Centralized playback request functions const requestSeek = useCallback((time: number) => { - setPlaybackCommand({ type: 'SEEK', time }); + console.log('[Page] requestSeek called with:', time); + setPlaybackCommand({ type: "SEEK", time }); }, []); const requestPlayTopic = useCallback((topic: Topic) => { - setPlaybackCommand({ type: 'PLAY_TOPIC', topic, autoPlay: true }); + setPlaybackCommand({ type: "PLAY_TOPIC", topic, autoPlay: true }); }, []); const requestPlayAll = useCallback(() => { @@ -428,7 +525,7 @@ export default function AnalyzePage() { // Set Play All state first setIsPlayingAll(true); setPlayAllIndex(0); - setPlaybackCommand({ type: 'PLAY_ALL', autoPlay: true }); + setPlaybackCommand({ type: "PLAY_ALL", autoPlay: true }); }, [topics]); const clearPlaybackCommand = useCallback(() => { @@ -438,7 +535,7 @@ export default function AnalyzePage() { const promptSignInForNotes = useCallback(() => { if (user) return; storeCurrentVideoForAuth(); - setAuthModalTrigger('save-note'); + setAuthModalTrigger("save-note"); setAuthModalOpen(true); }, [storeCurrentVideoForAuth, user, setAuthModalTrigger]); @@ -450,9 +547,10 @@ export default function AnalyzePage() { hasRedirectedForLimit.current = true; - const trimmedMessage = typeof message === "string" && message.trim().length > 0 - ? message.trim() - : GUEST_LIMIT_MESSAGE; + const trimmedMessage = + typeof message === "string" && message.trim().length > 0 + ? message.trim() + : GUEST_LIMIT_MESSAGE; const targetVideoId = pendingVideoId ?? videoId ?? routeVideoId ?? null; if (targetVideoId) { @@ -461,121 +559,143 @@ export default function AnalyzePage() { if (trimmedMessage) { try { - sessionStorage.setItem('limitRedirectMessage', trimmedMessage); + sessionStorage.setItem("limitRedirectMessage", trimmedMessage); } catch (error) { - console.error('Failed to persist limit redirect message:', error); + console.error("Failed to persist limit redirect message:", error); } } - router.push('/?auth=limit'); + router.push("/?auth=limit"); }, - [routeVideoId, router, storeCurrentVideoForAuth, videoId] + [routeVideoId, router, storeCurrentVideoForAuth, videoId], ); // Check for pending video linking after auth - const checkPendingVideoLink = useCallback(async (retryCount = 0) => { - // Check both sessionStorage and current videoId state - const pendingVideoId = sessionStorage.getItem('pendingVideoId'); - const currentVideoId = videoId; - const videoToLink = pendingVideoId || currentVideoId; - - console.log('Checking for video to link:', { - pendingVideoId, - currentVideoId, - user: user?.email, - retryCount - }); + const checkPendingVideoLink = useCallback( + async (retryCount = 0) => { + // Check both sessionStorage and current videoId state + const pendingVideoId = sessionStorage.getItem("pendingVideoId"); + const currentVideoId = videoId; + const videoToLink = pendingVideoId || currentVideoId; + + console.log("Checking for video to link:", { + pendingVideoId, + currentVideoId, + user: user?.email, + retryCount, + }); - if (videoToLink && user) { - console.log('Found video to link:', videoToLink); + if (videoToLink && user) { + console.log("Found video to link:", videoToLink); - // First, check if the video exists in the database - try { - // Construct YouTube URL from videoId for the cache check - const checkUrl = `https://www.youtube.com/watch?v=${videoToLink}`; - const checkResponse = await fetch('/api/check-video-cache', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: checkUrl }) - }); + // First, check if the video exists in the database + try { + // Construct YouTube URL from videoId for the cache check + const checkUrl = `https://www.youtube.com/watch?v=${videoToLink}`; + const checkResponse = await fetch("/api/check-video-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: checkUrl }), + }); - if (!checkResponse.ok || !(await checkResponse.json()).cached) { - // Video doesn't exist yet, don't try to link - console.log('Video not yet in database, skipping link'); + if (!checkResponse.ok || !(await checkResponse.json()).cached) { + // Video doesn't exist yet, don't try to link + console.log("Video not yet in database, skipping link"); + return; + } + } catch (error) { + console.error("Error checking video cache:", error); return; } - } catch (error) { - console.error('Error checking video cache:', error); - return; - } - try { - const response = await fetch('/api/link-video', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ videoId: videoToLink }) - }); + try { + const response = await fetch("/api/link-video", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ videoId: videoToLink }), + }); - if (response.ok) { - const data = await response.json(); - console.log('Link video response:', data); - // Only show toast for newly linked videos, not already linked ones - if (!data.alreadyLinked) { - toast.success('Video saved to your library!'); + if (response.ok) { + const data = await response.json(); + console.log("Link video response:", data); + // Only show toast for newly linked videos, not already linked ones + if (!data.alreadyLinked) { + toast.success("Video saved to your library!"); + } + sessionStorage.removeItem("pendingVideoId"); + } else if (response.status === 404 && retryCount < 3) { + // Retry with exponential backoff if video not found + console.log( + `Video not found, retrying in ${1000 * (retryCount + 1)}ms...`, + ); + setTimeout( + () => { + checkPendingVideoLink(retryCount + 1); + }, + 1000 * (retryCount + 1), + ); + } else if (response.status === 503 && retryCount < 2) { + // User profile not ready yet, retry after a short delay + console.log( + `Profile not ready, retrying in ${2000 * (retryCount + 1)}ms...`, + ); + setTimeout( + () => { + checkPendingVideoLink(retryCount + 1); + }, + 2000 * (retryCount + 1), + ); + } else { + const errorText = await response + .text() + .catch(() => "Unknown error"); + console.error("Failed to link video:", response.status, errorText); + // Don't remove pendingVideoId on error, so it can be retried later } - sessionStorage.removeItem('pendingVideoId'); - } else if (response.status === 404 && retryCount < 3) { - // Retry with exponential backoff if video not found - console.log(`Video not found, retrying in ${1000 * (retryCount + 1)}ms...`); - setTimeout(() => { - checkPendingVideoLink(retryCount + 1); - }, 1000 * (retryCount + 1)); - } else if (response.status === 503 && retryCount < 2) { - // User profile not ready yet, retry after a short delay - console.log(`Profile not ready, retrying in ${2000 * (retryCount + 1)}ms...`); - setTimeout(() => { - checkPendingVideoLink(retryCount + 1); - }, 2000 * (retryCount + 1)); - } else { - const errorText = await response.text().catch(() => 'Unknown error'); - console.error('Failed to link video:', response.status, errorText); - // Don't remove pendingVideoId on error, so it can be retried later + } catch (error) { + console.error("Error linking video:", error); } - } catch (error) { - console.error('Error linking video:', error); } - } - }, [videoId, user]); - - const checkRateLimit = useCallback(async (): Promise => { - try { - const response = await fetch('/api/check-limit'); - const data: LimitCheckResponse = await response.json(); + }, + [videoId, user], + ); - setAuthLimitReached(Boolean(data?.isAuthenticated && data?.canGenerate === false && data?.reason === 'LIMIT_REACHED')); + const checkRateLimit = + useCallback(async (): Promise => { + try { + const response = await fetch("/api/check-limit"); + const data: LimitCheckResponse = await response.json(); + + setAuthLimitReached( + Boolean( + data?.isAuthenticated && + data?.canGenerate === false && + data?.reason === "LIMIT_REACHED", + ), + ); - const usage = data?.usage; - const remainingValue = - typeof usage?.totalRemaining === 'number' - ? usage.totalRemaining - : usage?.totalRemaining === null - ? null - : -1; + const usage = data?.usage; + const remainingValue = + typeof usage?.totalRemaining === "number" + ? usage.totalRemaining + : usage?.totalRemaining === null + ? null + : -1; - const resetTimestamp = data?.resetAt ?? null; + const resetTimestamp = data?.resetAt ?? null; - setRateLimitInfo({ - remaining: remainingValue, - resetAt: resetTimestamp ? new Date(resetTimestamp) : null, - }); + setRateLimitInfo({ + remaining: remainingValue, + resetAt: resetTimestamp ? new Date(resetTimestamp) : null, + }); - return data; - } catch (error) { - console.error('Error checking rate limit:', error); - setAuthLimitReached(false); - return null; - } - }, []); + return data; + } catch (error) { + console.error("Error checking rate limit:", error); + setAuthLimitReached(false); + return null; + } + }, []); // Check rate limit status on mount useEffect(() => { @@ -584,7 +704,11 @@ export default function AnalyzePage() { // Handle pending video linking when user logs in and videoId is available useEffect(() => { - if (user && !hasAttemptedLinking.current && (videoId || sessionStorage.getItem('pendingVideoId'))) { + if ( + user && + !hasAttemptedLinking.current && + (videoId || sessionStorage.getItem("pendingVideoId")) + ) { hasAttemptedLinking.current = true; // Delay the link attempt to ensure authentication is fully propagated setTimeout(() => { @@ -603,7 +727,9 @@ export default function AnalyzePage() { }, []); const lastInitializedKey = useRef(null); - const normalizedUrl = urlParam ?? (routeVideoId ? `https://www.youtube.com/watch?v=${routeVideoId}` : ""); + const normalizedUrl = + urlParam ?? + (routeVideoId ? `https://www.youtube.com/watch?v=${routeVideoId}` : ""); // Clear auth errors from URL after notifying the user useEffect(() => { @@ -612,835 +738,985 @@ export default function AnalyzePage() { toast.error(`Authentication failed: ${decodeURIComponent(authErrorParam)}`); const params = new URLSearchParams(searchParams.toString()); - params.delete('auth_error'); + params.delete("auth_error"); const queryString = params.toString(); router.replace( - `/analyze/${routeVideoId}${queryString ? `?${queryString}` : ''}`, - { scroll: false } + `/analyze/${routeVideoId}${queryString ? `?${queryString}` : ""}`, + { scroll: false }, ); }, [authErrorParam, router, routeVideoId, searchParams]); // Automatically kick off analysis when arriving via dedicated route // Check if user can generate based on server-side rate limits - const checkGenerationLimit = useCallback(( - pendingVideoId?: string, - remainingOverride?: number | null, - latestLimitData?: LimitCheckResponse | null - ): boolean => { - if (user) { - const limitReached = - latestLimitData?.isAuthenticated + const checkGenerationLimit = useCallback( + ( + pendingVideoId?: string, + remainingOverride?: number | null, + latestLimitData?: LimitCheckResponse | null, + ): boolean => { + if (user) { + const limitReached = latestLimitData?.isAuthenticated ? latestLimitData.canGenerate === false : authLimitReached; - if (limitReached) { - const limitMessage = buildLimitExceededMessage(latestLimitData); - setIsRateLimitError(true); - setError(limitMessage); - toast.error(limitMessage); - return false; + if (limitReached) { + const limitMessage = buildLimitExceededMessage(latestLimitData); + setIsRateLimitError(true); + setError(limitMessage); + toast.error(limitMessage); + return false; + } + return true; } - return true; - } - let effectiveRemaining = - typeof remainingOverride === 'number' || remainingOverride === null - ? remainingOverride - : rateLimitInfo.remaining; + let effectiveRemaining = + typeof remainingOverride === "number" || remainingOverride === null + ? remainingOverride + : rateLimitInfo.remaining; - if (!latestLimitData?.isAuthenticated) { - const totalRemaining = latestLimitData?.usage?.totalRemaining; - if (typeof totalRemaining === 'number' || totalRemaining === null) { - effectiveRemaining = totalRemaining; + if (!latestLimitData?.isAuthenticated) { + const totalRemaining = latestLimitData?.usage?.totalRemaining; + if (typeof totalRemaining === "number" || totalRemaining === null) { + effectiveRemaining = totalRemaining; + } } - } - - if ( - typeof effectiveRemaining === 'number' && - effectiveRemaining !== -1 && - effectiveRemaining <= 0 - ) { - redirectToAuthForLimit(undefined, pendingVideoId); - return false; - } - return true; - }, [user, authLimitReached, rateLimitInfo.remaining, redirectToAuthForLimit]); - const processVideo = useCallback(async ( - url: string, - selectedMode: TopicGenerationMode, - preferredLanguage?: string - ) => { - const currentRemaining = rateLimitInfo.remaining; - try { - const extractedVideoId = extractVideoId(url); - if (!extractedVideoId) { - throw new Error("Invalid YouTube URL"); + if ( + typeof effectiveRemaining === "number" && + effectiveRemaining !== -1 && + effectiveRemaining <= 0 + ) { + redirectToAuthForLimit(undefined, pendingVideoId); + return false; } + return true; + }, + [user, authLimitReached, rateLimitInfo.remaining, redirectToAuthForLimit], + ); - // Cleanup any pending requests from previous analysis - abortManager.current.cleanup(); - pendingThemeRequestsRef.current.clear(); - activeThemeRequestIdRef.current = null; - nextThemeRequestIdRef.current = 0; - selectedThemeRef.current = null; - - setError(""); - setIsRateLimitError(false); - setTopics([]); - setBaseTopics([]); - setTranscript([]); - setThemes([]); - setSelectedTheme(null); - setThemeTopicsMap({}); - setThemeCandidateMap({}); - setUsedTopicKeys(new Set()); - setThemeError(null); - setIsLoadingThemeTopics(false); - setSelectedTopic(null); - setCurrentTime(0); - setVideoDuration(0); - setCitationHighlight(null); - setVideoInfo(null); - setVideoPreview(""); - setPlaybackCommand(null); - setIsPlayingAll(false); - setPlayAllIndex(0); - setIsShareReady(false); - - // Reset takeaways-related states - setTakeawaysContent(null); - setTakeawaysError(""); - setShowChatTab(false); - - // Reset cached suggested questions - setCachedSuggestedQuestions(null); - - // Store video ID immediately for potential post-auth linking - storeCurrentVideoForAuth(extractedVideoId); - - // Only set videoId if it's different to prevent unnecessary re-renders - if (videoId !== extractedVideoId) { - setVideoId(extractedVideoId); - } + const processVideo = useCallback( + async ( + url: string, + selectedMode: TopicGenerationMode, + preferredLanguage?: string, + ) => { + const currentRemaining = rateLimitInfo.remaining; + try { + const extractedVideoId = extractVideoId(url); + if (!extractedVideoId) { + throw new Error("Invalid YouTube URL"); + } - // Check cache first before fetching transcript/metadata unless forced regeneration - // Skip cache when a specific non-English language is requested (user wants a different native transcript) - const shouldSkipCacheForLanguage = preferredLanguage && preferredLanguage !== 'en'; - - if (!forceRegenerate && !shouldSkipCacheForLanguage) { - const cacheResponse = await fetch("/api/check-video-cache", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }) - }); + // Cleanup any pending requests from previous analysis + abortManager.current.cleanup(); + pendingThemeRequestsRef.current.clear(); + activeThemeRequestIdRef.current = null; + nextThemeRequestIdRef.current = 0; + selectedThemeRef.current = null; + + setError(""); + setIsRateLimitError(false); + setTopics([]); + setBaseTopics([]); + setTranscript([]); + setThemes([]); + setSelectedTheme(null); + setThemeTopicsMap({}); + setThemeCandidateMap({}); + setUsedTopicKeys(new Set()); + setThemeError(null); + setIsLoadingThemeTopics(false); + setSelectedTopic(null); + setCurrentTime(0); + setVideoDuration(0); + setCitationHighlight(null); + setVideoInfo(null); + setVideoPreview(""); + setPlaybackCommand(null); + setIsPlayingAll(false); + setPlayAllIndex(0); + setIsShareReady(false); + + // Reset takeaways-related states + setTakeawaysContent(null); + setTakeawaysError(""); + setShowChatTab(false); + + // Reset cached suggested questions + setCachedSuggestedQuestions(null); + + // Store video ID immediately for potential post-auth linking + storeCurrentVideoForAuth(extractedVideoId); + + // Only set videoId if it's different to prevent unnecessary re-renders + if (videoId !== extractedVideoId) { + setVideoId(extractedVideoId); + } - if (cacheResponse.ok) { - const cacheData = await cacheResponse.json(); + // Check cache first before fetching transcript/metadata unless forced regeneration + // Skip cache when a specific non-English language is requested (user wants a different native transcript) + const shouldSkipCacheForLanguage = + preferredLanguage && preferredLanguage !== "en"; - if (cacheData.cached) { - const sanitizedTranscript = normalizeTranscript(cacheData.transcript); - const flattenedTranscript = isFlattenedTranscript(sanitizedTranscript, cacheData.videoInfo); + if (!forceRegenerate && !shouldSkipCacheForLanguage) { + const cacheResponse = await fetch("/api/check-video-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); - if (!flattenedTranscript) { - // For cached videos, we're already in LOADING_CACHED state if isCached was true - // Otherwise, set it now - setPageState('LOADING_CACHED'); + if (cacheResponse.ok) { + const cacheData = await cacheResponse.json(); - const hydratedTopics = hydrateTopicsWithTranscript( - Array.isArray(cacheData.topics) ? cacheData.topics : [], + if (cacheData.cached) { + const sanitizedTranscript = normalizeTranscript( + cacheData.transcript, + ); + const flattenedTranscript = isFlattenedTranscript( sanitizedTranscript, + cacheData.videoInfo, ); - // Load all cached data - setTranscript(sanitizedTranscript); - - const cachedVideoInfo = cacheData.videoInfo ?? null; - if (cachedVideoInfo) { - setVideoInfo(cachedVideoInfo); - const rawDuration = (cachedVideoInfo as { duration?: number | string | null }).duration; - const numericDuration = - typeof rawDuration === "number" - ? rawDuration - : typeof rawDuration === "string" - ? Number(rawDuration) - : null; - if (numericDuration && !Number.isNaN(numericDuration) && numericDuration > 0) { - setVideoDuration(numericDuration); - } - } else { - setVideoInfo(null); - } - - setTopics(hydratedTopics); - setBaseTopics(hydratedTopics); - const initialKeys = new Set(); - hydratedTopics.forEach(topic => { - if (topic.quote?.timestamp && topic.quote.text) { - const key = `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`; - initialKeys.add(key); - } - }); - setUsedTopicKeys(initialKeys); - setSelectedTopic(hydratedTopics.length > 0 ? hydratedTopics[0] : null); - - // Set cached takeaways and questions - if (cacheData.summary) { - setTakeawaysContent(cacheData.summary); - setShowChatTab(true); - setIsGeneratingTakeaways(false); - } - if (cacheData.suggestedQuestions) { - setCachedSuggestedQuestions(cacheData.suggestedQuestions); - } - - // Store video ID for potential post-auth linking (for cached videos) - storeCurrentVideoForAuth(extractedVideoId); - - // Set page state back to idle - setPageState('IDLE'); - setLoadingStage(null); - setProcessingStartTime(null); - setSwitchingToLanguage(null); - setIsShareReady(true); - - backgroundOperation( - 'load-cached-themes', - async () => { - const response = await fetch("/api/video-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - videoId: extractedVideoId, - videoInfo: cacheData.videoInfo, - transcript: sanitizedTranscript, - includeCandidatePool: true, - mode: selectedMode, - forceRegenerate: false - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown error" })); - const message = buildApiErrorMessage(errorData, "Failed to generate themes"); - throw new Error(message); - } - - const data = await response.json(); - if (Array.isArray(data.themes)) { - setThemes(data.themes); + if (!flattenedTranscript) { + // For cached videos, we're already in LOADING_CACHED state if isCached was true + // Otherwise, set it now + setPageState("LOADING_CACHED"); + + const hydratedTopics = hydrateTopicsWithTranscript( + Array.isArray(cacheData.topics) ? cacheData.topics : [], + sanitizedTranscript, + ); + + // Load all cached data + setTranscript(sanitizedTranscript); + + const cachedVideoInfo = cacheData.videoInfo ?? null; + if (cachedVideoInfo) { + setVideoInfo(cachedVideoInfo); + const rawDuration = ( + cachedVideoInfo as { duration?: number | string | null } + ).duration; + const numericDuration = + typeof rawDuration === "number" + ? rawDuration + : typeof rawDuration === "string" + ? Number(rawDuration) + : null; + if ( + numericDuration && + !Number.isNaN(numericDuration) && + numericDuration > 0 + ) { + setVideoDuration(numericDuration); } - if (Array.isArray(data.topicCandidates)) { - setThemeCandidateMap(prev => ({ - ...prev, - __default: data.topicCandidates - })); - } - return data.themes; - }, - (error) => { - console.error("Failed to generate themes for cached video:", error); + } else { + setVideoInfo(null); } - ); - - // Fetch available transcript languages for cached videos - // This enables the language selector dropdown to show all available native languages - // NOTE: Only update availableLanguages, preserve the cached language value - backgroundOperation( - 'fetch-available-languages', - async () => { - const langResponse = await fetch("/api/transcript", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, lang: 'en' }), - }); - if (langResponse.ok) { - const langData = await langResponse.json(); - const availableLanguages = langData.availableLanguages; - - if (availableLanguages) { - setVideoInfo(prev => prev ? { - ...prev, - // Preserve the cached language - only update availableLanguages - // If no cached language exists, use the API response as fallback - language: prev.language ?? langData.language, - availableLanguages: availableLanguages ?? prev.availableLanguages - } : null); - } + setTopics(hydratedTopics); + setBaseTopics(hydratedTopics); + const initialKeys = new Set(); + hydratedTopics.forEach((topic) => { + if (topic.quote?.timestamp && topic.quote.text) { + const key = `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`; + initialKeys.add(key); } - }, - (error) => { - console.error("Failed to fetch available languages:", error); + }); + setUsedTopicKeys(initialKeys); + setSelectedTopic( + hydratedTopics.length > 0 ? hydratedTopics[0] : null, + ); + + // Set cached takeaways and questions + if (cacheData.summary) { + setTakeawaysContent(cacheData.summary); + setShowChatTab(true); + setIsGeneratingTakeaways(false); + } + if (cacheData.suggestedQuestions) { + setCachedSuggestedQuestions(cacheData.suggestedQuestions); } - ); - // Auto-start takeaways generation if not available - if (!cacheData.summary) { - setShowChatTab(true); - setIsGeneratingTakeaways(true); + // Store video ID for potential post-auth linking (for cached videos) + storeCurrentVideoForAuth(extractedVideoId); + + // Set page state back to idle + setPageState("IDLE"); + setLoadingStage(null); + setProcessingStartTime(null); + setSwitchingToLanguage(null); + setIsShareReady(true); backgroundOperation( - 'generate-cached-takeaways', + "load-cached-themes", async () => { - const summaryRes = await fetch("/api/generate-summary", { + const response = await fetch("/api/video-analysis", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - transcript: sanitizedTranscript, - videoInfo: cacheData.videoInfo, videoId: extractedVideoId, - targetLanguage: cacheData.videoInfo?.language + videoInfo: cacheData.videoInfo, + transcript: sanitizedTranscript, + includeCandidatePool: true, + mode: selectedMode, + forceRegenerate: false, }), }); - if (summaryRes.ok) { - const { summaryContent: generatedTakeaways } = await summaryRes.json(); - setTakeawaysContent(generatedTakeaways); - - // Update the video analysis with the takeaways (requires auth + ownership) - await backgroundOperation( - 'update-cached-takeaways', - async () => { - const res = await csrfFetch.post("/api/update-video-analysis", { - videoId: extractedVideoId, - summary: generatedTakeaways - }); - // 401/403 is expected for anonymous users or non-owners - if (!res.ok && res.status !== 401 && res.status !== 403) { - throw new Error('Failed to update takeaways'); - } - } + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + const message = buildApiErrorMessage( + errorData, + "Failed to generate themes", ); - return generatedTakeaways; - } else { - const errorData = await summaryRes.json().catch(() => ({ error: "Unknown error" })); - const message = buildApiErrorMessage(errorData, "Failed to generate takeaways"); throw new Error(message); } + + const data = await response.json(); + if (Array.isArray(data.themes)) { + setThemes(data.themes); + } + if (Array.isArray(data.topicCandidates)) { + setThemeCandidateMap((prev) => ({ + ...prev, + __default: data.topicCandidates, + })); + } + return data.themes; }, (error) => { - setTakeawaysError(error.message || "Failed to generate takeaways. Please try again."); - } - ).finally(() => { - setIsGeneratingTakeaways(false); - }); - } + console.error( + "Failed to generate themes for cached video:", + error, + ); + }, + ); + + // Fetch available transcript languages for cached videos + // This enables the language selector dropdown to show all available native languages + // NOTE: Only update availableLanguages, preserve the cached language value + backgroundOperation( + "fetch-available-languages", + async () => { + const langResponse = await fetch("/api/transcript", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, lang: "en" }), + }); + + if (langResponse.ok) { + const langData = await langResponse.json(); + const availableLanguages = langData.availableLanguages; + + if (availableLanguages) { + setVideoInfo((prev) => + prev + ? { + ...prev, + // Preserve the cached language - only update availableLanguages + // If no cached language exists, use the API response as fallback + language: prev.language ?? langData.language, + availableLanguages: + availableLanguages ?? prev.availableLanguages, + } + : null, + ); + } + } + }, + (error) => { + console.error( + "Failed to fetch available languages:", + error, + ); + }, + ); + + // Auto-start takeaways generation if not available + if (!cacheData.summary) { + setShowChatTab(true); + setIsGeneratingTakeaways(true); + + backgroundOperation( + "generate-cached-takeaways", + async () => { + const summaryRes = await fetch("/api/generate-summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript: sanitizedTranscript, + videoInfo: cacheData.videoInfo, + videoId: extractedVideoId, + targetLanguage: cacheData.videoInfo?.language, + }), + }); + + if (summaryRes.ok) { + const { summaryContent: generatedTakeaways } = + await summaryRes.json(); + setTakeawaysContent(generatedTakeaways); + + // Update the video analysis with the takeaways (requires auth + ownership) + await backgroundOperation( + "update-cached-takeaways", + async () => { + const res = await csrfFetch.post( + "/api/update-video-analysis", + { + videoId: extractedVideoId, + summary: generatedTakeaways, + }, + ); + // 401/403 is expected for anonymous users or non-owners + if ( + !res.ok && + res.status !== 401 && + res.status !== 403 + ) { + throw new Error("Failed to update takeaways"); + } + }, + ); + return generatedTakeaways; + } else { + const errorData = await summaryRes + .json() + .catch(() => ({ error: "Unknown error" })); + const message = buildApiErrorMessage( + errorData, + "Failed to generate takeaways", + ); + throw new Error(message); + } + }, + (error) => { + setTakeawaysError( + error.message || + "Failed to generate takeaways. Please try again.", + ); + }, + ).finally(() => { + setIsGeneratingTakeaways(false); + }); + } - return; // Exit early - no need to fetch anything else - } else { - console.warn("Cached transcript looks flattened; re-running full analysis."); + return; // Exit early - no need to fetch anything else + } else { + console.warn( + "Cached transcript looks flattened; re-running full analysis.", + ); + } } } } - } - let effectiveRemaining = currentRemaining; - const latestLimitData = await checkRateLimit(); + let effectiveRemaining = currentRemaining; + const latestLimitData = await checkRateLimit(); - if (!user && latestLimitData) { - const totalRemaining = latestLimitData.usage?.totalRemaining; - if (typeof totalRemaining === 'number' || totalRemaining === null) { - effectiveRemaining = totalRemaining; + if (!user && latestLimitData) { + const totalRemaining = latestLimitData.usage?.totalRemaining; + if (typeof totalRemaining === "number" || totalRemaining === null) { + effectiveRemaining = totalRemaining; + } } - } - - if (!checkGenerationLimit(extractedVideoId, effectiveRemaining, latestLimitData)) { - return; - } - setPageState('ANALYZING_NEW'); - setLoadingStage('fetching'); - - // Not cached, proceed with normal flow - // Create AbortControllers for both requests - const transcriptController = abortManager.current.createController('transcript', 300000); - const videoInfoController = abortManager.current.createController('videoInfo', 100000); - - // Fetch transcript and video info in parallel - const transcriptPromise = fetch("/api/transcript", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, lang: preferredLanguage }), - signal: transcriptController.signal, - }).catch(err => { - if (err.name === 'AbortError') { - throw new Error("Transcript request timed out. Please try again."); + if ( + !checkGenerationLimit( + extractedVideoId, + effectiveRemaining, + latestLimitData, + ) + ) { + return; } - throw new Error("Network error: Unable to fetch transcript. Please ensure the server is running."); - }); - const videoInfoPromise = fetch("/api/video-info", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }), - signal: videoInfoController.signal, - }).catch(err => { - if (err.name === 'AbortError') { - console.error("Video info request timed out"); - return null; - } - console.error("Failed to fetch video info:", err); - return null; - }); + setPageState("ANALYZING_NEW"); + setLoadingStage("fetching"); - // Wait for both requests to complete - const [transcriptRes, videoInfoRes] = await Promise.all([ - transcriptPromise, - videoInfoPromise - ]); + // Not cached, proceed with normal flow + // Create AbortControllers for both requests + const transcriptController = abortManager.current.createController( + "transcript", + 300000, + ); + const videoInfoController = abortManager.current.createController( + "videoInfo", + 100000, + ); - // AbortManager handles timeout cleanup automatically + // Fetch transcript and video info in parallel + const transcriptPromise = fetch("/api/transcript", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url, lang: preferredLanguage }), + signal: transcriptController.signal, + }).catch((err) => { + if (err.name === "AbortError") { + throw new Error("Transcript request timed out. Please try again."); + } + throw new Error( + "Network error: Unable to fetch transcript. Please ensure the server is running.", + ); + }); - // Process transcript response (required) - if (!transcriptRes || !transcriptRes.ok) { - const errorData = transcriptRes ? await transcriptRes.json().catch(() => ({ error: "Unknown error" })) : { error: "Failed to fetch transcript" }; - const message = buildApiErrorMessage(errorData, "Failed to fetch transcript"); - throw new Error(message); - } + const videoInfoPromise = fetch("/api/video-info", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: videoInfoController.signal, + }).catch((err) => { + if (err.name === "AbortError") { + console.error("Video info request timed out"); + return null; + } + console.error("Failed to fetch video info:", err); + return null; + }); - let fetchedTranscript; - let language: string | undefined; - let availableLanguages: string[] | undefined; - let transcriptDuration: number | undefined; - let transcriptIsPartial: boolean | undefined; - try { - const data = await transcriptRes.json(); - fetchedTranscript = data.transcript; - language = data.language; - availableLanguages = data.availableLanguages; - transcriptDuration = data.transcriptDuration; - transcriptIsPartial = data.isPartial; - - // Log transcript metadata for debugging - if (transcriptDuration !== undefined) { - console.log('[Transcript] Metadata:', { - transcriptDuration, - segmentCount: data.segmentCount, - rawSegmentCount: data.rawSegmentCount, - isPartial: transcriptIsPartial, - coverageRatio: data.coverageRatio - }); + // Wait for both requests to complete + const [transcriptRes, videoInfoRes] = await Promise.all([ + transcriptPromise, + videoInfoPromise, + ]); + + // AbortManager handles timeout cleanup automatically + + // Process transcript response (required) + if (!transcriptRes || !transcriptRes.ok) { + const errorData = transcriptRes + ? await transcriptRes + .json() + .catch(() => ({ error: "Unknown error" })) + : { error: "Failed to fetch transcript" }; + const message = buildApiErrorMessage( + errorData, + "Failed to fetch transcript", + ); + throw new Error(message); } - } catch (jsonError) { - if (jsonError instanceof Error && jsonError.name === 'AbortError') { - throw new Error("Transcript processing timed out. The video may be too long. Please try again."); + + let fetchedTranscript; + let language: string | undefined; + let availableLanguages: string[] | undefined; + let transcriptDuration: number | undefined; + let transcriptIsPartial: boolean | undefined; + try { + const data = await transcriptRes.json(); + fetchedTranscript = data.transcript; + language = data.language; + availableLanguages = data.availableLanguages; + transcriptDuration = data.transcriptDuration; + transcriptIsPartial = data.isPartial; + + // Log transcript metadata for debugging + if (transcriptDuration !== undefined) { + console.log("[Transcript] Metadata:", { + transcriptDuration, + segmentCount: data.segmentCount, + rawSegmentCount: data.rawSegmentCount, + isPartial: transcriptIsPartial, + coverageRatio: data.coverageRatio, + }); + } + } catch (jsonError) { + if (jsonError instanceof Error && jsonError.name === "AbortError") { + throw new Error( + "Transcript processing timed out. The video may be too long. Please try again.", + ); + } + throw new Error( + "Failed to process transcript data. Please try again.", + ); } - throw new Error("Failed to process transcript data. Please try again."); - } - const normalizedTranscriptData = normalizeTranscript(fetchedTranscript); - setTranscript(normalizedTranscriptData); + const normalizedTranscriptData = normalizeTranscript(fetchedTranscript); + setTranscript(normalizedTranscriptData); - // Process video info response (optional) - let fetchedVideoInfo: VideoInfo | null = null; - if (videoInfoRes && videoInfoRes.ok) { - try { - const videoInfoData = await videoInfoRes.json(); - if (videoInfoData && !videoInfoData.error) { - fetchedVideoInfo = { - ...videoInfoData, - language, - availableLanguages, - }; - setVideoInfo(fetchedVideoInfo); - const rawDuration = videoInfoData?.duration; - const numericDuration = - typeof rawDuration === "number" - ? rawDuration - : typeof rawDuration === "string" - ? Number(rawDuration) - : null; - if (numericDuration && !Number.isNaN(numericDuration) && numericDuration > 0) { - setVideoDuration(numericDuration); + // Process video info response (optional) + let fetchedVideoInfo: VideoInfo | null = null; + if (videoInfoRes && videoInfoRes.ok) { + try { + const videoInfoData = await videoInfoRes.json(); + if (videoInfoData && !videoInfoData.error) { + fetchedVideoInfo = { + ...videoInfoData, + language, + availableLanguages, + }; + setVideoInfo(fetchedVideoInfo); + const rawDuration = videoInfoData?.duration; + const numericDuration = + typeof rawDuration === "number" + ? rawDuration + : typeof rawDuration === "string" + ? Number(rawDuration) + : null; + if ( + numericDuration && + !Number.isNaN(numericDuration) && + numericDuration > 0 + ) { + setVideoDuration(numericDuration); + } } + } catch (error) { + console.error("Failed to parse video info:", error); } - } catch (error) { - console.error("Failed to parse video info:", error); } - } - // If we didn't get video info from the separate endpoint, try to use what we have, but update the languages - if (!fetchedVideoInfo) { - setVideoInfo(prev => prev ? { ...prev, language, availableLanguages } : null); - } - - // Check if transcript seems incomplete compared to video duration - if (transcriptDuration !== undefined && fetchedVideoInfo?.duration) { - const videoDuration = fetchedVideoInfo.duration; - const coverageRatio = transcriptDuration / videoDuration; - if (coverageRatio < 0.5) { - console.warn('[Transcript] WARNING: Transcript may be incomplete!', { - transcriptDuration: `${Math.round(transcriptDuration)}s (${Math.round(transcriptDuration / 60)}min)`, - videoDuration: `${videoDuration}s (${Math.round(videoDuration / 60)}min)`, - coverageRatio: `${Math.round(coverageRatio * 100)}%`, - message: 'The transcript covers less than 50% of the video duration. This may indicate an issue with caption availability.' - }); + // If we didn't get video info from the separate endpoint, try to use what we have, but update the languages + if (!fetchedVideoInfo) { + setVideoInfo((prev) => + prev ? { ...prev, language, availableLanguages } : null, + ); } - } - // Move to understanding stage - setLoadingStage('understanding'); - - // Generate quick preview (non-blocking) - fetch("/api/quick-preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - transcript: normalizedTranscriptData, - videoTitle: fetchedVideoInfo?.title, - videoDescription: fetchedVideoInfo?.description, - channelName: fetchedVideoInfo?.author, - tags: fetchedVideoInfo?.tags, - language: fetchedVideoInfo?.language - }), - }) - .then(res => { - if (!res.ok) { - console.error('Quick preview generation failed:', res.status); - return null; + // Check if transcript seems incomplete compared to video duration + if (transcriptDuration !== undefined && fetchedVideoInfo?.duration) { + const videoDuration = fetchedVideoInfo.duration; + const coverageRatio = transcriptDuration / videoDuration; + if (coverageRatio < 0.5) { + console.warn( + "[Transcript] WARNING: Transcript may be incomplete!", + { + transcriptDuration: `${Math.round(transcriptDuration)}s (${Math.round(transcriptDuration / 60)}min)`, + videoDuration: `${videoDuration}s (${Math.round(videoDuration / 60)}min)`, + coverageRatio: `${Math.round(coverageRatio * 100)}%`, + message: + "The transcript covers less than 50% of the video duration. This may indicate an issue with caption availability.", + }, + ); } - return res.json(); - }) - .then(data => { - if (data && data.preview) { - console.log('Quick preview generated:', data.preview); - setVideoPreview(data.preview); - } - }) - .catch((error) => { - console.error('Error generating quick preview:', error); - }); - - // Initiate parallel API requests for topics and takeaways - setLoadingStage('generating'); - setGenerationStartTime(Date.now()); - - // Create abort controllers for both requests - const topicsController = abortManager.current.createController('topics'); - const takeawaysController = abortManager.current.createController('takeaways', 60000); - - // Start topics generation using cached video-analysis endpoint - const topicsPromise = fetch("/api/video-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - videoId: extractedVideoId, - videoInfo: fetchedVideoInfo, - transcript: normalizedTranscriptData, - mode: selectedMode, - forceRegenerate - }), - signal: topicsController.signal, - }).catch(err => { - if (err.name === 'AbortError') { - throw new Error("Topic generation was canceled or interrupted. Please try again."); } - throw new Error("Network error: Unable to generate topics. Please check your connection."); - }); - // Start takeaways generation in parallel (will be ignored if cached) - const takeawaysPromise = fetch("/api/generate-summary", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - transcript: normalizedTranscriptData, - videoInfo: fetchedVideoInfo, - videoId: extractedVideoId, - targetLanguage: fetchedVideoInfo?.language - }), - signal: takeawaysController.signal, - }); + // Move to understanding stage + setLoadingStage("understanding"); + + // Generate quick preview (non-blocking) + fetch("/api/quick-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript: normalizedTranscriptData, + videoTitle: fetchedVideoInfo?.title, + videoDescription: fetchedVideoInfo?.description, + channelName: fetchedVideoInfo?.author, + tags: fetchedVideoInfo?.tags, + language: fetchedVideoInfo?.language, + }), + }) + .then((res) => { + if (!res.ok) { + console.error("Quick preview generation failed:", res.status); + return null; + } + return res.json(); + }) + .then((data) => { + if (data && data.preview) { + console.log("Quick preview generated:", data.preview); + setVideoPreview(data.preview); + } + }) + .catch((error) => { + console.error("Error generating quick preview:", error); + }); - // Show takeaways tab and loading state immediately (optimistic UI) - setShowChatTab(true); - setIsGeneratingTakeaways(true); + // Initiate parallel API requests for topics and takeaways + setLoadingStage("generating"); + setGenerationStartTime(Date.now()); - const toSettled = (promise: Promise) => - promise.then( - (value) => ({ status: 'fulfilled', value } as const), - (reason) => ({ status: 'rejected', reason } as const) + // Create abort controllers for both requests + const topicsController = + abortManager.current.createController("topics"); + const takeawaysController = abortManager.current.createController( + "takeaways", + 60000, ); - const topicsSettledPromise = toSettled(topicsPromise); - const takeawaysSettledPromise = toSettled(takeawaysPromise); + // Start topics generation using cached video-analysis endpoint + const topicsPromise = fetch("/api/video-analysis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + videoId: extractedVideoId, + videoInfo: fetchedVideoInfo, + transcript: normalizedTranscriptData, + mode: selectedMode, + forceRegenerate, + }), + signal: topicsController.signal, + }).catch((err) => { + if (err.name === "AbortError") { + throw new Error( + "Topic generation was canceled or interrupted. Please try again.", + ); + } + throw new Error( + "Network error: Unable to generate topics. Please check your connection.", + ); + }); - const topicsResult = await topicsSettledPromise; - if (topicsResult.status === 'rejected') { - takeawaysController.abort(); - await takeawaysSettledPromise; - throw topicsResult.reason; - } + // Start takeaways generation in parallel (will be ignored if cached) + const takeawaysPromise = fetch("/api/generate-summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript: normalizedTranscriptData, + videoInfo: fetchedVideoInfo, + videoId: extractedVideoId, + targetLanguage: fetchedVideoInfo?.language, + }), + signal: takeawaysController.signal, + }); - const topicsRes = topicsResult.value; - if (!topicsRes.ok) { - const errorData = await topicsRes.json().catch(() => ({ error: "Unknown error" })); - const requiresAuth = Boolean((errorData as any)?.requiresAuth); - const authMessage = - typeof (errorData as any)?.message === "string" - ? (errorData as any).message - : undefined; + // Show takeaways tab and loading state immediately (optimistic UI) + setShowChatTab(true); + setIsGeneratingTakeaways(true); - if (requiresAuth || topicsRes.status === 401 || topicsRes.status === 403) { - takeawaysController.abort(); - await takeawaysSettledPromise; - redirectToAuthForLimit( - authMessage, - extractedVideoId + const toSettled = (promise: Promise) => + promise.then( + (value) => ({ status: "fulfilled", value }) as const, + (reason) => ({ status: "rejected", reason }) as const, ); - return; - } - if (topicsRes.status === 429) { - setIsRateLimitError(true); - checkRateLimit(); + const topicsSettledPromise = toSettled(topicsPromise); + const takeawaysSettledPromise = toSettled(takeawaysPromise); + + const topicsResult = await topicsSettledPromise; + if (topicsResult.status === "rejected") { takeawaysController.abort(); await takeawaysSettledPromise; + throw topicsResult.reason; + } - const limitMessageRaw = + const topicsRes = topicsResult.value; + if (!topicsRes.ok) { + const errorData = await topicsRes + .json() + .catch(() => ({ error: "Unknown error" })); + const requiresAuth = Boolean((errorData as any)?.requiresAuth); + const authMessage = typeof (errorData as any)?.message === "string" - ? (errorData as any).message.trim() - : ""; - - const limitErrorRaw = - typeof (errorData as any)?.error === "string" - ? (errorData as any).error.trim() - : ""; - - const limitMessage = - limitMessageRaw.length > 0 - ? limitMessageRaw - : limitErrorRaw.length > 0 - ? limitErrorRaw - : AUTH_LIMIT_MESSAGE; - - throw new Error(limitMessage); - } + ? (errorData as any).message + : undefined; + + if ( + requiresAuth || + topicsRes.status === 401 || + topicsRes.status === 403 + ) { + takeawaysController.abort(); + await takeawaysSettledPromise; + redirectToAuthForLimit(authMessage, extractedVideoId); + return; + } - takeawaysController.abort(); - await takeawaysSettledPromise; - const message = buildApiErrorMessage(errorData, "Failed to generate topics"); - throw new Error(message); - } + if (topicsRes.status === 429) { + setIsRateLimitError(true); + checkRateLimit(); + takeawaysController.abort(); + await takeawaysSettledPromise; + + const limitMessageRaw = + typeof (errorData as any)?.message === "string" + ? (errorData as any).message.trim() + : ""; + + const limitErrorRaw = + typeof (errorData as any)?.error === "string" + ? (errorData as any).error.trim() + : ""; + + const limitMessage = + limitMessageRaw.length > 0 + ? limitMessageRaw + : limitErrorRaw.length > 0 + ? limitErrorRaw + : AUTH_LIMIT_MESSAGE; + + throw new Error(limitMessage); + } - const topicsData = await topicsRes.json(); - const rawTopics = Array.isArray(topicsData.topics) ? topicsData.topics : []; - const generatedTopics: Topic[] = hydrateTopicsWithTranscript(rawTopics, normalizedTranscriptData); - const generatedThemes: string[] = Array.isArray(topicsData.themes) ? topicsData.themes : []; - const rawCandidates: TopicCandidate[] = Array.isArray(topicsData.topicCandidates) ? topicsData.topicCandidates : []; - const generatedCandidates: TopicCandidate[] = rawCandidates.map(candidate => ({ - ...candidate, - key: `${candidate.quote.timestamp}|${normalizeWhitespace(candidate.quote.text)}` - })); - - const takeawaysResult = await takeawaysSettledPromise; - - // Move to processing stage - setLoadingStage('processing'); - setGenerationStartTime(null); - setProcessingStartTime(Date.now()); - - // Process takeaways result from parallel execution - let generatedTakeaways = null; - let takeawaysGenerationError = null; - if (takeawaysResult.status === 'fulfilled') { - const summaryRes = takeawaysResult.value; - - if (summaryRes.ok) { - const summaryData = await summaryRes.json(); - generatedTakeaways = summaryData.summaryContent; - } else { - const errorData = await summaryRes.json().catch(() => ({ error: "Unknown error" })); - takeawaysGenerationError = buildApiErrorMessage(errorData, "Failed to generate takeaways. Please try again."); + takeawaysController.abort(); + await takeawaysSettledPromise; + const message = buildApiErrorMessage( + errorData, + "Failed to generate topics", + ); + throw new Error(message); } - } else { - const error = takeawaysResult.reason; - if (error && error.name === 'AbortError') { - takeawaysGenerationError = "Takeaways generation timed out. The video might be too long."; + + const topicsData = await topicsRes.json(); + const rawTopics = Array.isArray(topicsData.topics) + ? topicsData.topics + : []; + const generatedTopics: Topic[] = hydrateTopicsWithTranscript( + rawTopics, + normalizedTranscriptData, + ); + const generatedThemes: string[] = Array.isArray(topicsData.themes) + ? topicsData.themes + : []; + const rawCandidates: TopicCandidate[] = Array.isArray( + topicsData.topicCandidates, + ) + ? topicsData.topicCandidates + : []; + const generatedCandidates: TopicCandidate[] = rawCandidates.map( + (candidate) => ({ + ...candidate, + key: `${candidate.quote.timestamp}|${normalizeWhitespace(candidate.quote.text)}`, + }), + ); + + const takeawaysResult = await takeawaysSettledPromise; + + // Move to processing stage + setLoadingStage("processing"); + setGenerationStartTime(null); + setProcessingStartTime(Date.now()); + + // Process takeaways result from parallel execution + let generatedTakeaways = null; + let takeawaysGenerationError = null; + if (takeawaysResult.status === "fulfilled") { + const summaryRes = takeawaysResult.value; + + if (summaryRes.ok) { + const summaryData = await summaryRes.json(); + generatedTakeaways = summaryData.summaryContent; + } else { + const errorData = await summaryRes + .json() + .catch(() => ({ error: "Unknown error" })); + takeawaysGenerationError = buildApiErrorMessage( + errorData, + "Failed to generate takeaways. Please try again.", + ); + } } else { - takeawaysGenerationError = error?.message || "Failed to generate takeaways. Please try again."; + const error = takeawaysResult.reason; + if (error && error.name === "AbortError") { + takeawaysGenerationError = + "Takeaways generation timed out. The video might be too long."; + } else { + takeawaysGenerationError = + error?.message || + "Failed to generate takeaways. Please try again."; + } } - } - // Synchronous batch state update - all at once - setTopics(generatedTopics); - setBaseTopics(generatedTopics); - const initialKeys = new Set(); - generatedTopics.forEach(topic => { - if (topic.quote?.timestamp && topic.quote.text) { - initialKeys.add(`${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`); + // Synchronous batch state update - all at once + setTopics(generatedTopics); + setBaseTopics(generatedTopics); + const initialKeys = new Set(); + generatedTopics.forEach((topic) => { + if (topic.quote?.timestamp && topic.quote.text) { + initialKeys.add( + `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`, + ); + } + }); + setUsedTopicKeys(initialKeys); + setThemeCandidateMap((prev) => ({ + ...prev, + __default: generatedCandidates, + })); + setSelectedTopic( + generatedTopics.length > 0 ? generatedTopics[0] : null, + ); + setThemes(generatedThemes); + if (generatedTakeaways) { + setTakeawaysContent(generatedTakeaways); + setShowChatTab(true); + setIsGeneratingTakeaways(false); + } else if (takeawaysGenerationError) { + setTakeawaysError(takeawaysGenerationError); + setShowChatTab(true); + setIsGeneratingTakeaways(false); } - }); - setUsedTopicKeys(initialKeys); - setThemeCandidateMap(prev => ({ - ...prev, - __default: generatedCandidates - })); - setSelectedTopic(generatedTopics.length > 0 ? generatedTopics[0] : null); - setThemes(generatedThemes); - if (generatedTakeaways) { - setTakeawaysContent(generatedTakeaways); - setShowChatTab(true); - setIsGeneratingTakeaways(false); - } else if (takeawaysGenerationError) { - setTakeawaysError(takeawaysGenerationError); - setShowChatTab(true); - setIsGeneratingTakeaways(false); - } - - // Rate limit is handled server-side now - checkRateLimit(); - // Confirm the analysis has been persisted before switching to the shareable /v/ URL - backgroundOperation( - 'confirm-share-ready', - async () => { - if (!url) return false; + // Rate limit is handled server-side now + checkRateLimit(); - const cacheCheck = await fetch("/api/check-video-cache", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }) - }); + // Confirm the analysis has been persisted before switching to the shareable /v/ URL + backgroundOperation( + "confirm-share-ready", + async () => { + if (!url) return false; - if (!cacheCheck.ok) { - return false; - } + const cacheCheck = await fetch("/api/check-video-cache", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); - const cacheData = await cacheCheck.json(); - if (cacheData?.cached) { - setIsShareReady(true); - return true; - } + if (!cacheCheck.ok) { + return false; + } - return false; - }, - (error) => { - console.error("Failed to confirm cached analysis for sharing:", error); - } - ); + const cacheData = await cacheCheck.json(); + if (cacheData?.cached) { + setIsShareReady(true); + return true; + } - // NOTE: Video analysis is now saved server-side in /api/video-analysis - // to prevent client-side cache poisoning attacks + return false; + }, + (error) => { + console.error( + "Failed to confirm cached analysis for sharing:", + error, + ); + }, + ); - // Generate suggested questions - backgroundOperation( - 'generate-questions', - async () => { - const res = await fetch("/api/suggested-questions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - transcript: normalizedTranscriptData, - topics: generatedTopics, - videoTitle: fetchedVideoInfo?.title, - language: fetchedVideoInfo?.language - }), - }); + // NOTE: Video analysis is now saved server-side in /api/video-analysis + // to prevent client-side cache poisoning attacks + + // Generate suggested questions + backgroundOperation( + "generate-questions", + async () => { + const res = await fetch("/api/suggested-questions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transcript: normalizedTranscriptData, + topics: generatedTopics, + videoTitle: fetchedVideoInfo?.title, + language: fetchedVideoInfo?.language, + }), + }); - const applyCachedQuestions = (questions: string[]) => { - if (questions.length === 0) { - return questions; - } - setCachedSuggestedQuestions(prev => { - if (prev && prev.length > 0) { - return prev; + const applyCachedQuestions = (questions: string[]) => { + if (questions.length === 0) { + return questions; } + setCachedSuggestedQuestions((prev) => { + if (prev && prev.length > 0) { + return prev; + } + return questions; + }); return questions; - }); - return questions; - }; - - if (!res.ok) { - console.error("Failed to generate suggested questions:", res.status, res.statusText); - return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); - } - - let parsed: unknown; - try { - parsed = await res.json(); - } catch (error) { - console.error("Failed to parse suggested questions payload:", error); - return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); - } + }; - const questions = Array.isArray((parsed as any)?.questions) - ? (parsed as any).questions - .filter((item: unknown): item is string => typeof item === "string" && item.trim().length > 0) - .map((item: string) => item.trim()) - : []; - - const normalizedQuestions = questions.length > 0 - ? questions.slice(0, 3) - : buildSuggestedQuestionFallbacks(3); - - applyCachedQuestions(normalizedQuestions); - - // Update video analysis with suggested questions (requires auth + ownership) - await backgroundOperation( - 'update-questions', - async () => { - const updateRes = await csrfFetch.post("/api/update-video-analysis", { - videoId: extractedVideoId, - suggestedQuestions: normalizedQuestions - }); + if (!res.ok) { + console.error( + "Failed to generate suggested questions:", + res.status, + res.statusText, + ); + return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); + } - // 401/403 is expected for anonymous users or non-owners - if (!updateRes.ok && updateRes.status !== 404 && updateRes.status !== 401 && updateRes.status !== 403) { - throw new Error('Failed to update suggested questions'); - } + let parsed: unknown; + try { + parsed = await res.json(); + } catch (error) { + console.error( + "Failed to parse suggested questions payload:", + error, + ); + return applyCachedQuestions(buildSuggestedQuestionFallbacks(3)); } - ); - return normalizedQuestions; - }, - (error) => { - console.error("Failed to generate suggested questions:", error); - } - ); + const questions = Array.isArray((parsed as any)?.questions) + ? (parsed as any).questions + .filter( + (item: unknown): item is string => + typeof item === "string" && item.trim().length > 0, + ) + .map((item: string) => item.trim()) + : []; + + const normalizedQuestions = + questions.length > 0 + ? questions.slice(0, 3) + : buildSuggestedQuestionFallbacks(3); + + applyCachedQuestions(normalizedQuestions); + + // Update video analysis with suggested questions (requires auth + ownership) + // Silently fail for anonymous users - this is expected behavior + await backgroundOperation("update-questions", async () => { + try { + await csrfFetch.post("/api/update-video-analysis", { + videoId: extractedVideoId, + suggestedQuestions: normalizedQuestions, + }); + } catch (error) { + // Silently ignore all errors - anonymous users cannot update + // This is expected and should not throw errors + } + }); - } catch (err) { - setError( - normalizeErrorMessage( - err instanceof Error ? err.message : undefined, - "An error occurred" - ) - ); - } finally { - setPageState('IDLE'); - setLoadingStage(null); - setGenerationStartTime(null); - setProcessingStartTime(null); - setIsGeneratingTakeaways(false); - setSwitchingToLanguage(null); - } - }, [ - rateLimitInfo.remaining, - storeCurrentVideoForAuth, - videoId, - checkRateLimit, - user, - checkGenerationLimit, - redirectToAuthForLimit, - forceRegenerate - ]); + return normalizedQuestions; + }, + (error) => { + console.error("Failed to generate suggested questions:", error); + }, + ); + } catch (err) { + setError( + normalizeErrorMessage( + err instanceof Error ? err.message : undefined, + "An error occurred", + ), + ); + } finally { + setPageState("IDLE"); + setLoadingStage(null); + setGenerationStartTime(null); + setProcessingStartTime(null); + setIsGeneratingTakeaways(false); + setSwitchingToLanguage(null); + } + }, + [ + rateLimitInfo.remaining, + storeCurrentVideoForAuth, + videoId, + checkRateLimit, + user, + checkGenerationLimit, + redirectToAuthForLimit, + forceRegenerate, + ], + ); useEffect(() => { if (!routeVideoId || isModeLoading) return; - const key = `${routeVideoId}|${urlParam ?? ''}|${cachedParam ?? ''}|${mode}`; + const key = `${routeVideoId}|${urlParam ?? ""}|${cachedParam ?? ""}|${mode}`; if (lastInitializedKey.current === key) return; lastInitializedKey.current = key; // Store video ID for potential post-auth linking before loading if (!user) { - sessionStorage.setItem('pendingVideoId', routeVideoId); - console.log('Stored route video ID for potential post-auth linking:', routeVideoId); + sessionStorage.setItem("pendingVideoId", routeVideoId); + console.log( + "Stored route video ID for potential post-auth linking:", + routeVideoId, + ); } processVideo(normalizedUrl, mode); - }, [routeVideoId, urlParam, cachedParam, user, normalizedUrl, isModeLoading, mode, processVideo]); + }, [ + routeVideoId, + urlParam, + cachedParam, + user, + normalizedUrl, + isModeLoading, + mode, + processVideo, + ]); const handleCitationClick = (citation: Citation) => { // Reset Play All mode when clicking a citation @@ -1452,14 +1728,21 @@ export default function AnalyzePage() { const videoContainer = document.getElementById("video-container"); if (videoContainer) { - videoContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); + videoContainer.scrollIntoView({ behavior: "smooth", block: "start" }); } // Request seek through centralized command system requestSeek(citation.start); }; - const handleTimestampClick = (seconds: number, _endSeconds?: number, isCitation: boolean = false, _citationText?: string, isWithinHighlightReel: boolean = false, isWithinCitationHighlight: boolean = false) => { + const handleTimestampClick = ( + seconds: number, + _endSeconds?: number, + isCitation: boolean = false, + _citationText?: string, + isWithinHighlightReel: boolean = false, + isWithinCitationHighlight: boolean = false, + ) => { // Reset Play All mode when clicking any timestamp setIsPlayingAll(false); setPlayAllIndex(0); @@ -1479,10 +1762,11 @@ export default function AnalyzePage() { // Scroll to video player const videoContainer = document.getElementById("video-container"); if (videoContainer) { - videoContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); + videoContainer.scrollIntoView({ behavior: "smooth", block: "start" }); } // Request seek through centralized command system + console.log('[Page] calling requestSeek with:', seconds); requestSeek(seconds); }; @@ -1490,29 +1774,32 @@ export default function AnalyzePage() { setCurrentTime(seconds); }, []); - const handleTopicSelect = useCallback((topic: Topic | null, fromPlayAll: boolean = false) => { - // Reset Play All mode only when manually selecting a topic (not from Play All) - if (!fromPlayAll && isPlayingAll) { - setIsPlayingAll(false); - setPlayAllIndex(0); - } + const handleTopicSelect = useCallback( + (topic: Topic | null, fromPlayAll: boolean = false) => { + // Reset Play All mode only when manually selecting a topic (not from Play All) + if (!fromPlayAll && isPlayingAll) { + setIsPlayingAll(false); + setPlayAllIndex(0); + } - // Clear citation highlight when selecting a topic - setCitationHighlight(null); - setSelectedTopic(topic); + // Clear citation highlight when selecting a topic + setCitationHighlight(null); + setSelectedTopic(topic); - // Request to play the topic through centralized command system - if (topic && !fromPlayAll) { - requestPlayTopic(topic); - } - }, [isPlayingAll, requestPlayTopic]); + // Request to play the topic through centralized command system + if (topic && !fromPlayAll) { + requestPlayTopic(topic); + } + }, + [isPlayingAll, requestPlayTopic], + ); const handleTogglePlayAll = useCallback(() => { if (isPlayingAll) { // Stop playing all setIsPlayingAll(false); setPlayAllIndex(0); - setPlaybackCommand({ type: 'PAUSE' }); + setPlaybackCommand({ type: "PAUSE" }); } else { // Clear any existing selection to start fresh setSelectedTopic(null); @@ -1525,203 +1812,232 @@ export default function AnalyzePage() { selectedThemeRef.current = selectedTheme; }, [selectedTheme]); - const handleThemeSelect = useCallback(async (themeLabel: string | null) => { - if (!videoId) return; + const handleThemeSelect = useCallback( + async (themeLabel: string | null) => { + if (!videoId) return; - const resetToDefault = (options?: { preserveError?: boolean }) => { - if (!options?.preserveError) { - setThemeError(null); - } - setSelectedTheme(null); - selectedThemeRef.current = null; - setTopics(baseTopics); - setSelectedTopic(null); - setIsPlayingAll(false); - setPlayAllIndex(0); - setIsLoadingThemeTopics(false); - activeThemeRequestIdRef.current = null; - setUsedTopicKeys(new Set(baseTopicKeySet)); - }; + const resetToDefault = (options?: { preserveError?: boolean }) => { + if (!options?.preserveError) { + setThemeError(null); + } + setSelectedTheme(null); + selectedThemeRef.current = null; + setTopics(baseTopics); + setSelectedTopic(null); + setIsPlayingAll(false); + setPlayAllIndex(0); + setIsLoadingThemeTopics(false); + activeThemeRequestIdRef.current = null; + setUsedTopicKeys(new Set(baseTopicKeySet)); + }; - if (!themeLabel) { - resetToDefault(); - return; - } + if (!themeLabel) { + resetToDefault(); + return; + } - const normalizedTheme = themeLabel.trim(); + const normalizedTheme = themeLabel.trim(); - if (!normalizedTheme) { - resetToDefault(); - return; - } + if (!normalizedTheme) { + resetToDefault(); + return; + } - if (selectedTheme === normalizedTheme) { - resetToDefault(); - return; - } + if (selectedTheme === normalizedTheme) { + resetToDefault(); + return; + } - let themedTopics = themeTopicsMap[normalizedTheme]; - const needsHydration = - Array.isArray(themedTopics) && - themedTopics.some((topic) => { - const firstSegment = Array.isArray(topic?.segments) ? topic.segments[0] : null; - return !firstSegment || typeof firstSegment.start !== 'number' || typeof firstSegment.end !== 'number'; - }); + let themedTopics = themeTopicsMap[normalizedTheme]; + const needsHydration = + Array.isArray(themedTopics) && + themedTopics.some((topic) => { + const firstSegment = Array.isArray(topic?.segments) + ? topic.segments[0] + : null; + return ( + !firstSegment || + typeof firstSegment.start !== "number" || + typeof firstSegment.end !== "number" + ); + }); - if (themedTopics && needsHydration) { - themedTopics = hydrateTopicsWithTranscript(themedTopics, transcript); - setThemeTopicsMap(prev => ({ - ...prev, - [normalizedTheme]: themedTopics || [], - })); - } + if (themedTopics && needsHydration) { + themedTopics = hydrateTopicsWithTranscript(themedTopics, transcript); + setThemeTopicsMap((prev) => ({ + ...prev, + [normalizedTheme]: themedTopics || [], + })); + } - setSelectedTheme(normalizedTheme); - selectedThemeRef.current = normalizedTheme; - setThemeError(null); - setSelectedTopic(null); - setIsPlayingAll(false); - setPlayAllIndex(0); + setSelectedTheme(normalizedTheme); + selectedThemeRef.current = normalizedTheme; + setThemeError(null); + setSelectedTopic(null); + setIsPlayingAll(false); + setPlayAllIndex(0); - const pendingRequestId = pendingThemeRequestsRef.current.get(normalizedTheme); + const pendingRequestId = + pendingThemeRequestsRef.current.get(normalizedTheme); - if (!themedTopics && typeof pendingRequestId === "number") { - activeThemeRequestIdRef.current = pendingRequestId; - setIsLoadingThemeTopics(true); - return; - } + if (!themedTopics && typeof pendingRequestId === "number") { + activeThemeRequestIdRef.current = pendingRequestId; + setIsLoadingThemeTopics(true); + return; + } - if (!themedTopics) { - const requestId = ++nextThemeRequestIdRef.current; - pendingThemeRequestsRef.current.set(normalizedTheme, requestId); - activeThemeRequestIdRef.current = requestId; - setIsLoadingThemeTopics(true); - const requestKey = `theme-topics:${normalizedTheme}:${requestId}`; - const controller = abortManager.current.createController(requestKey); - const exclusionKeys = Array.from(baseTopicKeySet).map((key) => key.slice(0, 500)); + if (!themedTopics) { + const requestId = ++nextThemeRequestIdRef.current; + pendingThemeRequestsRef.current.set(normalizedTheme, requestId); + activeThemeRequestIdRef.current = requestId; + setIsLoadingThemeTopics(true); + const requestKey = `theme-topics:${normalizedTheme}:${requestId}`; + const controller = abortManager.current.createController(requestKey); + const exclusionKeys = Array.from(baseTopicKeySet).map((key) => + key.slice(0, 500), + ); - try { - const response = await fetch("/api/video-analysis", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - videoId, - videoInfo, - transcript, - theme: normalizedTheme, - excludeTopicKeys: exclusionKeys, - mode - }), - signal: controller.signal - }); + try { + const response = await fetch("/api/video-analysis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + videoId, + videoInfo, + transcript, + theme: normalizedTheme, + excludeTopicKeys: exclusionKeys, + mode, + }), + signal: controller.signal, + }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown error" })); - const message = buildApiErrorMessage(errorData, "Failed to generate themed topics"); - throw new Error(message); - } + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ error: "Unknown error" })); + const message = buildApiErrorMessage( + errorData, + "Failed to generate themed topics", + ); + throw new Error(message); + } - const data = await response.json(); + const data = await response.json(); - // Check if the API returned an error (e.g., no content found for theme) - if (data.error) { - throw new Error(data.error); - } + // Check if the API returned an error (e.g., no content found for theme) + if (data.error) { + throw new Error(data.error); + } - const hydratedThemeTopics = hydrateTopicsWithTranscript(Array.isArray(data.topics) ? data.topics : [], transcript); - const candidatePool = Array.isArray(data.topicCandidates) ? data.topicCandidates : undefined; - setThemeCandidateMap(prev => ({ - ...prev, - [normalizedTheme]: candidatePool ?? [] - })); - const nextUsedKeys = new Set(usedTopicKeys); - hydratedThemeTopics.forEach(topic => { - if (topic.quote?.timestamp && topic.quote.text) { - nextUsedKeys.add(`${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`); + const hydratedThemeTopics = hydrateTopicsWithTranscript( + Array.isArray(data.topics) ? data.topics : [], + transcript, + ); + const candidatePool = Array.isArray(data.topicCandidates) + ? data.topicCandidates + : undefined; + setThemeCandidateMap((prev) => ({ + ...prev, + [normalizedTheme]: candidatePool ?? [], + })); + const nextUsedKeys = new Set(usedTopicKeys); + hydratedThemeTopics.forEach((topic) => { + if (topic.quote?.timestamp && topic.quote.text) { + nextUsedKeys.add( + `${topic.quote.timestamp}|${normalizeWhitespace(topic.quote.text)}`, + ); + } + }); + setUsedTopicKeys(nextUsedKeys); + themedTopics = hydratedThemeTopics; + setThemeTopicsMap((prev) => ({ + ...prev, + [normalizedTheme]: themedTopics || [], + })); + } catch (error) { + const isAbortError = + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: string }).name === "AbortError"; + + if (isAbortError) { + return; } - }); - setUsedTopicKeys(nextUsedKeys); - themedTopics = hydratedThemeTopics; - setThemeTopicsMap(prev => ({ - ...prev, - [normalizedTheme]: themedTopics || [] - })); - } catch (error) { - const isAbortError = - typeof error === "object" && - error !== null && - "name" in error && - (error as { name?: string }).name === "AbortError"; - if (isAbortError) { + const message = + error instanceof Error + ? error.message + : "Failed to generate themed topics"; + console.error("Theme-specific generation failed:", error); + if (selectedThemeRef.current === normalizedTheme) { + resetToDefault({ preserveError: true }); + setThemeError(message); + } return; + } finally { + abortManager.current.cleanup(requestKey); + pendingThemeRequestsRef.current.delete(normalizedTheme); + if ( + activeThemeRequestIdRef.current === requestId && + selectedThemeRef.current === normalizedTheme + ) { + setIsLoadingThemeTopics(false); + activeThemeRequestIdRef.current = null; + } } - - const message = error instanceof Error ? error.message : "Failed to generate themed topics"; - console.error("Theme-specific generation failed:", error); - if (selectedThemeRef.current === normalizedTheme) { - resetToDefault({ preserveError: true }); - setThemeError(message); - } - return; - } finally { - abortManager.current.cleanup(requestKey); - pendingThemeRequestsRef.current.delete(normalizedTheme); - if ( - activeThemeRequestIdRef.current === requestId && - selectedThemeRef.current === normalizedTheme - ) { - setIsLoadingThemeTopics(false); - activeThemeRequestIdRef.current = null; - } + } else { + activeThemeRequestIdRef.current = null; + setIsLoadingThemeTopics(false); } - } else { - activeThemeRequestIdRef.current = null; - setIsLoadingThemeTopics(false); - } - if (!themedTopics) { - themedTopics = []; - } + if (!themedTopics) { + themedTopics = []; + } - if (themedTopics.length === 0) { - setThemeCandidateMap(prev => ({ - ...prev, - [normalizedTheme]: prev[normalizedTheme] ?? [] - })); - } + if (themedTopics.length === 0) { + setThemeCandidateMap((prev) => ({ + ...prev, + [normalizedTheme]: prev[normalizedTheme] ?? [], + })); + } - if (selectedThemeRef.current !== normalizedTheme) { - return; - } + if (selectedThemeRef.current !== normalizedTheme) { + return; + } - setTopics(themedTopics); - if (themedTopics.length > 0) { - setSelectedTopic(themedTopics[0]); - setThemeError(null); - } else { - setThemeError("No highlights available for this theme yet."); - setSelectedTopic(null); - } - }, [ - videoId, - videoInfo, - transcript, - selectedTheme, - baseTopics, - baseTopicKeySet, - themeTopicsMap, - usedTopicKeys, - mode, - setIsPlayingAll, - setPlayAllIndex - ]); + setTopics(themedTopics); + if (themedTopics.length > 0) { + setSelectedTopic(themedTopics[0]); + setThemeError(null); + } else { + setThemeError("No highlights available for this theme yet."); + setSelectedTopic(null); + } + }, + [ + videoId, + videoInfo, + transcript, + selectedTheme, + baseTopics, + baseTopicKeySet, + themeTopicsMap, + usedTopicKeys, + mode, + setIsPlayingAll, + setPlayAllIndex, + ], + ); // Dynamically adjust right column height to match video container useEffect(() => { const adjustRightColumnHeight = () => { const videoContainer = document.getElementById("video-container"); - const rightColumnContainer = document.getElementById("right-column-container"); + const rightColumnContainer = document.getElementById( + "right-column-container", + ); if (videoContainer && rightColumnContainer) { const videoHeight = videoContainer.offsetHeight; @@ -1758,67 +2074,90 @@ export default function AnalyzePage() { return; } - setIsLoadingNotes(true); - fetchNotes({ youtubeId: videoId }) - .then(setNotes) - .catch((error) => { - console.error("Failed to load notes", error); - }) - .finally(() => setIsLoadingNotes(false)); + // MVP: Notes functionality disabled + // setIsLoadingNotes(true); + // fetchNotes({ youtubeId: videoId }) + // .then(setNotes) + // .catch((error) => { + // console.error("Failed to load notes", error); + // }) + // .finally(() => setIsLoadingNotes(false)); }, [videoId, user]); - // Auto-switch to Chat tab when Explain is triggered from transcript + // Auto-open AI Chat when Explain is triggered from transcript useEffect(() => { const handleExplainFromSelection = () => { - // Switch to chat tab when explain is triggered - rightColumnTabsRef.current?.switchToChat?.(); + // Open floating AI chat when explain is triggered + setIsAIChatOpen(true); }; - window.addEventListener(EXPLAIN_SELECTION_EVENT, handleExplainFromSelection as EventListener); + window.addEventListener( + EXPLAIN_SELECTION_EVENT, + handleExplainFromSelection as EventListener, + ); return () => { - window.removeEventListener(EXPLAIN_SELECTION_EVENT, handleExplainFromSelection as EventListener); + window.removeEventListener( + EXPLAIN_SELECTION_EVENT, + handleExplainFromSelection as EventListener, + ); }; }, []); - const handleSaveNote = useCallback(async ({ text, source, sourceId, metadata }: { text: string; source: NoteSource; sourceId?: string | null; metadata?: NoteMetadata | null }) => { - if (!videoId) return; - if (!user) { - promptSignInForNotes(); - return; - } + const handleSaveNote = useCallback( + async ({ + text, + source, + sourceId, + metadata, + }: { + text: string; + source: NoteSource; + sourceId?: string | null; + metadata?: NoteMetadata | null; + }) => { + if (!videoId) return; + if (!user) { + promptSignInForNotes(); + return; + } - try { - const note = await saveNote({ - youtubeId: videoId, - source, - sourceId: sourceId ?? undefined, - text, - metadata: metadata ?? undefined, - }); - setNotes((prev) => [note, ...prev]); - toast.success("Note saved"); - } catch (error) { - console.error("Failed to save note", error); - toast.error("Failed to save note"); - } - }, [videoId, user, promptSignInForNotes]); + try { + const note = await saveNote({ + youtubeId: videoId, + source, + sourceId: sourceId ?? undefined, + text, + metadata: metadata ?? undefined, + }); + setNotes((prev) => [note, ...prev]); + toast.success("Note saved"); + } catch (error) { + console.error("Failed to save note", error); + toast.error("Failed to save note"); + } + }, + [videoId, user, promptSignInForNotes], + ); - const handleTakeNoteFromSelection = useCallback((payload: SelectionActionPayload) => { - if (!user) { - promptSignInForNotes(); - return; - } + const handleTakeNoteFromSelection = useCallback( + (payload: SelectionActionPayload) => { + if (!user) { + promptSignInForNotes(); + return; + } - // Switch to notes tab - rightColumnTabsRef.current?.switchToNotes(); + // Switch to notes tab + rightColumnTabsRef.current?.switchToNotes(); - // Set editing state with selected text, metadata, and source - setEditingNote({ - text: payload.text, - metadata: payload.metadata ?? null, - source: payload.source, - }); - }, [promptSignInForNotes, user]); + // Set editing state with selected text, metadata, and source + setEditingNote({ + text: payload.text, + metadata: payload.metadata ?? null, + source: payload.source, + }); + }, + [promptSignInForNotes, user], + ); const handleAddNote = useCallback(() => { if (!user) { @@ -1835,49 +2174,79 @@ export default function AnalyzePage() { }); }, [user, promptSignInForNotes]); - const handleSaveEditingNote = useCallback(async ({ noteText, selectedText, metadata }: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => { - if (!editingNote || !videoId) return; - - // Use source from editing note or determine from metadata - let source: NoteSource = "custom"; - if (editingNote.source) { - source = editingNote.source as NoteSource; - } else if (editingNote.metadata?.chat) { - source = "chat"; - } else if (editingNote.metadata?.transcript) { - source = "transcript"; - } - - const normalizedSelected = selectedText.trim(); - const mergedMetadata = normalizedSelected - ? { - ...(editingNote.metadata ?? {}), - ...(metadata ?? {}), - selectedText: normalizedSelected, + const handleSaveEditingNote = useCallback( + async ({ + noteText, + selectedText, + metadata, + }: { + noteText: string; + selectedText: string; + metadata?: NoteMetadata; + }) => { + if (!editingNote || !videoId) return; + + // Use source from editing note or determine from metadata + let source: NoteSource = "custom"; + if (editingNote.source) { + source = editingNote.source as NoteSource; + } else if (editingNote.metadata?.chat) { + source = "chat"; + } else if (editingNote.metadata?.transcript) { + source = "transcript"; } - : { - ...(editingNote.metadata ?? {}), - ...(metadata ?? {}) - }; - await handleSaveNote({ - text: noteText, - source, - sourceId: editingNote.metadata?.chat?.messageId ?? null, - metadata: mergedMetadata, - }); + const normalizedSelected = selectedText.trim(); + const mergedMetadata = normalizedSelected + ? { + ...(editingNote.metadata ?? {}), + ...(metadata ?? {}), + selectedText: normalizedSelected, + } + : { + ...(editingNote.metadata ?? {}), + ...(metadata ?? {}), + }; - // Clear editing state - setEditingNote(null); - }, [editingNote, videoId, handleSaveNote]); + await handleSaveNote({ + text: noteText, + source, + sourceId: editingNote.metadata?.chat?.messageId ?? null, + metadata: mergedMetadata, + }); + + // Clear editing state + setEditingNote(null); + }, + [editingNote, videoId, handleSaveNote], + ); const handleCancelEditing = useCallback(() => { setEditingNote(null); }, []); + const handleEditNote = useCallback( + (note: Note) => { + if (!user) { + promptSignInForNotes(); + return; + } + + rightColumnTabsRef.current?.switchToNotes(); + + setEditingNote({ + id: note.id, + text: note.text || "", + metadata: note.metadata || null, + source: note.source, + }); + }, + [user, promptSignInForNotes], + ); + return (
- {pageState === 'IDLE' && !videoId && !routeVideoId && !urlParam && ( + {pageState === "IDLE" && !videoId && !routeVideoId && !urlParam && (
{error && (
@@ -1888,9 +2257,12 @@ export default function AnalyzePage() { )}
-

Ready to analyze a video?

+

+ Ready to analyze a video? +

- Head back to the home page to paste a YouTube link and generate highlight reels, searchable transcripts, and AI takeaways. + Head back to the home page to paste a YouTube link and generate + highlight reels, searchable transcripts, and AI takeaways.

)} - {pageState === 'LOADING_CACHED' && ( + {pageState === "LOADING_CACHED" && (
@@ -1913,7 +2285,7 @@ export default function AnalyzePage() {
)} - {pageState === 'ANALYZING_NEW' && ( + {pageState === "ANALYZING_NEW" && (
{error && (
@@ -1927,22 +2299,21 @@ export default function AnalyzePage() {

{switchingToLanguage ? `Switching to ${getLanguageName(switchingToLanguage)}...` - : 'Analyzing video and generating highlight reels'} + : "Analyzing video and generating highlight reels"}

{!switchingToLanguage && (

- {loadingStage === 'fetching' && 'Fetching transcript...'} - {loadingStage === 'understanding' && 'Fetching transcript...'} - {loadingStage === 'generating' && `Creating highlight reels... (${elapsedTime} seconds)`} - {loadingStage === 'processing' && `Processing and matching quotes... (${processingElapsedTime} seconds)`} + {loadingStage === "fetching" && "Fetching transcript..."} + {loadingStage === "understanding" && "Fetching transcript..."} + {loadingStage === "generating" && + `Creating highlight reels... (${elapsedTime} seconds)`} + {loadingStage === "processing" && + `Processing and matching quotes... (${processingElapsedTime} seconds)`}

)}
- +
@@ -1950,18 +2321,18 @@ export default function AnalyzePage() {
)} - {pageState === 'IDLE' && videoId && topics.length === 0 && error && ( + {pageState === "IDLE" && videoId && topics.length === 0 && error && (

- {isRateLimitError ? 'Monthly limit reached' : 'We couldn\'t finish analyzing this video'} + {isRateLimitError + ? "Monthly limit reached" + : "We couldn't finish analyzing this video"}

- {isRateLimitError - ? AUTH_LIMIT_MESSAGE - : error} + {isRateLimitError ? AUTH_LIMIT_MESSAGE : error}

@@ -1995,37 +2366,54 @@ export default function AnalyzePage() {
)} - {videoId && topics.length > 0 && pageState === 'IDLE' && ( -
+ {videoId && topics.length > 0 && pageState === "IDLE" && ( +
{error && ( -
+
{error}
)} -
- {/* Left Column - Video (2/3 width) */} -
-
- - {(themes.length > 0 || isLoadingThemeTopics || themeError || selectedTheme) && ( + + {/* Left Column - History Sidebar (collapsible) */} + + + + + + {/* Center Column - Video & Highlights (main content) */} + +
+
+ +
+ {(themes.length > 0 || + isLoadingThemeTopics || + themeError || + selectedTheme) && (
-
+
+ - {/* Right Column - Tabbed Interface (1/3 width) */} -
-
- +
+ { + onLanguageChange={(langCode: string | null) => { // Check if this is a request for a native transcript - const availableLanguages = videoInfo?.availableLanguages || []; + const availableLanguages = + videoInfo?.availableLanguages || []; if (langCode && availableLanguages.includes(langCode)) { // It's a native language request if (videoInfo?.language !== langCode) { - // Only re-fetch if it's different from current - // Set language switching state for loading indicator - setSwitchingToLanguage(langCode); - processVideo(normalizedUrl, mode, langCode); - // Clear any translation override - handleLanguageChange(null); + // Only re-fetch if it's different from current + // Set language switching state for loading indicator + setSwitchingToLanguage(langCode); + processVideo(normalizedUrl, mode, langCode); + // Clear any translation override + handleLanguageChange(null); } } else { // It's a translation request @@ -2114,8 +2499,8 @@ export default function AnalyzePage() { exportButtonState={exportButtonState} />
-
-
+ +
)} @@ -2127,7 +2512,7 @@ export default function AnalyzePage() { storeCurrentVideoForAuth(); } if (!open) { - setAuthModalTrigger('generation-limit'); + setAuthModalTrigger("generation-limit"); } setAuthModalOpen(open); }} @@ -2149,10 +2534,12 @@ export default function AnalyzePage() { targetLanguage={targetLanguage} onTargetLanguageChange={setTargetLanguage} includeSpeakers={includeSpeakers} - onIncludeSpeakersChange={(value) => setIncludeSpeakers(value && hasSpeakerData)} + onIncludeSpeakersChange={(value) => + setIncludeSpeakers(value && hasSpeakerData) + } includeTimestamps={includeTimestamps} onIncludeTimestampsChange={setIncludeTimestamps} - disableTimestampToggle={exportFormat === 'srt'} + disableTimestampToggle={exportFormat === "srt"} onConfirm={handleConfirmExport} isExporting={isExportingTranscript} error={exportErrorMessage} @@ -2167,6 +2554,29 @@ export default function AnalyzePage() { onOpenChange={setShowExportUpsell} onUpgradeClick={handleUpgradeClick} /> + + {/* Floating AI Assistant */} + {videoId && topics.length > 0 && ( + + )}
); } diff --git a/app/api/ai/provider/route.ts b/app/api/ai/provider/route.ts new file mode 100644 index 00000000..295e94d8 --- /dev/null +++ b/app/api/ai/provider/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { availableProviders, getProviderKey } from '@/lib/ai-providers'; +import { withSecurity, SECURITY_PRESETS } from '@/lib/security-middleware'; + +// GET /api/ai/provider - Get current provider +async function getHandler(req: NextRequest) { + try { + // Check cookie first, then fall back to default + const cookieProvider = req.cookies.get('ai-provider')?.value; + const provider = cookieProvider || getProviderKey(); + const available = availableProviders(); + + return NextResponse.json({ + provider, + available, + }); + } catch (error) { + return NextResponse.json( + { error: 'Failed to get provider info' }, + { status: 500 } + ); + } +} + +// POST /api/ai/provider - Set provider preference (session-based) +async function postHandler(req: NextRequest) { + try { + const body = await req.json(); + const { provider } = body; + + const validProviders = ['grok', 'gemini', 'deepseek'] as const; + + if (!provider || !validProviders.includes(provider)) { + return NextResponse.json( + { error: 'Invalid provider' }, + { status: 400 } + ); + } + + // Check if the provider has an API key configured + const hasApiKey = + (provider === 'grok' && process.env.XAI_API_KEY) || + (provider === 'gemini' && process.env.GEMINI_API_KEY) || + (provider === 'deepseek' && process.env.DEEPSEEK_API_KEY); + + if (!hasApiKey) { + return NextResponse.json( + { error: `${provider} API key is not configured` }, + { status: 400 } + ); + } + + // Set provider preference in a cookie + const response = NextResponse.json({ + success: true, + provider, + }); + + response.cookies.set('ai-provider', provider, { + httpOnly: false, // Allow client-side access for UI + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + return response; + } catch (error) { + return NextResponse.json( + { error: 'Failed to set provider' }, + { status: 500 } + ); + } +} + +export const GET = withSecurity(getHandler, SECURITY_PRESETS.PUBLIC); + +export const POST = withSecurity(postHandler, { + ...SECURITY_PRESETS.PUBLIC, + rateLimit: { + windowMs: 60 * 1000, // 1 minute + maxRequests: 10 // 10 requests per minute + }, + csrfProtection: true +}); diff --git a/app/api/auth/signout/route.ts b/app/api/auth/signout/route.ts index 722820c4..2bc44b42 100644 --- a/app/api/auth/signout/route.ts +++ b/app/api/auth/signout/route.ts @@ -1,8 +1,10 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { cookies } from 'next/headers' +import { withSecurity } from '@/lib/security-middleware' +import type { NextRequest } from 'next/server' -export async function POST() { +async function handler(req: NextRequest) { const supabase = await createClient() // Sign out server-side @@ -23,3 +25,9 @@ export async function POST() { return response } + +export const POST = withSecurity(handler, { + requireAuth: true, + csrfProtection: true, + allowedMethods: ['POST'] +}) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 669ce1ee..ea3aee72 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -71,6 +71,9 @@ function findClosestSegment(transcript: TranscriptSegment[], targetSeconds: numb } async function handler(request: NextRequest) { + // Check for user's provider preference from cookie + const cookieProvider = request.cookies.get('ai-provider')?.value; + try { // Parse and validate request body const body = await request.json(); @@ -124,44 +127,45 @@ async function handler(request: NextRequest) { : ''; const prompt = ` -You are an expert AI assistant for video transcripts. Prefer the provided transcript when the user asks about the video, but answer general knowledge questions directly.${languageInstruction} - +You are a helpful AI assistant. You have access to a video transcript for video-specific questions, but you can also answer general knowledge questions using your own training.${languageInstruction} + + + + First, determine if the user's question is ABOUT the video content: + • VIDEO-RELATED: Questions about the video's content, topics discussed, speaker's words, timestamps in the video, "what did they say about X", "explain this from the video" + • GENERAL KNOWLEDGE: Questions about facts, definitions, explanations, current events, "who is the smartest", "explain quantum physics", "what's the capital of France" + + + + For VIDEO-RELATED questions: + - Use ONLY the transcript below + - Cite timestamps in [MM:SS] format + - If answer isn't in transcript, say "The transcript doesn't contain this information" + For GENERAL KNOWLEDGE questions: + - IGNORE the transcript completely + - Use your own knowledge to answer directly + - Return empty timestamps array: [] + - Do NOT say "the transcript doesn't mention" - just answer the question + + + ${topicsContext || 'None provided'} + - -Deliver concise, factual answers. Use the transcript when it is relevant to the question; otherwise respond with your best general knowledge. - - - Decide whether the user's question requires information from the transcript. - If the question is general knowledge or unrelated to the video, answer directly without forcing transcript references. - If the transcript lacks the requested information, clearly state that and return an empty timestamps array. - - - When referencing the video, rely exclusively on the transcript. - Whenever you make a factual claim based on the transcript, append the exact supporting timestamp in brackets like [MM:SS] or [HH:MM:SS]. Never use numeric citation markers like [1]. - List the same timestamps in the timestamps array, zero-padded and in the order they appear. Provide no more than five unique timestamps. - - - Respond in concise, complete sentences that mirror the transcript's language when applicable. - If the transcript lacks the requested information or was unnecessary, state that clearly and return an empty timestamps array. - - - - If you cited the transcript, does every factual statement have a supporting timestamp in brackets? - Are all timestamps valid moments within the transcript? - If the transcript was unnecessary or lacked the answer, did you state that and keep the timestamps array empty? - -Return strict JSON object: {"answer":"string","timestamps":["MM:SS"]}. No extra commentary. - +]]> + + +Return strict JSON object: {"answer":"string","timestamps":["MM:SS"]}. No extra commentary. `; const maxOutputTokens = 65536; @@ -170,6 +174,7 @@ ${message} try { response = await generateAIResponse(prompt, { + provider: cookieProvider, // Use user's preferred provider from cookie temperature: 0.6, maxOutputTokens: Math.min(1024, maxOutputTokens), zodSchema: chatResponseSchema diff --git a/app/api/notes/enhance/route.ts b/app/api/notes/enhance/route.ts index 571478ba..fe230035 100644 --- a/app/api/notes/enhance/route.ts +++ b/app/api/notes/enhance/route.ts @@ -6,7 +6,7 @@ import { z } from "zod"; const enhancePayloadSchema = z.object({ quote: z .string() - .min(12, "Quote is too short to enhance") + .min(1, "Quote is required") .max(2000, "Quote must be under 2,000 characters"), }); @@ -75,6 +75,7 @@ async function handler(req: NextRequest) { const parsed = enhancePayloadSchema.safeParse(body); if (!parsed.success) { + console.error('[Enhance API] Validation failed:', parsed.error.issues); return NextResponse.json( { error: parsed.error.issues[0]?.message || "Invalid request" }, { status: 400 } @@ -90,6 +91,8 @@ async function handler(req: NextRequest) { ); } + console.log('[Enhance API] Processing quote:', quote.substring(0, 50) + '...'); + const prompt = ` You are a meticulous transcript editor tasked with polishing noisy speech. Rewrite the snippet so it reads like clean prose while preserving meaning. @@ -129,12 +132,12 @@ async function handler(req: NextRequest) { } export const POST = withSecurity(handler, { - requireAuth: true, + requireAuth: false, // Allow anonymous users to enhance notes rateLimit: { windowMs: 60 * 1000, - maxRequests: 20, + maxRequests: 30, }, maxBodySize: 32 * 1024, allowedMethods: ["POST"], - csrfProtection: true, + csrfProtection: false, // Disable CSRF for anonymous requests }); diff --git a/app/api/notes/route.ts b/app/api/notes/route.ts index 9e6575bb..7322db91 100644 --- a/app/api/notes/route.ts +++ b/app/api/notes/route.ts @@ -50,8 +50,12 @@ async function handler(req: NextRequest) { const youtubeId = searchParams.get('youtubeId'); const videoIdParam = searchParams.get('videoId'); + // Debug logging + console.log('📝 [GET /api/notes] Request params:', { youtubeId, videoIdParam, url: req.url }); + try { const validated = getNotesQuerySchema.parse({ youtubeId, videoId: videoIdParam }); + console.log('✅ [GET /api/notes] Validation passed:', validated); let targetVideoId: string | undefined; @@ -94,13 +98,17 @@ async function handler(req: NextRequest) { return NextResponse.json({ notes }); } catch (error) { if (error instanceof z.ZodError) { + console.error('❌ [GET /api/notes] Zod validation error:', { + issues: error.issues, + formattedError: formatValidationError(error) + }); return NextResponse.json( { error: 'Validation failed', details: formatValidationError(error) }, { status: 400 } ); } - console.error('Error fetching notes:', error); + console.error('❌ [GET /api/notes] Unexpected error:', error); return NextResponse.json( { error: 'Failed to fetch notes' }, { status: 500 } diff --git a/app/api/stripe/confirm-checkout/route.ts b/app/api/stripe/confirm-checkout/route.ts index ec7eebfe..f0ba3ae2 100644 --- a/app/api/stripe/confirm-checkout/route.ts +++ b/app/api/stripe/confirm-checkout/route.ts @@ -28,6 +28,13 @@ async function handler(req: NextRequest) { } const stripe = getStripeClient(); + if (!stripe) { + return NextResponse.json( + { error: 'Payment system not configured' }, + { status: 503 } + ); + } + const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ['subscription'], }); diff --git a/app/api/stripe/create-checkout-session/route.ts b/app/api/stripe/create-checkout-session/route.ts index dbec6fab..d2848099 100644 --- a/app/api/stripe/create-checkout-session/route.ts +++ b/app/api/stripe/create-checkout-session/route.ts @@ -175,6 +175,13 @@ async function handler(req: NextRequest) { // Create Stripe Checkout session const stripe = getStripeClient(); + if (!stripe) { + return NextResponse.json( + { error: 'Payment system not configured' }, + { status: 503 } + ); + } + const session = await stripe.checkout.sessions.create({ customer: customerId, mode: mode, diff --git a/app/api/stripe/create-portal-session/route.ts b/app/api/stripe/create-portal-session/route.ts index bded08f3..743ac7ea 100644 --- a/app/api/stripe/create-portal-session/route.ts +++ b/app/api/stripe/create-portal-session/route.ts @@ -55,6 +55,13 @@ async function handler(req: NextRequest) { // Create Stripe billing portal session const stripe = getStripeClient(); + if (!stripe) { + return NextResponse.json( + { error: 'Payment system not configured' }, + { status: 503 } + ); + } + const portalSession = await stripe.billingPortal.sessions.create({ customer: profile.stripe_customer_id, return_url: `${appUrl}/settings`, diff --git a/app/api/update-video-analysis/route.ts b/app/api/update-video-analysis/route.ts index f45a8ece..28763d26 100644 --- a/app/api/update-video-analysis/route.ts +++ b/app/api/update-video-analysis/route.ts @@ -38,25 +38,46 @@ async function handler(req: NextRequest) { } // Use secure update function with ownership verification - const { data: result, error: updateError } = await supabase + // Note: The function returns TABLE (success boolean, video_id uuid) + // which returns as an array of rows, not a single object + const { data: resultRows, error: updateError } = await supabase .rpc('update_video_analysis_secure', { p_youtube_id: videoId, p_user_id: user.id, p_summary: summary ?? null, p_suggested_questions: suggestedQuestions ?? null - }) - .single(); + }); if (updateError) { - console.error('Error updating video analysis:', updateError); + console.error('Error updating video analysis:', { + message: updateError.message, + details: updateError.details, + hint: updateError.hint, + code: updateError.code, + videoId, + userId: user.id + }); return NextResponse.json( - { error: 'Failed to update video analysis' }, + { error: 'Failed to update video analysis', details: updateError.message }, + { status: 500 } + ); + } + + // The function returns a table (array of rows), get the first result + const result = Array.isArray(resultRows) && resultRows.length > 0 + ? resultRows[0] as UpdateResult + : null; + + if (!result) { + console.error('No result returned from update_video_analysis_secure'); + return NextResponse.json( + { error: 'Failed to update video analysis: No result' }, { status: 500 } ); } // Check if update was authorized - if (!result?.success) { + if (!result.success) { return NextResponse.json( { error: 'Not authorized to update this video analysis' }, { status: 403 } diff --git a/app/api/video-analysis/route.ts b/app/api/video-analysis/route.ts index 9329e8e1..1406200c 100644 --- a/app/api/video-analysis/route.ts +++ b/app/api/video-analysis/route.ts @@ -127,7 +127,8 @@ async function handler(req: NextRequest) { cachedVideo = data ?? null; } - const isCachedAnalysis = Boolean(cachedVideo?.topics); + // Check if we have valid cached topics (must be non-empty array) + const isCachedAnalysis = Array.isArray(cachedVideo?.topics) && cachedVideo.topics.length > 0; let generationDecision: GenerationDecision | null = null; let alreadyCountedThisPeriod = false; diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 15f9966f..9fcd024b 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -23,6 +23,16 @@ async function handler(req: NextRequest) { const supabase = createServiceRoleClient(); const stripe = getStripeClient(); + + // Stripe not configured - webhooks disabled + if (!stripe) { + console.error('❌ Stripe is not configured - webhook processing disabled'); + return NextResponse.json( + { error: 'Payment system not configured' }, + { status: 503 } + ); + } + let eventId: string | null = null; let eventLocked = false; diff --git a/app/layout.tsx b/app/layout.tsx index c91461e3..74d0d7dc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import Image from "next/image"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from '@vercel/analytics/react'; +import { Agentation } from "agentation"; import { AuthProvider } from '@/contexts/auth-context'; import { UserMenu } from '@/components/user-menu'; import { ToastProvider } from '@/components/toast-provider'; @@ -20,7 +21,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "LongCut - The best way to learn from long videos", + title: "Little universe - The best way to learn from long videos", description: "Smart video navigation that transforms long YouTube videos into topic-driven learning experiences", icons: { icon: "/Video_Play.svg", @@ -33,9 +34,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +
@@ -47,7 +49,7 @@ export default function RootLayout({ > LongCut logo
-
+
{children}
-
+ {process.env.NODE_ENV === "development" && } ); diff --git a/app/page.tsx b/app/page.tsx index 9bd07a7d..04b8abcf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -198,48 +198,18 @@ function HomeContent() { return ( <> -
-
-
-
-

LongCut

-
-

- The best way to learn from long videos. -

-
-
- - - -
-

- Don't take the shortcut. -

-

- LongCut doesn't summarize. We show you where to look instead. Find the highlights. Take notes. Ask questions. -

-
-
-
- Gradient silhouette illustration -
-
-
-
+
+
+
Basic

Free

- Try LongCut for free, no card required + Try Little universe for free, no card required

diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index c4567051..0aefce43 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from 'next' export const metadata: Metadata = { - title: 'Privacy Policy | LongCut', + title: 'Privacy Policy | Little universe', description: - 'Learn how LongCut collects, uses, and protects your personal information and video analysis data.', + 'Learn how Little universe collects, uses, and protects your personal information and video analysis data.', } const supportEmail = 'zara@longcut.ai' @@ -15,7 +15,7 @@ export default function PrivacyPage() {

Privacy Policy

Last updated: November 15, 2025

- This Privacy Policy describes how LongCut (“we”, “us”, or “our”) collects, + This Privacy Policy describes how Little universe (“we”, “us”, or “our”) collects, uses, and protects your personal information when you use our service. We are committed to protecting your privacy and being transparent about our data practices.

@@ -161,7 +161,7 @@ export default function PrivacyPage() {

Access and Portability

- You can access your video analyses, notes, and account information at any time through your LongCut account. If + You can access your video analyses, notes, and account information at any time through your Little universe account. If you would like to export your data in a portable format, please contact us.

@@ -213,7 +213,7 @@ export default function PrivacyPage() {

Children's Privacy

- LongCut is not intended for children under the age of 13. We do not knowingly collect personal information from + Little universe is not intended for children under the age of 13. We do not knowingly collect personal information from children under 13. If you believe we have collected information from a child under 13, please contact us immediately and we will delete it.

@@ -223,7 +223,7 @@ export default function PrivacyPage() {

International Data Transfers

Your information may be transferred to and processed in countries other than your country of residence. These - countries may have data protection laws that are different from the laws of your country. By using LongCut, you + countries may have data protection laws that are different from the laws of your country. By using Little universe, you consent to the transfer of your information to our facilities and third-party service providers.

@@ -237,7 +237,7 @@ export default function PrivacyPage() { last revised.

- Your continued use of LongCut after any changes to this Privacy Policy constitutes your acceptance of the + Your continued use of Little universe after any changes to this Privacy Policy constitutes your acceptance of the updated policy.

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 56dc1ce9..23ca2088 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -2,9 +2,9 @@ import type { Metadata } from 'next' import Link from 'next/link' export const metadata: Metadata = { - title: 'Terms of Service | LongCut', + title: 'Terms of Service | Little universe', description: - 'Understand how LongCut subscriptions, billing, and the 48-hour refund window for the annual Pro plan work.', + 'Understand how Little universe subscriptions, billing, and the 48-hour refund window for the annual Pro plan work.', } const supportEmail = 'zara@longcut.ai' @@ -16,16 +16,16 @@ export default function TermsPage() {

Terms of Service

Last updated: November 11, 2025

- These Terms of Service (“Terms”) govern your access to and use of LongCut (“we”, + These Terms of Service (“Terms”) govern your access to and use of Little universe (“we”, “us”, or “our”). By creating an account or using the product, you agree to these - Terms. If you do not agree, please do not use LongCut. + Terms. If you do not agree, please do not use Little universe.

Account & Eligibility

- You are responsible for maintaining the security of your LongCut account and the credentials associated with it. + You are responsible for maintaining the security of your Little universe account and the credentials associated with it. You must provide accurate information when you sign up and keep your contact details up to date so we can send important notices about your subscription.

@@ -34,7 +34,7 @@ export default function TermsPage() {

Subscriptions & Billing

- LongCut offers both free access and paid Pro subscriptions that deliver additional features and higher usage + Little universe offers both free access and paid Pro subscriptions that deliver additional features and higher usage limits. When you activate a paid plan, Stripe securely processes your payment information on our behalf. You authorize us to charge the applicable subscription fees (and any related taxes) at the start of each billing period until you cancel. @@ -71,7 +71,7 @@ export default function TermsPage() {

Cancellation

- You can cancel your subscription at any time from your LongCut account settings. Navigate to{' '} + You can cancel your subscription at any time from your Little universe account settings. Navigate to{' '} Settings → Manage billing {' '} @@ -83,7 +83,7 @@ export default function TermsPage() {

Acceptable Use

- You agree not to misuse LongCut, interfere with other users, or attempt to access the service using automated + You agree not to misuse Little universe, interfere with other users, or attempt to access the service using automated scripts at a rate that would degrade performance. We may suspend or terminate accounts that violate these Terms or applicable law.

@@ -93,7 +93,7 @@ export default function TermsPage() {

Changes to These Terms

We may update these Terms from time to time. If we make material changes, we will notify you via email or an - in-app message and indicate the effective date. Your continued use of LongCut after the update becomes effective + in-app message and indicate the effective date. Your continued use of Little universe after the update becomes effective means you accept the revised Terms.

diff --git a/app/v/[slug]/page.tsx b/app/v/[slug]/page.tsx index d6e68e40..f954d81c 100644 --- a/app/v/[slug]/page.tsx +++ b/app/v/[slug]/page.tsx @@ -86,7 +86,7 @@ export async function generateMetadata({ params }: PageProps): Promise if (!resolved) { return { - title: 'Video Not Found - LongCut', + title: 'Video Not Found - Little universe', description: 'This video analysis could not be found.' }; } @@ -106,7 +106,7 @@ export async function generateMetadata({ params }: PageProps): Promise const thumbnailUrl = video.thumbnail_url || `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; return { - title: `${video.title} - Transcript & Analysis | LongCut`, + title: `${video.title} - Transcript & Analysis | Little universe`, description, keywords: [ video.title, @@ -123,7 +123,7 @@ export async function generateMetadata({ params }: PageProps): Promise description: description, type: 'video.other', url: `https://longcut.ai/v/${slugForMeta}`, - siteName: 'LongCut', + siteName: 'Little universe', images: [ { url: thumbnailUrl, @@ -251,7 +251,7 @@ export default async function VideoPage({ params }: PageProps) { }, "publisher": { "@type": "Organization", - "name": "LongCut", + "name": "Little universe", "url": "https://longcut.ai" }, "author": { @@ -275,7 +275,7 @@ export default async function VideoPage({ params }: PageProps) { }, "publisher": { "@type": "Organization", - "name": "LongCut", + "name": "Little universe", "url": "https://longcut.ai" }, "mainEntityOfPage": { diff --git a/components/ai-assistant-floating.tsx b/components/ai-assistant-floating.tsx new file mode 100644 index 00000000..4b9d6b9f --- /dev/null +++ b/components/ai-assistant-floating.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { MessageSquare, X, Minimize2, Maximize2, Pin, PinOff, GripVertical } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { AIChat } from "@/components/ai-chat"; +import { AIProviderSelector } from "@/components/ai-provider-selector"; +import { cn } from "@/lib/utils"; +import { + TranscriptSegment, + Topic, + Citation, + NoteSource, + NoteMetadata, + VideoInfo, + TranslationRequestHandler, +} from "@/lib/types"; +import { SelectionActionPayload } from "@/components/selection-actions"; + +interface AIAssistantFloatingProps { + transcript: TranscriptSegment[]; + topics: Topic[]; + videoId: string; + videoTitle?: string; + videoInfo?: VideoInfo | null; + onCitationClick: (citation: Citation) => void; + onTimestampClick: ( + seconds: number, + endSeconds?: number, + isCitation?: boolean, + citationText?: string, + ) => void; + cachedSuggestedQuestions?: string[] | null; + onSaveNote?: (payload: { + text: string; + source: NoteSource; + sourceId?: string | null; + metadata?: NoteMetadata | null; + }) => Promise; + onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void; + selectedLanguage?: string | null; + translationCache?: Map; + onRequestTranslation?: TranslationRequestHandler; + isAuthenticated?: boolean; + onRequestSignIn?: () => void; + // External control props + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function AIAssistantFloating({ + transcript, + topics, + videoId, + videoTitle, + videoInfo, + onCitationClick, + onTimestampClick, + cachedSuggestedQuestions, + onSaveNote, + onTakeNoteFromSelection, + selectedLanguage, + translationCache, + onRequestTranslation, + isAuthenticated, + onRequestSignIn, + open, + onOpenChange, +}: AIAssistantFloatingProps) { + // Use internal state if not controlled externally + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = open !== undefined ? open : internalOpen; + const handleOpenChange = onOpenChange || setInternalOpen; + + // Prevent closing when pinned - wrapper function + const handleOpenChangeWrapper = (newOpen: boolean) => { + // If trying to close while pinned, ignore it + if (!newOpen && isPinned) { + return; + } + handleOpenChange(newOpen); + }; + const [isMaximized, setIsMaximized] = useState(false); + + // Draggable state + const [isPinned, setIsPinned] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const dragStartPos = useRef({ x: 0, y: 0 }); + const dragStartMousePos = useRef({ x: 0, y: 0 }); + const dialogRef = useRef(null); + + // Load saved position on mount + useEffect(() => { + const savedPosition = localStorage.getItem('ai-assistant-position'); + const savedPinned = localStorage.getItem('ai-assistant-pinned'); + if (savedPosition) { + setPosition(JSON.parse(savedPosition)); + } + if (savedPinned) { + setIsPinned(JSON.parse(savedPinned)); + } + }, []); + + // Save position to localStorage + useEffect(() => { + if (isPinned) { + localStorage.setItem('ai-assistant-position', JSON.stringify(position)); + localStorage.setItem('ai-assistant-pinned', JSON.stringify(isPinned)); + } else { + localStorage.removeItem('ai-assistant-position'); + localStorage.removeItem('ai-assistant-pinned'); + } + }, [position, isPinned]); + + // Drag handlers + const handleMouseDown = (e: React.MouseEvent) => { + if (!isPinned) return; + setIsDragging(true); + dragStartPos.current = { ...position }; + dragStartMousePos.current = { x: e.clientX, y: e.clientY }; + e.preventDefault(); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !isPinned) return; + + const deltaX = e.clientX - dragStartMousePos.current.x; + const deltaY = e.clientY - dragStartMousePos.current.y; + + const newX = dragStartPos.current.x + deltaX; + const newY = dragStartPos.current.y + deltaY; + + // Constrain to viewport + const maxX = window.innerWidth - 100; + const maxY = window.innerHeight - 100; + + setPosition({ + x: Math.max(0, Math.min(newX, maxX)), + y: Math.max(0, Math.min(newY, maxY)), + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, isPinned]); + + return ( + <> + {/* Floating Action Button */} + + + {/* Custom Draggable Dialog */} + + + {/* Only show overlay when not pinned */} + {!isPinned && ( + + )} + + + {/* Header */} +
+
+ {isPinned && } + + + AI 助手 + + +
+
+ + + + + +
+
+ + {/* Content */} +
+ +
+
+
+
+ + ); +} diff --git a/components/ai-provider-selector.tsx b/components/ai-provider-selector.tsx new file mode 100644 index 00000000..6d4e9469 --- /dev/null +++ b/components/ai-provider-selector.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "sonner"; + +const PROVIDERS = [ + { value: "grok", label: "Grok", description: "xAI Grok - Fast & capable" }, + { value: "gemini", label: "Gemini", description: "Google Gemini AI" }, + { value: "deepseek", label: "DeepSeek", description: "DeepSeek with web search" }, +] as const; + +type ProviderValue = typeof PROVIDERS[number]["value"]; + +const STORAGE_KEY = "ai-provider-preference"; + +export function AIProviderSelector() { + const [currentProvider, setCurrentProvider] = useState("grok"); + const [isOpen, setIsOpen] = useState(false); + + // Load saved preference on mount + useEffect(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && PROVIDERS.some(p => p.value === saved)) { + setCurrentProvider(saved as ProviderValue); + } else { + // Check current environment + const checkProvider = async () => { + try { + const res = await fetch("/api/ai/provider"); + if (res.ok) { + const data = await res.json(); + if (data.provider) { + setCurrentProvider(data.provider); + } + } + } catch { + // Ignore error, use default + } + }; + void checkProvider(); + } + }, []); + + const handleProviderChange = async (value: ProviderValue) => { + setIsOpen(false); + + // Call API to switch provider + try { + const res = await fetch("/api/ai/provider", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: value }), + }); + + const data = await res.json(); + + if (!res.ok) { + toast.error(`切换失败: ${data.error || '未知错误'}`); + return; + } + + // Success - update local state and storage + setCurrentProvider(value); + localStorage.setItem(STORAGE_KEY, value); + toast.success(`已切换到 ${PROVIDERS.find(p => p.value === value)?.label}`); + } catch (error) { + console.error("Error switching provider:", error); + toast.error("切换提供商时发生错误"); + } + }; + + const current = PROVIDERS.find(p => p.value === currentProvider) || PROVIDERS[0]; + + return ( + + + + + + {PROVIDERS.map((provider) => ( + handleProviderChange(provider.value)} + className="flex flex-col items-start gap-1 py-2" + > +
+ {provider.label} + {provider.value === currentProvider && ( + (Active) + )} +
+ + {provider.description} + +
+ ))} +
+
+ ); +} diff --git a/components/auth-modal.tsx b/components/auth-modal.tsx index 30829450..e6cda443 100644 --- a/components/auth-modal.tsx +++ b/components/auth-modal.tsx @@ -156,7 +156,7 @@ export function AuthModal({ open, onOpenChange, onSuccess, trigger = 'manual', c } default: return { - title: 'Sign in to LongCut', + title: 'Sign in to Little universe', description: 'Create an account or sign in to save your video analyses and access them anytime.', benefits: [ 'Save your analyzed videos', diff --git a/components/highlights-panel.tsx b/components/highlights-panel.tsx index 31dc7f9c..93768884 100644 --- a/components/highlights-panel.tsx +++ b/components/highlights-panel.tsx @@ -15,6 +15,15 @@ const DEFAULT_LABELS = { generatingYourReels: "Generating your reels...", }; +// Pastel color palette from reference design +const CATEGORY_COLORS = [ + "bg-[#FF8A80]", // Coral + "bg-[#80CBC4]", // Mint green + "bg-[#F48FB1]", // Light pink + "bg-[#B39DDB]", // Lavender + "bg-[#81D4FA]", // Light blue +]; + interface HighlightsPanelProps { topics: Topic[]; selectedTopic: Topic | null; @@ -89,12 +98,26 @@ export function HighlightsPanel({ }; }, [selectedLanguage, onRequestTranslation]); + // Get color for topic based on index + const getTopicColor = (index: number) => { + return CATEGORY_COLORS[index % CATEGORY_COLORS.length]; + }; + return ( - -
+ +
- + {formatDuration(currentTime)} / {formatDuration(videoDuration)}
@@ -121,7 +144,10 @@ export function HighlightsPanel({ size="sm" variant={isPlayingAll ? "secondary" : "default"} onClick={onPlayAll} - className="h-7 text-xs" + className="h-8 px-4 text-xs text-white border-0 shadow-md rounded-full" + style={{ + background: "linear-gradient(135deg, #81D4FA 0%, #4FC3F7 100%)", + }} > {isPlayingAll ? ( <> @@ -142,8 +168,8 @@ export function HighlightsPanel({ {/* Loading overlay */} {isLoadingThemeTopics && (
- -

+ +

{translatedLabels.generatingYourReels}

diff --git a/components/history-sidebar.tsx b/components/history-sidebar.tsx new file mode 100644 index 00000000..80170e5d --- /dev/null +++ b/components/history-sidebar.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { History, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; + +const HISTORY_STORAGE_KEY = "longcut-watch-history"; +const MAX_HISTORY_ITEMS = 50; + +export interface WatchHistoryItem { + videoId: string; + title: string; + thumbnail: string; + watchedAt: number; // timestamp + duration?: number; + lastPosition?: number; // seconds +} + +interface HistorySidebarProps { + currentVideoId?: string; + onCollapsedChange?: (collapsed: boolean) => void; + defaultCollapsed?: boolean; +} + +export function useWatchHistory() { + const [history, setHistory] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(HISTORY_STORAGE_KEY); + if (stored) { + try { + setHistory(JSON.parse(stored)); + } catch { + setHistory([]); + } + } + }, []); + + const addToHistory = useCallback( + (item: Omit) => { + setHistory((prev) => { + // Remove existing entry for same video + const filtered = prev.filter((h) => h.videoId !== item.videoId); + const newHistory = [ + { ...item, watchedAt: Date.now() }, + ...filtered, + ].slice(0, MAX_HISTORY_ITEMS); + + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(newHistory)); + return newHistory; + }); + }, + [], + ); + + const removeFromHistory = useCallback((videoId: string) => { + setHistory((prev) => { + const filtered = prev.filter((h) => h.videoId !== videoId); + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(filtered)); + return filtered; + }); + }, []); + + const clearHistory = useCallback(() => { + localStorage.removeItem(HISTORY_STORAGE_KEY); + setHistory([]); + }, []); + + return { history, addToHistory, removeFromHistory, clearHistory }; +} + +export function HistorySidebar({ + currentVideoId, + onCollapsedChange, + defaultCollapsed = false, +}: HistorySidebarProps) { + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + const { history, removeFromHistory } = useWatchHistory(); + + const toggleCollapsed = () => { + const newState = !isCollapsed; + setIsCollapsed(newState); + onCollapsedChange?.(newState); + }; + + // Format relative time + const formatRelativeTime = (timestamp: number) => { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 7) return `${days}天前`; + return new Date(timestamp).toLocaleDateString("zh-CN"); + }; + + if (isCollapsed) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + 观看历史 +
+ +
+ + {/* History List */} + +
+ {history.length === 0 ? ( +
+ 暂无观看记录 +
+ ) : ( + history.map((item) => ( +
+ +
+ {item.title} + {currentVideoId === item.videoId && ( +
+ + 当前 + +
+ )} +
+
+

+ {item.title} +

+

+ {formatRelativeTime(item.watchedAt)} +

+
+ + {/* Delete button */} + +
+ )) + )} +
+
+
+ ); +} diff --git a/components/notes-panel.tsx b/components/notes-panel.tsx index fc9be09e..7e0f6011 100644 --- a/components/notes-panel.tsx +++ b/components/notes-panel.tsx @@ -1,21 +1,24 @@ -import { useMemo, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Note, NoteSource, NoteMetadata } from "@/lib/types"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Trash2, Clock, Plus } from "lucide-react"; +import { Trash2, Clock, Plus, Download, Edit, Search, Filter, X } from "lucide-react"; import { NoteEditor } from "@/components/note-editor"; import { cn } from "@/lib/utils"; +import { exportNotesToMarkdown } from "@/lib/markdown-exporter"; +import { getLocalNotes, type LocalNote } from "@/lib/local-notes"; function formatDateOnly(dateString: string): string { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' }); } @@ -80,6 +83,7 @@ export interface EditingNote { text: string; metadata?: NoteMetadata | null; source?: string; + id?: string; // Note ID for editing existing note } interface NotesPanelProps { @@ -88,54 +92,148 @@ interface NotesPanelProps { editingNote?: EditingNote | null; onSaveEditingNote?: (payload: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => void; onCancelEditing?: () => void; + onEditNote?: (note: Note) => void; // New: Edit existing note isAuthenticated?: boolean; onSignInClick?: () => void; currentTime?: number; onTimestampClick?: (seconds: number) => void; onAddNote?: () => void; + // Export props + videoInfo?: { youtubeId: string; title?: string; author?: string; duration?: number; description?: string; thumbnailUrl?: string } | null; + topics?: any[]; // Topic array from video analysis } function getSourceLabel(source: NoteSource) { switch (source) { case "chat": - return "AI Message"; + return "💬 AI 对话"; case "takeaways": - return "Takeaways"; + return "🎯 关键要点"; case "transcript": - return "Transcript"; + return "🎬 视频片段"; default: - return "Custom"; + return "✏️ 自定义笔记"; } } +type FilterType = 'all' | 'transcript' | 'chat' | 'custom' | 'takeaways'; + export function NotesPanel({ notes = [], onDeleteNote, editingNote, onSaveEditingNote, onCancelEditing, + onEditNote, isAuthenticated = true, onSignInClick, currentTime, onTimestampClick, - onAddNote + onAddNote, + videoInfo, + topics }: NotesPanelProps) { + const [isExporting, setIsExporting] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + + const handleExport = async () => { + if (!videoInfo) return; + + setIsExporting(true); + try { + // Get local notes for this video + const localNotes = getLocalNotes(videoInfo.youtubeId); + + // Convert cloud notes to LocalNote format + const cloudNotesAsLocal: LocalNote[] = notes.map(note => ({ + id: note.id, + youtubeId: videoInfo.youtubeId, + source: note.source, + sourceId: note.sourceId || undefined, + text: note.text, + metadata: note.metadata || undefined, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + synced: true + })); + + // Combine with displayed notes (removing duplicates) + const allNotes: LocalNote[] = [ + ...cloudNotesAsLocal, + ...localNotes.filter(ln => !cloudNotesAsLocal.find(n => n.id === ln.id)) + ]; + + // Export to markdown - convert videoInfo to VideoInfo format + const exportVideoInfo = videoInfo ? { + videoId: videoInfo.youtubeId, + youtubeId: videoInfo.youtubeId, + title: videoInfo.title || '未命名视频', + author: videoInfo.author || '未知作者', + thumbnail: videoInfo.thumbnailUrl || '', + duration: videoInfo.duration || 0, + description: videoInfo.description + } : null; + + if (exportVideoInfo) { + await exportNotesToMarkdown(exportVideoInfo, allNotes, topics); + } + } catch (error) { + console.error('Export failed:', error); + } finally { + setIsExporting(false); + } + }; + + const handleEditClick = (note: Note) => { + if (onEditNote) { + onEditNote(note); + } + }; + + // Filter and search notes + const filteredNotes = useMemo(() => { + let filtered = notes; + + // Apply source filter + if (filterType !== 'all') { + filtered = filtered.filter(note => note.source === filterType); + } + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(note => + note.text?.toLowerCase().includes(query) || + note.metadata?.selectedText?.toLowerCase().includes(query) || + note.metadata?.selectionContext?.toLowerCase().includes(query) + ); + } + + return filtered; + }, [notes, filterType, searchQuery]); + + // Group filtered notes by source const groupedNotes = useMemo(() => { - return notes.reduce>((acc, note) => { + return filteredNotes.reduce>((acc, note) => { const list = acc[note.source] || []; list.push(note); acc[note.source] = list; return acc; }, {} as Record); - }, [notes]); + }, [filteredNotes]); + + const noteCount = notes.length; + const filteredCount = filteredNotes.length; if (!isAuthenticated) { return (
-

Sign in to save notes

+

登录以保存笔记

- Highlight transcript moments and keep your takeaways in one place. + 高亮字幕或聊天内容来记录笔记

); } - if (!notes.length && !editingNote) { - return ( -
-

Your saved notes will appear here. Highlight transcript or chat text to take a note.

- {onAddNote && ( - - )} -
- ); - } - return ( -
- {/* Add Note Button */} - {!editingNote && onAddNote && ( - - )} +
+ {/* Top Action Bar */} +
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9 text-xs rounded-xl border-slate-200 bg-white/50 focus:bg-white" + /> + {searchQuery && ( + + )} +
+ + {/* Action Buttons Row */} +
+ {/* Export Button */} + {videoInfo && ( + + + + + + 导出为 Markdown 文件 + + + )} + + {/* Filter Toggle */} + + + + + + 筛选笔记类型 + + + + {/* Add Note Button */} + {!editingNote && onAddNote && ( + + )} +
+ + {/* Filter Options (Expandable) */} + {showFilters && ( +
+ {[ + { value: 'all' as FilterType, label: '全部' }, + { value: 'transcript' as FilterType, label: '🎬 视频' }, + { value: 'chat' as FilterType, label: '💬 对话' }, + { value: 'custom' as FilterType, label: '✏️ 自定义' }, + { value: 'takeaways' as FilterType, label: '🎯 要点' }, + ].map((filter) => ( + + ))} +
+ )} + + {/* Note Count */} + {(searchQuery || filterType !== 'all') && ( +
+ {filteredCount} / {noteCount} 条笔记 +
+ )} +
{/* Note Editor - shown when editing */} {editingNote && onSaveEditingNote && onCancelEditing && ( @@ -193,11 +370,28 @@ export function NotesPanel({ /> )} + {/* Empty State */} + {!filteredNotes.length && !editingNote && ( +
+ {searchQuery || filterType !== 'all' ? ( +

没有找到匹配的笔记

+ ) : ( + <> +

你的笔记将显示在这里

+

高亮字幕或聊天内容来创建笔记

+ + )} +
+ )} + {/* Saved Notes - grouped by source */} {Object.entries(groupedNotes).map(([source, sourceNotes]) => (
-
+
{getSourceLabel(source as NoteSource)} + + {sourceNotes.length} +
{sourceNotes.map((note) => { @@ -263,9 +457,12 @@ export function NotesPanel({ !isTranscriptNote && note.metadata?.transcript?.segmentIndex !== undefined; return ( - +
-
+
handleEditClick(note)} + > {quoteText && (
{shouldShowSegmentInfo && note.metadata?.transcript && note.metadata.transcript.segmentIndex !== undefined && ( - Segment #{note.metadata.transcript.segmentIndex + 1} + 片段 #{note.metadata.transcript.segmentIndex + 1} )}
- {onDeleteNote && ( - - - - - - Delete note - - - )} + + {/* Action Buttons - Always visible on hover */} +
+ {onEditNote && ( + + )} + {onDeleteNote && ( + + )} +
); diff --git a/components/right-column-panel.tsx b/components/right-column-panel.tsx new file mode 100644 index 00000000..5d295e0c --- /dev/null +++ b/components/right-column-panel.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useState, useImperativeHandle, forwardRef } from "react"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { TranscriptViewer } from "@/components/transcript-viewer"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Languages, PenLine, Columns2, LayoutList } from "lucide-react"; +import { + TranscriptSegment, + Topic, + Citation, + Note, + NoteSource, + NoteMetadata, + VideoInfo, + TranslationRequestHandler, +} from "@/lib/types"; +import { SelectionActionPayload } from "@/components/selection-actions"; +import { NotesPanel, EditingNote } from "@/components/notes-panel"; +import { cn } from "@/lib/utils"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { LanguageSelector } from "@/components/language-selector"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const translationSelectorEnabled = (() => { + const raw = process.env.NEXT_PUBLIC_ENABLE_TRANSLATION_SELECTOR; + if (!raw) { + return false; + } + const normalized = raw.toLowerCase(); + return ( + normalized === "true" || + normalized === "1" || + normalized === "yes" || + normalized === "on" + ); +})(); + +type ViewMode = "tabs" | "split"; + +interface RightColumnPanelProps { + transcript: TranscriptSegment[]; + selectedTopic: Topic | null; + onTimestampClick: ( + seconds: number, + endSeconds?: number, + isCitation?: boolean, + citationText?: string, + isWithinHighlightReel?: boolean, + isWithinCitationHighlight?: boolean, + ) => void; + currentTime?: number; + topics?: Topic[]; + citationHighlight?: Citation | null; + videoId: string; + videoTitle?: string; + videoInfo?: VideoInfo | null; + onCitationClick: (citation: Citation) => void; + notes?: Note[]; + onSaveNote?: (payload: { + text: string; + source: NoteSource; + sourceId?: string | null; + metadata?: NoteMetadata | null; + }) => Promise; + onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void; + editingNote?: EditingNote | null; + onSaveEditingNote?: (payload: { + noteText: string; + selectedText: string; + metadata?: NoteMetadata; + }) => void; + onCancelEditing?: () => void; + onEditNote?: (note: Note) => void; + isAuthenticated?: boolean; + onRequestSignIn?: () => void; + selectedLanguage?: string | null; + translationCache?: Map; + onRequestTranslation?: TranslationRequestHandler; + onLanguageChange?: (languageCode: string | null) => void; + availableLanguages?: string[]; + currentSourceLanguage?: string; + onRequestExport?: () => void; + exportButtonState?: { + tooltip?: string; + disabled?: boolean; + badgeLabel?: string; + isLoading?: boolean; + }; + onAddNote?: () => void; +} + +export interface RightColumnPanelHandle { + switchToTranscript: () => void; + switchToNotes: () => void; + setViewMode: (mode: ViewMode) => void; +} + +export const RightColumnPanel = forwardRef< + RightColumnPanelHandle, + RightColumnPanelProps +>( + ( + { + transcript, + selectedTopic, + onTimestampClick, + currentTime, + topics, + citationHighlight, + videoId, + videoTitle, + videoInfo, + onCitationClick, + notes, + onSaveNote, + onTakeNoteFromSelection, + editingNote, + onSaveEditingNote, + onCancelEditing, + onEditNote, + isAuthenticated, + onRequestSignIn, + selectedLanguage = null, + translationCache, + onRequestTranslation, + onLanguageChange, + availableLanguages, + currentSourceLanguage, + onRequestExport, + exportButtonState, + onAddNote, + }, + ref, + ) => { + const [viewMode, setViewMode] = useState("tabs"); + const [activeTab, setActiveTab] = useState<"transcript" | "notes">( + "transcript", + ); + const showTranslationSelector = translationSelectorEnabled; + + // Expose methods to parent + useImperativeHandle(ref, () => ({ + switchToTranscript: () => { + setActiveTab("transcript"); + }, + switchToNotes: () => { + setActiveTab("notes"); + }, + setViewMode: (mode: ViewMode) => { + setViewMode(mode); + }, + })); + + // Transcript component (reused in both modes) + const transcriptComponent = ( + + ); + + // Notes component (reused in both modes) + const notesComponent = ( + + + + ); + + return ( + + {/* Header with mode toggle */} +
+
+ {showTranslationSelector ? ( + { + if (tab === "transcript" || tab === "notes") { + setActiveTab(tab); + } + }} + onLanguageChange={onLanguageChange} + onRequestSignIn={onRequestSignIn} + /> + ) : ( +
+ +
+ )} +
+ + {/* Notes tab button (only in tabs mode) */} + {viewMode === "tabs" && ( + + )} + + {/* View mode toggle */} + + + + + + + {viewMode === "split" ? "切换到标签模式" : "切换到分屏模式"} + + + +
+ + {/* Content area */} +
+ {viewMode === "split" ? ( + /* Split mode: Transcript on top, Notes on bottom with resizable divider */ + + +
+ {transcriptComponent} +
+
+ + +
+
+ + 笔记 +
+
+ {notesComponent} +
+
+
+
+ ) : ( + /* Tabs mode: Show one at a time */ + <> +
+ {transcriptComponent} +
+
+ {notesComponent} +
+ + )} +
+
+ ); + }, +); + +RightColumnPanel.displayName = "RightColumnPanel"; diff --git a/components/right-column-tabs.tsx b/components/right-column-tabs.tsx index 5d90fa88..de5a47b0 100644 --- a/components/right-column-tabs.tsx +++ b/components/right-column-tabs.tsx @@ -6,7 +6,16 @@ import { AIChat } from "@/components/ai-chat"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Languages, MessageSquare, PenLine } from "lucide-react"; -import { TranscriptSegment, Topic, Citation, Note, NoteSource, NoteMetadata, VideoInfo, TranslationRequestHandler } from "@/lib/types"; +import { + TranscriptSegment, + Topic, + Citation, + Note, + NoteSource, + NoteMetadata, + VideoInfo, + TranslationRequestHandler, +} from "@/lib/types"; import { SelectionActionPayload } from "@/components/selection-actions"; import { NotesPanel, EditingNote } from "@/components/notes-panel"; import { cn } from "@/lib/utils"; @@ -19,13 +28,25 @@ const translationSelectorEnabled = (() => { return false; } const normalized = raw.toLowerCase(); - return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on"; + return ( + normalized === "true" || + normalized === "1" || + normalized === "yes" || + normalized === "on" + ); })(); interface RightColumnTabsProps { transcript: TranscriptSegment[]; selectedTopic: Topic | null; - onTimestampClick: (seconds: number, endSeconds?: number, isCitation?: boolean, citationText?: string, isWithinHighlightReel?: boolean, isWithinCitationHighlight?: boolean) => void; + onTimestampClick: ( + seconds: number, + endSeconds?: number, + isCitation?: boolean, + citationText?: string, + isWithinHighlightReel?: boolean, + isWithinCitationHighlight?: boolean, + ) => void; currentTime?: number; topics?: Topic[]; citationHighlight?: Citation | null; @@ -36,10 +57,19 @@ interface RightColumnTabsProps { showChatTab?: boolean; cachedSuggestedQuestions?: string[] | null; notes?: Note[]; - onSaveNote?: (payload: { text: string; source: NoteSource; sourceId?: string | null; metadata?: NoteMetadata | null }) => Promise; + onSaveNote?: (payload: { + text: string; + source: NoteSource; + sourceId?: string | null; + metadata?: NoteMetadata | null; + }) => Promise; onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void; editingNote?: EditingNote | null; - onSaveEditingNote?: (payload: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => void; + onSaveEditingNote?: (payload: { + noteText: string; + selectedText: string; + metadata?: NoteMetadata; + }) => void; onCancelEditing?: () => void; isAuthenticated?: boolean; onRequestSignIn?: () => void; @@ -65,117 +95,129 @@ export interface RightColumnTabsHandle { switchToNotes: () => void; } -export const RightColumnTabs = forwardRef(({ - transcript, - selectedTopic, - onTimestampClick, - currentTime, - topics, - citationHighlight, - videoId, - videoTitle, - videoInfo, - onCitationClick, - showChatTab, - cachedSuggestedQuestions, - notes, - onSaveNote, - onTakeNoteFromSelection, - editingNote, - onSaveEditingNote, - onCancelEditing, - isAuthenticated, - onRequestSignIn, - selectedLanguage = null, - translationCache, - onRequestTranslation, - onLanguageChange, - availableLanguages, - currentSourceLanguage, - onRequestExport, - exportButtonState, - onAddNote -}, ref) => { - const [activeTab, setActiveTab] = useState<"transcript" | "chat" | "notes">("transcript"); - const showTranslationSelector = translationSelectorEnabled; - - // Expose methods to parent to switch tabs - useImperativeHandle(ref, () => ({ - switchToTranscript: () => { - setActiveTab("transcript"); - }, - switchToChat: () => { - if (showChatTab) { - setActiveTab("chat"); - } +export const RightColumnTabs = forwardRef< + RightColumnTabsHandle, + RightColumnTabsProps +>( + ( + { + transcript, + selectedTopic, + onTimestampClick, + currentTime, + topics, + citationHighlight, + videoId, + videoTitle, + videoInfo, + onCitationClick, + showChatTab, + cachedSuggestedQuestions, + notes, + onSaveNote, + onTakeNoteFromSelection, + editingNote, + onSaveEditingNote, + onCancelEditing, + isAuthenticated, + onRequestSignIn, + selectedLanguage = null, + translationCache, + onRequestTranslation, + onLanguageChange, + availableLanguages, + currentSourceLanguage, + onRequestExport, + exportButtonState, + onAddNote, }, - switchToNotes: () => { - setActiveTab("notes"); - } - })); + ref, + ) => { + const [activeTab, setActiveTab] = useState<"transcript" | "chat" | "notes">( + "transcript", + ); + const showTranslationSelector = translationSelectorEnabled; - useEffect(() => { - // If chat tab is removed while active, switch to transcript - if (!showChatTab && activeTab === "chat") { - setActiveTab("transcript"); - } - }, [showChatTab, activeTab]); + // Expose methods to parent to switch tabs + useImperativeHandle(ref, () => ({ + switchToTranscript: () => { + setActiveTab("transcript"); + }, + switchToChat: () => { + if (showChatTab) { + setActiveTab("chat"); + } + }, + switchToNotes: () => { + setActiveTab("notes"); + }, + })); - return ( - -
-
- {showTranslationSelector ? ( - - ) : ( -
- -
- )} -
- {showChatTab && ( - +
)} - > - - Chat - - )} +
+ {showChatTab && ( + + )} + {/* Notes Tab - Temporarily Disabled -
- -
- {/* Keep both components mounted but toggle visibility */} -
- -
-
- + */}
+ +
+ {/* Keep both components mounted but toggle visibility */} +
+ +
+
+ +
+ {/* Notes Panel - Temporarily Disabled
@@ -246,9 +300,11 @@ export const RightColumnTabs = forwardRef
-
- - ); -}); + */} +
+ + ); + }, +); RightColumnTabs.displayName = "RightColumnTabs"; diff --git a/components/topic-card.tsx b/components/topic-card.tsx index bb149efb..5c81c1e1 100644 --- a/components/topic-card.tsx +++ b/components/topic-card.tsx @@ -5,6 +5,15 @@ import { Topic, TranslationRequestHandler } from "@/lib/types"; import { formatDuration, getTopicHSLColor } from "@/lib/utils"; import { cn } from "@/lib/utils"; +// Pastel color palette from reference design +const CATEGORY_COLORS = [ + "#FF8A80", // Coral + "#80CBC4", // Mint green + "#F48FB1", // Light pink + "#B39DDB", // Lavender + "#81D4FA", // Light blue +]; + interface TopicCardProps { topic: Topic; isSelected: boolean; @@ -17,7 +26,7 @@ interface TopicCardProps { } export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic, videoId, selectedLanguage = null, onRequestTranslation }: TopicCardProps) { - const topicColor = getTopicHSLColor(topicIndex, videoId); + const topicColor = CATEGORY_COLORS[topicIndex % CATEGORY_COLORS.length]; const [translatedTitle, setTranslatedTitle] = useState(null); const [isLoadingTranslation, setIsLoadingTranslation] = useState(false); @@ -35,12 +44,12 @@ export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic, // Request translation setIsLoadingTranslation(true); - + // Cache key includes source text to avoid collisions when topic ids are reused const cacheKey = `topic-title:${selectedLanguage}:${topic.title}`; - + let isCancelled = false; - + onRequestTranslation(topic.title, cacheKey, 'topic') .then(translation => { if (!isCancelled) { @@ -71,34 +80,30 @@ export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic, onPlayTopic(); } }; - + return ( diff --git a/components/transcript-viewer.tsx b/components/transcript-viewer.tsx index b55a71f9..4811e8c8 100644 --- a/components/transcript-viewer.tsx +++ b/components/transcript-viewer.tsx @@ -560,6 +560,7 @@ export function TranscriptViewer({ // But usually click implies mousedown and mouseup at same location. // Seek to the start of the segment + console.log('[TranscriptViewer] handleSegmentClick:', segment.start, segment.text.substring(0, 50)); onTimestampClick(segment.start); }; diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx new file mode 100644 index 00000000..abaf0edf --- /dev/null +++ b/components/ui/resizable.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { GripVertical } from "lucide-react"; +import { + Group, + Panel, + Separator, + type GroupProps, +} from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +// Wrapper that supports "direction" alias (for shadcn compatibility) while using the library's "orientation" prop +interface ResizablePanelGroupProps extends Omit { + direction?: "horizontal" | "vertical"; + orientation?: "horizontal" | "vertical"; +} + +const ResizablePanelGroup = ({ + className, + direction, + orientation, + ...props +}: ResizablePanelGroupProps) => ( + +); + +const ResizablePanel = Panel; + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean; +}) => ( + div]:rotate-90", + className, + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+); + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/components/url-input-with-branding.tsx b/components/url-input-with-branding.tsx index 73e89a83..dd66be04 100644 --- a/components/url-input-with-branding.tsx +++ b/components/url-input-with-branding.tsx @@ -66,21 +66,21 @@ export function UrlInputWithBranding({ onSubmit, isLoading = false, initialUrl, > {/* Top row: Branding + Input field only */}
- {/* Left: LongCut Logo and Text */} + {/* Left: Little universe Logo and Text */} LongCut logo -

LongCut

+

Little universe

{/* Vertical Separator */} diff --git a/components/video-progress-bar.tsx b/components/video-progress-bar.tsx index 21de2a2f..12ce7932 100644 --- a/components/video-progress-bar.tsx +++ b/components/video-progress-bar.tsx @@ -6,6 +6,15 @@ import { getTopicHSLColor } from "@/lib/utils"; import { TopicCard } from "@/components/topic-card"; import { cn } from "@/lib/utils"; +// Pastel color palette from reference design +const CATEGORY_COLORS = [ + "#FF8A80", // Coral + "#80CBC4", // Mint green + "#F48FB1", // Light pink + "#B39DDB", // Lavender + "#81D4FA", // Light blue +]; + interface VideoProgressBarProps { videoDuration: number; currentTime: number; @@ -105,23 +114,28 @@ export function VideoProgressBar({ }; }; + // Get color for topic based on index + const getTopicColor = (index: number) => { + return CATEGORY_COLORS[index % CATEGORY_COLORS.length]; + }; + return (
{/* Main progress bar - Click to navigate */} {hasDuration && (
- {/* Heatmap background */} + {/* Heatmap background with gradient */}
{density.map((d, i) => (
))} @@ -137,14 +151,15 @@ export function VideoProgressBar({
handleTopicClick(e, topic)} /> @@ -154,12 +169,12 @@ export function VideoProgressBar({ {/* Current time indicator */}
-
+
)} diff --git a/components/youtube-player.tsx b/components/youtube-player.tsx index 48bce3a7..0ce30b73 100644 --- a/components/youtube-player.tsx +++ b/components/youtube-player.tsx @@ -218,8 +218,15 @@ export function YouTubePlayer({ switch (playbackCommand.type) { case 'SEEK': if (playbackCommand.time !== undefined) { + console.log('[YouTubePlayer] SEEK command:', playbackCommand.time, 'player ready:', playerReady); + isSeekingRef.current = true; playerRef.current.seekTo(playbackCommand.time, true); - playerRef.current.playVideo(); + // Don't auto-play on seek - let user control playback + // playerRef.current.playVideo(); + // Clear seeking flag after a short delay + setTimeout(() => { + isSeekingRef.current = false; + }, 200); } break; @@ -401,6 +408,7 @@ export function YouTubePlayer({ const handleSeek = (time: number) => { + console.log('[YouTubePlayer] handleSeek:', time, 'player exists:', !!playerRef.current); playerRef.current?.seekTo(time, true); setCurrentTime(time); }; diff --git a/lib/ai-providers/deepseek-adapter.ts b/lib/ai-providers/deepseek-adapter.ts new file mode 100644 index 00000000..eeaa4c1e --- /dev/null +++ b/lib/ai-providers/deepseek-adapter.ts @@ -0,0 +1,430 @@ +import type { ProviderAdapter, ProviderGenerateParams, ProviderGenerateResult } from './types'; + +const DEFAULT_MODEL = 'deepseek-chat'; +const PROVIDER_NAME = 'deepseek'; + +const DEFAULT_SEARCH_COUNT = 5; +const MAX_TOOL_STEPS = 2; +const BRAVE_SEARCH_BASE_URL = 'https://api.search.brave.com/res/v1/web/search'; + +function buildAbortController(timeoutMs?: number) { + if (!timeoutMs || timeoutMs <= 0 || typeof AbortController === 'undefined') { + return { controller: undefined, clear: () => undefined }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + const clear = () => clearTimeout(timer); + + return { controller, clear }; +} + +function extractTextFromChoice(choice: any): string { + if (!choice) return ''; + + const message = choice.message ?? choice.delta ?? {}; + const { content } = message; + + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + for (const part of content) { + if (!part) continue; + if (typeof part === 'string') { + return part; + } + if (typeof part.text === 'string') { + return part.text; + } + } + } + + if (typeof message.text === 'string') { + return message.text; + } + + return ''; +} + +function normalizeUsage(raw: any, latencyMs: number | undefined) { + if (!raw) { + return latencyMs ? { latencyMs } : undefined; + } + + const promptTokens = + raw.prompt_tokens ?? + raw.promptTokens ?? + raw.input_tokens ?? + raw.inputTokens; + const completionTokens = + raw.completion_tokens ?? + raw.completionTokens ?? + raw.output_tokens ?? + raw.outputTokens; + const totalTokens = + raw.total_tokens ?? raw.totalTokens ?? + (typeof promptTokens === 'number' && typeof completionTokens === 'number' + ? promptTokens + completionTokens + : undefined); + + return { + promptTokens, + completionTokens, + totalTokens, + latencyMs, + }; +} + +function buildMessages(params: ProviderGenerateParams, enableSearch: boolean): any[] { + const messages: any[] = []; + + if (enableSearch) { + messages.push({ + role: 'system', + content: + 'You can access current information by calling the web_search tool when needed. ' + + 'If the question requires up-to-date facts, call web_search. Otherwise answer directly.', + }); + } + + messages.push({ + role: 'user', + content: params.prompt, + }); + + return messages; +} + +function buildTools(enableSearch: boolean, braveApiKey?: string): any[] | undefined { + if (!enableSearch || !braveApiKey) return undefined; + return [ + { + type: 'function', + function: { + name: 'web_search', + description: 'Search the web for up-to-date information.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query.' }, + count: { + type: 'integer', + description: 'Number of results (1-5).', + }, + }, + required: ['query'], + }, + }, + }, + ]; +} + +function parseToolArguments(raw: unknown): Record { + if (!raw) return {}; + if (typeof raw === 'object') return raw as Record; + if (typeof raw !== 'string') return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function normalizeBraveResults(payload: any, maxResults: number) { + const items = Array.isArray(payload?.web?.results) + ? payload.web.results + : Array.isArray(payload?.results) + ? payload.results + : []; + + return items.slice(0, maxResults).map((item: any) => ({ + title: item?.title ?? item?.name ?? '', + url: item?.url ?? item?.link ?? '', + description: item?.description ?? item?.snippet ?? item?.summary ?? '', + age: item?.age ?? item?.page_age ?? undefined, + })); +} + +async function braveWebSearch( + braveApiKey: string, + query: string, + count: number, + signal?: AbortSignal +): Promise<{ query: string; results: Array<{ title: string; url: string; description: string; age?: string }> }> { + const url = new URL(BRAVE_SEARCH_BASE_URL); + url.searchParams.set('q', query); + url.searchParams.set('count', String(count)); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-Subscription-Token': braveApiKey, + }, + signal, + }); + + const text = await response.text(); + let parsed: any; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = undefined; + } + + if (!response.ok) { + const message = + parsed?.error?.message || + parsed?.message || + response.statusText || + 'Unknown error'; + throw new Error(`Brave Search error: ${message}`); + } + + return { + query, + results: normalizeBraveResults(parsed, count), + }; +} + +function extractToolCalls(choice: any): any[] { + const message = choice?.message ?? {}; + const toolCalls = message.tool_calls ?? message.toolCalls ?? []; + return Array.isArray(toolCalls) ? toolCalls : []; +} + +async function runToolCall( + toolCall: any, + braveApiKey?: string, + signal?: AbortSignal +): Promise<{ query?: string; results?: Array<{ title: string; url: string; description: string; age?: string }>; error?: string }> { + const name = toolCall?.function?.name; + if (name !== 'web_search') { + return { error: `Unsupported tool: ${name ?? 'unknown'}` }; + } + + if (!braveApiKey) { + return { error: 'Brave Search API key is not configured.' }; + } + + const args = parseToolArguments(toolCall?.function?.arguments); + const query = typeof args.query === 'string' ? args.query.trim() : ''; + if (!query) { + return { error: 'Missing search query.' }; + } + + const countRaw = typeof args.count === 'number' ? args.count : Number(args.count); + const count = Number.isFinite(countRaw) + ? clampNumber(countRaw, 1, 5) + : DEFAULT_SEARCH_COUNT; + + return braveWebSearch(braveApiKey, query, count, signal); +} + +// Get the appropriate model based on whether search is enabled +function getModelForRequest(params: ProviderGenerateParams, enableSearch: boolean): string { + // If user specified a model, use it + if (params.model) { + return params.model; + } + if (enableSearch) { + return process.env.DEEPSEEK_SEARCH_MODEL ?? DEFAULT_MODEL; + } + return DEFAULT_MODEL; +} + +function buildPayload(options: { + params: ProviderGenerateParams; + messages: any[]; + tools?: any[]; + includeResponseFormat: boolean; + model: string; +}): Record { + const { params, messages, tools, includeResponseFormat, model } = options; + + const payload: Record = { + model, + messages, + }; + + if (typeof params.temperature === 'number') { + payload.temperature = params.temperature; + } + if (typeof params.topP === 'number') { + payload.top_p = params.topP; + } + if (typeof params.maxOutputTokens === 'number') { + payload.max_tokens = params.maxOutputTokens; + } + + if (tools && tools.length > 0) { + payload.tools = tools; + payload.tool_choice = 'auto'; + } + + if (includeResponseFormat && params.zodSchema) { + payload.response_format = { type: 'json_object' }; + } + + return payload; +} + +export function createDeepSeekAdapter(): ProviderAdapter { + const apiKey = process.env.DEEPSEEK_API_KEY; + if (!apiKey) { + throw new Error( + 'DEEPSEEK_API_KEY is required to use the DeepSeek provider. Set the environment variable and try again.' + ); + } + + const baseUrl = + process.env.DEEPSEEK_API_BASE_URL?.replace(/\/$/, '') ?? 'https://api.deepseek.com'; + + // Check if web search is enabled + const enableSearch = process.env.DEEPSEEK_ENABLE_SEARCH === 'true'; + const braveApiKey = process.env.BRAVE_SEARCH_API_KEY; + const canUseSearchTools = enableSearch && !!braveApiKey; + + if (enableSearch && !braveApiKey) { + console.warn( + '[DeepSeek] DEEPSEEK_ENABLE_SEARCH is true but BRAVE_SEARCH_API_KEY is missing. Search tools disabled.' + ); + } + + return { + name: PROVIDER_NAME, + defaultModel: DEFAULT_MODEL, + async generate(params: ProviderGenerateParams): Promise { + const { controller, clear } = buildAbortController(params.timeoutMs); + const requestStartedAt = Date.now(); + + try { + const resolvedModel = getModelForRequest(params, enableSearch); + const messages = buildMessages(params, canUseSearchTools); + const tools = buildTools(canUseSearchTools, braveApiKey); + let toolUsed = false; + let allowResponseFormat = !!params.zodSchema; + + for (let step = 0; step <= MAX_TOOL_STEPS; step += 1) { + const payload = buildPayload({ + params, + messages, + tools, + includeResponseFormat: allowResponseFormat, + model: resolvedModel, + }); + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller?.signal, + }); + + const responseText = await response.text(); + let parsed: any; + + try { + parsed = responseText ? JSON.parse(responseText) : undefined; + } catch (parseError) { + console.error('[DeepSeek] Failed to parse JSON response', parseError); + throw new Error('DeepSeek API returned a non-JSON response.'); + } + + if (!response.ok) { + const message = + parsed?.error?.message || + parsed?.message || + response.statusText || + 'Unknown error'; + const code = parsed?.error?.code || parsed?.code; + const lowerMessage = String(message).toLowerCase(); + + if ( + allowResponseFormat && + (lowerMessage.includes('response_format') || + lowerMessage.includes('json_object') || + lowerMessage.includes('json_schema')) + ) { + console.warn( + '[DeepSeek] response_format rejected; retrying without response_format.' + ); + allowResponseFormat = false; + continue; + } + + throw new Error( + `DeepSeek API error${code ? ` (${code})` : ''}: ${message}` + ); + } + + const choice = Array.isArray(parsed?.choices) + ? parsed.choices[0] + : undefined; + + const toolCalls = extractToolCalls(choice); + if (toolCalls.length > 0) { + toolUsed = true; + + messages.push({ + role: 'assistant', + content: choice?.message?.content ?? null, + tool_calls: toolCalls, + }); + + const toolResults = await Promise.all( + toolCalls.map((call: any) => runToolCall(call, braveApiKey, controller?.signal)) + ); + + toolResults.forEach((result, index) => { + const call = toolCalls[index]; + messages.push({ + role: 'tool', + tool_call_id: call?.id ?? call?.function?.name ?? `web_search_${index}`, + content: JSON.stringify(result), + }); + }); + + continue; + } + + const latencyMs = Date.now() - requestStartedAt; + const content = extractTextFromChoice(choice); + + if (!content) { + throw new Error('DeepSeek API returned an empty response.'); + } + + return { + content, + rawResponse: parsed, + provider: PROVIDER_NAME, + model: parsed?.model ?? resolvedModel, + usage: normalizeUsage(parsed?.usage, latencyMs), + }; + } + + throw new Error('DeepSeek API returned no final response after tool calls.'); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('DeepSeek request timed out.'); + } + throw error; + } finally { + clear(); + } + }, + }; +} diff --git a/lib/ai-providers/registry.ts b/lib/ai-providers/registry.ts index 92926e36..67eb90b1 100644 --- a/lib/ai-providers/registry.ts +++ b/lib/ai-providers/registry.ts @@ -1,31 +1,33 @@ import { createGeminiAdapter } from './gemini-adapter'; import { createGrokAdapter } from './grok-adapter'; +import { createDeepSeekAdapter } from './deepseek-adapter'; import type { ProviderAdapter, ProviderGenerateParams, ProviderGenerateResult } from './types'; -type ProviderKey = 'grok' | 'gemini'; +type ProviderKey = 'grok' | 'gemini' | 'deepseek'; type ProviderFactory = () => ProviderAdapter; const providerFactories: Record = { grok: createGrokAdapter, gemini: createGeminiAdapter, + deepseek: createDeepSeekAdapter, }; const providerEnvGuards: Record string | undefined> = { grok: () => process.env.XAI_API_KEY, gemini: () => process.env.GEMINI_API_KEY, + deepseek: () => process.env.DEEPSEEK_API_KEY, }; const providerCache: Partial> = {}; -function resolveProviderKey(preferred?: string): ProviderKey { - const envPreference = - preferred ?? - process.env.AI_PROVIDER ?? - process.env.NEXT_PUBLIC_AI_PROVIDER; +function resolveProviderKey(preferred?: string, cookieProvider?: string): ProviderKey { + // Priority: cookie > preferred parameter > env > first available + const resolvedPreference = + cookieProvider || preferred || process.env.AI_PROVIDER || process.env.NEXT_PUBLIC_AI_PROVIDER; - if (envPreference && envPreference in providerFactories) { - return envPreference as ProviderKey; + if (resolvedPreference && resolvedPreference in providerFactories) { + return resolvedPreference as ProviderKey; } if (providerEnvGuards.grok()) { @@ -80,14 +82,42 @@ function isRetryableError(error: unknown): boolean { if (!error) return false; const message = error instanceof Error ? error.message : String(error); const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes('service unavailable') || - lowerMessage.includes('503') || - lowerMessage.includes('502') || - lowerMessage.includes('504') || - lowerMessage.includes('timeout') || - lowerMessage.includes('overload') - ); + + // HTTP status codes + if (lowerMessage.includes('503') || + lowerMessage.includes('502') || + lowerMessage.includes('504') || + lowerMessage.includes('429')) { + return true; + } + + // Service errors + if (lowerMessage.includes('service unavailable') || + lowerMessage.includes('timeout') || + lowerMessage.includes('overload') || + lowerMessage.includes('rate limit') || + lowerMessage.includes('too many requests') || + lowerMessage.includes('temporarily unavailable') || + lowerMessage.includes('connection error') || + lowerMessage.includes('econnreset') || + lowerMessage.includes('etimedout') || + lowerMessage.includes('socket hang up')) { + return true; + } + + // Configuration errors that might work with another provider + if (lowerMessage.includes('api key') && + (lowerMessage.includes('invalid') || lowerMessage.includes('expired') || lowerMessage.includes('revoked'))) { + return true; + } + + // JSON parsing errors (model might return invalid JSON) + if (lowerMessage.includes('json') && + (lowerMessage.includes('parse') || lowerMessage.includes('invalid') || lowerMessage.includes('unexpected'))) { + return true; + } + + return false; } function getFallbackProvider(currentKey: ProviderKey): ProviderKey | null { diff --git a/lib/email/templates/monthly-update.ts b/lib/email/templates/monthly-update.ts index 3e30d2c7..53554bfa 100644 --- a/lib/email/templates/monthly-update.ts +++ b/lib/email/templates/monthly-update.ts @@ -1,4 +1,4 @@ -export const getSubject = () => "TLDW is now LongCut + My favorite videos from 2025"; +export const getSubject = () => "TLDW is now Little universe + My favorite videos from 2025"; export const getHtmlBody = (unsubscribeUrl: string) => ` @@ -64,11 +64,11 @@ export const getHtmlBody = (unsubscribeUrl: string) => `

Hey!

-

This is Zara, one of the people behind TLDW. First, a big announcement: we renamed it to LongCut. Our new tagline is "Don't take the shortcut in your learning; take the longcut." We felt like the new name reflects our philosophy better. Plus, there were way too many products called TLDW.

+

This is Zara, one of the people behind TLDW. First, a big announcement: we renamed it to Little universe. Our new tagline is "Don't take the shortcut in your learning; take the longcut." We felt like the new name reflects our philosophy better. Plus, there were way too many products called TLDW.

I wanted to share some features we've shipped recently based on your feedback:

-

Multilingual videos & translation: You asked, we delivered. LongCut now parses transcripts for videos in any language (as long as there are subtitles on YouTube). You can also translate transcripts and view them side by side, which is great for language learning.

+

Multilingual videos & translation: You asked, we delivered. Little universe now parses transcripts for videos in any language (as long as there are subtitles on YouTube). You can also translate transcripts and view them side by side, which is great for language learning.

Multilingual videos and transcript translation feature @@ -84,7 +84,7 @@ export const getHtmlBody = (unsubscribeUrl: string) => `

Flexible payment: We added a credit-based pay-as-you-go option if you don't want a subscription.

-

I also wanted to share my 5 favorite video podcasts from 2025 (all parsed on LongCut if you want to check them out):

+

I also wanted to share my 5 favorite video podcasts from 2025 (all parsed on Little universe if you want to check them out):

  1. Helping Founders Go Direct in a New Era of PR & Comms with Lulu Cheng Meservey (Uncapped with Jack Altman)
  2. diff --git a/lib/email/templates/welcome.ts b/lib/email/templates/welcome.ts index ece8127b..06291195 100644 --- a/lib/email/templates/welcome.ts +++ b/lib/email/templates/welcome.ts @@ -1,4 +1,4 @@ -export const getWelcomeSubject = () => "Welcome to LongCut"; +export const getWelcomeSubject = () => "Welcome to Little universe"; export const getWelcomeHtmlBody = (fullName?: string | null) => { const greeting = fullName ? `Hey ${fullName},` : 'Hey,'; @@ -52,14 +52,14 @@ export const getWelcomeHtmlBody = (fullName?: string | null) => {

    ${greeting}

    -

    I'm Zara, one of the creators of LongCut. I just wanted to say thanks for signing up.

    +

    I'm Zara, one of the creators of Little universe. I just wanted to say thanks for signing up.

    -

    We built LongCut to help you learn better from long YouTube videos. Here's a video walking through how to use it.

    +

    We built Little universe to help you learn better from long YouTube videos. Here's a video walking through how to use it.

    If you have a second, I would love to hear from you on:

      -
    1. What are you using LongCut for
    2. +
    3. What are you using Little universe for
    4. What's your favorite feature
    5. Any suggestions/feedback
    @@ -72,7 +72,7 @@ export const getWelcomeHtmlBody = (fullName?: string | null) => { diff --git a/lib/hooks/use-local-notes.ts b/lib/hooks/use-local-notes.ts new file mode 100644 index 00000000..7fd7150f --- /dev/null +++ b/lib/hooks/use-local-notes.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +const NOTES_STORAGE_PREFIX = "longcut-notes-"; + +export interface LocalNote { + id: string; + text: string; + timestamp?: number; // Video timestamp in seconds + thumbnailUrl?: string; // Video thumbnail at that moment + createdAt: number; + updatedAt: number; + source?: "manual" | "selection" | "ai"; + selectedText?: string; // Original text if from selection +} + +interface UseLocalNotesOptions { + videoId: string; +} + +export function useLocalNotes({ videoId }: UseLocalNotesOptions) { + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const storageKey = `${NOTES_STORAGE_PREFIX}${videoId}`; + + // Load notes from localStorage + useEffect(() => { + if (!videoId) { + setNotes([]); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + setNotes(JSON.parse(stored)); + } else { + setNotes([]); + } + } catch { + setNotes([]); + } + setIsLoading(false); + }, [videoId, storageKey]); + + // Save notes to localStorage + const saveToStorage = useCallback( + (updatedNotes: LocalNote[]) => { + localStorage.setItem(storageKey, JSON.stringify(updatedNotes)); + }, + [storageKey] + ); + + // Add a new note + const addNote = useCallback( + (noteData: Omit) => { + const now = Date.now(); + const newNote: LocalNote = { + ...noteData, + id: `note-${now}-${Math.random().toString(36).substr(2, 9)}`, + createdAt: now, + updatedAt: now, + }; + + setNotes((prev) => { + const updated = [newNote, ...prev]; + saveToStorage(updated); + return updated; + }); + + return newNote; + }, + [saveToStorage] + ); + + // Update an existing note + const updateNote = useCallback( + (noteId: string, updates: Partial>) => { + setNotes((prev) => { + const updated = prev.map((note) => + note.id === noteId + ? { ...note, ...updates, updatedAt: Date.now() } + : note + ); + saveToStorage(updated); + return updated; + }); + }, + [saveToStorage] + ); + + // Delete a note + const deleteNote = useCallback( + (noteId: string) => { + setNotes((prev) => { + const updated = prev.filter((note) => note.id !== noteId); + saveToStorage(updated); + return updated; + }); + }, + [saveToStorage] + ); + + // Clear all notes for this video + const clearNotes = useCallback(() => { + localStorage.removeItem(storageKey); + setNotes([]); + }, [storageKey]); + + // Create a note with "screenshot" (timestamp + thumbnail) + const addNoteWithScreenshot = useCallback( + ( + text: string, + currentTime: number, + videoThumbnail: string, + source: LocalNote["source"] = "manual" + ) => { + return addNote({ + text, + timestamp: currentTime, + thumbnailUrl: videoThumbnail, + source, + }); + }, + [addNote] + ); + + return { + notes, + isLoading, + addNote, + updateNote, + deleteNote, + clearNotes, + addNoteWithScreenshot, + }; +} + +// Helper to get thumbnail URL for a YouTube video +export function getYouTubeThumbnail( + videoId: string, + quality: "default" | "medium" | "high" | "maxres" = "medium" +): string { + const qualityMap = { + default: "default", + medium: "mqdefault", + high: "hqdefault", + maxres: "maxresdefault", + }; + return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}.jpg`; +} diff --git a/lib/hooks/use-transcript-export.ts b/lib/hooks/use-transcript-export.ts index c145c8f1..774dfe1a 100644 --- a/lib/hooks/use-transcript-export.ts +++ b/lib/hooks/use-transcript-export.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { createTranscriptExport, type TranscriptExportFormat, type TranscriptExportMode } from '@/lib/transcript-export'; -import { isProSubscriptionActive, type SubscriptionStatusResponse } from './use-subscription'; +// MVP: Subscription checks removed - all logged-in users can export import type { TranscriptSegment, Topic, VideoInfo, TranslationRequestHandler } from '@/lib/types'; import type { BulkTranslationHandler } from './use-translation'; @@ -13,9 +13,7 @@ interface UseTranscriptExportOptions { videoInfo: VideoInfo | null; user: any; hasSpeakerData: boolean; - subscriptionStatus: SubscriptionStatusResponse | null; - isCheckingSubscription: boolean; - fetchSubscriptionStatus: (options?: { force?: boolean }) => Promise; + // MVP: Subscription props removed onAuthRequired: () => void; onRequestTranslation: TranslationRequestHandler; onBulkTranslation: BulkTranslationHandler; @@ -29,9 +27,7 @@ export function useTranscriptExport({ videoInfo, user, hasSpeakerData, - subscriptionStatus, - isCheckingSubscription, - fetchSubscriptionStatus, + // MVP: Subscription destructuring removed onAuthRequired, onBulkTranslation, translationCache, @@ -86,30 +82,7 @@ export function useTranscriptExport({ return; } - const status = await fetchSubscriptionStatus(); - if (!status) { - return; - } - - if (status.tier !== 'pro') { - setShowExportUpsell(true); - return; - } - - if (!isProSubscriptionActive(status)) { - setExportDisableMessage('Your subscription is not active. Visit billing to reactivate and continue exporting transcripts.'); - setExportErrorMessage(null); - setIsExportDialogOpen(true); - return; - } - - if (status.usage.totalRemaining <= 0) { - setExportDisableMessage("You've hit your export limit. Purchase a top-up or wait for your cycle reset."); - setExportErrorMessage(null); - setIsExportDialogOpen(true); - return; - } - + // MVP: Allow all logged-in users to export without subscription checks setExportDisableMessage(null); setExportErrorMessage(null); setIsExportDialogOpen(true); @@ -118,7 +91,6 @@ export function useTranscriptExport({ transcript.length, user, onAuthRequired, - fetchSubscriptionStatus, ]); const handleConfirmExport = useCallback(async () => { @@ -127,27 +99,7 @@ export function useTranscriptExport({ return; } - const status = await fetchSubscriptionStatus(); - if (!status) { - setExportErrorMessage('Unable to verify your subscription. Please try again.'); - return; - } - - if (status.tier !== 'pro') { - setShowExportUpsell(true); - setIsExportDialogOpen(false); - return; - } - - if (!isProSubscriptionActive(status)) { - setExportDisableMessage('Your subscription is not active. Visit billing to reactivate and continue exporting transcripts.'); - return; - } - - if (status.usage.totalRemaining <= 0) { - setExportDisableMessage("You've hit your export limit. Purchase a top-up or wait for your cycle reset."); - return; - } + // MVP: Skip subscription checks - allow all logged-in users to export setIsExportingTranscript(true); setExportErrorMessage(null); @@ -163,12 +115,12 @@ export function useTranscriptExport({ const segmentsToTranslate: { index: number; text: string }[] = []; transcript.forEach((segment, index) => { - const cacheKey = `transcript:${index}:${targetLanguage}`; - if (translationCache.has(cacheKey)) { - translations[index] = translationCache.get(cacheKey)!; - } else { - segmentsToTranslate.push({ index, text: segment.text }); - } + const cacheKey = `transcript:${index}:${targetLanguage}`; + if (translationCache.has(cacheKey)) { + translations[index] = translationCache.get(cacheKey)!; + } else { + segmentsToTranslate.push({ index, text: segment.text }); + } }); // 2. Request missing translations using bulk API for faster processing @@ -215,7 +167,7 @@ export function useTranscriptExport({ toast.success('Transcript export started'); setIsExportDialogOpen(false); setExportDisableMessage(null); - await fetchSubscriptionStatus({ force: true }); + // MVP: Subscription status refresh removed } catch (error) { console.error('Transcript export failed:', error); const message = @@ -227,7 +179,7 @@ export function useTranscriptExport({ } }, [ transcript, - fetchSubscriptionStatus, + exportFormat, exportMode, targetLanguage, @@ -260,42 +212,13 @@ export function useTranscriptExport({ }; } - if (isCheckingSubscription) { - return { - disabled: true, - isLoading: true, - tooltip: 'Checking export availability…', - }; - } - + // MVP: Only require login, no subscription checks if (!user) { return { - badgeLabel: 'Pro', tooltip: 'Sign in to export transcripts', }; } - if (subscriptionStatus && subscriptionStatus.tier !== 'pro') { - return { - badgeLabel: 'Pro', - tooltip: 'Upgrade to Pro to export transcripts', - }; - } - - if (subscriptionStatus && !isProSubscriptionActive(subscriptionStatus)) { - return { - badgeLabel: 'Pro', - tooltip: 'Reactivate your subscription to export transcripts', - }; - } - - if (subscriptionStatus && subscriptionStatus.usage.totalRemaining <= 0) { - return { - badgeLabel: 'Pro', - tooltip: "You've hit your export limit. Purchase a top-up or wait for reset.", - }; - } - return { tooltip: 'Export transcript', }; @@ -303,9 +226,7 @@ export function useTranscriptExport({ videoId, transcript.length, isExportingTranscript, - isCheckingSubscription, user, - subscriptionStatus, ]); return { diff --git a/lib/local-notes.ts b/lib/local-notes.ts new file mode 100644 index 00000000..1425de0f --- /dev/null +++ b/lib/local-notes.ts @@ -0,0 +1,206 @@ +import { Note, NoteSource, NoteMetadata } from './types'; + +const STORAGE_KEY = 'longcut_local_notes'; +const STORAGE_VERSION = 1; + +interface LocalNoteData { + version: number; + notes: LocalNote[]; +} + +export interface LocalNote { + id: string; + youtubeId: string; + source: NoteSource; + sourceId?: string; + text: string; + metadata?: NoteMetadata; + createdAt: string; + updatedAt: string; + synced: boolean; // Whether successfully synced to cloud +} + +// Get all local notes for a specific video +export function getLocalNotes(youtubeId: string): LocalNote[] { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return []; + + const parsed: LocalNoteData = JSON.parse(data); + + // Version migration if needed + if (parsed.version !== STORAGE_VERSION) { + migrateStorage(parsed); + } + + return parsed.notes.filter(note => note.youtubeId === youtubeId); + } catch (error) { + console.error('Error reading local notes:', error); + return []; + } +} + +// Get a single local note by ID +export function getLocalNote(noteId: string): LocalNote | null { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return null; + + const parsed: LocalNoteData = JSON.parse(data); + return parsed.notes.find(note => note.id === noteId) || null; + } catch (error) { + console.error('Error reading local note:', error); + return null; + } +} + +// Save a note to localStorage +export function saveLocalNote( + youtubeId: string, + source: NoteSource, + text: string, + metadata?: NoteMetadata, + sourceId?: string, + existingNoteId?: string +): LocalNote { + try { + const data = localStorage.getItem(STORAGE_KEY); + const parsed: LocalNoteData = data ? JSON.parse(data) : { version: STORAGE_VERSION, notes: [] }; + + const now = new Date().toISOString(); + + if (existingNoteId) { + // Update existing note + const noteIndex = parsed.notes.findIndex(n => n.id === existingNoteId); + if (noteIndex !== -1) { + parsed.notes[noteIndex] = { + ...parsed.notes[noteIndex], + text, + metadata, + updatedAt: now, + synced: false, // Mark as needing re-sync + }; + } + } else { + // Create new note + const newNote: LocalNote = { + id: `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + youtubeId, + source, + sourceId, + text, + metadata, + createdAt: now, + updatedAt: now, + synced: false, + }; + parsed.notes.push(newNote); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + + // Return the saved note + const savedNote = parsed.notes.find(n => + existingNoteId ? n.id === existingNoteId : + n.youtubeId === youtubeId && n.source === source && n.createdAt === now + ); + + if (!savedNote) { + throw new Error('Failed to retrieve saved note'); + } + + return savedNote; + } catch (error) { + console.error('Error saving local note:', error); + throw new Error('Failed to save note locally'); + } +} + +// Delete a note from localStorage +export function deleteLocalNote(noteId: string): boolean { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return false; + + const parsed: LocalNoteData = JSON.parse(data); + const initialLength = parsed.notes.length; + parsed.notes = parsed.notes.filter(note => note.id !== noteId); + + if (parsed.notes.length < initialLength) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + return true; + } + + return false; + } catch (error) { + console.error('Error deleting local note:', error); + return false; + } +} + +// Mark a note as synced to cloud +export function markNoteSynced(noteId: string, cloudNoteId: string): void { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return; + + const parsed: LocalNoteData = JSON.parse(data); + const note = parsed.notes.find(n => n.id === noteId); + + if (note) { + note.synced = true; + // Store the cloud note ID for future reference + note.metadata = { + ...note.metadata, + cloudNoteId, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + } + } catch (error) { + console.error('Error marking note as synced:', error); + } +} + +// Get all unsynced notes +export function getUnsyncedNotes(): LocalNote[] { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return []; + + const parsed: LocalNoteData = JSON.parse(data); + return parsed.notes.filter(note => !note.synced); + } catch (error) { + console.error('Error reading unsynced notes:', error); + return []; + } +} + +// Clear all local notes (useful for testing) +export function clearLocalNotes(): void { + localStorage.removeItem(STORAGE_KEY); +} + +// Storage migration for future versions +function migrateStorage(parsed: LocalNoteData): void { + // Handle version migrations here if needed + parsed.version = STORAGE_VERSION; +} + +// Get storage usage info +export function getStorageInfo(): { used: number; total: number; noteCount: number } { + try { + const data = localStorage.getItem(STORAGE_KEY); + const used = data ? new Blob([data]).size : 0; + const total = 5 * 1024 * 1024; // 5MB typical localStorage limit + + const parsed: LocalNoteData = data ? JSON.parse(data) : { version: STORAGE_VERSION, notes: [] }; + + return { + used, + total, + noteCount: parsed.notes.length, + }; + } catch (error) { + return { used: 0, total: 5 * 1024 * 1024, noteCount: 0 }; + } +} diff --git a/lib/markdown-exporter.ts b/lib/markdown-exporter.ts new file mode 100644 index 00000000..50f0bc32 --- /dev/null +++ b/lib/markdown-exporter.ts @@ -0,0 +1,224 @@ +import { LocalNote } from './local-notes'; +import { VideoInfo, Topic } from './types'; +import { formatDuration } from './utils'; + +export interface ExportData { + videoInfo: VideoInfo; + notes: LocalNote[]; + topics?: Topic[]; + exportDate: string; +} + +export function generateMarkdown(data: ExportData): string { + const { videoInfo, notes, topics, exportDate } = data; + const lines: string[] = []; + + // Header + lines.push(`# 📹 视频笔记:${videoInfo.title || '未命名视频'}`); + lines.push(''); + + // Metadata section + lines.push('---'); + lines.push(''); + lines.push('## 📋 视频信息'); + lines.push(''); + lines.push(`| 项目 | 内容 |`); + lines.push(`|------|------|`); + lines.push(`| **🎬 视频标题** | ${videoInfo.title || 'N/A'} |`); + lines.push(`| **👤 作者/频道** | ${videoInfo.author || 'N/A'} |`); + lines.push(`| **⏱️ 总时长** | ${formatDuration(videoInfo.duration || 0)} |`); + lines.push(`| **🔗 视频链接** | [https://youtube.com/watch?v=${videoInfo.videoId}](https://youtube.com/watch?v=${videoInfo.videoId}) |`); + lines.push(`| **📝 导出时间** | ${new Date(exportDate).toLocaleString('zh-CN')} |`); + lines.push(`| **📌 笔记数量** | ${notes.length} 条 |`); + lines.push(''); + + // Group notes by source + const transcriptNotes = notes.filter(n => n.source === 'transcript'); + const chatNotes = notes.filter(n => n.source === 'chat'); + const customNotes = notes.filter(n => n.source === 'custom'); + const takeawayNotes = notes.filter(n => n.source === 'takeaways'); + + // Notes section + if (notes.length > 0) { + lines.push('---'); + lines.push(''); + lines.push('## 📌 我的笔记'); + lines.push(''); + + // Transcript quotes + if (transcriptNotes.length > 0) { + lines.push('### 🎬 来自视频片段'); + lines.push(''); + + transcriptNotes.forEach((note, index) => { + const timestamp = note.metadata?.transcript?.start + ? `(${formatDuration(note.metadata.transcript.start)})` + : ''; + const segmentStart = note.metadata?.transcript?.end + ? ` [${formatDuration(note.metadata.transcript.end)}]` + : ''; + + lines.push(`#### ${index + 1}. ${timestamp}${segmentStart}`); + + if (note.metadata?.originalText) { + lines.push(''); + lines.push(`> ${note.metadata.originalText}`); + lines.push(''); + } + + if (note.text) { + lines.push(note.text); + lines.push(''); + } + + lines.push('---'); + lines.push(''); + }); + } + + // Chat notes + if (chatNotes.length > 0) { + lines.push('### 💬 来自 AI 对话'); + lines.push(''); + + chatNotes.forEach((note, index) => { + const question = note.metadata?.question || '问题'; + lines.push(`**Q:** ${question}`); + lines.push(''); + lines.push(`**A:** ${note.text}`); + lines.push(''); + lines.push('---'); + lines.push(''); + }); + } + + // Custom notes + if (customNotes.length > 0) { + lines.push('### ✏️ 自定义笔记'); + lines.push(''); + + customNotes.forEach((note, index) => { + const timeStr = note.metadata?.transcript?.start + ? `[${formatDuration(note.metadata.transcript.start)}]` + : `[${new Date(note.createdAt).toLocaleString('zh-CN')}]`; + + lines.push(`#### ${index + 1}. ${timeStr}`); + lines.push(''); + lines.push(note.text); + lines.push(''); + lines.push('---'); + lines.push(''); + }); + } + + // Takeaways + if (takeawayNotes.length > 0) { + lines.push('### 🎯 关键要点'); + lines.push(''); + + takeawayNotes.forEach((note, index) => { + lines.push(`${index + 1}. ${note.text}`); + }); + lines.push(''); + } + } + + // Topics section (if available) + if (topics && topics.length > 0) { + lines.push('---'); + lines.push(''); + lines.push('## 🏷️ 话题回顾'); + lines.push(''); + + const colorLabels: Record = { + '#FF8A80': '🌸 珊瑚色', + '#80CBC4': '🌿 薄荷绿', + '#F48FB1': '🌺 浅粉色', + '#B39DDB': '💜 淡紫色', + '#81D4FA': '💙 浅蓝色', + }; + + topics.forEach((topic, index) => { + const color = (topic as any).color || '#81D4FA'; + const label = colorLabels[color as keyof typeof colorLabels] || '📌'; + + lines.push(`${index + 1}. **${label} [${formatDuration(topic.segments[0]?.start || 0)} - ${formatDuration(topic.segments[topic.segments.length - 1]?.end || 0)}]** ${topic.title}`); + + if (topic.quote) { + lines.push(` > "${topic.quote.text}"`); + } + + if (topic.keywords && topic.keywords.length > 0) { + lines.push(` *关键词: ${topic.keywords.join(', ')}*`); + } + + lines.push(''); + }); + } + + // Summary section (if available in videoInfo) + if (videoInfo.description) { + lines.push('---'); + lines.push(''); + lines.push('## 📝 视频简介'); + lines.push(''); + lines.push(videoInfo.description); + lines.push(''); + } + + // Footer + lines.push('---'); + lines.push(''); + lines.push('*📤 本笔记由 [Little universe](https://github.com/yourusername/longcut) 自动生成*'); + + return lines.join('\n'); +} + +export function downloadMarkdownFile(markdown: string, filename: string): void { + // Create blob with markdown content + const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }); + + // Create download link + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + + // Trigger download + document.body.appendChild(link); + link.click(); + + // Cleanup + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export function generateNoteFilename(videoInfo: VideoInfo): string { + // Sanitize video title for filename + const sanitizedTitle = (videoInfo.title || 'video') + .replace(/[<>:"/\\|?*]/g, '') // Remove invalid filename chars + .replace(/\s+/g, '_') // Replace spaces with underscores + .substring(0, 50); // Limit length + + const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + return `Little universe_笔记_${sanitizedTitle}_${date}.md`; +} + +// Export notes with all data +export async function exportNotesToMarkdown( + videoInfo: VideoInfo, + notes: LocalNote[], + topics?: Topic[] +): Promise { + const exportData: ExportData = { + videoInfo, + notes, + topics, + exportDate: new Date().toISOString(), + }; + + const markdown = generateMarkdown(exportData); + const filename = generateNoteFilename(videoInfo); + + downloadMarkdownFile(markdown, filename); +} diff --git a/lib/notes-client.ts b/lib/notes-client.ts index d7273500..a6a3466c 100644 --- a/lib/notes-client.ts +++ b/lib/notes-client.ts @@ -1,5 +1,12 @@ import { csrfFetch } from '@/lib/csrf-client'; import { Note, NoteMetadata, NoteSource, NoteWithVideo } from '@/lib/types'; +import { + saveLocalNote, + getLocalNotes, + deleteLocalNote, + markNoteSynced, + type LocalNote +} from '@/lib/local-notes'; interface SaveNotePayload { youtubeId: string; @@ -8,36 +15,157 @@ interface SaveNotePayload { sourceId?: string; text: string; metadata?: NoteMetadata; + existingNoteId?: string; // For editing existing notes } -export async function fetchNotes(params: { youtubeId: string }): Promise { - const query = new URLSearchParams(); - query.set('youtubeId', params.youtubeId); +/** + * Convert LocalNote to Note format + */ +function localToNote(localNote: LocalNote): Note { + return { + id: localNote.id, + userId: 'local', + videoId: localNote.youtubeId, + source: localNote.source, + sourceId: localNote.sourceId, + text: localNote.text, + metadata: localNote.metadata, + createdAt: localNote.createdAt, + updatedAt: localNote.updatedAt, + }; +} - const response = await csrfFetch.get(`/api/notes?${query.toString()}`); +/** + * Merge local and cloud notes, removing duplicates + */ +function mergeNotes(localNotes: LocalNote[], cloudNotes: Note[]): Note[] { + const noteMap = new Map(); - if (!response.ok) { - throw new Error('Failed to fetch notes'); + // Add cloud notes first + cloudNotes.forEach(note => { + noteMap.set(note.id, note); + }); + + // Override/add local notes (local takes precedence) + localNotes.forEach(localNote => { + noteMap.set(localNote.id, localToNote(localNote)); + }); + + // Convert to array and sort by updated time + return Array.from(noteMap.values()).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); +} + +/** + * Fetch notes - combines local and cloud notes + */ +export async function fetchNotes(params: { youtubeId: string }): Promise { + // Get local notes first + const localNotes = getLocalNotes(params.youtubeId); + + // Try to fetch cloud notes + try { + const query = new URLSearchParams(); + query.set('youtubeId', params.youtubeId); + + const response = await csrfFetch.get(`/api/notes?${query.toString()}`); + + if (response.ok) { + const data = await response.json(); + const cloudNotes = (data.notes || []) as Note[]; + + // Merge local and cloud notes + // Local notes take precedence for unsynced notes + const mergedNotes = mergeNotes(localNotes, cloudNotes); + return mergedNotes; + } + } catch (error) { + console.warn('Failed to fetch cloud notes, using local only:', error); } - const data = await response.json(); - return (data.notes || []) as Note[]; + // Return only local notes if cloud fetch failed + return localNotes.map(localToNote); } +/** + * Save note - saves to local first, then tries to sync to cloud + */ export async function saveNote(payload: SaveNotePayload): Promise { - const response = await csrfFetch.post('/api/notes', payload); + const { youtubeId, source, text, metadata, sourceId, existingNoteId } = payload; + + // Step 1: Always save to localStorage first (immediate success) + const localNote = saveLocalNote( + youtubeId, + source, + text, + metadata, + sourceId, + existingNoteId + ); + + // Step 2: Try to sync to cloud in background (non-blocking) + syncToCloud(payload, localNote.id).catch((error) => { + console.warn('Failed to sync note to cloud:', error); + // Note remains in localStorage, can be synced later + }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error?.error || 'Failed to save note'); + // Return immediately with local note + return localToNote(localNote); +} + +/** + * Background sync to cloud + */ +async function syncToCloud(payload: SaveNotePayload, localNoteId: string): Promise { + try { + const response = await csrfFetch.post('/api/notes', payload); + + if (response.ok) { + const data = await response.json(); + const cloudNote = data.note as Note; + + // Mark local note as synced + markNoteSynced(localNoteId, cloudNote.id); + } else { + const error = await response.json().catch(() => ({})); + throw new Error(error?.error || 'Failed to sync to cloud'); + } + } catch (error) { + // Don't throw - local save already succeeded + console.warn('Cloud sync failed, keeping local note:', error); } +} - const data = await response.json(); - return data.note as Note; +/** + * Delete note - removes from both local and cloud + */ +export async function deleteNote(noteId: string): Promise { + // Delete from local first + deleteLocalNote(noteId); + + // Try to delete from cloud + try { + const response = await csrfFetch.delete('/api/notes', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ noteId }), + }); + + if (!response.ok) { + console.warn('Failed to delete note from cloud:', await response.text()); + } + } catch (error) { + console.warn('Cloud delete failed:', error); + } } export async function enhanceNoteQuote(quote: string): Promise { - const response = await csrfFetch.post('/api/notes/enhance', { quote }); + // Use regular fetch instead of csrfFetch for anonymous access + const response = await fetch('/api/notes/enhance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quote }), + }); if (!response.ok) { const error = await response.json().catch(() => ({})); @@ -54,18 +182,6 @@ export async function enhanceNoteQuote(quote: string): Promise { return cleanedText; } -export async function deleteNote(noteId: string): Promise { - const response = await csrfFetch.delete('/api/notes', { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ noteId }) - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error(error?.error || 'Failed to delete note'); - } -} - export async function fetchAllNotes(): Promise { const response = await csrfFetch.get('/api/notes/all'); @@ -76,3 +192,6 @@ export async function fetchAllNotes(): Promise { const data = await response.json(); return (data.notes || []) as NoteWithVideo[]; } + +// Export types for use in components +export type { LocalNote }; diff --git a/lib/rate-limiter.ts b/lib/rate-limiter.ts index d95b1d1d..f233d083 100644 --- a/lib/rate-limiter.ts +++ b/lib/rate-limiter.ts @@ -118,10 +118,11 @@ export class RateLimiter { const windowStart = now - config.windowMs; try { - // First, clean up old entries + // First, clean up old entries for this specific key only (not full table) await supabase .from('rate_limits') .delete() + .eq('key', rateLimitKey) .lt('timestamp', new Date(windowStart).toISOString()); // Count recent requests @@ -211,10 +212,10 @@ export const RATE_LIMITS = { maxRequests: 10 // 10 messages per minute }, - // Authenticated users (legacy - kept for backwards compatibility) + // Authenticated users - MVP: Very permissive limits AUTH_GENERATION: { - windowMs: 60 * 60 * 1000, // 1 hour - maxRequests: 20 // 20 generations per hour + windowMs: 60 * 1000, // 1 minute + maxRequests: 500 // MVP: Very high limit for development }, AUTH_VIDEO_GENERATION: { windowMs: 24 * 60 * 60 * 1000, // 24 hours diff --git a/lib/security-middleware.ts b/lib/security-middleware.ts index 3b95a41c..7a012ede 100644 --- a/lib/security-middleware.ts +++ b/lib/security-middleware.ts @@ -68,8 +68,11 @@ export function withSecurity( // 3. Apply rate limiting if (config.rateLimit) { + // Use pathname only (without query string) for consistent rate limiting + const url = new URL(req.url); + const rateLimitKey = url.pathname; const rateLimitResult = await RateLimiter.check( - req.url, + rateLimitKey, config.rateLimit ); @@ -166,7 +169,7 @@ export function withSecurity( */ function isAllowedOrigin(origin: string): boolean { const allowedOrigins = [ - process.env.NEXT_PUBLIC_BASE_URL, + process.env.NEXT_PUBLIC_APP_URL, 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002' diff --git a/lib/stripe-client.ts b/lib/stripe-client.ts index 048c6be7..14913ec5 100644 --- a/lib/stripe-client.ts +++ b/lib/stripe-client.ts @@ -2,53 +2,61 @@ import Stripe from 'stripe'; /** * Lazily instantiated Stripe client for server-side operations - * This avoids hard failures during build/deploy when secrets are missing - * yet still surfaces a descriptive error the moment Stripe is actually used. + * Returns null if Stripe is not configured (payments disabled) */ -let stripeClient: Stripe | null = null; +let stripeClient: Stripe | null | undefined = undefined; -function createStripeClient(): Stripe { +function createStripeClient(): Stripe | null { const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) { - throw new Error( - 'STRIPE_SECRET_KEY is not set. Please add it to your .env.local file.\n' + - 'Get your test key from: https://dashboard.stripe.com/test/apikeys' - ); + console.warn('[Stripe] STRIPE_SECRET_KEY not set - payment features disabled'); + return null; } return new Stripe(secretKey, { apiVersion: '2025-10-29.clover', typescript: true, appInfo: { - name: 'LongCut', + name: 'Little universe', version: '1.0.0', url: 'https://github.com/SamuelZ12/longcut', }, }); } -export function getStripeClient(): Stripe { - if (!stripeClient) { +/** + * Get Stripe client if configured, otherwise returns null + * Payment features will be disabled when Stripe is not configured + */ +export function getStripeClient(): Stripe | null { + if (stripeClient === undefined) { stripeClient = createStripeClient(); } return stripeClient; } +/** + * Check if Stripe is configured and available + */ +export function isStripeConfigured(): boolean { + return getStripeClient() !== null; +} + /** * Stripe Price IDs from environment variables * These are configured in .env.local and created in the Stripe Dashboard */ export const STRIPE_PRICE_IDS = { /** Pro subscription: $9.99/month recurring */ - PRO_SUBSCRIPTION: process.env.STRIPE_PRO_PRICE_ID!, + PRO_SUBSCRIPTION: process.env.STRIPE_PRO_PRICE_ID, /** Pro subscription: discounted annual option ($99.99/year) */ - PRO_SUBSCRIPTION_ANNUAL: process.env.STRIPE_PRO_ANNUAL_PRICE_ID!, + PRO_SUBSCRIPTION_ANNUAL: process.env.STRIPE_PRO_ANNUAL_PRICE_ID, /** Top-Up credits: $2.99 one-time for +20 video credits (USD) */ - TOPUP_CREDITS: process.env.STRIPE_TOPUP_PRICE_ID!, + TOPUP_CREDITS: process.env.STRIPE_TOPUP_PRICE_ID, /** Top-Up credits: ¥20 one-time for +20 video credits (CNY) - Optional for WeChat Pay */ TOPUP_CREDITS_CNY: process.env.STRIPE_TOPUP_PRICE_ID_CNY, @@ -56,11 +64,9 @@ export const STRIPE_PRICE_IDS = { /** * Validates that all required Stripe configuration is present - * Call this during app initialization or in API routes to fail fast - * - * @throws Error if any required config is missing or misconfigured + * Returns validation result without throwing - Stripe is optional */ -export function validateStripeConfig(): void { +export function validateStripeConfig(): { configured: boolean; missing: string[] } { const missing: string[] = []; if (!process.env.STRIPE_SECRET_KEY) { @@ -87,47 +93,14 @@ export function validateStripeConfig(): void { missing.push('STRIPE_TOPUP_PRICE_ID'); } - if (missing.length > 0) { - throw new Error( - `Missing required Stripe configuration: ${missing.join(', ')}\n` + - 'Please add these to your .env.local file.\n' + - 'Get your keys from: https://dashboard.stripe.com/test/apikeys\n' + - 'Get your webhook secret from: https://dashboard.stripe.com/test/webhooks' + const configured = missing.length === 0; + + if (!configured) { + console.warn( + `[Stripe] Missing configuration: ${missing.join(', ')}\n` + + 'Payment features are disabled. Add these to .env.local to enable.' ); } - // Validate that price IDs match the Stripe key mode (test vs live) - const secretKey = process.env.STRIPE_SECRET_KEY; - const isTestMode = secretKey?.startsWith('sk_test_'); - const isLiveMode = secretKey?.startsWith('sk_live_'); - - if (isTestMode || isLiveMode) { - const priceIds = { - 'STRIPE_PRO_PRICE_ID': process.env.STRIPE_PRO_PRICE_ID, - 'STRIPE_PRO_ANNUAL_PRICE_ID': process.env.STRIPE_PRO_ANNUAL_PRICE_ID, - 'STRIPE_TOPUP_PRICE_ID': process.env.STRIPE_TOPUP_PRICE_ID, - }; - - const modeMismatches: string[] = []; - - for (const [varName, priceId] of Object.entries(priceIds)) { - if (!priceId) continue; - - // Price IDs don't have explicit test/live prefix, but we can warn about potential issues - // Stripe will throw a proper error when trying to use them - if (isTestMode && priceId.includes('live')) { - modeMismatches.push(`${varName} (${priceId}) may be a live mode price but test keys are being used`); - } - } - - if (modeMismatches.length > 0) { - console.warn( - '⚠️ Potential Stripe mode mismatch detected:\n' + - modeMismatches.map(m => ` - ${m}`).join('\n') + - '\n' + - 'If you encounter "No such price" errors, verify that your price IDs match your Stripe key mode.\n' + - `Current mode: ${isTestMode ? 'TEST' : 'LIVE'}` - ); - } - } + return { configured, missing }; } diff --git a/lib/stripe-topup.ts b/lib/stripe-topup.ts index ea768ae6..9c579076 100644 --- a/lib/stripe-topup.ts +++ b/lib/stripe-topup.ts @@ -39,6 +39,12 @@ export async function extractTopupValuesFromSession( const stripe = getStripeClient(); try { + // If Stripe is not configured, use defaults + if (!stripe) { + console.warn('Stripe not configured, using default top-up values'); + return { credits: DEFAULT_CREDITS, amountCents: DEFAULT_AMOUNT_CENTS }; + } + const sessionWithItems = session.line_items?.data?.length && session.line_items.data[0]?.price ? session diff --git a/lib/subscription-manager.ts b/lib/subscription-manager.ts index 2d94421e..c82a5547 100644 --- a/lib/subscription-manager.ts +++ b/lib/subscription-manager.ts @@ -545,6 +545,13 @@ export async function createOrRetrieveStripeCustomer( userId: string, email: string ): Promise<{ customerId: string; error?: string }> { + const stripe = getStripeClient(); + + // Stripe not configured - payment features disabled + if (!stripe) { + return { customerId: '', error: 'STRIPE_NOT_CONFIGURED' }; + } + const supabase = await createClient(); const { data: profile } = await supabase @@ -558,7 +565,6 @@ export async function createOrRetrieveStripeCustomer( } try { - const stripe = getStripeClient(); const customer = await stripe.customers.create({ email: email || profile?.email, metadata: { diff --git a/middleware.ts b/middleware.ts index 172daf9a..6bf1bd5b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -86,6 +86,6 @@ export const config = { * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ - '/((?!api/webhooks|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)' + '/((?!api/|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)' ] }; diff --git a/package-lock.json b/package-lock.json index 623e3896..d395f13c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,9 +31,11 @@ "@types/dompurify": "^3.0.5", "@types/jsdom": "^21.1.7", "@vercel/analytics": "^1.5.0", + "agentation": "^1.3.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.2.7", + "dotenv": "^17.2.3", "jsdom": "^27.0.0", "lucide-react": "^0.542.0", "next": "15.5.7", @@ -41,6 +43,7 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.5.5", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "stripe": "^19.2.0", @@ -48,6 +51,7 @@ "zod": "^4.1.9" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.3", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -188,7 +192,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -233,7 +236,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2898,7 +2900,6 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz", "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", "license": "MIT", - "peer": true, "dependencies": { "@supabase/auth-js": "2.71.1", "@supabase/functions-js": "2.4.6", @@ -3324,7 +3325,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3335,7 +3335,6 @@ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3413,7 +3412,6 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -3975,7 +3973,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4002,6 +3999,16 @@ "node": ">= 14" } }, + "node_modules/agentation": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/agentation/-/agentation-1.3.2.tgz", + "integrity": "sha512-9yZ/3hTcNePr1asnMyipxAZU8nFdBibNfw7wTdLUd3ULTTQCp9QZX7Y5PTMzkYWX4fhqEI2LOjMCb3vkmZga9w==", + "license": "PolyForm-Shield-1.0.0", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5045,6 +5052,18 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5369,7 +5388,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5544,7 +5562,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8792,7 +8809,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", @@ -9266,7 +9282,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9409,7 +9424,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz", "integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9419,7 +9433,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -9508,6 +9521,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "4.5.5", + "resolved": "https://registry.npmmirror.com/react-resizable-panels/-/react-resizable-panels-4.5.5.tgz", + "integrity": "sha512-C1N+mcHF5lOkELEx8QpFsiHCibOCu82LVqULp2q00/88Mcofqp1oy4tZv0H4eArzWJsCo6RZ0jeTb0/4tpVlNg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -10660,7 +10683,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10896,7 +10918,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 8d305fee..4e675d29 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/dompurify": "^3.0.5", "@types/jsdom": "^21.1.7", "@vercel/analytics": "^1.5.0", + "agentation": "^1.3.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.2.7", @@ -47,6 +48,7 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.5.5", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "stripe": "^19.2.0", diff --git "a/public/\350\203\214\346\231\257.png" "b/public/\350\203\214\346\231\257.png" new file mode 100644 index 00000000..ad3f79ab Binary files /dev/null and "b/public/\350\203\214\346\231\257.png" differ diff --git "a/public/\350\203\214\346\231\2571.png" "b/public/\350\203\214\346\231\2571.png" new file mode 100644 index 00000000..95dbe85f Binary files /dev/null and "b/public/\350\203\214\346\231\2571.png" differ diff --git a/scripts/create-new-prices.ts b/scripts/create-new-prices.ts index 28ae9da8..bb7a62b9 100644 --- a/scripts/create-new-prices.ts +++ b/scripts/create-new-prices.ts @@ -57,7 +57,7 @@ async function main() { // Get the Pro product const products = await stripe.products.list({ limit: 20 }); const proProduct = products.data.find(p => - p.name.includes('LongCut Pro') || + p.name.includes('Little universe Pro') || p.name.includes('TLDW Pro') || p.name.includes('Pro Subscription') ); @@ -69,7 +69,7 @@ async function main() { ); if (!proProduct) { - console.error('❌ Could not find LongCut Pro product'); + console.error('❌ Could not find Little universe Pro product'); console.error(' Please create the product first in your Stripe dashboard'); process.exit(1); } @@ -121,8 +121,8 @@ async function main() { console.log('\n⚠️ Could not find Top-Up product'); console.log(' Creating new Top-Up product...'); const newTopupProduct = await stripe.products.create({ - name: 'LongCut Top-Up Credits', - description: '20 additional video credits for LongCut', + name: 'Little universe Top-Up Credits', + description: '20 additional video credits for Little universe', }); console.log(`✅ Created product: ${newTopupProduct.id}`); diff --git a/scripts/setup-stripe-portal.ts b/scripts/setup-stripe-portal.ts index 1ceb4ae8..45670fc7 100644 --- a/scripts/setup-stripe-portal.ts +++ b/scripts/setup-stripe-portal.ts @@ -69,7 +69,7 @@ function initializeStripe(): Stripe { apiVersion: '2024-10-28.acacia' as any, typescript: true, appInfo: { - name: 'LongCut', + name: 'Little universe', version: '1.0.0', }, }); @@ -149,7 +149,7 @@ async function createPortalConfiguration(stripe: Stripe): Promise { try { const configuration = await stripe.billingPortal.configurations.create({ business_profile: { - headline: 'Manage your LongCut subscription', + headline: 'Manage your Little universe subscription', privacy_policy_url: `${appUrl}/privacy`, terms_of_service_url: `${appUrl}/terms`, }, @@ -268,7 +268,7 @@ async function updatePortalConfiguration( try { const configuration = await stripe.billingPortal.configurations.update(configId, { business_profile: { - headline: 'Manage your LongCut subscription', + headline: 'Manage your Little universe subscription', privacy_policy_url: `${appUrl}/privacy`, terms_of_service_url: `${appUrl}/terms`, }, diff --git a/scripts/update-product-description.ts b/scripts/update-product-description.ts index e3030dd6..6e7269de 100644 --- a/scripts/update-product-description.ts +++ b/scripts/update-product-description.ts @@ -2,7 +2,7 @@ /** * Update Stripe Product Description Script * - * This script updates the LongCut Pro product description to show the correct + * This script updates the Little universe Pro product description to show the correct * video limit (100 videos per month instead of 40). * * Usage: @@ -60,20 +60,20 @@ function initializeStripe(): Stripe { apiVersion: '2024-10-28.acacia' as any, typescript: true, appInfo: { - name: 'LongCut', + name: 'Little universe', version: '1.0.0', }, }); } /** - * Find the LongCut Pro product + * Find the Little universe Pro product */ -async function findLongCutProProduct(stripe: Stripe): Promise { +async function findLittleUniverseProProduct(stripe: Stripe): Promise { try { const products = await stripe.products.list({ limit: 100 }); const proProduct = products.data.find( - p => p.name.includes('LongCut Pro') || p.name.includes('Pro') + p => p.name.includes('Little universe Pro') || p.name.includes('Pro') ); return proProduct || null; } catch (error) { @@ -127,7 +127,7 @@ async function updateProductDescription( */ async function main() { console.log('🚀 Update Stripe Product Description\n'); - console.log('This script will update the LongCut Pro product description.\n'); + console.log('This script will update the Little universe Pro product description.\n'); // Initialize Stripe const stripe = initializeStripe(); @@ -135,12 +135,12 @@ async function main() { const isTestMode = process.env.STRIPE_SECRET_KEY?.includes('_test_'); console.log(`ℹ️ Mode: ${isTestMode ? 'TEST' : 'LIVE'}\n`); - // Find the LongCut Pro product - console.log('🔍 Searching for LongCut Pro product...\n'); - const product = await findLongCutProProduct(stripe); + // Find the Little universe Pro product + console.log('🔍 Searching for Little universe Pro product...\n'); + const product = await findLittleUniverseProProduct(stripe); if (!product) { - console.error('❌ Error: Could not find LongCut Pro product'); + console.error('❌ Error: Could not find Little universe Pro product'); console.error(' Please check your Stripe dashboard to verify the product exists\n'); process.exit(1); } diff --git a/supabase/migrations/20241107000000_initial_schema.sql b/supabase/migrations/20241107000000_initial_schema.sql index b6d770d6..e5a62a0e 100644 --- a/supabase/migrations/20241107000000_initial_schema.sql +++ b/supabase/migrations/20241107000000_initial_schema.sql @@ -1,5 +1,5 @@ -- ============================================================================ --- LongCut Initial Schema Migration +-- Little universe Initial Schema Migration -- ============================================================================ -- This migration captures the complete database schema including: -- - Extensions @@ -673,7 +673,7 @@ CREATE POLICY "Service role full access to stripe_events" ON public.stripe_event -- MIGRATION COMPLETE -- ============================================================================ --- This migration establishes the complete database schema for the LongCut application. +-- This migration establishes the complete database schema for the Little universe application. -- It includes all tables, indexes, functions, triggers, and security policies. -- -- Note: The auth.users trigger (on_auth_user_created) should be created separately diff --git a/supabase/migrations/20260131120000_fix_update_video_analysis_final.sql b/supabase/migrations/20260131120000_fix_update_video_analysis_final.sql new file mode 100644 index 00000000..350bbab4 --- /dev/null +++ b/supabase/migrations/20260131120000_fix_update_video_analysis_final.sql @@ -0,0 +1,68 @@ +-- Migration: Fix update_video_analysis_secure - Clean recreate +-- Purpose: Ensure the function exists with correct signature and no overloading issues +-- Date: 2026-01-31 +-- +-- This migration: +-- 1. Drops ALL versions of the function (cascade to remove all overloads) +-- 2. Recreates it with the correct 4-parameter signature +-- 3. Re-grants permissions + +-- Drop the function completely (cascade removes all overloads) +DROP FUNCTION IF EXISTS public.update_video_analysis_secure CASCADE; + +-- Recreate with correct signature +CREATE OR REPLACE FUNCTION public.update_video_analysis_secure( + p_youtube_id text, + p_user_id uuid, + p_summary jsonb DEFAULT NULL, + p_suggested_questions jsonb DEFAULT NULL +) +RETURNS TABLE (success boolean, video_id uuid) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_video_id uuid; + v_created_by uuid; +BEGIN + -- Get video and check ownership + SELECT id, created_by INTO v_video_id, v_created_by + FROM public.video_analyses + WHERE youtube_id = p_youtube_id; + + -- Video doesn't exist + IF v_video_id IS NULL THEN + RETURN QUERY SELECT false::boolean, NULL::uuid; + RETURN; + END IF; + + -- Ownership check: + -- 1. If created_by is NULL (anonymous creation), any authenticated user can update + -- 2. If created_by matches p_user_id, owner can update + -- 3. Otherwise, reject the update + IF v_created_by IS NOT NULL AND v_created_by != p_user_id THEN + RETURN QUERY SELECT false::boolean, v_video_id; + RETURN; + END IF; + + -- Perform the update + UPDATE public.video_analyses SET + summary = COALESCE(p_summary, summary), + suggested_questions = COALESCE(p_suggested_questions, suggested_questions), + updated_at = timezone('utc'::text, now()) + WHERE id = v_video_id; + + RETURN QUERY SELECT true::boolean, v_video_id; +END; +$$; + +-- Grant execute permissions +GRANT EXECUTE ON FUNCTION public.update_video_analysis_secure TO authenticated; + +-- Add helpful comment +COMMENT ON FUNCTION public.update_video_analysis_secure IS +'Updates video analysis summary and suggested questions with ownership verification. +Returns: TABLE (success boolean, video_id uuid) +- success=true: Update performed +- success=false: Not authorized or video not found';