Initial commit: FuerDieCuts Vite/React app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+1794
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user