Grundgerüst mit Server, PIN-Anmeldung und Docker-Deploy aufbauen
Die alte Clip-Auswahl-Oberfläche entfällt. Neu sind ein Express-Server, der im Entwicklungsbetrieb Vite einbindet, eine PIN-Anmeldung mit signiertem Sitzungs-Cookie und ein Docker-Image mit ffmpeg für VCH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8ed1396b1c
commit
f7d249f28b
23 files changed
+4456
-1358
No files matched your search
+28
-551
@@ -1,569 +1,46 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import './styles.css'
|
||||
import {
|
||||
useTweaks, TweaksPanel, TweakSection,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
} from './TweaksPanel.jsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from './api.js'
|
||||
import Login from './Login.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 [session, setSession] = useState(null)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
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",
|
||||
async function loadSession() {
|
||||
try {
|
||||
setSession(await api('session'))
|
||||
} catch (err) {
|
||||
setLoadError(err.message)
|
||||
}
|
||||
}
|
||||
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)
|
||||
loadSession()
|
||||
}, [])
|
||||
|
||||
// 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])
|
||||
async function logout() {
|
||||
await api('logout', { method: 'POST' })
|
||||
loadSession()
|
||||
}
|
||||
|
||||
const onDrag = (e, id) => { dragId.current = id; e.dataTransfer.effectAllowed = "move" }
|
||||
const onDragOver = (e, id) => {
|
||||
if (!dragId.current || dragId.current === id) return
|
||||
e.preventDefault()
|
||||
if (loadError) return <main className="login"><p className="error">{loadError}</p></main>
|
||||
if (!session) return null
|
||||
if (!session.authenticated) {
|
||||
return <Login pinConfigured={session.pinConfigured} onLogin={loadSession} />
|
||||
}
|
||||
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>
|
||||
<header className="topbar">
|
||||
<span className="brand">FuerDieCuts</span>
|
||||
<button type="button" className="secondary" onClick={logout}>Abmelden</button>
|
||||
</header>
|
||||
<main className="content">
|
||||
<section className="card empty">
|
||||
<h1>Neues Video</h1>
|
||||
<p>Hier kommt als Nächstes der Import des WhatsApp-Exports hin.</p>
|
||||
</section>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user