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:
1 parent
782d306cb1
commit
d7102d04a4
12 files changed
+873
-11
No files matched your search
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
+11
-5
@@ -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 <Login pinConfigured={session.pinConfigured} onLogin={loadSession} />
|
||||
}
|
||||
|
||||
const home = () => setView({ name: 'home' })
|
||||
const openProject = (id) => setView({ name: 'project', id })
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<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>
|
||||
</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>
|
||||
{view.name === 'home' && <Home onNew={() => setView({ name: 'import' })} onOpen={openProject} />}
|
||||
{view.name === 'import' && <ImportView onCancel={home} onDone={openProject} />}
|
||||
{view.name === 'project' && <ProjectView key={view.id} id={view.id} onBack={home} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user