Initial commit: FuerDieCuts Vite/React app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:45:43 +00:00
commit 278d47c793
12 changed files with 3226 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
.env.*.local
+1
View File
@@ -0,0 +1 @@
A desktop tool for assembling weekly "Best-of" video reels from WhatsApp clips. Import clips, get automatic transcriptions via Whisper, review AI recommendations for each clip, select what goes into the reel, and reorder via drag-and-drop before exporting with ffmpeg.
+85
View File
@@ -0,0 +1,85 @@
# Project
## Environment
You are working inside a VCH cloud development environment.
- **OS:** Debian-based LXC container
- **Editor:** code-server (VS Code in browser) on port 8080
- **CLI:** Claude Code is available as `claude` in terminal
- **Git:** Pre-configured. Gitea integration available through VCH.
- **Preview:** Start a dev server on any port — VCH detects listening ports automatically and provides preview URLs with SSL.
- **Publishing:** Assign a public subdomain to any port via the VCH Publish page (automatic SSL).
- **User:** `vchuser` with home at `/home/vchuser`
## Preview Access (IMPORTANT)
Apps are accessed through a **reverse proxy**, not directly. There are two modes:
1. **Subdomain proxy** (preferred): `https://lxc{VMID}-{PORT}.dev.example.com/` — your app is at the domain root, everything works normally.
2. **Path-based proxy** (fallback): `https://host/proxy/{INSTANCE}/p/{PORT}/` — your app is behind a path prefix.
### Rules for portable apps that work in both modes:
- **ALWAYS use relative paths** for assets, API calls, and links:
- `css/style.css` ✅ — NOT `/css/style.css`
- `api/users` ✅ — NOT `/api/users`
- `fetch('api/data')` ✅ — NOT `fetch('/api/data')`
- **Static file serving:** Configure your server to serve from the request path, not the filesystem root.
- **`<base>` tag:** If you must use absolute paths, add `<base href="./">` in `<head>`.
### Framework-specific configuration:
- **Express:** `app.use(express.static('public'))` works — just ensure HTML references are relative.
- **Vite:** Set `base: './'` in `vite.config.ts`.
- **Next.js:** Set `basePath` in `next.config.js` if using path-based proxy, or leave default for subdomain proxy.
- **Create React App:** Set `"homepage": "."` in `package.json`.
## Development Conventions
- Write clear, descriptive commit messages (imperative mood: "Add feature", not "Added feature")
- Prefer small, focused commits over large ones
- Run linters and formatters before committing
- Write tests for critical business logic
- Keep dependencies minimal — use native browser/Node APIs where possible
## README Requirement (MANDATORY)
Every project MUST have a meaningful `README.md` in the project root. This is enforced by the VCH audit system — projects without a proper README cannot be published.
Your README must include at minimum:
- **Project description** — what the project does (not just the template placeholder)
- **Installation instructions** — how to install dependencies
- **Development instructions** — how to start the dev server
- **Tech stack** — what technologies are used
Update the README whenever you add features, change setup steps, or modify the tech stack. The audit will reject READMEs that are still just the default template.
## Project Description (MANDATORY)
Every project has a `.vch-description` file in the project root. Keep this file up to date with a clear, non-technical description of your project:
- What the project does
- What problem it solves or what it's used for
- Who it's for
Do NOT include technical details (tech stack, dependencies, setup instructions) — those belong in the README. The `.vch-description` content is shown in the VCH Showcase and is synced automatically when an audit runs.
Update `.vch-description` whenever the project's purpose or scope changes significantly.
## Web Best Practices
- Use semantic HTML elements (`nav`, `main`, `article`, `section`, etc.)
- Mobile-first responsive design
- Follow accessibility guidelines (ARIA labels, keyboard navigation, color contrast)
- Optimize images and assets for performance
- Use environment variables for configuration — never hardcode secrets
## Commands
Fill in project-specific commands below:
- **Install dependencies:** `npm install`
- **Start dev server:** `npm run dev`
- **Run tests:** `npm test`
- **Build for production:** `npm run build`
- **Lint:** `npm run lint`
+38
View File
@@ -0,0 +1,38 @@
# Reelkit
Desktop UI for assembling weekly "Best-of" video reels from WhatsApp clips. Shows the Clip-Auswahl (clip selection) screen: browse transcribed clips, review AI recommendations, pick what goes into the reel, and reorder via drag-and-drop.
## Installation
```bash
npm install
```
## Development
```bash
npm run dev
```
## Build for production
```bash
npm run build
```
## Tech Stack
- React 18
- Vite 6
- Plain CSS with CSS custom properties (oklch color space)
- Inter Tight + JetBrains Mono (Google Fonts)
## Project Structure
```
src/
main.jsx — React entry point
App.jsx — Main app: clip list, sidebar, sticky footer
TweaksPanel.jsx — Floating tweaks panel (theme, density, export settings, …)
styles.css — All styles via CSS custom properties
```
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reelkit — Clip-Auswahl · KW 17</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
+1794
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "reelkit",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.3.3"
}
}
+569
View File
@@ -0,0 +1,569 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import './styles.css'
import {
useTweaks, TweaksPanel, TweakSection,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
} from './TweaksPanel.jsx'
// ===== DATA =====
const CLIPS_SEED = [
{ id: "c1", name: "IMG-20260418-WA0023.mp4", from: "Mama", duration: 18, size: "4.2 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Klare Pointe am Ende, gut hörbar, reagiert direkt auf Papas Witz.", transcript: "… also ich sag' euch, der Hund hat sich den ganzen Kuchen geschnappt und ist damit unter den Tisch —", tags: ["Familie","Moment"] },
{ id: "c2", name: "IMG-20260418-WA0027.mp4", from: "Jonas", duration: 42, size: "9.8 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Starker visueller Moment (Sonnenuntergang), ruhig eingesprochen.", transcript: "Guck mal wie das Licht gerade auf dem Wasser tanzt, das ist so verrückt, ich steh' hier schon seit zehn Minuten.", tags: ["Outdoor","Ruhig"] },
{ id: "c3", name: "IMG-20260418-WA0031.mp4", from: "Lena", duration: 8, size: "1.9 MB", hue: 340, status: "done", rec: "optional", reason: "Kurz und charmant, aber Ton etwas leise.", transcript: "Heey, ich wollt nur kurz sagen — ich hab's geschafft!", tags: ["Kurz"] },
{ id: "c4", name: "IMG-20260419-WA0002.mp4", from: "Papa", duration: 55, size: "12.1 MB", hue: 35, status: "done", rec: "überspringen", reason: "Lange Stille in der Mitte, Kameraruckler ab 0:32.", transcript: "So, jetzt müsste das eigentlich funktionieren … ähm … moment …", tags: ["Technik"] },
{ id: "c5", name: "IMG-20260419-WA0014.mp4", from: "Tante Rita", duration: 23, size: "5.4 MB", hue: 120, status: "done", rec: "empfohlen", reason: "Warme Begrüßung, gute Energie, passender Einstieg.", transcript: "Ihr Lieben, ich wünsch euch allen einen wunderbaren Sonntag und denkt dran —", tags: ["Gruß","Warm"] },
{ id: "c6", name: "IMG-20260419-WA0019.mp4", from: "Max", duration: 31, size: "7.2 MB", hue: 260, status: "done", rec: "optional", reason: "Gute Musik im Hintergrund, Sprache teils überlagert.", transcript: "Das Konzert gestern war einfach — ich hab keine Worte, Leute, das war —", tags: ["Musik"] },
{ id: "c7", name: "IMG-20260420-WA0005.mp4", from: "Mama", duration: 12, size: "2.8 MB", hue: 18, status: "done", rec: "empfohlen", reason: "Lacher, hohe emotionale Dichte in kurzer Zeit.", transcript: "Nein nein nein das kann nicht sein haha das ist nicht euer Ernst —", tags: ["Lacher"] },
{ id: "c8", name: "IMG-20260420-WA0011.mp4", from: "Jonas", duration: 47, size: "10.9 MB", hue: 210, status: "transcribing", progress: 64, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c9", name: "IMG-20260420-WA0018.mp4", from: "Lena", duration: 15, size: "3.4 MB", hue: 340, status: "transcribing", progress: 22, rec: null, reason: null, transcript: null, tags: [] },
{ id: "c10", name: "IMG-20260421-WA0003.mp4", from: "Papa", duration: 29, size: "6.7 MB", hue: 35, status: "queued", rec: null, reason: null, transcript: null, tags: [] },
{ id: "c11", name: "IMG-20260421-WA0009.mp4", from: "Oma", duration: 38, size: "8.6 MB", hue: 80, status: "done", rec: "empfohlen", reason: "Seltener O-Ton von Oma, sehr bewegend.", transcript: "Weißt du, als ich in deinem Alter war, gab's das alles noch nicht und trotzdem —", tags: ["Herz","Familie"] },
{ id: "c12", name: "IMG-20260421-WA0021.mp4", from: "Max", duration: 19, size: "4.5 MB", hue: 260, status: "done", rec: "optional", reason: "Solider Moment, aber thematisch ähnlich zu Clip 06.", transcript: "Und dann hat er noch gesagt, dass das nächste Album schon —", tags: ["Musik"] },
{ id: "c13", name: "IMG-20260422-WA0001.mp4", from: "Tante Rita", duration: 52, size: "11.8 MB", hue: 120, status: "done", rec: "überspringen", reason: "Großer Teil ist Anleitung, wenig Reel-Material.", transcript: "Also ihr nehmt zuerst die Zwiebeln, schneidet die klein, dann kommt der Knoblauch dazu —", tags: ["Tutorial"] },
{ id: "c14", name: "IMG-20260422-WA0008.mp4", from: "Jonas", duration: 11, size: "2.6 MB", hue: 210, status: "done", rec: "empfohlen", reason: "Punchline mit klarer Pointe, starker Abschluss.", transcript: "… und das, meine Damen und Herren, ist der Grund warum ich keine Katzen mag.", tags: ["Punchline"] },
{ id: "c15", name: "IMG-20260422-WA0015.mp4", from: "Mama", duration: 26, size: "6.0 MB", hue: 18, status: "done", rec: "optional", reason: "Nett, aber wiederholt Thema aus Clip 01.", transcript: "Der Hund macht's schon wieder, der sitzt schon wieder unter'm Tisch —", tags: ["Familie"] },
{ id: "c16", name: "IMG-20260423-WA0004.mp4", from: "Lena", duration: 33, size: "7.5 MB", hue: 340, status: "done", rec: "empfohlen", reason: "Persönliche Reflexion, gut geschnitten, ruhige Kamera.", transcript: "Ich hab in der Woche echt viel über das nachgedacht was du letztens meintest und —", tags: ["Persönlich"] },
{ id: "c17", name: "IMG-20260423-WA0012.mp4", from: "Max", duration: 44, size: "10.2 MB", hue: 260, status: "done", rec: "überspringen", reason: "Vertikal/horizontal-Wechsel, Schnitt wird unruhig.", transcript: "Wartet, ich dreh mal um — nee andersrum — ok jetzt —", tags: ["Unruhig"] },
{ id: "c18", name: "IMG-20260423-WA0020.mp4", from: "Papa", duration: 21, size: "4.9 MB", hue: 35, status: "done", rec: "optional", reason: "Ordentlicher Ton, Inhalt wiederholt sich später.", transcript: "Hab gestern das neue Regal aufgebaut und es steht, gradmal vier Stunden gedauert.", tags: ["Alltag"] },
]
const SELECTED_SEED = ["c2","c5","c1","c7","c14","c16","c11"]
// ===== THUMB =====
function Thumb({ hue, duration, selected, index, w = 180, h = 108 }) {
const bg = `linear-gradient(135deg, oklch(0.22 0.04 ${hue}) 0%, oklch(0.14 0.03 ${hue}) 60%, oklch(0.09 0.02 ${hue}) 100%)`
const mm = String(Math.floor(duration / 60)).padStart(1, "0")
const ss = String(duration % 60).padStart(2, "0")
return (
<div className="thumb" style={{ background: bg, width: w, height: h }}>
<svg className="thumb-stripes" viewBox="0 0 160 100" preserveAspectRatio="none">
<defs>
<pattern id={`p-${hue}`} width="18" height="18" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
<line x1="0" y1="0" x2="0" y2="18" stroke={`oklch(0.30 0.03 ${hue})`} strokeWidth="1" />
</pattern>
</defs>
<rect width="160" height="100" fill={`url(#p-${hue})`} opacity="0.35" />
</svg>
<div className="thumb-play">
<svg width="22" height="22" viewBox="0 0 22 22"><path d="M8 6 L16 11 L8 16 Z" fill="rgba(255,255,255,0.92)" /></svg>
</div>
<div className="thumb-dur">{mm}:{ss}</div>
{selected && <div className="thumb-badge">#{index}</div>}
</div>
)
}
// ===== SIDEBAR =====
function Sidebar({ current, counts }) {
const steps = [
{ id: "import", label: "Import", sub: `${counts.total} Clips geladen` },
{ id: "transcribe",label: "Transkription", sub: `${counts.done}/${counts.total} fertig` },
{ id: "select", label: "Clip-Auswahl", sub: `${counts.selected} ausgewählt` },
{ id: "export", label: "Assembly & Export", sub: `~${counts.totalDur}s Reel` },
]
return (
<aside className="sidebar">
<div className="brand">
<div className="brand-mark"><div /><div /><div /></div>
<div className="brand-text">
<div className="brand-name">Reelkit</div>
<div className="brand-sub">Wochenrückblick</div>
</div>
</div>
<div className="project">
<div className="project-label">Aktuelles Projekt</div>
<div className="project-name">KW 17 · Familie & Freunde</div>
<div className="project-meta">18 Clips · 9,3 min Rohmaterial</div>
</div>
<nav className="steps">
{steps.map((s, i) => (
<a key={s.id} className={`step ${s.id === current ? "is-current" : ""} ${i < steps.findIndex(x => x.id === current) ? "is-done" : ""}`}>
<span className="step-num">{String(i + 1).padStart(2, "0")}</span>
<span className="step-body">
<span className="step-label">{s.label}</span>
<span className="step-sub">{s.sub}</span>
</span>
</a>
))}
</nav>
<div className="side-foot">
<div className="foot-row"><span>Speicher</span><span className="mono">2,1 / 10 GB</span></div>
<div className="foot-bar"><div style={{ width: "21%" }} /></div>
<div className="foot-hint">Whisper-Modell: <span className="mono">medium · de</span></div>
</div>
</aside>
)
}
// ===== CLIP ROW =====
function ClipRow({ clip, selected, orderIndex, onToggle, onDrag, onDragOver, onDrop, onHover, hovered, tweaks }) {
const thumbSizes = { small: { w: 130, h: 78 }, medium: { w: 180, h: 108 }, large: { w: 230, h: 138 } }
const ts = thumbSizes[tweaks.thumbSize] || thumbSizes.medium
const recMeta = {
"empfohlen": { dot: "rec-good", label: "empfohlen" },
"optional": { dot: "rec-mid", label: "optional" },
"überspringen": { dot: "rec-skip", label: "überspringen" },
}[clip.rec] || null
const statusLabel = {
"queued": "in Warteschlange",
"transcribing": `transkribiere … ${clip.progress ?? 0}%`,
"done": "Transkript fertig",
}[clip.status]
return (
<div
className={`row ${selected ? "is-selected" : ""} ${hovered ? "is-hovered" : ""} ${clip.status !== "done" ? "is-pending" : ""}`}
draggable={clip.status === "done"}
onDragStart={(e) => onDrag(e, clip.id)}
onDragOver={(e) => onDragOver(e, clip.id)}
onDrop={(e) => onDrop(e, clip.id)}
onMouseEnter={() => onHover(clip.id)}
onMouseLeave={() => onHover(null)}
>
<div className="row-check">
<button className={`check ${selected ? "is-on" : ""}`} onClick={() => onToggle(clip.id)} disabled={clip.status !== "done"}>
{selected && <svg viewBox="0 0 12 12" width="11" height="11"><path d="M2 6.2 L5 9 L10 3.2" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</button>
{selected && <div className="order-pill mono">#{orderIndex}</div>}
</div>
<div className="row-thumb" style={{ width: ts.w }}>
<Thumb hue={clip.hue} duration={clip.duration} selected={selected} index={orderIndex} w={ts.w} h={ts.h} />
</div>
<div className="row-body">
<div className="row-head">
<div className="row-title">
<span className="from">{clip.from}</span>
{tweaks.showFilename && <>
<span className="sep">·</span>
<span className="filename mono">{clip.name}</span>
</>}
</div>
{tweaks.showMetaBar && <div className="row-meta mono">
<span>{clip.duration}s</span>
<span className="dotsep">·</span>
<span>{clip.size}</span>
<span className="dotsep">·</span>
<span className={`status s-${clip.status}`}>
<span className="status-dot" />
{statusLabel}
</span>
</div>}
</div>
{clip.status === "done" ? (
<div className="transcript" style={{ WebkitLineClamp: tweaks.transcriptLines, display: "-webkit-box", WebkitBoxOrient: "vertical", overflow: "hidden" }}>
<span className="quote"></span>
{clip.transcript}
<span className="quote">"</span>
</div>
) : clip.status === "transcribing" ? (
<div className="transcribing">
<div className="transcribe-bar"><div style={{ width: `${clip.progress}%` }} /></div>
<div className="transcribe-hint mono">Whisper · medium · verarbeite Audio …</div>
</div>
) : (
<div className="transcribing">
<div className="transcribe-hint mono">wartet auf GPU-Slot</div>
</div>
)}
{tweaks.showTags && clip.tags.length > 0 && (
<div className="tags">
{clip.tags.map(t => <span key={t} className="tag">{t}</span>)}
</div>
)}
</div>
<div className="row-rec">
{recMeta && (
<div className={`rec ${recMeta.dot}`}>
<div className="rec-head">
<span className="rec-dot" />
<span className="rec-label">{recMeta.label}</span>
</div>
{tweaks.showReasons && clip.reason && <div className="rec-reason">{clip.reason}</div>}
</div>
)}
</div>
<div className="row-grip" title="Zum Sortieren ziehen">
<svg width="10" height="16" viewBox="0 0 10 16">
<circle cx="2" cy="3" r="1.2" /><circle cx="8" cy="3" r="1.2" />
<circle cx="2" cy="8" r="1.2" /><circle cx="8" cy="8" r="1.2" />
<circle cx="2" cy="13" r="1.2" /><circle cx="8" cy="13" r="1.2" />
</svg>
</div>
</div>
)
}
// ===== MAIN =====
export default function App() {
const [clips, setClips] = useState(CLIPS_SEED)
const [selected, setSelected] = useState(SELECTED_SEED)
const [filter, setFilter] = useState("alle")
const [sort, setSort] = useState("empfehlung")
const [query, setQuery] = useState("")
const [hovered, setHovered] = useState(null)
const dragId = useRef(null)
const DEFAULTS = {
density: "comfortable",
showReasons: true,
accent: "moss",
targetLength: 90,
theme: "light",
thumbSize: "medium",
transcriptLines: 2,
showTags: true,
showFilename: true,
showMetaBar: true,
autoSelectRecommended: false,
groupBySender: false,
hideSkipped: false,
whisperModel: "medium",
whisperLang: "de",
aiStrictness: 2,
exportFormat: "mp4",
exportResolution: "1080p",
aspectRatio: "9:16",
addCrossfade: true,
crossfadeMs: 300,
addCaptions: false,
captionStyle: "minimal",
musicBed: "keine",
musicVolume: 20,
watermark: false,
minClipLength: 4,
maxClipLength: 60,
fontFamily: "inter-tight",
radius: 10,
cornerStyle: "soft",
}
const [tweaks, setTweaks] = useTweaks(DEFAULTS)
// Simulate transcription progress
useEffect(() => {
const t = setInterval(() => {
setClips(cs => cs.map(c => {
if (c.status === "transcribing") {
const p = Math.min(100, (c.progress ?? 0) + 3)
if (p >= 100) return { ...c, status: "done", progress: 100, rec: "optional", reason: "Frisch transkribiert, noch ungeprüft.", transcript: " (gerade fertig transkribiert) ", tags: ["Neu"] }
return { ...c, progress: p }
}
return c
}))
}, 1200)
return () => clearInterval(t)
}, [])
// Apply tweaks to CSS vars
useEffect(() => {
const map = {
amber: { h: 60, c: 0.13 },
ocean: { h: 230, c: 0.10 },
moss: { h: 145, c: 0.09 },
plum: { h: 340, c: 0.10 },
crimson: { h: 25, c: 0.14 },
graphite:{ h: 260, c: 0.02 },
}
const a = map[tweaks.accent] || map.moss
const root = document.documentElement
root.style.setProperty("--accent", `oklch(0.68 ${a.c} ${a.h})`)
root.style.setProperty("--accent-2", `oklch(0.58 ${a.c} ${a.h})`)
root.style.setProperty("--accent-bg", `oklch(0.96 ${a.c * 0.3} ${a.h})`)
root.style.setProperty("--density", tweaks.density === "compact" ? "10px" : tweaks.density === "spacious" ? "22px" : "16px")
root.style.setProperty("--radius", tweaks.cornerStyle === "sharp" ? "2px" : tweaks.cornerStyle === "round" ? "16px" : "10px")
const fonts = {
"inter-tight": '"Inter Tight", "Inter", system-ui, sans-serif',
"geist": '"Geist", "Inter", system-ui, sans-serif',
"ibm-plex": '"IBM Plex Sans", system-ui, sans-serif',
"system": 'system-ui, -apple-system, sans-serif',
}
root.style.setProperty("--font", fonts[tweaks.fontFamily] || fonts["inter-tight"])
root.style.fontFamily = fonts[tweaks.fontFamily] || fonts["inter-tight"]
if (tweaks.theme === "dark") {
root.style.setProperty("--bg", "oklch(0.18 0.01 80)")
root.style.setProperty("--bg-2", "oklch(0.22 0.01 80)")
root.style.setProperty("--panel", "oklch(0.24 0.01 80)")
root.style.setProperty("--line", "oklch(0.32 0.01 80)")
root.style.setProperty("--line-2", "oklch(0.38 0.01 80)")
root.style.setProperty("--ink", "oklch(0.96 0.004 80)")
root.style.setProperty("--ink-2", "oklch(0.78 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
} else {
root.style.setProperty("--bg", "oklch(0.985 0.004 85)")
root.style.setProperty("--bg-2", "oklch(0.97 0.004 85)")
root.style.setProperty("--panel", "#ffffff")
root.style.setProperty("--line", "oklch(0.92 0.004 85)")
root.style.setProperty("--line-2", "oklch(0.88 0.004 85)")
root.style.setProperty("--ink", "oklch(0.18 0.01 80)")
root.style.setProperty("--ink-2", "oklch(0.38 0.008 80)")
root.style.setProperty("--ink-3", "oklch(0.58 0.006 80)")
}
const thumbCols = { small: "130px", medium: "180px", large: "230px" }
root.style.setProperty("--thumb-col", thumbCols[tweaks.thumbSize] || thumbCols.medium)
}, [tweaks.accent, tweaks.density, tweaks.cornerStyle, tweaks.fontFamily, tweaks.theme, tweaks.thumbSize])
const filtered = useMemo(() => {
let list = clips.slice()
if (filter === "empfohlen") list = list.filter(c => c.rec === "empfohlen")
if (filter === "pending") list = list.filter(c => c.status !== "done")
if (filter === "ausgewählt") list = list.filter(c => selected.includes(c.id))
if (tweaks.hideSkipped) list = list.filter(c => c.rec !== "überspringen")
if (query.trim()) {
const q = query.toLowerCase()
list = list.filter(c => (c.transcript || "").toLowerCase().includes(q) || c.from.toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
}
if (sort === "empfehlung") {
const rank = { "empfohlen": 0, "optional": 1, "überspringen": 2 }
list.sort((a, b) => (rank[a.rec] ?? 3) - (rank[b.rec] ?? 3))
} else if (sort === "dauer") {
list.sort((a, b) => b.duration - a.duration)
} else if (sort === "absender") {
list.sort((a, b) => a.from.localeCompare(b.from))
}
return list
}, [clips, filter, sort, query, selected, tweaks.hideSkipped])
// Auto-select recommended
useEffect(() => {
if (!tweaks.autoSelectRecommended) return
setSelected(s => {
const add = clips.filter(c => c.rec === "empfohlen" && !s.includes(c.id)).map(c => c.id)
return add.length ? [...s, ...add] : s
})
}, [tweaks.autoSelectRecommended, clips])
const toggle = (id) => {
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id])
}
const onDrag = (e, id) => { dragId.current = id; e.dataTransfer.effectAllowed = "move" }
const onDragOver = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
}
const onDrop = (e, id) => {
if (!dragId.current || dragId.current === id) return
e.preventDefault()
setSelected(s => {
if (!s.includes(dragId.current) || !s.includes(id)) return s
const next = s.filter(x => x !== dragId.current)
const idx = next.indexOf(id)
next.splice(idx, 0, dragId.current)
return next
})
dragId.current = null
}
const counts = {
total: clips.length,
done: clips.filter(c => c.status === "done").length,
selected: selected.length,
totalDur: selected.reduce((n, id) => n + (clips.find(c => c.id === id)?.duration || 0), 0),
}
const overTarget = counts.totalDur > tweaks.targetLength
return (
<div className="app">
<Sidebar current="select" counts={counts} />
<main className="main">
<header className="top">
<div>
<div className="breadcrumb mono">Projekt · KW 17 · <span>Clip-Auswahl</span></div>
<h1>Welche Clips kommen ins Reel?</h1>
<p className="lede">18 Clips, 7 bereits empfohlen. Wähle aus, sortiere per Drag-and-drop und prüfe die KI-Begründung. Transkripte laufen im Hintergrund.</p>
</div>
<div className="top-actions">
<button className="btn-ghost">Auswahl zurücksetzen</button>
<button className="btn-primary">Weiter zu Export
<svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 7 H11 M7.5 3.5 L11 7 L7.5 10.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
</header>
<div className="strip">
<div className="strip-group">
<div className="strip-label">Auswahl</div>
<div className="strip-value">
<span className="big">{counts.selected}</span>
<span className="unit">Clips</span>
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Länge gewählt</div>
<div className="strip-value">
<span className={`big ${overTarget ? "over" : ""}`}>{counts.totalDur}s</span>
<span className="unit">/ Ziel {tweaks.targetLength}s</span>
</div>
<div className="strip-bar">
<div className={overTarget ? "over" : ""} style={{ width: `${Math.min(100, counts.totalDur / tweaks.targetLength * 100)}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group">
<div className="strip-label">Transkription</div>
<div className="strip-value">
<span className="big">{counts.done}</span>
<span className="unit">/ {counts.total} fertig</span>
</div>
<div className="strip-bar">
<div style={{ width: `${counts.done / counts.total * 100}%` }} />
</div>
</div>
<div className="strip-sep" />
<div className="strip-group right">
<div className="strip-label">KI-Empfehlungen</div>
<div className="rec-legend">
<span><i className="rec-good" /> 7 empfohlen</span>
<span><i className="rec-mid" /> 5 optional</span>
<span><i className="rec-skip" /> 3 überspringen</span>
</div>
</div>
</div>
<div className="toolbar">
<div className="filters">
{[
["alle", `Alle · ${clips.length}`],
["empfohlen", `Nur empfohlen · ${clips.filter(c => c.rec === "empfohlen").length}`],
["ausgewählt", `Ausgewählt · ${selected.length}`],
["pending", `In Arbeit · ${clips.filter(c => c.status !== "done").length}`],
].map(([k, l]) => (
<button key={k} className={`chip ${filter === k ? "is-on" : ""}`} onClick={() => setFilter(k)}>{l}</button>
))}
</div>
<div className="tools">
<div className="search">
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="M9.2 9.2 L12 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>
<input placeholder="Im Transkript suchen " value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="sort">
Sortieren
<select value={sort} onChange={e => setSort(e.target.value)}>
<option value="empfehlung">KI-Empfehlung</option>
<option value="dauer">Länge</option>
<option value="absender">Absender</option>
</select>
</label>
</div>
</div>
<div className={`list density-${tweaks.density}`}>
<div className="list-head">
<div />
<div>Vorschau</div>
<div>Absender & Transkript</div>
<div>KI-Empfehlung</div>
<div />
</div>
{filtered.map(c => {
const orderIndex = selected.indexOf(c.id) + 1
return (
<ClipRow
key={c.id}
clip={c}
selected={selected.includes(c.id)}
orderIndex={orderIndex}
hovered={hovered === c.id}
onHover={setHovered}
onToggle={toggle}
onDrag={onDrag}
onDragOver={onDragOver}
onDrop={onDrop}
tweaks={tweaks}
/>
)
})}
</div>
<div className="sticky-foot">
<div className="foot-info">
<div className="foot-title">Reihenfolge deines Reels</div>
<div className="foot-chain">
{selected.length === 0 && <span className="foot-empty">Noch keine Clips ausgewählt.</span>}
{selected.map((id, i) => {
const c = clips.find(x => x.id === id)
if (!c) return null
return (
<span key={id} style={{ display: 'contents' }}>
<div className="chain-clip">
<div className="chain-thumb" style={{ background: `linear-gradient(135deg, oklch(0.22 0.04 ${c.hue}), oklch(0.12 0.02 ${c.hue}))` }} />
<div className="chain-meta">
<div className="chain-from">#{i + 1} {c.from}</div>
<div className="chain-dur mono">{c.duration}s</div>
</div>
</div>
{i < selected.length - 1 && <div className="chain-arrow">→</div>}
</span>
)
})}
</div>
</div>
<div className="foot-actions">
<button className="btn-ghost">Als Entwurf speichern</button>
<button className="btn-primary big">
Weiter → Assembly ({counts.totalDur}s)
</button>
</div>
</div>
</main>
<TweaksPanel title="Tweaks">
<TweakSection label="Erscheinungsbild">
<TweakRadio label="Theme" value={tweaks.theme} options={[{ value: "light", label: "Hell" }, { value: "dark", label: "Dunkel" }]} onChange={v => setTweaks('theme', v)} />
<TweakRadio label="Akzent" value={tweaks.accent} options={[{ value: "moss", label: "Moss" }, { value: "amber", label: "Amber" }, { value: "ocean", label: "Ocean" }, { value: "plum", label: "Plum" }, { value: "crimson", label: "Crimson" }, { value: "graphite", label: "Graphite" }]} onChange={v => setTweaks('accent', v)} />
<TweakSelect label="Schriftart" value={tweaks.fontFamily} options={[{ value: "inter-tight", label: "Inter Tight" }, { value: "geist", label: "Geist" }, { value: "ibm-plex", label: "IBM Plex Sans" }, { value: "system", label: "System" }]} onChange={v => setTweaks('fontFamily', v)} />
<TweakRadio label="Ecken" value={tweaks.cornerStyle} options={[{ value: "sharp", label: "Scharf" }, { value: "soft", label: "Weich" }, { value: "round", label: "Rund" }]} onChange={v => setTweaks('cornerStyle', v)} />
</TweakSection>
<TweakSection label="Liste & Layout">
<TweakRadio label="Dichte" value={tweaks.density} options={[{ value: "compact", label: "Kompakt" }, { value: "comfortable", label: "Komfortabel" }, { value: "spacious", label: "Luftig" }]} onChange={v => setTweaks('density', v)} />
<TweakRadio label="Thumbnail-Größe" value={tweaks.thumbSize} options={[{ value: "small", label: "S" }, { value: "medium", label: "M" }, { value: "large", label: "L" }]} onChange={v => setTweaks('thumbSize', v)} />
<TweakSlider label="Transkript-Zeilen" value={tweaks.transcriptLines} min={1} max={5} step={1} onChange={v => setTweaks('transcriptLines', v)} />
<TweakToggle label="KI-Begründungen zeigen" value={tweaks.showReasons} onChange={v => setTweaks('showReasons', v)} />
<TweakToggle label="Tags zeigen" value={tweaks.showTags} onChange={v => setTweaks('showTags', v)} />
<TweakToggle label="Dateinamen zeigen" value={tweaks.showFilename} onChange={v => setTweaks('showFilename', v)} />
<TweakToggle label="Meta-Zeile zeigen" value={tweaks.showMetaBar} onChange={v => setTweaks('showMetaBar', v)} />
</TweakSection>
<TweakSection label="Verhalten">
<TweakToggle label="Empfohlene automatisch auswählen" value={tweaks.autoSelectRecommended} onChange={v => setTweaks('autoSelectRecommended', v)} />
<TweakToggle label="Nach Absender gruppieren" value={tweaks.groupBySender} onChange={v => setTweaks('groupBySender', v)} />
<TweakToggle label={'„Überspringen" ausblenden'} value={tweaks.hideSkipped} onChange={v => setTweaks('hideSkipped', v)} />
</TweakSection>
<TweakSection label="KI & Transkription">
<TweakSelect label="Whisper-Modell" value={tweaks.whisperModel} options={[{ value: "tiny", label: "tiny (schnell)" }, { value: "base", label: "base" }, { value: "small", label: "small" }, { value: "medium", label: "medium" }, { value: "large-v3", label: "large-v3 (genau)" }]} onChange={v => setTweaks('whisperModel', v)} />
<TweakSelect label="Sprache" value={tweaks.whisperLang} options={[{ value: "de", label: "Deutsch" }, { value: "en", label: "Englisch" }, { value: "auto", label: "Automatisch" }]} onChange={v => setTweaks('whisperLang', v)} />
<TweakSlider label="KI-Strenge" value={tweaks.aiStrictness} min={1} max={5} step={1} onChange={v => setTweaks('aiStrictness', v)} />
<TweakSlider label="Min. Clip-Länge (s)" value={tweaks.minClipLength} min={1} max={30} step={1} onChange={v => setTweaks('minClipLength', v)} />
<TweakSlider label="Max. Clip-Länge (s)" value={tweaks.maxClipLength} min={10} max={180} step={5} onChange={v => setTweaks('maxClipLength', v)} />
</TweakSection>
<TweakSection label="Reel-Export">
<TweakSlider label="Ziel-Länge (s)" value={tweaks.targetLength} min={30} max={300} step={10} onChange={v => setTweaks('targetLength', v)} />
<TweakRadio label="Seitenverhältnis" value={tweaks.aspectRatio} options={[{ value: "9:16", label: "9:16" }, { value: "1:1", label: "1:1" }, { value: "16:9", label: "16:9" }, { value: "4:5", label: "4:5" }]} onChange={v => setTweaks('aspectRatio', v)} />
<TweakSelect label="Auflösung" value={tweaks.exportResolution} options={[{ value: "720p", label: "720p" }, { value: "1080p", label: "1080p" }, { value: "1440p", label: "1440p" }, { value: "2160p", label: "4K" }]} onChange={v => setTweaks('exportResolution', v)} />
<TweakSelect label="Format" value={tweaks.exportFormat} options={[{ value: "mp4", label: "MP4 (H.264)" }, { value: "mov", label: "MOV (ProRes)" }, { value: "webm", label: "WebM (VP9)" }]} onChange={v => setTweaks('exportFormat', v)} />
<TweakToggle label="Crossfade zwischen Clips" value={tweaks.addCrossfade} onChange={v => setTweaks('addCrossfade', v)} />
{tweaks.addCrossfade && <TweakSlider label="Crossfade (ms)" value={tweaks.crossfadeMs} min={50} max={1500} step={50} onChange={v => setTweaks('crossfadeMs', v)} />}
<TweakToggle label="Untertitel einbrennen" value={tweaks.addCaptions} onChange={v => setTweaks('addCaptions', v)} />
{tweaks.addCaptions && <TweakRadio label="Untertitel-Stil" value={tweaks.captionStyle} options={[{ value: "minimal", label: "Minimal" }, { value: "bold", label: "Bold" }, { value: "karaoke", label: "Karaoke" }]} onChange={v => setTweaks('captionStyle', v)} />}
<TweakSelect label="Musikbett" value={tweaks.musicBed} options={[{ value: "keine", label: "Keine" }, { value: "ambient-01", label: "Ambient · ruhig" }, { value: "upbeat-01", label: "Upbeat · warm" }, { value: "cinematic", label: "Cinematic" }, { value: "eigene", label: "Eigene Datei …" }]} onChange={v => setTweaks('musicBed', v)} />
{tweaks.musicBed !== "keine" && <TweakSlider label="Musik-Lautstärke" value={tweaks.musicVolume} min={0} max={100} step={5} onChange={v => setTweaks('musicVolume', v)} />}
<TweakToggle label="Wasserzeichen" value={tweaks.watermark} onChange={v => setTweaks('watermark', v)} />
</TweakSection>
</TweaksPanel>
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
border-radius:6px;cursor:default;padding:0}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`
export function useTweaks(defaults) {
const init = { ...defaults, ...(window.__INITIAL_TWEAKS__ || {}) }
const [values, setValues] = useState(init)
const setTweak = useCallback((key, val) => {
setValues((prev) => ({ ...prev, [key]: val }))
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*')
}, [])
return [values, setTweak]
}
export function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = useState(false)
const dragRef = useRef(null)
const offsetRef = useRef({ x: 16, y: 16 })
const PAD = 16
const clampToViewport = useCallback(() => {
const panel = dragRef.current
if (!panel) return
const w = panel.offsetWidth, h = panel.offsetHeight
const maxRight = Math.max(PAD, window.innerWidth - w - PAD)
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD)
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
}
panel.style.right = offsetRef.current.x + 'px'
panel.style.bottom = offsetRef.current.y + 'px'
}, [])
useEffect(() => {
if (!open) return
clampToViewport()
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport)
return () => window.removeEventListener('resize', clampToViewport)
}
const ro = new ResizeObserver(clampToViewport)
ro.observe(document.documentElement)
return () => ro.disconnect()
}, [open, clampToViewport])
useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type
if (t === '__activate_edit_mode') setOpen(true)
else if (t === '__deactivate_edit_mode') setOpen(false)
}
window.addEventListener('message', onMsg)
window.parent.postMessage({ type: '__edit_mode_available' }, '*')
return () => window.removeEventListener('message', onMsg)
}, [])
const dismiss = () => {
setOpen(false)
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*')
}
const onDragStart = (e) => {
const panel = dragRef.current
if (!panel) return
const r = panel.getBoundingClientRect()
const sx = e.clientX, sy = e.clientY
const startRight = window.innerWidth - r.right
const startBottom = window.innerHeight - r.bottom
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
}
clampToViewport()
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
if (!open) return null
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
)
}
export function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
)
}
export function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
)
}
export function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
)
}
export function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
)
}
export function TweakRadio({ label, value, options, onChange }) {
const trackRef = useRef(null)
const [dragging, setDragging] = useState(false)
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }))
const idx = Math.max(0, opts.findIndex((o) => o.value === value))
const n = opts.length
const valueRef = useRef(value)
valueRef.current = value
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect()
const inner = r.width - 4
const i = Math.floor(((clientX - r.left - 2) / inner) * n)
return opts[Math.max(0, Math.min(n - 1, i))].value
}
const onPointerDown = (e) => {
setDragging(true)
const v0 = segAt(e.clientX)
if (v0 !== valueRef.current) onChange(v0)
const move = (ev) => {
if (!trackRef.current) return
const v = segAt(ev.clientX)
if (v !== valueRef.current) onChange(v)
}
const up = () => {
setDragging(false)
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
)
}
export function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o
const l = typeof o === 'object' ? o.label : o
return <option key={v} value={v}>{l}</option>
})}
</select>
</TweakRow>
)
}
export function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
)
}
export function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min
if (max != null && n > max) return max
return n
}
const startRef = useRef({ x: 0, val: 0 })
const onScrubStart = (e) => {
e.preventDefault()
startRef.current = { x: e.clientX, val: value }
const decimals = (String(step).split('.')[1] || '').length
const move = (ev) => {
const dx = ev.clientX - startRef.current.x
const raw = startRef.current.val + dx * step
const snapped = Math.round(raw / step) * step
onChange(clamp(Number(snapped.toFixed(decimals))))
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
)
}
export function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
)
}
export function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+325
View File
@@ -0,0 +1,325 @@
:root {
--bg: oklch(0.985 0.004 85);
--bg-2: oklch(0.97 0.004 85);
--panel: #ffffff;
--line: oklch(0.92 0.004 85);
--line-2: oklch(0.88 0.004 85);
--ink: oklch(0.18 0.01 80);
--ink-2: oklch(0.38 0.008 80);
--ink-3: oklch(0.58 0.006 80);
--accent: oklch(0.68 0.09 145);
--accent-2: oklch(0.58 0.09 145);
--accent-bg: oklch(0.96 0.027 145);
--rec-good: oklch(0.62 0.11 150);
--rec-mid: oklch(0.70 0.09 85);
--rec-skip: oklch(0.60 0.04 30);
--density: 16px;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgba(20,15,10,0.04), 0 4px 20px -8px rgba(20,15,10,0.06);
--font: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-family: var(--font);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font-size: 14px; line-height: 1.45; }
.mono { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-feature-settings: "tnum"; }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font-family: inherit; font-size: inherit; color: inherit; }
.app { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
/* ===== SIDEBAR ===== */
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--line);
padding: 28px 22px;
display: flex;
flex-direction: column;
gap: 28px;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; width: 28px; height: 28px; align-content: center; justify-content: center; background: var(--ink); border-radius: 6px; padding: 6px; }
.brand-mark > div { background: var(--bg-2); height: 4px; border-radius: 1px; }
.brand-mark > div:nth-child(2) { background: var(--accent); }
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.brand-sub { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; text-transform: uppercase; }
.project {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
background: var(--panel);
}
.project-label { font-size: 10px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; }
.project-name { font-weight: 600; font-size: 14px; margin-top: 4px; letter-spacing: -0.01em; }
.project-meta { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
.steps { display: flex; flex-direction: column; gap: 2px; }
.step {
display: flex;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
text-decoration: none;
color: var(--ink-2);
position: relative;
transition: background 0.12s;
cursor: default;
}
.step:hover { background: rgba(0,0,0,0.025); }
.step.is-current { background: var(--panel); box-shadow: 0 0 0 1px var(--line); }
.step.is-current .step-label { color: var(--ink); font-weight: 600; }
.step.is-current .step-num { color: var(--accent-2); background: var(--accent-bg); }
.step.is-done .step-num { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.step-num {
width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--line);
display: grid; place-items: center; font-family: "JetBrains Mono", monospace; font-size: 11px; color: var(--ink-3);
flex-shrink: 0;
}
.step-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.step-label { font-size: 13px; }
.step-sub { font-size: 11px; color: var(--ink-3); }
.side-foot { margin-top: auto; font-size: 12px; color: var(--ink-3); display: flex; flex-direction: column; gap: 8px; }
.foot-row { display: flex; justify-content: space-between; }
.foot-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.foot-bar > div { height: 100%; background: var(--ink-2); }
.foot-hint { font-size: 11px; }
/* ===== MAIN ===== */
.main { padding: 36px 44px 140px; max-width: 1400px; }
.top { display: flex; justify-content: space-between; align-items: flex-end; gap: 32px; margin-bottom: 28px; }
.breadcrumb { font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-bottom: 8px; }
.breadcrumb span { color: var(--ink); }
.top h1 { margin: 0; font-size: 30px; font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; }
.lede { margin: 12px 0 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; max-width: 60ch; letter-spacing: 0; text-wrap: pretty; }
.top-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
.btn-ghost {
padding: 9px 14px; border-radius: 8px; font-size: 13px; color: var(--ink-2);
border: 1px solid var(--line); background: var(--panel); transition: all 0.12s;
}
.btn-ghost:hover { border-color: var(--line-2); color: var(--ink); }
.btn-primary {
padding: 9px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: var(--ink); color: var(--bg); display: inline-flex; align-items: center; gap: 8px;
transition: background 0.12s;
}
.btn-primary:hover { background: oklch(0.10 0.01 80); }
.btn-primary.big { padding: 12px 20px; font-size: 14px; }
/* ===== STRIP ===== */
.strip {
display: grid; grid-template-columns: 1fr 1fr 1fr 1.2fr;
gap: 0;
background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 22px;
margin-bottom: 24px;
align-items: center;
}
.strip-sep { width: 1px; background: var(--line); height: 42px; justify-self: center; display: none; }
.strip-group { display: flex; flex-direction: column; gap: 6px; padding: 0 22px; border-left: 1px solid var(--line); }
.strip-group:first-child { border-left: none; padding-left: 0; }
.strip-group.right { align-items: flex-start; }
.strip-label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.strip-value { display: flex; align-items: baseline; gap: 6px; }
.strip-value .big { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; font-family: "JetBrains Mono", monospace; font-feature-settings: "tnum"; }
.strip-value .big.over { color: oklch(0.55 0.14 30); }
.strip-value .unit { font-size: 12px; color: var(--ink-3); }
.strip-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin-top: 2px; max-width: 160px; }
.strip-bar > div { height: 100%; background: var(--accent); transition: width 0.3s; }
.strip-bar > div.over { background: oklch(0.60 0.14 30); }
.rec-legend { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--ink-2); }
.rec-legend span { display: inline-flex; align-items: center; gap: 7px; }
.rec-legend i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.rec-legend i.rec-good { background: var(--rec-good); }
.rec-legend i.rec-mid { background: var(--rec-mid); }
.rec-legend i.rec-skip { background: var(--rec-skip); }
/* ===== TOOLBAR ===== */
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 16px; flex-wrap: wrap; }
.filters { display: flex; gap: 6px; }
.chip {
padding: 7px 12px; border-radius: 999px; font-size: 12.5px;
color: var(--ink-2); border: 1px solid var(--line); background: var(--panel);
transition: all 0.12s;
}
.chip:hover { border-color: var(--line-2); }
.chip.is-on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.tools { display: flex; gap: 10px; align-items: center; }
.search {
display: flex; align-items: center; gap: 8px;
padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
color: var(--ink-3);
}
.search:focus-within { border-color: var(--ink-2); color: var(--ink-2); }
.search input { border: none; outline: none; background: none; width: 260px; font-size: 13px; color: var(--ink); }
.search input::placeholder { color: var(--ink-3); }
.sort { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
.sort select {
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel);
font-size: 13px; color: var(--ink);
}
/* ===== LIST ===== */
.list {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden;
}
.list-head {
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: var(--bg-2);
font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3);
}
.row {
display: grid;
grid-template-columns: 56px var(--thumb-col, 180px) 1fr 210px 24px;
gap: 20px;
padding: var(--density) 18px;
border-bottom: 1px solid var(--line);
align-items: center;
transition: background 0.1s;
cursor: grab;
}
.row:last-child { border-bottom: none; }
.row:hover, .row.is-hovered { background: var(--bg-2); }
.row.is-selected { background: color-mix(in oklch, var(--accent-bg) 40%, var(--panel)); }
.row.is-selected:hover { background: color-mix(in oklch, var(--accent-bg) 60%, var(--panel)); }
.row.is-pending { opacity: 0.75; cursor: default; }
.row-check { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.check {
width: 20px; height: 20px; border-radius: 5px;
border: 1.5px solid var(--line-2); background: var(--panel);
display: grid; place-items: center;
transition: all 0.12s;
color: var(--bg);
}
.check:hover:not(:disabled) { border-color: var(--ink-2); }
.check:disabled { opacity: 0.35; cursor: not-allowed; }
.check.is-on { background: var(--accent-2); border-color: var(--accent-2); }
.order-pill {
font-size: 10px; padding: 2px 6px; border-radius: 4px;
background: var(--ink); color: var(--bg);
letter-spacing: 0.02em;
}
/* THUMB */
.thumb {
width: 180px; height: 108px; border-radius: var(--radius-sm);
position: relative; overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 0 0 1px rgba(255,255,255,0.04);
}
.thumb-stripes { position: absolute; inset: 0; width: 100%; height: 100%; }
.thumb-play {
position: absolute; inset: 0; display: grid; place-items: center;
background: radial-gradient(circle at center, rgba(0,0,0,0.25), transparent 60%);
}
.thumb-dur {
position: absolute; bottom: 6px; right: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
color: #fff; background: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px;
letter-spacing: 0.02em;
}
.thumb-badge {
position: absolute; top: 6px; left: 6px;
font-family: "JetBrains Mono", monospace; font-size: 10.5px;
background: var(--accent); color: var(--ink); padding: 2px 6px; border-radius: 3px;
font-weight: 600;
}
/* BODY */
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.row-head { display: flex; flex-direction: column; gap: 2px; }
.row-title { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.row-title .from { font-weight: 600; font-size: 14px; letter-spacing: -0.005em; }
.row-title .sep { color: var(--ink-3); }
.row-title .filename { font-size: 11.5px; color: var(--ink-3); }
.row-meta { font-size: 11.5px; color: var(--ink-3); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dotsep { opacity: 0.5; }
.status { display: inline-flex; align-items: center; gap: 5px; }
.status-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; display: inline-block; }
.status.s-done { color: var(--rec-good); }
.status.s-transcribing { color: var(--accent-2); }
.status.s-queued { color: var(--ink-3); }
.transcript {
font-size: 13px; color: var(--ink-2);
line-height: 1.5;
max-width: 62ch;
font-style: italic;
position: relative;
padding-left: 10px;
border-left: 2px solid var(--line-2);
}
.transcript .quote { opacity: 0.4; font-style: normal; }
.transcribing { display: flex; flex-direction: column; gap: 6px; max-width: 360px; }
.transcribe-bar { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; }
.transcribe-bar > div { height: 100%; background: var(--accent); animation: pulse 2s ease-in-out infinite alternate; }
@keyframes pulse { from { opacity: 0.7; } to { opacity: 1; } }
.transcribe-hint { font-size: 11px; color: var(--ink-3); }
.tags { display: flex; gap: 5px; margin-top: 2px; }
.tag { font-size: 10.5px; padding: 2px 7px; border-radius: 999px; background: var(--bg-2); color: var(--ink-2); border: 1px solid var(--line); }
/* REC */
.row-rec { display: flex; flex-direction: column; gap: 6px; }
.rec { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-2); }
.rec-head { display: flex; align-items: center; gap: 8px; }
.rec-dot { width: 7px; height: 7px; border-radius: 50%; }
.rec-label { font-size: 12px; font-weight: 600; letter-spacing: -0.005em; }
.rec-reason { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; line-height: 1.4; }
.rec.rec-good .rec-dot { background: var(--rec-good); }
.rec.rec-good { background: color-mix(in oklch, var(--rec-good) 8%, var(--panel)); border-color: color-mix(in oklch, var(--rec-good) 25%, var(--line)); }
.rec.rec-good .rec-label { color: oklch(0.45 0.10 150); }
.rec.rec-mid .rec-dot { background: var(--rec-mid); }
.rec.rec-mid .rec-label { color: oklch(0.48 0.09 85); }
.rec.rec-skip .rec-dot { background: var(--rec-skip); }
.rec.rec-skip .rec-label { color: var(--ink-3); }
.rec.rec-skip { opacity: 0.75; }
.row-grip { color: var(--ink-3); opacity: 0; transition: opacity 0.12s; cursor: grab; }
.row-grip svg { fill: currentColor; }
.row:hover .row-grip { opacity: 0.5; }
/* ===== STICKY FOOT ===== */
.sticky-foot {
position: fixed; bottom: 0; left: 280px; right: 0;
background: color-mix(in oklch, var(--panel) 90%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--line);
padding: 16px 44px;
display: flex; justify-content: space-between; align-items: center; gap: 24px;
z-index: 10;
}
.foot-info { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
.foot-title { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.foot-chain { display: flex; gap: 10px; align-items: center; overflow-x: auto; padding-bottom: 2px; }
.foot-chain::-webkit-scrollbar { height: 4px; }
.foot-chain::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 2px; }
.foot-empty { font-size: 12.5px; color: var(--ink-3); }
.chain-clip {
display: flex; align-items: center; gap: 8px; padding: 4px 10px 4px 4px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: 6px;
flex-shrink: 0;
}
.chain-thumb { width: 28px; height: 20px; border-radius: 3px; flex-shrink: 0; }
.chain-meta { display: flex; flex-direction: column; gap: 0; }
.chain-from { font-size: 11.5px; font-weight: 500; letter-spacing: -0.005em; }
.chain-dur { font-size: 10px; color: var(--ink-3); }
.chain-arrow { color: var(--ink-3); font-size: 12px; flex-shrink: 0; }
.foot-actions { display: flex; gap: 10px; flex-shrink: 0; }
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
})