diff --git a/.gitignore b/.gitignore
index dd31b51..3ef7aab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
node_modules/
+.next/
+out/
dist/
build/
.env
@@ -10,3 +12,11 @@ __pycache__/
.vscode/
.idea/
*.log
+
+# api
+api/.venv/
+api/jobs/
+
+# web
+web/.next/
+web/out/
diff --git a/.memory/worklog.json b/.memory/worklog.json
index 619acf6..ffffc2e 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -13,6 +13,13 @@
"message": "init: project scaffold",
"hash": "56d435f",
"files_changed": 7
+ },
+ {
+ "ts": "2026-05-12T15:42:02+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-12 15:41 (+1, ~3)",
+ "hash": "bbd41fa",
+ "files_changed": 4
}
]
}
diff --git a/.project.json b/.project.json
index 109e876..6fd2b01 100644
--- a/.project.json
+++ b/.project.json
@@ -8,7 +8,12 @@
"ports": [
{
"port": 4290,
- "label": "dev",
+ "label": "web-dev",
+ "fixed": true
+ },
+ {
+ "port": 4291,
+ "label": "api-dev",
"fixed": true
}
],
diff --git a/api/.env.example b/api/.env.example
new file mode 100644
index 0000000..9e2b44f
--- /dev/null
+++ b/api/.env.example
@@ -0,0 +1,15 @@
+# Gemini API(优先用 Poe 中转,按用户偏好)
+# Poe 网关示例:GEMINI_API_BASE=https://api.poe.com/v1 + key
+# Google 直连示例:留空 GEMINI_API_BASE,用 google-generativeai SDK
+GEMINI_API_KEY=
+GEMINI_API_BASE=
+GEMINI_MODEL=gemini-2.5-flash
+
+# 工作目录
+JOBS_DIR=./jobs
+
+# CORS
+CORS_ORIGINS=http://localhost:4290
+
+# 端口(启动用 uvicorn --port 4291 覆盖)
+API_PORT=4291
diff --git a/api/.gitignore b/api/.gitignore
new file mode 100644
index 0000000..003c4ef
--- /dev/null
+++ b/api/.gitignore
@@ -0,0 +1,5 @@
+.venv/
+__pycache__/
+*.pyc
+.env
+jobs/
diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000..c11ebdb
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,33 @@
+# SKG TK 二创 API
+
+FastAPI 后端,跑 yt-dlp + ffmpeg + Gemini ASR/翻译 管线。
+
+## 启动
+
+```bash
+cd api
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+cp .env.example .env # 按需填 GEMINI_API_KEY
+uvicorn main:app --port 4291 --reload
+```
+
+## 路由
+
+- `GET /health` — 健康检查 + 配置状态
+- `POST /jobs` `{url}` — 创建 job,后台跑下载/拆轨/抽帧
+- `GET /jobs/{id}` — 当前状态 + 产物
+- `POST /jobs/{id}/transcribe` — 触发 Gemini ASR + 翻译
+- `GET /jobs/{id}/video.mp4` — 原视频
+- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧(0-9)
+
+## Mock 模式
+
+未设 `GEMINI_API_KEY` 时,转录走本地 mock,便于 UI 联调。
+
+## 依赖
+
+- `ffmpeg` 系统二进制(拆轨 / 抽帧)
+- `yt-dlp` 系统二进制(也可走 Python 包)
+- `google-generativeai` Python(ASR + 翻译)
diff --git a/api/main.py b/api/main.py
new file mode 100644
index 0000000..e34dbcc
--- /dev/null
+++ b/api/main.py
@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import shutil
+import subprocess
+import uuid
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Literal
+
+from dotenv import load_dotenv
+from fastapi import BackgroundTasks, FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+from pydantic import BaseModel, Field
+
+load_dotenv()
+
+JOBS_DIR = Path(os.getenv("JOBS_DIR", "./jobs")).resolve()
+JOBS_DIR.mkdir(parents=True, exist_ok=True)
+CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:4290").split(",") if o.strip()]
+GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
+GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
+
+# Pipeline 状态:created → downloading → splitting → frames_extracted → transcribing → transcribed | failed
+JobStatus = Literal[
+ "created", "downloading", "splitting", "frames_extracted",
+ "transcribing", "transcribed", "failed",
+]
+
+
+class KeyFrame(BaseModel):
+ index: int
+ timestamp: float
+ url: str
+
+
+class TranscriptSegment(BaseModel):
+ index: int
+ start: float
+ end: float
+ en: str
+ zh: str = ""
+
+
+class Job(BaseModel):
+ id: str
+ url: str
+ status: JobStatus = "created"
+ progress: int = 0
+ message: str = ""
+ video_url: str = ""
+ duration: float = 0.0
+ width: int = 0
+ height: int = 0
+ frames: list[KeyFrame] = Field(default_factory=list)
+ transcript: list[TranscriptSegment] = Field(default_factory=list)
+ error: str = ""
+
+
+JOBS: dict[str, Job] = {}
+
+
+def job_dir(job_id: str) -> Path:
+ d = JOBS_DIR / job_id
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def save_state(job: Job) -> None:
+ (job_dir(job.id) / "state.json").write_text(job.model_dump_json(indent=2))
+
+
+def update(job: Job, **kw) -> None:
+ for k, v in kw.items():
+ setattr(job, k, v)
+ save_state(job)
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+ # 启动时从磁盘恢复 jobs(简化版:只列目录)
+ for p in JOBS_DIR.iterdir():
+ if p.is_dir() and (p / "state.json").exists():
+ try:
+ JOBS[p.name] = Job.model_validate_json((p / "state.json").read_text())
+ except Exception:
+ pass
+ yield
+
+
+app = FastAPI(title="SKG TK 二创 API", lifespan=lifespan)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=CORS_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+# ---------- Pipeline 实现 ----------
+
+def run(cmd: list[str], cwd: Path | None = None) -> str:
+ res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
+ if res.returncode != 0:
+ raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {res.stderr[:500]}")
+ return res.stdout
+
+
+def ffprobe_meta(mp4: Path) -> dict:
+ out = run([
+ "ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(mp4),
+ ])
+ return json.loads(out)
+
+
+async def pipeline_download_split_frames(job_id: str) -> None:
+ """步骤 1+2+3:下载 + 拆音轨 + 抽取关键帧"""
+ job = JOBS[job_id]
+ d = job_dir(job_id)
+ try:
+ # ---- 1. yt-dlp 下载
+ update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
+ mp4 = d / "source.mp4"
+ run([
+ "yt-dlp", "-f", "best[ext=mp4]/best",
+ "-o", str(mp4),
+ "--no-warnings", "--no-playlist",
+ "--retries", "3",
+ job.url,
+ ])
+ if not mp4.exists():
+ raise RuntimeError("下载完成但找不到 source.mp4")
+
+ # 元数据
+ meta = ffprobe_meta(mp4)
+ v_stream = next((s for s in meta["streams"] if s["codec_type"] == "video"), None)
+ duration = float(meta["format"]["duration"])
+ update(
+ job,
+ video_url=f"/jobs/{job_id}/video.mp4",
+ duration=duration,
+ width=int(v_stream["width"]) if v_stream else 0,
+ height=int(v_stream["height"]) if v_stream else 0,
+ progress=20,
+ message=f"下载完成 · {duration:.1f}s",
+ )
+
+ # ---- 2. 拆音轨
+ update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=30)
+ wav = d / "audio.wav"
+ run([
+ "ffmpeg", "-y", "-i", str(mp4),
+ "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
+ str(wav),
+ ])
+
+ # ---- 3. 关键帧抽取(场景切换 + 均匀采样兜底,最多 10 张)
+ update(job, message="抽取关键帧…", progress=50)
+ frames_dir = d / "frames"
+ if frames_dir.exists():
+ shutil.rmtree(frames_dir)
+ frames_dir.mkdir(parents=True)
+
+ # 先用场景切换检测
+ run([
+ "ffmpeg", "-y", "-i", str(mp4),
+ "-vf", "select='gt(scene,0.4)',showinfo",
+ "-vsync", "vfr",
+ "-frames:v", "30",
+ str(frames_dir / "scene_%03d.jpg"),
+ ])
+ scene_frames = sorted(frames_dir.glob("scene_*.jpg"))
+
+ # 均匀采样兜底 / 补足
+ if len(scene_frames) < 10:
+ sample_count = 10 - len(scene_frames)
+ step = duration / (sample_count + 1)
+ for i in range(sample_count):
+ t = step * (i + 1)
+ out = frames_dir / f"sample_{i:03d}.jpg"
+ run([
+ "ffmpeg", "-y", "-ss", str(t), "-i", str(mp4),
+ "-frames:v", "1", "-q:v", "3", str(out),
+ ])
+
+ # 统一排序、按时间戳读取、限制 10 张
+ all_frames = sorted(frames_dir.glob("*.jpg"))[:10]
+ renamed: list[KeyFrame] = []
+ for i, src in enumerate(all_frames):
+ dst = frames_dir / f"{i:03d}.jpg"
+ if src != dst:
+ src.rename(dst)
+ # 简化:用均匀分布估算时间戳(场景切换的精确时间需要解析 showinfo 输出,先省)
+ ts = duration * (i + 0.5) / max(len(all_frames), 1)
+ renamed.append(KeyFrame(index=i, timestamp=round(ts, 2), url=f"/jobs/{job_id}/frames/{i}.jpg"))
+
+ update(
+ job,
+ status="frames_extracted",
+ frames=renamed,
+ progress=70,
+ message=f"已抽取 {len(renamed)} 张关键帧",
+ )
+
+ except Exception as e:
+ update(job, status="failed", error=str(e), message="管线失败")
+
+
+# ---------- Gemini ASR + 翻译 ----------
+
+async def pipeline_transcribe(job_id: str) -> None:
+ job = JOBS[job_id]
+ d = job_dir(job_id)
+ wav = d / "audio.wav"
+ try:
+ if not wav.exists():
+ raise RuntimeError("audio.wav 不存在")
+
+ update(job, status="transcribing", message="Gemini ASR 处理中…", progress=75)
+
+ if not GEMINI_API_KEY:
+ # 无 key 模式:mock 数据,方便 UI 联调
+ await asyncio.sleep(1.2)
+ mock_segments = [
+ TranscriptSegment(index=0, start=0.0, end=3.5,
+ en="Welcome back to my channel, today we're testing something new.",
+ zh="欢迎回来我的频道,今天我们要测试一些新东西。"),
+ TranscriptSegment(index=1, start=3.5, end=7.2,
+ en="This device looks really sleek and the design is quite minimal.",
+ zh="这个设备看起来非常时尚,设计也相当简约。"),
+ TranscriptSegment(index=2, start=7.2, end=11.0,
+ en="Let me show you how it works in real life situations.",
+ zh="让我向你展示它在实际场景中如何工作。"),
+ ]
+ update(job, transcript=mock_segments, status="transcribed", progress=100,
+ message="转录完成(MOCK 模式 · 未设 GEMINI_API_KEY)")
+ return
+
+ # 真模式:调 Gemini
+ import google.generativeai as genai
+ genai.configure(api_key=GEMINI_API_KEY)
+ model = genai.GenerativeModel(GEMINI_MODEL)
+
+ audio_file = genai.upload_file(str(wav), mime_type="audio/wav")
+ prompt = (
+ "Transcribe the English audio with sentence-level timestamps. "
+ "Then provide a Chinese translation for each segment. "
+ "Return strictly as JSON array, no prose, schema: "
+ '[{"start": float_seconds, "end": float_seconds, "en": "...", "zh": "..."}]'
+ )
+ resp = await asyncio.to_thread(
+ model.generate_content,
+ [audio_file, prompt],
+ generation_config={"response_mime_type": "application/json"},
+ )
+ raw = resp.text or "[]"
+ data = json.loads(raw)
+ segs = [
+ TranscriptSegment(
+ index=i,
+ start=float(s.get("start", 0)),
+ end=float(s.get("end", 0)),
+ en=str(s.get("en", "")),
+ zh=str(s.get("zh", "")),
+ )
+ for i, s in enumerate(data)
+ ]
+ update(job, transcript=segs, status="transcribed", progress=100,
+ message=f"转录完成 · {len(segs)} 段")
+
+ except Exception as e:
+ update(job, status="failed", error=str(e), message="转录失败")
+
+
+# ---------- API 路由 ----------
+
+class CreateJobReq(BaseModel):
+ url: str
+
+
+@app.get("/health")
+def health() -> dict:
+ return {"ok": True, "gemini_configured": bool(GEMINI_API_KEY), "model": GEMINI_MODEL}
+
+
+@app.post("/jobs", response_model=Job)
+async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
+ if not req.url.strip():
+ raise HTTPException(400, "url required")
+ job_id = uuid.uuid4().hex[:12]
+ job = Job(id=job_id, url=req.url.strip())
+ JOBS[job_id] = job
+ save_state(job)
+ bg.add_task(pipeline_download_split_frames, job_id)
+ return job
+
+
+@app.get("/jobs/{job_id}", response_model=Job)
+def get_job(job_id: str) -> Job:
+ job = JOBS.get(job_id)
+ if not job:
+ raise HTTPException(404, "job not found")
+ return job
+
+
+@app.post("/jobs/{job_id}/transcribe", response_model=Job)
+async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job:
+ job = JOBS.get(job_id)
+ if not job:
+ raise HTTPException(404, "job not found")
+ if job.status != "frames_extracted":
+ raise HTTPException(409, f"status must be frames_extracted, got {job.status}")
+ bg.add_task(pipeline_transcribe, job_id)
+ return job
+
+
+@app.get("/jobs/{job_id}/video.mp4")
+def get_video(job_id: str):
+ p = job_dir(job_id) / "source.mp4"
+ if not p.exists():
+ raise HTTPException(404, "video not found")
+ return FileResponse(p, media_type="video/mp4")
+
+
+@app.get("/jobs/{job_id}/frames/{idx}.jpg")
+def get_frame(job_id: str, idx: int):
+ p = job_dir(job_id) / "frames" / f"{idx:03d}.jpg"
+ if not p.exists():
+ raise HTTPException(404, "frame not found")
+ return FileResponse(p, media_type="image/jpeg")
diff --git a/api/requirements.txt b/api/requirements.txt
new file mode 100644
index 0000000..1304c47
--- /dev/null
+++ b/api/requirements.txt
@@ -0,0 +1,8 @@
+fastapi==0.115.4
+uvicorn[standard]==0.32.0
+pydantic==2.9.2
+python-multipart==0.0.12
+python-dotenv==1.0.1
+yt-dlp==2026.3.17
+google-generativeai==0.8.3
+httpx==0.27.2
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..f650315
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
\ No newline at end of file
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..69d1a93
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,105 @@
+# Art Gallery Slider
+
+A full-screen art gallery experience with GSAP-powered horizontal scrolling, dynamic color extraction, and animated ambient backgrounds.
+
+## Features
+
+- **Dynamic Ambient Backgrounds**: Extracts dominant colors from each artwork to create animated gradient backgrounds that shift as you navigate
+- **Heavy Drag Interactions**: Smooth drag with resistance and momentum scrolling using Framer Motion
+- **Trackpad/Magic Mouse Support**: Horizontal scroll gestures with accumulated delta thresholds
+- **Keyboard Navigation**: Arrow keys, A/D, Home/End for full keyboard accessibility
+- **Parallax Effects**: Cards shift during drag with parallax motion
+- **Hover Animations**: Artwork lifts with slide-up info panels on hover
+- **Glassmorphism UI**: Dark editorial aesthetic with blur effects and elegant typography
+
+## Tech Stack
+
+- Next.js 15 (App Router)
+- React 19
+- Framer Motion (animations, drag gestures)
+- GSAP (scroll-triggered animations)
+- Tailwind CSS v4
+- TypeScript
+
+## Project Structure
+
+\`\`\`
+├── app/
+│ ├── page.tsx # Main page
+│ ├── layout.tsx # Root layout with fonts
+│ └── globals.css # Tailwind config & design tokens
+├── components/
+│ ├── art-gallery-slider.tsx # Main slider component
+│ ├── artwork-card.tsx # Individual artwork cards
+│ └── navigation-dots.tsx # Navigation indicator
+├── hooks/
+│ ├── use-slider-navigation.ts # Keyboard & index control
+│ ├── use-slider-drag.ts # Mouse/touch drag logic
+│ ├── use-slider-wheel.ts # Trackpad/scroll handling
+│ └── use-color-extraction.ts # Color management
+├── lib/
+│ ├── color-extractor.ts # Canvas-based color extraction
+│ └── constants.ts # Configuration values
+├── data/
+│ └── artworks.ts # Artwork data
+└── types/
+ └── artwork.ts # TypeScript interfaces
+\`\`\`
+
+## Prompt to Recreate
+
+\`\`\`
+Build a full-screen art gallery slider with the following features:
+
+1. LAYOUT & DESIGN
+- Full viewport height gallery with horizontal card-based slider
+- Dark editorial aesthetic (#0a0a0a background)
+- Glassmorphism navigation elements with backdrop blur
+- Playfair Display for headings, Inter for body text
+- Cards sized at 70vh height with 3:4 aspect ratio
+
+2. DYNAMIC AMBIENT BACKGROUNDS
+- Extract 3 dominant colors from each artwork image using canvas
+- Create animated radial gradient backgrounds using extracted colors
+- Smooth 600ms crossfade transitions between backgrounds as slides change
+- Colors should be distinct (filter similar colors) and not too light
+
+3. DRAG INTERACTIONS
+- Heavy drag feel with 0.4 resistance factor (mouse movement dampened)
+- Momentum-based settling with slow easing (0.1 factor)
+- Parallax effect on cards during drag (slight horizontal offset based on drag delta)
+- Snap to nearest slide on release
+
+4. TRACKPAD/MAGIC MOUSE SUPPORT
+- Detect horizontal wheel events (deltaX)
+- Accumulate scroll delta with 0.3 resistance
+- Trigger slide change when accumulated delta exceeds 120px threshold
+- Reset accumulator after 150ms of inactivity
+
+5. KEYBOARD NAVIGATION
+- Left/Right arrows and A/D keys for prev/next
+- Home/End keys to jump to first/last slide
+- Visual hint text showing keyboard controls
+
+6. ARTWORK CARDS
+- Image with object-cover fill
+- Hover state: lift card with translateY(-8px) and scale(1.02)
+- Info panel slides up from bottom on hover (glassmorphism background)
+- Display: title, artist name, year
+- Prevent text selection on artwork info
+
+7. NAVIGATION DOTS
+- Fixed position at bottom center
+- Dots use extracted primary color for active state
+- Scale animation on active dot
+- Click to navigate to specific slide
+
+8. CODE ARCHITECTURE
+- Separate custom hooks for: navigation, drag, wheel, color extraction
+- Centralized types, constants, and data files
+- Clean component composition in main slider
+\`\`\`
+
+## License
+
+MIT
diff --git a/web/app/globals.css b/web/app/globals.css
new file mode 100644
index 0000000..532f8c6
--- /dev/null
+++ b/web/app/globals.css
@@ -0,0 +1,131 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+/* Updated color tokens for dark gallery aesthetic */
+:root {
+ --background: oklch(0.08 0 0);
+ --foreground: oklch(0.95 0 0);
+ --card: oklch(0.12 0 0);
+ --card-foreground: oklch(0.95 0 0);
+ --popover: oklch(0.1 0 0);
+ --popover-foreground: oklch(0.95 0 0);
+ --primary: oklch(0.95 0 0);
+ --primary-foreground: oklch(0.1 0 0);
+ --secondary: oklch(0.2 0 0);
+ --secondary-foreground: oklch(0.95 0 0);
+ --muted: oklch(0.2 0 0);
+ --muted-foreground: oklch(0.6 0 0);
+ --accent: oklch(0.25 0 0);
+ --accent-foreground: oklch(0.95 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.25 0 0);
+ --input: oklch(0.2 0 0);
+ --ring: oklch(0.5 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.75rem;
+ --sidebar: oklch(0.1 0 0);
+ --sidebar-foreground: oklch(0.95 0 0);
+ --sidebar-primary: oklch(0.95 0 0);
+ --sidebar-primary-foreground: oklch(0.1 0 0);
+ --sidebar-accent: oklch(0.2 0 0);
+ --sidebar-accent-foreground: oklch(0.95 0 0);
+ --sidebar-border: oklch(0.25 0 0);
+ --sidebar-ring: oklch(0.5 0 0);
+}
+
+/* Added Playfair Display font for serif headings */
+@theme inline {
+ --font-sans: "Geist", "Geist Fallback";
+ --font-serif: "Playfair Display", Georgia, serif;
+ --font-mono: "Geist Mono", "Geist Mono Fallback";
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Workbench: keep dark gallery aesthetic but allow scroll */
+html, body {
+ background: #0a0a0a;
+ min-height: 100vh;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-thumb {
+ background: oklch(0.25 0 0);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* Ambient glow utility — 04 风格核心 */
+.ambient-glow {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: radial-gradient(circle at 30% 30%, oklch(0.35 0.1 250 / 0.25), transparent 60%),
+ radial-gradient(circle at 70% 70%, oklch(0.35 0.08 300 / 0.18), transparent 55%);
+ filter: blur(80px);
+ z-index: 0;
+}
+
+/* Glass card — 04 风格 */
+.glass-card {
+ background: rgba(255, 255, 255, 0.04);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius);
+}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..2ca2bb6
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,28 @@
+import type React from "react"
+import type { Metadata } from "next"
+import { Geist, Geist_Mono, Playfair_Display } from "next/font/google"
+import "./globals.css"
+
+const _geist = Geist({ subsets: ["latin"] })
+const _geistMono = Geist_Mono({ subsets: ["latin"] })
+const _playfairDisplay = Playfair_Display({
+ subsets: ["latin"],
+ variable: "--font-playfair",
+})
+
+export const metadata: Metadata = {
+ title: "SKG TK 二创工作台",
+ description: "SKG AI 素材生产管线 · TK 链接 → 关键帧 + 双语转录 → 改写 / 生图 / 生视频",
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode
+}>) {
+ return (
+
+
{children}
+
+ )
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..0032ff3
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,162 @@
+"use client"
+import { useCallback, useEffect, useRef, useState } from "react"
+import { Toaster, toast } from "sonner"
+import { UrlInput } from "@/components/url-input"
+import { JobStatusBar } from "@/components/job-status"
+import { KeyframeGallery } from "@/components/keyframe-gallery"
+import { TranscriptPanel } from "@/components/transcript-panel"
+import { createJob, getJob, triggerTranscribe, videoUrl, type Job } from "@/lib/api"
+
+export default function Home() {
+ const [job, setJob] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [selected, setSelected] = useState>(new Set())
+ const videoRef = useRef(null)
+ const pollRef = useRef | null>(null)
+ const transcribeTriggeredRef = useRef(null)
+
+ const handleSubmit = useCallback(async (url: string) => {
+ setSubmitting(true)
+ setSelected(new Set())
+ transcribeTriggeredRef.current = null
+ try {
+ const created = await createJob(url)
+ setJob(created)
+ toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
+ } catch (e) {
+ toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setSubmitting(false)
+ }
+ }, [])
+
+ // 轮询 job 状态
+ useEffect(() => {
+ if (!job) return
+ if (job.status === "transcribed" || job.status === "failed") {
+ if (pollRef.current) clearInterval(pollRef.current)
+ return
+ }
+ pollRef.current = setInterval(async () => {
+ try {
+ const latest = await getJob(job.id)
+ setJob(latest)
+ } catch {
+ // silent
+ }
+ }, 1500)
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current)
+ }
+ }, [job?.id, job?.status])
+
+ // 抽帧完成后自动触发 ASR
+ useEffect(() => {
+ if (!job) return
+ if (job.status !== "frames_extracted") return
+ if (transcribeTriggeredRef.current === job.id) return
+ transcribeTriggeredRef.current = job.id
+ triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message))
+ }, [job?.id, job?.status])
+
+ const toggleFrame = (idx: number) => {
+ setSelected((prev) => {
+ const next = new Set(prev)
+ if (next.has(idx)) next.delete(idx)
+ else if (next.size < 10) next.add(idx)
+ return next
+ })
+ }
+
+ const handleSeek = (sec: number) => {
+ if (videoRef.current) {
+ videoRef.current.currentTime = sec
+ videoRef.current.play().catch(() => {})
+ }
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {/* URL 输入 */}
+
+
+ {job && (
+ <>
+ {/* 状态条 */}
+
+
+ {/* 视频预览 + 关键帧 */}
+ {job.video_url && (
+
+
+
+
+ {job.width}×{job.height} · {job.duration?.toFixed(1)}s
+
+
+
+
+
关键帧 · Keyframes
+
自动抽取,点击勾选最多 10 张作为生图参考
+
+
+
+
+ )}
+
+ {/* 双语转录 */}
+ {(job.frames.length > 0 || job.transcript.length > 0) && (
+
+
+
双语转录 · Transcript
+
点击段落跳转视频时间点
+
+
+
+ )}
+ >
+ )}
+
+ {!job && (
+
+ )}
+
+ {/* Footer */}
+
+
+
+
+ )
+}
diff --git a/web/components.json b/web/components.json
new file mode 100644
index 0000000..4ee62ee
--- /dev/null
+++ b/web/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/web/components/job-status.tsx b/web/components/job-status.tsx
new file mode 100644
index 0000000..69db84a
--- /dev/null
+++ b/web/components/job-status.tsx
@@ -0,0 +1,56 @@
+"use client"
+import { type Job, type JobStatus } from "@/lib/api"
+import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react"
+
+const STAGES: { key: JobStatus; label: string }[] = [
+ { key: "downloading", label: "下载视频" },
+ { key: "splitting", label: "拆分音视频" },
+ { key: "frames_extracted", label: "抽取关键帧" },
+ { key: "transcribing", label: "Gemini 转录+翻译" },
+ { key: "transcribed", label: "完成" },
+]
+
+const ORDER: JobStatus[] = ["created", "downloading", "splitting", "frames_extracted", "transcribing", "transcribed"]
+
+export function JobStatusBar({ job }: { job: Job }) {
+ const currentIdx = ORDER.indexOf(job.status)
+ return (
+
+
+
Job {job.id.slice(0, 8)}
+
+ {job.status === "failed" ? `失败: ${job.error ?? "未知错误"}` : (job.message ?? "")}
+
+
+
+ {STAGES.map((s, i) => {
+ const stageIdx = ORDER.indexOf(s.key)
+ const done = currentIdx >= stageIdx && job.status !== "failed"
+ const active = currentIdx === stageIdx - 1 && job.status !== "failed" && job.status !== "transcribed"
+ const failed = job.status === "failed" && currentIdx + 1 === stageIdx
+ return (
+
+
+ {failed ? (
+
+ ) : done ? (
+
+ ) : active ? (
+
+ ) : (
+
+ )}
+
+ {s.label}
+
+
+ {i < STAGES.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/web/components/keyframe-gallery.tsx b/web/components/keyframe-gallery.tsx
new file mode 100644
index 0000000..26fc97c
--- /dev/null
+++ b/web/components/keyframe-gallery.tsx
@@ -0,0 +1,92 @@
+"use client"
+import { useState } from "react"
+import { motion, AnimatePresence } from "framer-motion"
+import { Check, Plus } from "lucide-react"
+import { type KeyFrame } from "@/lib/api"
+
+interface Props {
+ frames: KeyFrame[]
+ selected: Set
+ onToggle: (frameIndex: number) => void
+ maxSelect?: number
+}
+
+function formatTimestamp(t: number) {
+ const m = Math.floor(t / 60)
+ const s = Math.floor(t % 60)
+ return `${m}:${s.toString().padStart(2, "0")}`
+}
+
+export function KeyframeGallery({ frames, selected, onToggle, maxSelect = 10 }: Props) {
+ const [hoverIdx, setHoverIdx] = useState(null)
+
+ if (frames.length === 0) {
+ return (
+
+ 关键帧将在下载完成后出现
+
+ )
+ }
+
+ return (
+
+
+ 已选 {selected.size} / {maxSelect}
+
+
+ {frames.map((f) => {
+ const isSelected = selected.has(f.index)
+ const isHover = hoverIdx === f.index
+ const disabled = !isSelected && selected.size >= maxSelect
+ return (
+
!disabled && onToggle(f.index)}
+ onMouseEnter={() => setHoverIdx(f.index)}
+ onMouseLeave={() => setHoverIdx(null)}
+ disabled={disabled}
+ animate={{
+ scale: isSelected ? 1 : isHover ? 0.98 : 0.92,
+ opacity: isSelected ? 1 : disabled ? 0.3 : isHover ? 0.95 : 0.7,
+ }}
+ transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
+ className="relative shrink-0 snap-start rounded-lg overflow-hidden border border-white/10 disabled:cursor-not-allowed"
+ style={{ width: 200, height: 120 }}
+ >
+
+ {/* 选中态:ambient glow */}
+
+ {isSelected && (
+
+ )}
+
+ {/* 时间戳 */}
+
+ {formatTimestamp(f.timestamp)}
+
+ {/* 选中标记 */}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/web/components/theme-provider.tsx b/web/components/theme-provider.tsx
new file mode 100644
index 0000000..55c2f6e
--- /dev/null
+++ b/web/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ThemeProvider as NextThemesProvider,
+ type ThemeProviderProps,
+} from 'next-themes'
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/web/components/transcript-panel.tsx b/web/components/transcript-panel.tsx
new file mode 100644
index 0000000..8f24fbb
--- /dev/null
+++ b/web/components/transcript-panel.tsx
@@ -0,0 +1,57 @@
+"use client"
+import { type TranscriptSegment } from "@/lib/api"
+
+interface Props {
+ segments: TranscriptSegment[]
+ loading?: boolean
+ onSeek?: (sec: number) => void
+}
+
+function formatTs(t: number) {
+ const m = Math.floor(t / 60)
+ const s = Math.floor(t % 60)
+ return `${m}:${s.toString().padStart(2, "0")}`
+}
+
+export function TranscriptPanel({ segments, loading, onSeek }: Props) {
+ if (segments.length === 0) {
+ return (
+
+ {loading ? "Gemini 转录中…" : "转录将在抽帧后自动开始"}
+
+ )
+ }
+
+ return (
+
+
+
+ {segments.map((seg) => (
+
onSeek?.(seg.start)}
+ >
+
+
+ {formatTs(seg.start)} → {formatTs(seg.end)}
+
+
{seg.en}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/web/components/ui/accordion.tsx b/web/components/ui/accordion.tsx
new file mode 100644
index 0000000..e538a33
--- /dev/null
+++ b/web/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/web/components/ui/alert-dialog.tsx b/web/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..9704452
--- /dev/null
+++ b/web/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/web/components/ui/alert.tsx b/web/components/ui/alert.tsx
new file mode 100644
index 0000000..e6751ab
--- /dev/null
+++ b/web/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/web/components/ui/aspect-ratio.tsx b/web/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..40bb120
--- /dev/null
+++ b/web/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/web/components/ui/avatar.tsx b/web/components/ui/avatar.tsx
new file mode 100644
index 0000000..aa98465
--- /dev/null
+++ b/web/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/web/components/ui/badge.tsx b/web/components/ui/badge.tsx
new file mode 100644
index 0000000..fc4126b
--- /dev/null
+++ b/web/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/web/components/ui/breadcrumb.tsx b/web/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..1750ff2
--- /dev/null
+++ b/web/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/web/components/ui/button-group.tsx b/web/components/ui/button-group.tsx
new file mode 100644
index 0000000..09d4430
--- /dev/null
+++ b/web/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx
new file mode 100644
index 0000000..f64632d
--- /dev/null
+++ b/web/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/web/components/ui/calendar.tsx b/web/components/ui/calendar.tsx
new file mode 100644
index 0000000..eaa373e
--- /dev/null
+++ b/web/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+