From d7102d04a4b42be84a2d6849940a0bba96e6849c Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 29 Sep 2026 10:04:40 +0000 Subject: [PATCH] =?UTF-8?q?Import-Oberfl=C3=A4che=20mit=20Zeitraum,=20Clip?= =?UTF-8?q?auswahl=20und=20Upload=20bauen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Browser liest den entpackten Exportordner selbst, schlägt Montag bis Freitag der Vorwoche vor, zeigt Videos pro Tag und warnt bei Lücken und fehlenden Dateien. Nur die gewählten Videos werden hochgeladen; ein abgebrochener Upload lässt sich in der Projektansicht fortsetzen. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 19 +++- server/index.js | 9 ++ src/App.jsx | 16 ++- src/ExportPicker.jsx | 30 ++++++ src/Home.jsx | 49 +++++++++ src/ImportView.jsx | 233 +++++++++++++++++++++++++++++++++++++++++ src/ProjectView.jsx | 159 ++++++++++++++++++++++++++++ src/UploadProgress.jsx | 15 +++ src/lib/exportFiles.js | 31 ++++++ src/lib/format.js | 48 +++++++++ src/lib/upload.js | 78 ++++++++++++++ src/styles.css | 197 +++++++++++++++++++++++++++++++++- 12 files changed, 873 insertions(+), 11 deletions(-) create mode 100644 src/ExportPicker.jsx create mode 100644 src/Home.jsx create mode 100644 src/ImportView.jsx create mode 100644 src/ProjectView.jsx create mode 100644 src/UploadProgress.jsx create mode 100644 src/lib/exportFiles.js create mode 100644 src/lib/format.js create mode 100644 src/lib/upload.js 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 +
-
-

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 ( +
+ + + + +
+ ) +} 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

+ +
+ + {projects && projects.length === 0 && ( +
+

Noch keine Projekte. Starte mit „Neues Video“ und dem WhatsApp-Export.

+
+ )} + + {projects && projects.length > 0 && ( +
    + {projects.map((project) => ( +
  • + +
  • + ))} +
+ )} + +

{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

+ +
+ +
+

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 && ( +
+

2. Zeitraum und Videos

+
+ + +
+ +
    + {days.map(({ day, count }) => ( +
  • + {formatDay(day)}: {count} +
  • + ))} +
+ + {missing.length > 0 && ( +

+ {missing.length} Video(s) aus diesem Zeitraum stehen im Chat, fehlen aber im Export. + Wahrscheinlich sind sie nicht auf dem Handy gespeichert. +

+ )} + {days.some(({ count }) => count === 0) && ( +

+ An manchen Tagen gibt es keine Videos. Fehlen Videos, waren sie beim Export + vermutlich nicht auf dem Handy heruntergeladen. +

+ )} + + {clips.length > 0 ? ( + <> +
+ + +
+
    + {clips.map((clip) => ( +
  • + +
  • + ))} +
+ + ) : ( +

Keine Videos in diesem Zeitraum.

+ )} +
+ )} + + {source && ( +
+

3. Hochladen

+ +
+ +
+
+ )} + +

{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'} +

+ +
+ + {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) => ( +
  1. +
    + {formatDateTime(clip.timestamp)} + + {clip.sender ?? 'Unbekannt'} + {clip.caption && · {clip.caption}} + + + {clipDetails(clip)} + + {clip.status === 'ready' && ( + + )} +
    + {preview === clip.index && ( +
  2. + ))} +
+
+ +
+

Schnitt

+

Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.

+
+ +
+ +
+ +

{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); +}