Import-Oberfläche mit Zeitraum, Clipauswahl und Upload bauen

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) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 10:04:40 +00:00
1 parent 782d306cb1
commit d7102d04a4
12 files changed
+873 -11

No files matched your search

+15 -4
View File
@@ -17,7 +17,7 @@ automatisch passieren soll:
Ausgabe ist ein MP4 in 1920×1080 mit 30 fps. Hochkant-Clips werden mittig mit 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. 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 ## Voraussetzungen
@@ -29,11 +29,11 @@ unscharfem Hintergrund eingesetzt. Zugang ist über eine PIN geschützt.
```bash ```bash
npm install npm install
cp .env.example .env # APP_PIN eintragen 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 `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 ```bash
npm run build npm run build
@@ -49,6 +49,15 @@ NODE_ENV=production APP_PIN=… npm start
| `PORT` | Port des Servers, Vorgabe 3000 | | `PORT` | Port des Servers, Vorgabe 3000 |
| `VCH_DATA_DIR` | Ablage für Projekte und Uploads, Vorgabe `./data` | | `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 ## Befehle
- `npm test` – Tests (Node-Testrunner) - `npm test` – Tests (Node-Testrunner)
@@ -57,8 +66,10 @@ NODE_ENV=production APP_PIN=… npm start
## Aufbau ## 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/` – 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 - `test/` – Tests
- `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`) - `Dockerfile` – Produktionsimage mit ffmpeg; Deploy über VCH (`.vch/deploy.yaml`)
+9
View File
@@ -10,6 +10,15 @@ import { createProjectStore } from './projects.js';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 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(); const config = loadConfig();
if (!config.pin) { if (!config.pin) {
console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.'); console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.');
+11 -5
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from './api.js' import { api } from './api.js'
import Home from './Home.jsx'
import ImportView from './ImportView.jsx'
import Login from './Login.jsx' import Login from './Login.jsx'
import ProjectView from './ProjectView.jsx'
export default function App() { export default function App() {
const [session, setSession] = useState(null) const [session, setSession] = useState(null)
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const [view, setView] = useState({ name: 'home' })
async function loadSession() { async function loadSession() {
try { try {
@@ -29,17 +33,19 @@ export default function App() {
return <Login pinConfigured={session.pinConfigured} onLogin={loadSession} /> return <Login pinConfigured={session.pinConfigured} onLogin={loadSession} />
} }
const home = () => setView({ name: 'home' })
const openProject = (id) => setView({ name: 'project', id })
return ( return (
<div className="app"> <div className="app">
<header className="topbar"> <header className="topbar">
<span className="brand">FuerDieCuts</span> <button type="button" className="brand" onClick={home}>FuerDieCuts</button>
<button type="button" className="secondary" onClick={logout}>Abmelden</button> <button type="button" className="secondary" onClick={logout}>Abmelden</button>
</header> </header>
<main className="content"> <main className="content">
<section className="card empty"> {view.name === 'home' && <Home onNew={() => setView({ name: 'import' })} onOpen={openProject} />}
<h1>Neues Video</h1> {view.name === 'import' && <ImportView onCancel={home} onDone={openProject} />}
<p>Hier kommt als Nächstes der Import des WhatsApp-Exports hin.</p> {view.name === 'project' && <ProjectView key={view.id} id={view.id} onBack={home} />}
</section>
</main> </main>
</div> </div>
) )
+30
View File
@@ -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 (
<div className="actions">
<button type="button" onClick={() => folderInput.current.click()} disabled={disabled}>
{folderLabel}
</button>
<button
type="button"
className="secondary"
onClick={() => filesInput.current.click()}
disabled={disabled}
>
Einzelne Dateien wählen
</button>
<input ref={folderInput} type="file" webkitdirectory="" multiple hidden onChange={handleChange} />
<input ref={filesInput} type="file" multiple hidden onChange={handleChange} />
</div>
)
}
+49
View File
@@ -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 (
<div className="stack">
<div className="heading-row">
<h1>Deine Videos</h1>
<button type="button" onClick={onNew}>Neues Video</button>
</div>
{projects && projects.length === 0 && (
<section className="card">
<p className="muted">Noch keine Projekte. Starte mit „Neues Video“ und dem WhatsApp-Export.</p>
</section>
)}
{projects && projects.length > 0 && (
<ul className="project-list">
{projects.map((project) => (
<li key={project.id}>
<button type="button" className="project-card" onClick={() => onOpen(project.id)}>
<strong>
{project.range.from && project.range.to
? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
: 'Projekt'}
</strong>
<span className="muted">
{project.readyCount} von {project.clipCount} Clips hochgeladen · angelegt am{' '}
{new Date(project.createdAt).toLocaleDateString('de-DE')}
</span>
</button>
</li>
))}
</ul>
)}
<p className="error" role="alert">{error}</p>
</div>
)
}
+233
View File
@@ -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 (
<div className="stack">
<div className="heading-row">
<h1>Neues Video</h1>
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
</div>
<section className="card stack">
<h2>1. WhatsApp-Export wählen</h2>
<p className="muted">
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.
</p>
<ExportPicker onPick={pick} disabled={locked} />
{source && (
<p>
{source.clips.length} Videos im Export gefunden
{source.clips.length > 0 &&
`, vom ${formatDateTime(source.clips[0].timestamp)} bis ${formatDateTime(source.clips.at(-1).timestamp)}`}
.
</p>
)}
</section>
{source && (
<section className="card stack">
<h2>2. Zeitraum und Videos</h2>
<div className="actions">
<label className="field">
Von
<input
type="date"
value={range.from}
max={range.to}
disabled={locked}
onChange={(event) => setRange({ ...range, from: event.target.value })}
/>
</label>
<label className="field">
Bis
<input
type="date"
value={range.to}
min={range.from}
disabled={locked}
onChange={(event) => setRange({ ...range, to: event.target.value })}
/>
</label>
</div>
<ul className="days" aria-label="Videos pro Tag">
{days.map(({ day, count }) => (
<li key={day} className={count === 0 ? 'warn' : ''}>
{formatDay(day)}: {count}
</li>
))}
</ul>
{missing.length > 0 && (
<p className="warning" role="status">
{missing.length} Video(s) aus diesem Zeitraum stehen im Chat, fehlen aber im Export.
Wahrscheinlich sind sie nicht auf dem Handy gespeichert.
</p>
)}
{days.some(({ count }) => count === 0) && (
<p className="warning" role="status">
An manchen Tagen gibt es keine Videos. Fehlen Videos, waren sie beim Export
vermutlich nicht auf dem Handy heruntergeladen.
</p>
)}
{clips.length > 0 ? (
<>
<div className="actions">
<button type="button" className="secondary" onClick={() => selectAll(true)} disabled={locked}>
Alle
</button>
<button type="button" className="secondary" onClick={() => selectAll(false)} disabled={locked}>
Keine
</button>
</div>
<ul className="clip-list">
{clips.map((clip) => (
<li key={clip.file}>
<label>
<input
type="checkbox"
checked={!excluded.has(clip.file)}
disabled={locked}
onChange={() => toggle(clip.file)}
/>
<span className="clip-time">{formatDateTime(clip.timestamp)}</span>
<span className="clip-text">
<strong>{clip.sender ?? 'Unbekannt'}</strong>
{clip.caption && <span className="muted"> · {clip.caption}</span>}
</span>
{clip.duplicateOf && <span className="badge">doppelt</span>}
<span className="clip-size muted">{formatBytes(clip.size)}</span>
</label>
</li>
))}
</ul>
</>
) : (
<p className="muted">Keine Videos in diesem Zeitraum.</p>
)}
</section>
)}
{source && (
<section className="card stack">
<h2>3. Hochladen</h2>
<UploadProgress progress={progress} />
<div className="actions">
<button type="button" onClick={upload} disabled={busy || selected.length === 0}>
{project
? 'Upload fortsetzen'
: `${selected.length} Videos hochladen (${formatBytes(selectedBytes)})`}
</button>
</div>
</section>
)}
<p className="error" role="alert">{error}</p>
</div>
)
}
+159
View File
@@ -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 <p className="error" role="alert">{error}</p>
}
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 (
<div className="stack">
<div className="heading-row">
<h1>
{project.range.from && project.range.to
? `${formatDate(project.range.from)} – ${formatDate(project.range.to)}`
: 'Projekt'}
</h1>
<button type="button" className="secondary" onClick={onBack} disabled={busy}>
Zurück
</button>
</div>
{pending.length > 0 && (
<section className="card stack">
<h2>Upload unvollständig</h2>
<p className="muted">
{pending.length} Video(s) fehlen noch. Wähle den Exportordner erneut, der Upload macht
dort weiter, wo er aufgehört hat.
</p>
<UploadProgress progress={progress} />
<ExportPicker onPick={resume} disabled={busy} folderLabel="Exportordner wählen und fortsetzen" />
</section>
)}
<section className="card stack">
<h2>
{project.clips.length} Clips
{ready.length > 0 && (
<span className="muted"> · gekürzt etwa {formatDuration(totalSeconds)} min</span>
)}
</h2>
<ol className="clip-list">
{project.clips.map((clip) => (
<li key={clip.index} className="stack-tight">
<div className="clip-row">
<span className="clip-time">{formatDateTime(clip.timestamp)}</span>
<span className="clip-text">
<strong>{clip.sender ?? 'Unbekannt'}</strong>
{clip.caption && <span className="muted"> · {clip.caption}</span>}
</span>
<span className={`clip-size ${clip.status === 'error' ? 'error' : 'muted'}`}>
{clipDetails(clip)}
</span>
{clip.status === 'ready' && (
<button
type="button"
className="secondary small"
aria-expanded={preview === clip.index}
onClick={() => setPreview(preview === clip.index ? null : clip.index)}
>
{preview === clip.index ? 'Schließen' : 'Ansehen'}
</button>
)}
</div>
{preview === clip.index && (
<video
className="preview"
src={`api/projects/${project.id}/clips/${clip.index}/file`}
controls
autoPlay
/>
)}
</li>
))}
</ol>
</section>
<section className="card stack">
<h2>Schnitt</h2>
<p className="muted">Die Einstellungen für Intro, Outro, Musik und Voice-over folgen im nächsten Schritt.</p>
</section>
<div className="actions">
<button type="button" className="danger" onClick={remove} disabled={busy}>
Projekt löschen
</button>
</div>
<p className="error" role="alert">{error}</p>
</div>
)
}
+15
View File
@@ -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 (
<div className="progress-block">
<progress max="100" value={percent} aria-label="Upload-Fortschritt">{percent} %</progress>
<p className="muted">
Video {progress.current} von {progress.count} · {formatBytes(progress.done)} von{' '}
{formatBytes(progress.total)} · {percent} %
</p>
</div>
)
}
+31
View File
@@ -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]))
}
+48
View File
@@ -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
}
+78
View File
@@ -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
}
}
+195 -2
View File
@@ -7,6 +7,8 @@
--accent: #1f6f4a; --accent: #1f6f4a;
--accent-text: #ffffff; --accent-text: #ffffff;
--error: #b3261e; --error: #b3261e;
--warn-bg: #fff4d6;
--warn-text: #6b4e00;
--radius: 10px; --radius: 10px;
color-scheme: light; color-scheme: light;
} }
@@ -21,6 +23,8 @@
--accent: #4cc38a; --accent: #4cc38a;
--accent-text: #0d1f16; --accent-text: #0d1f16;
--error: #ff8a80; --error: #ff8a80;
--warn-bg: #3a300f;
--warn-text: #f3d77a;
color-scheme: dark; color-scheme: dark;
} }
} }
@@ -34,6 +38,8 @@
--accent: #4cc38a; --accent: #4cc38a;
--accent-text: #0d1f16; --accent-text: #0d1f16;
--error: #ff8a80; --error: #ff8a80;
--warn-bg: #3a300f;
--warn-text: #f3d77a;
color-scheme: dark; color-scheme: dark;
} }
@@ -137,6 +143,10 @@ input {
.brand { .brand {
font-weight: 700; font-weight: 700;
background: none;
border: none;
color: var(--text);
padding: 0.25rem 0;
} }
.content { .content {
@@ -145,7 +155,190 @@ input {
padding: 1.5rem 16px; padding: 1.5rem 16px;
} }
.empty p {
color: var(--muted); h2 {
margin: 0;
font-size: 1.1rem;
}
p {
margin: 0; 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);
}