diff --git a/README.md b/README.md
index 7f7650e..950b9cf 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ automatisch passieren soll:
Ausgabe ist ein MP4 in 1920×1080 mit 30 fps. Hochkant-Clips werden mittig mit
unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt.
-> Stand: Grundgerüst mit Anmeldung. Import, Schnitt und Oberfläche folgen.
+> Stand: Anmeldung, Import des Exports und Upload der Videos laufen. Der Schnitt folgt.
## Voraussetzungen
@@ -29,11 +29,11 @@ unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt.
```bash
npm install
cp .env.example .env # APP_PIN eintragen
-APP_PIN=1234 npm run dev
+npm run dev
```
`npm run dev` startet den Express-Server samt Vite im Entwicklungsmodus auf
-einem Port (`PORT`, Vorgabe 3000). In Produktion:
+einem Port (`PORT`, Vorgabe 3000) und liest dabei die lokale `.env`. In Produktion:
```bash
npm run build
@@ -49,6 +49,15 @@ NODE_ENV=production APP_PIN=… npm start
| `PORT` | Port des Servers, Vorgabe 3000 |
| `VCH_DATA_DIR` | Ablage für Projekte und Uploads, Vorgabe `./data` |
+## Ablauf
+
+1. WhatsApp-Chat mit „Medien anhängen“ exportieren und entpacken.
+2. In der App „Neues Video“, den Exportordner wählen. Der Browser liest den
+ Chat selbst; vorgewählt ist Montag bis Freitag der Vorwoche.
+3. Videos prüfen (Duplikate sind abgewählt) und hochladen. Nur die gewählten
+ Videos werden übertragen, in Stücken von 8 MB; ein Abbruch lässt sich durch
+ erneutes Wählen des Ordners fortsetzen.
+
## Befehle
- `npm test` – Tests (Node-Testrunner)
@@ -57,8 +66,10 @@ NODE_ENV=production APP_PIN=… npm start
## Aufbau
-- `server/` – Express-Server: Anmeldung, API, später Import und ffmpeg-Schnitt
+- `server/` – Express-Server: Anmeldung, Projekte, Upload in Stücken, ffprobe
- `src/` – React-Oberfläche (Vite)
+- `src/lib/whatsapp.js` – liest den Chatexport und ordnet die Videos
+- `src/lib/upload.js` – Upload in Stücken mit Wiederaufnahme
- `test/` – Tests
- `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`)
diff --git a/server/index.js b/server/index.js
index fc9d93d..c192a85 100644
--- a/server/index.js
+++ b/server/index.js
@@ -10,6 +10,15 @@ import { createProjectStore } from './projects.js';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+// Lokal liest der Entwicklungsserver eine .env; in Produktion setzt VCH die Umgebung.
+if (process.env.NODE_ENV !== 'production') {
+ try {
+ process.loadEnvFile(path.join(rootDir, '.env'));
+ } catch {
+ // keine .env vorhanden
+ }
+}
+
const config = loadConfig();
if (!config.pin) {
console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.');
diff --git a/src/App.jsx b/src/App.jsx
index 3bae47b..a51cc5c 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react'
import { api } from './api.js'
+import Home from './Home.jsx'
+import ImportView from './ImportView.jsx'
import Login from './Login.jsx'
+import ProjectView from './ProjectView.jsx'
export default function App() {
const [session, setSession] = useState(null)
const [loadError, setLoadError] = useState('')
+ const [view, setView] = useState({ name: 'home' })
async function loadSession() {
try {
@@ -29,17 +33,19 @@ export default function App() {
return
}
+ const home = () => setView({ name: 'home' })
+ const openProject = (id) => setView({ name: 'project', id })
+
return (
- FuerDieCuts
+ FuerDieCuts
Abmelden
-
- Neues Video
- Hier kommt als Nächstes der Import des WhatsApp-Exports hin.
-
+ {view.name === 'home' && setView({ name: 'import' })} onOpen={openProject} />}
+ {view.name === 'import' && }
+ {view.name === 'project' && }
)
diff --git a/src/ExportPicker.jsx b/src/ExportPicker.jsx
new file mode 100644
index 0000000..5810548
--- /dev/null
+++ b/src/ExportPicker.jsx
@@ -0,0 +1,30 @@
+import { useRef } from 'react'
+
+// Ordnerauswahl für den entpackten Export, dazu die Auswahl einzelner Dateien.
+export default function ExportPicker({ onPick, disabled, folderLabel = 'Exportordner wählen' }) {
+ const folderInput = useRef(null)
+ const filesInput = useRef(null)
+
+ function handleChange(event) {
+ if (event.target.files.length > 0) onPick(event.target.files)
+ event.target.value = ''
+ }
+
+ return (
+
+ folderInput.current.click()} disabled={disabled}>
+ {folderLabel}
+
+ filesInput.current.click()}
+ disabled={disabled}
+ >
+ Einzelne Dateien wählen
+
+
+
+
+ )
+}
diff --git a/src/Home.jsx b/src/Home.jsx
new file mode 100644
index 0000000..09ec881
--- /dev/null
+++ b/src/Home.jsx
@@ -0,0 +1,49 @@
+import { useEffect, useState } from 'react'
+import { api } from './api.js'
+import { formatDate } from './lib/format.js'
+
+export default function Home({ onNew, onOpen }) {
+ const [projects, setProjects] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ api('projects').then(setProjects, (err) => setError(err.message))
+ }, [])
+
+ return (
+
+
+
Deine Videos
+ Neues Video
+
+
+ {projects && projects.length === 0 && (
+
+ Noch keine Projekte. Starte mit „Neues Video“ und dem WhatsApp-Export.
+
+ )}
+
+ {projects && projects.length > 0 && (
+
+ {projects.map((project) => (
+
+ onOpen(project.id)}>
+
+ {project.range.from && project.range.to
+ ? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
+ : 'Projekt'}
+
+
+ {project.readyCount} von {project.clipCount} Clips hochgeladen · angelegt am{' '}
+ {new Date(project.createdAt).toLocaleDateString('de-DE')}
+
+
+
+ ))}
+
+ )}
+
+
{error}
+
+ )
+}
diff --git a/src/ImportView.jsx b/src/ImportView.jsx
new file mode 100644
index 0000000..66fb5ae
--- /dev/null
+++ b/src/ImportView.jsx
@@ -0,0 +1,233 @@
+import { useState } from 'react'
+import { api } from './api.js'
+import ExportPicker from './ExportPicker.jsx'
+import UploadProgress from './UploadProgress.jsx'
+import { filesByBaseName, readExport } from './lib/exportFiles.js'
+import { daysInRange, formatBytes, formatDateTime, formatDay } from './lib/format.js'
+import { uploadProject } from './lib/upload.js'
+import { clipsInRange, collectClips, parseChat, previousWorkWeek } from './lib/whatsapp.js'
+
+const inDayRange = (timestamp, range) => {
+ const day = timestamp.slice(0, 10)
+ return day >= range.from && day <= range.to
+}
+
+export default function ImportView({ onCancel, onDone }) {
+ const [source, setSource] = useState(null)
+ const [range, setRange] = useState(() => previousWorkWeek())
+ const [excluded, setExcluded] = useState(() => new Set())
+ const [project, setProject] = useState(null)
+ const [progress, setProgress] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+
+ async function pick(fileList) {
+ setError('')
+ setBusy(true)
+ try {
+ const { chatText, files } = await readExport(fileList)
+ const { clips, missing } = collectClips(parseChat(chatText), files)
+ if (clips.length === 0) {
+ throw new Error('Im Export sind keine Videos. Wurde er mit „Medien anhängen“ erstellt?')
+ }
+ setSource({ clips, missing, fileByName: filesByBaseName(files) })
+ setExcluded(new Set(clips.filter((clip) => clip.duplicateOf).map((clip) => clip.file)))
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const clips = source ? clipsInRange(source.clips, range.from, range.to) : []
+ const missing = source ? source.missing.filter((entry) => inDayRange(entry.timestamp, range)) : []
+ const selected = clips.filter((clip) => !excluded.has(clip.file))
+ const selectedBytes = selected.reduce((sum, clip) => sum + clip.size, 0)
+ const days = daysInRange(range.from, range.to).map((day) => ({
+ day,
+ count: clips.filter((clip) => clip.timestamp.startsWith(day)).length,
+ }))
+ const locked = Boolean(project) || busy
+
+ function toggle(file) {
+ setExcluded((current) => {
+ const next = new Set(current)
+ if (next.has(file)) next.delete(file)
+ else next.add(file)
+ return next
+ })
+ }
+
+ function selectAll(include) {
+ setExcluded((current) => {
+ const next = new Set(current)
+ for (const clip of clips) {
+ if (include) next.delete(clip.file)
+ else next.add(clip.file)
+ }
+ return next
+ })
+ }
+
+ // Legt das Projekt einmal an; ein zweiter Aufruf setzt einen abgebrochenen Upload fort.
+ async function upload() {
+ setBusy(true)
+ setError('')
+ try {
+ let current = project
+ if (current) {
+ current = await api(`projects/${current.id}`)
+ } else {
+ current = await api('projects', {
+ method: 'POST',
+ body: {
+ range,
+ clips: selected.map(({ file, size, timestamp, sender, caption }) => ({
+ file,
+ size,
+ timestamp,
+ sender,
+ caption,
+ })),
+ },
+ })
+ setProject(current)
+ }
+ await uploadProject({ project: current, fileByName: source.fileByName, onProgress: setProgress })
+ onDone(current.id)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
Neues Video
+
+ Abbrechen
+
+
+
+
+ 1. WhatsApp-Export wählen
+
+ Den Chat mit „Medien anhängen“ exportieren und den entpackten Ordner wählen. Hochgeladen
+ werden später nur die Videos aus dem gewählten Zeitraum.
+
+
+ {source && (
+
+ {source.clips.length} Videos im Export gefunden
+ {source.clips.length > 0 &&
+ `, vom ${formatDateTime(source.clips[0].timestamp)} bis ${formatDateTime(source.clips.at(-1).timestamp)}`}
+ .
+
+ )}
+
+
+ {source && (
+
+ )}
+
+ {source && (
+
+ 3. Hochladen
+
+
+
+ {project
+ ? 'Upload fortsetzen'
+ : `${selected.length} Videos hochladen (${formatBytes(selectedBytes)})`}
+
+
+
+ )}
+
+
{error}
+
+ )
+}
diff --git a/src/ProjectView.jsx b/src/ProjectView.jsx
new file mode 100644
index 0000000..f039e42
--- /dev/null
+++ b/src/ProjectView.jsx
@@ -0,0 +1,159 @@
+import { useCallback, useEffect, useState } from 'react'
+import { api } from './api.js'
+import ExportPicker from './ExportPicker.jsx'
+import UploadProgress from './UploadProgress.jsx'
+import { filesByBaseName } from './lib/exportFiles.js'
+import { formatDate, formatDateTime, formatDuration } from './lib/format.js'
+import { uploadProject } from './lib/upload.js'
+
+function clipDetails(clip) {
+ if (clip.status === 'error') return `Fehler: ${clip.error}`
+ if (clip.status !== 'ready') return 'Upload offen'
+ const { duration, width, height, hasAudio } = clip.media
+ return [formatDuration(duration), width > height ? 'Quer' : 'Hoch', !hasAudio && 'ohne Ton']
+ .filter(Boolean)
+ .join(' · ')
+}
+
+export default function ProjectView({ id, onBack }) {
+ const [project, setProject] = useState(null)
+ const [preview, setPreview] = useState(null)
+ const [progress, setProgress] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+
+ const load = useCallback(async () => {
+ try {
+ setProject(await api(`projects/${id}`))
+ } catch (err) {
+ setError(err.message)
+ }
+ }, [id])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ async function resume(fileList) {
+ setBusy(true)
+ setError('')
+ try {
+ const files = Array.from(fileList).map((file) => ({
+ name: file.webkitRelativePath || file.name,
+ file,
+ }))
+ await uploadProject({
+ project: await api(`projects/${id}`),
+ fileByName: filesByBaseName(files),
+ onProgress: setProgress,
+ })
+ setProgress(null)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ load()
+ }
+ }
+
+ async function remove() {
+ if (!window.confirm('Projekt samt hochgeladenen Videos löschen?')) return
+ try {
+ await api(`projects/${id}`, { method: 'DELETE' })
+ onBack()
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ if (!project) {
+ return {error}
+ }
+
+ const pending = project.clips.filter((clip) => clip.status === 'pending')
+ const ready = project.clips.filter((clip) => clip.status === 'ready')
+ const totalSeconds = ready.reduce((sum, clip) => sum + Math.min(clip.media.duration, 10), 0)
+
+ return (
+
+
+
+ {project.range.from && project.range.to
+ ? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
+ : 'Projekt'}
+
+
+ Zurück
+
+
+
+ {pending.length > 0 && (
+
+ Upload unvollständig
+
+ {pending.length} Video(s) fehlen noch. Wähle den Exportordner erneut, der Upload macht
+ dort weiter, wo er aufgehört hat.
+
+
+
+
+ )}
+
+
+
+ {project.clips.length} Clips
+ {ready.length > 0 && (
+ · gekürzt etwa {formatDuration(totalSeconds)} min
+ )}
+
+
+ {project.clips.map((clip) => (
+
+
+ {formatDateTime(clip.timestamp)}
+
+ {clip.sender ?? 'Unbekannt'}
+ {clip.caption && · {clip.caption} }
+
+
+ {clipDetails(clip)}
+
+ {clip.status === 'ready' && (
+ setPreview(preview === clip.index ? null : clip.index)}
+ >
+ {preview === clip.index ? 'Schließen' : 'Ansehen'}
+
+ )}
+
+ {preview === clip.index && (
+
+ )}
+
+ ))}
+
+
+
+
+ Schnitt
+ Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.
+
+
+
+
+ Projekt löschen
+
+
+
+
{error}
+
+ )
+}
diff --git a/src/UploadProgress.jsx b/src/UploadProgress.jsx
new file mode 100644
index 0000000..2efd145
--- /dev/null
+++ b/src/UploadProgress.jsx
@@ -0,0 +1,15 @@
+import { formatBytes } from './lib/format.js'
+
+export default function UploadProgress({ progress }) {
+ if (!progress) return null
+ const percent = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 100
+ return (
+
+
{percent} %
+
+ Video {progress.current} von {progress.count} · {formatBytes(progress.done)} von{' '}
+ {formatBytes(progress.total)} · {percent} %
+
+
+ )
+}
diff --git a/src/lib/exportFiles.js b/src/lib/exportFiles.js
new file mode 100644
index 0000000..d03743a
--- /dev/null
+++ b/src/lib/exportFiles.js
@@ -0,0 +1,31 @@
+import { baseName } from './whatsapp.js'
+
+function pickChatFile(files) {
+ const texts = files.filter((file) => /\.txt$/i.test(file.name))
+ return (
+ texts.find((file) => baseName(file.name) === '_chat.txt') ??
+ texts.find((file) => /chat/i.test(baseName(file.name))) ??
+ (texts.length === 1 ? texts[0] : null)
+ )
+}
+
+// Nimmt die Auswahl aus einem Ordner- oder Dateidialog entgegen.
+export async function readExport(fileList) {
+ const files = Array.from(fileList).map((file) => ({
+ name: file.webkitRelativePath || file.name,
+ size: file.size,
+ file,
+ }))
+ if (files.some((file) => /\.zip$/i.test(file.name)) && !pickChatFile(files)) {
+ throw new Error('Bitte die ZIP-Datei zuerst entpacken (Doppelklick) und dann den Ordner wählen.')
+ }
+ const chat = pickChatFile(files)
+ if (!chat) {
+ throw new Error('Keine Chatdatei (_chat.txt) gefunden. Bitte den ganzen Exportordner wählen.')
+ }
+ return { chatText: await chat.file.text(), files }
+}
+
+export function filesByBaseName(files) {
+ return new Map(files.map((file) => [baseName(file.name), file.file]))
+}
diff --git a/src/lib/format.js b/src/lib/format.js
new file mode 100644
index 0000000..cbdf783
--- /dev/null
+++ b/src/lib/format.js
@@ -0,0 +1,48 @@
+import { toDateString } from './whatsapp.js'
+
+const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
+
+export function formatBytes(bytes) {
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`
+ return `${Math.max(1, Math.round(bytes / 1e6))} MB`
+}
+
+// "2026-09-21T10:15:30" → "21.09. 10:15"
+export function formatDateTime(timestamp) {
+ const [date, time] = timestamp.split('T')
+ const [, month, day] = date.split('-')
+ return `${day}.${month}. ${time.slice(0, 5)}`
+}
+
+// "2026-09-21" → "Mo 21.09."
+export function formatDay(date) {
+ const [year, month, day] = date.split('-').map(Number)
+ const weekday = WEEKDAYS[new Date(year, month - 1, day).getDay()]
+ return `${weekday} ${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.`
+}
+
+// "2026-09-21" → "21.09.2026"
+export function formatDate(date) {
+ const [year, month, day] = date.split('-')
+ return `${day}.${month}.${year}`
+}
+
+export function formatDuration(seconds) {
+ const whole = Math.round(seconds)
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`
+}
+
+// Alle Tage von from bis to, beide eingeschlossen, höchstens 62.
+export function daysInRange(from, to) {
+ if (!from || !to || from > to) return []
+ const days = []
+ const [year, month, day] = from.split('-').map(Number)
+ const cursor = new Date(year, month - 1, day)
+ while (days.length < 62) {
+ const value = toDateString(cursor)
+ if (value > to) break
+ days.push(value)
+ cursor.setDate(cursor.getDate() + 1)
+ }
+ return days
+}
diff --git a/src/lib/upload.js b/src/lib/upload.js
new file mode 100644
index 0000000..1725431
--- /dev/null
+++ b/src/lib/upload.js
@@ -0,0 +1,78 @@
+const CHUNK_BYTES = 8 * 1024 * 1024
+const MAX_RETRIES = 6
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+
+// Lädt ein Video in Stücken hoch. Bei Netzfehlern wird mit wachsender Pause
+// wiederholt; weicht der Stand ab, nennt der Server den richtigen Offset.
+async function uploadClip({ projectId, index, blob, startAt, onProgress, signal }) {
+ let offset = startAt
+ let failures = 0
+
+ const retry = async (message) => {
+ failures += 1
+ if (failures > MAX_RETRIES) throw new Error(message)
+ await wait(Math.min(30000, 1000 * 2 ** failures))
+ }
+
+ for (;;) {
+ let res
+ try {
+ res = await fetch(`api/projects/${projectId}/clips/${index}?offset=${offset}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: blob.slice(offset, Math.min(offset + CHUNK_BYTES, blob.size)),
+ credentials: 'same-origin',
+ signal,
+ })
+ } catch (error) {
+ if (signal?.aborted) throw error
+ await retry('Verbindung verloren. Bitte erneut versuchen.')
+ continue
+ }
+
+ const data = await res.json().catch(() => ({}))
+ if (res.status === 409) {
+ if (Number.isSafeInteger(data.received)) offset = data.received
+ else await retry(data.error || 'Upload hängt.')
+ continue
+ }
+ if (res.status >= 500) {
+ await retry(data.error || `Serverfehler (${res.status})`)
+ continue
+ }
+ if (!res.ok) throw new Error(data.error || `Upload fehlgeschlagen (${res.status})`)
+
+ failures = 0
+ offset = data.received
+ onProgress(offset)
+ if (data.status !== 'pending') return data.status
+ }
+}
+
+// Lädt alle noch offenen Clips eines Projekts hoch. fileByName: Dateiname → File.
+export async function uploadProject({ project, fileByName, onProgress, signal }) {
+ const pending = project.clips.filter((clip) => clip.status === 'pending')
+ const missing = pending.filter((clip) => !fileByName.has(clip.file))
+ if (missing.length > 0) {
+ throw new Error(`${missing.length} Video(s) fehlen in der Auswahl, z. B. ${missing[0].file}.`)
+ }
+
+ const total = pending.reduce((sum, clip) => sum + clip.size, 0)
+ let completed = 0
+
+ for (const [position, clip] of pending.entries()) {
+ const report = (received) =>
+ onProgress({ done: completed + received, total, current: position + 1, count: pending.length })
+ report(clip.received)
+ await uploadClip({
+ projectId: project.id,
+ index: clip.index,
+ blob: fileByName.get(clip.file),
+ startAt: clip.received,
+ signal,
+ onProgress: report,
+ })
+ completed += clip.size
+ }
+}
diff --git a/src/styles.css b/src/styles.css
index 34d935c..1cc82e3 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -7,6 +7,8 @@
--accent: #1f6f4a;
--accent-text: #ffffff;
--error: #b3261e;
+ --warn-bg: #fff4d6;
+ --warn-text: #6b4e00;
--radius: 10px;
color-scheme: light;
}
@@ -21,6 +23,8 @@
--accent: #4cc38a;
--accent-text: #0d1f16;
--error: #ff8a80;
+ --warn-bg: #3a300f;
+ --warn-text: #f3d77a;
color-scheme: dark;
}
}
@@ -34,6 +38,8 @@
--accent: #4cc38a;
--accent-text: #0d1f16;
--error: #ff8a80;
+ --warn-bg: #3a300f;
+ --warn-text: #f3d77a;
color-scheme: dark;
}
@@ -137,6 +143,10 @@ input {
.brand {
font-weight: 700;
+ background: none;
+ border: none;
+ color: var(--text);
+ padding: 0.25rem 0;
}
.content {
@@ -145,7 +155,190 @@ input {
padding: 1.5rem 16px;
}
-.empty p {
- color: var(--muted);
+
+h2 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+p {
margin: 0;
}
+
+.muted {
+ color: var(--muted);
+}
+
+.stack {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.stack-tight {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: flex-end;
+}
+
+.heading-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.heading-row h1 {
+ margin: 0;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.9rem;
+ color: var(--muted);
+}
+
+button.small {
+ padding: 0.3rem 0.7rem;
+ font-size: 0.9rem;
+}
+
+button.danger {
+ background: transparent;
+ color: var(--error);
+ border-color: var(--error);
+}
+
+.warning {
+ background: var(--warn-bg);
+ color: var(--warn-text);
+ padding: 0.6rem 0.8rem;
+ border-radius: var(--radius);
+}
+
+.days {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.days li {
+ padding: 0.25rem 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ font-size: 0.9rem;
+}
+
+.days li.warn {
+ background: var(--warn-bg);
+ color: var(--warn-text);
+ border-color: transparent;
+}
+
+.clip-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--border);
+}
+
+.clip-list li {
+ border-bottom: 1px solid var(--border);
+ padding: 0.5rem 0;
+}
+
+.clip-list label,
+.clip-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem 0.75rem;
+}
+
+.clip-list label {
+ cursor: pointer;
+}
+
+.clip-list input[type='checkbox'] {
+ width: 1.2rem;
+ height: 1.2rem;
+ accent-color: var(--accent);
+}
+
+.clip-time {
+ font-variant-numeric: tabular-nums;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.clip-text {
+ flex: 1 1 14rem;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.clip-size {
+ white-space: nowrap;
+ font-size: 0.9rem;
+}
+
+.badge {
+ font-size: 0.8rem;
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ background: var(--warn-bg);
+ color: var(--warn-text);
+}
+
+.preview {
+ width: 100%;
+ max-height: 60vh;
+ background: #000;
+ border-radius: var(--radius);
+}
+
+.progress-block {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+progress {
+ width: 100%;
+ height: 0.75rem;
+ accent-color: var(--accent);
+}
+
+.project-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.project-card {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ text-align: left;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+}