486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
// frontend/src/components/ui/FinishedDownloads.tsx
|
||
'use client'
|
||
|
||
import * as React from 'react'
|
||
import { useMemo } from 'react'
|
||
import Table, { type Column, type SortState } from './Table'
|
||
import Card from './Card'
|
||
import type { RecordJob } from '../../types'
|
||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
|
||
import { buildDownloadContextMenu } from './DownloadContextMenu'
|
||
import Button from './Button'
|
||
|
||
type Props = {
|
||
jobs: RecordJob[]
|
||
doneJobs: RecordJob[]
|
||
onOpenPlayer: (job: RecordJob) => void
|
||
}
|
||
|
||
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim()
|
||
const baseName = (p: string) => {
|
||
const n = norm(p)
|
||
const parts = n.split('/')
|
||
return parts[parts.length - 1] || ''
|
||
}
|
||
const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id
|
||
|
||
function formatDuration(ms: number): string {
|
||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||
const totalSec = Math.floor(ms / 1000)
|
||
const h = Math.floor(totalSec / 3600)
|
||
const m = Math.floor((totalSec % 3600) / 60)
|
||
const s = totalSec % 60
|
||
if (h > 0) return `${h}h ${m}m`
|
||
if (m > 0) return `${m}m ${s}s`
|
||
return `${s}s`
|
||
}
|
||
|
||
// Fallback: reine Aufnahmezeit aus startedAt/endedAt
|
||
function runtimeFromTimestamps(job: RecordJob): string {
|
||
const start = Date.parse(String(job.startedAt || ''))
|
||
const end = Date.parse(String(job.endedAt || ''))
|
||
if (!Number.isFinite(start) || !Number.isFinite(end)) return '—'
|
||
return formatDuration(end - start)
|
||
}
|
||
|
||
const httpCodeFromError = (err?: string) => {
|
||
const m = (err ?? '').match(/\bHTTP\s+(\d{3})\b/i)
|
||
return m ? `HTTP ${m[1]}` : null
|
||
}
|
||
|
||
const modelNameFromOutput = (output?: string) => {
|
||
const file = baseName(output || '')
|
||
if (!file) return '—'
|
||
const stem = file.replace(/\.[^.]+$/, '')
|
||
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||
if (m?.[1]) return m[1]
|
||
const i = stem.lastIndexOf('_')
|
||
return i > 0 ? stem.slice(0, i) : stem
|
||
}
|
||
|
||
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
|
||
const PAGE_SIZE = 50
|
||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
|
||
|
||
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
|
||
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
|
||
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
|
||
|
||
const [sort, setSort] = React.useState<SortState>(null)
|
||
|
||
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos
|
||
const [thumbTick, setThumbTick] = React.useState(0)
|
||
|
||
React.useEffect(() => {
|
||
const id = window.setInterval(() => {
|
||
setThumbTick((t) => t + 1)
|
||
}, 3000) // alle 3 Sekunden
|
||
|
||
return () => window.clearInterval(id)
|
||
}, [])
|
||
|
||
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
|
||
const [durations, setDurations] = React.useState<Record<string, number>>({})
|
||
|
||
const openCtx = (job: RecordJob, e: React.MouseEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setCtx({ x: e.clientX, y: e.clientY, job })
|
||
}
|
||
|
||
const openCtxAt = (job: RecordJob, x: number, y: number) => {
|
||
setCtx({ x, y, job })
|
||
}
|
||
|
||
const markDeleting = React.useCallback((key: string, value: boolean) => {
|
||
setDeletingKeys((prev) => {
|
||
const next = new Set(prev)
|
||
if (value) next.add(key)
|
||
else next.delete(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const markDeleted = React.useCallback((key: string) => {
|
||
setDeletedKeys((prev) => {
|
||
const next = new Set(prev)
|
||
next.add(key)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const deleteVideo = React.useCallback(
|
||
async (job: RecordJob) => {
|
||
const file = baseName(job.output || '')
|
||
const key = keyFor(job)
|
||
if (!file) {
|
||
window.alert('Kein Dateiname gefunden – kann nicht löschen.')
|
||
return
|
||
}
|
||
if (deletingKeys.has(key)) return
|
||
|
||
markDeleting(key, true)
|
||
try {
|
||
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, {
|
||
method: 'POST',
|
||
})
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
markDeleted(key)
|
||
} catch (e: any) {
|
||
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
|
||
} finally {
|
||
markDeleting(key, false)
|
||
}
|
||
},
|
||
[deletingKeys, markDeleted, markDeleting]
|
||
)
|
||
|
||
const items = React.useMemo<ContextMenuItem[]>(() => {
|
||
if (!ctx) return []
|
||
const j = ctx.job
|
||
const model = modelNameFromOutput(j.output)
|
||
|
||
return buildDownloadContextMenu({
|
||
job: j,
|
||
modelName: model,
|
||
state: {
|
||
watching: false,
|
||
liked: null,
|
||
favorite: false,
|
||
hot: false,
|
||
keep: false,
|
||
},
|
||
actions: {
|
||
onPlay: onOpenPlayer,
|
||
|
||
onToggleWatch: (job) => console.log('toggle watch', job.id),
|
||
onSetLike: (job, liked) => console.log('set like', job.id, liked),
|
||
onToggleFavorite: (job) => console.log('toggle favorite', job.id),
|
||
onMoreFromModel: (modelName) => console.log('more from', modelName),
|
||
|
||
onRevealInExplorer: (job) => console.log('reveal in explorer', job.output),
|
||
onAddToDownloadList: (job) => console.log('add to download list', job.id),
|
||
onToggleHot: (job) => console.log('toggle hot', job.id),
|
||
|
||
onToggleKeep: (job) => console.log('toggle keep', job.id),
|
||
onDelete: (job) => {
|
||
setCtx(null)
|
||
void deleteVideo(job)
|
||
},
|
||
},
|
||
})
|
||
}, [ctx, deleteVideo, onOpenPlayer])
|
||
|
||
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
|
||
const k = keyFor(job)
|
||
const sec = durations[k]
|
||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
|
||
|
||
const start = Date.parse(String(job.startedAt || ''))
|
||
const end = Date.parse(String(job.endedAt || ''))
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return Number.POSITIVE_INFINITY
|
||
return (end - start) / 1000
|
||
}, [durations])
|
||
|
||
const rows = useMemo(() => {
|
||
const map = new Map<string, RecordJob>()
|
||
|
||
// Basis: Files aus dem Done-Ordner
|
||
for (const j of doneJobs) map.set(keyFor(j), j)
|
||
|
||
// Jobs aus /list drübermergen (z.B. frisch fertiggewordene)
|
||
for (const j of jobs) {
|
||
const k = keyFor(j)
|
||
if (map.has(k)) map.set(k, { ...map.get(k)!, ...j })
|
||
}
|
||
|
||
const list = Array.from(map.values()).filter((j) => {
|
||
if (deletedKeys.has(keyFor(j))) return false
|
||
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
|
||
})
|
||
|
||
list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || '')))
|
||
return list
|
||
}, [jobs, doneJobs, deletedKeys])
|
||
|
||
React.useEffect(() => {
|
||
setVisibleCount(PAGE_SIZE)
|
||
}, [rows.length])
|
||
|
||
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
|
||
|
||
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
|
||
const runtimeOf = (job: RecordJob): string => {
|
||
const k = keyFor(job)
|
||
const sec = durations[k]
|
||
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
|
||
return formatDuration(sec * 1000)
|
||
}
|
||
return runtimeFromTimestamps(job)
|
||
}
|
||
|
||
// Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
|
||
const handleDuration = React.useCallback((job: RecordJob, seconds: number) => {
|
||
if (!Number.isFinite(seconds) || seconds <= 0) return
|
||
const k = keyFor(job)
|
||
setDurations((prev) => {
|
||
const old = prev[k]
|
||
if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) {
|
||
return prev // keine unnötigen Re-Renders
|
||
}
|
||
return { ...prev, [k]: seconds }
|
||
})
|
||
}, [])
|
||
|
||
const columns: Column<RecordJob>[] = [
|
||
{
|
||
key: 'preview',
|
||
header: 'Vorschau',
|
||
cell: (j) => (
|
||
<FinishedVideoPreview
|
||
job={j}
|
||
getFileName={baseName}
|
||
durationSeconds={durations[keyFor(j)]}
|
||
onDuration={handleDuration}
|
||
thumbTick={thumbTick}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'model',
|
||
header: 'Modelname',
|
||
sortable: true,
|
||
sortValue: (j) => modelNameFromOutput(j.output),
|
||
cell: (j) => {
|
||
const name = modelNameFromOutput(j.output)
|
||
return (
|
||
<span className="truncate" title={name}>
|
||
{name}
|
||
</span>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
key: 'output',
|
||
header: 'Datei',
|
||
sortable: true,
|
||
sortValue: (j) => baseName(j.output || ''),
|
||
cell: (j) => baseName(j.output || ''),
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: 'Status',
|
||
sortable: true,
|
||
sortValue: (j) => (j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9),
|
||
cell: (j) => {
|
||
if (j.status !== 'failed') return j.status
|
||
const code = httpCodeFromError(j.error)
|
||
const label = code ? `failed (${code})` : 'failed'
|
||
return (
|
||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||
{label}
|
||
</span>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
key: 'runtime',
|
||
header: 'Dauer',
|
||
sortable: true,
|
||
sortValue: (j) => runtimeSecondsForSort(j),
|
||
cell: (j) => runtimeOf(j),
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: 'Aktion',
|
||
align: 'right',
|
||
srOnlyHeader: true,
|
||
cell: (j) => {
|
||
const k = keyFor(j)
|
||
const busy = deletingKeys.has(k)
|
||
return (
|
||
<Button
|
||
disabled={busy}
|
||
variant='soft'
|
||
color='red'
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
void deleteVideo(j)
|
||
}}
|
||
aria-label="Video löschen"
|
||
title="Video löschen"
|
||
>
|
||
{busy ? '…' : 'Löschen'}
|
||
</Button>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
if (rows.length === 0) {
|
||
return (
|
||
<Card grayBody>
|
||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||
Keine abgeschlossenen Downloads im Zielordner vorhanden.
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* ✅ Mobile: Cards */}
|
||
<div className="sm:hidden space-y-3">
|
||
{visibleRows.map((j) => {
|
||
const model = modelNameFromOutput(j.output)
|
||
const file = baseName(j.output || '')
|
||
const dur = runtimeOf(j)
|
||
|
||
const statusNode =
|
||
j.status === 'failed' ? (
|
||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
||
</span>
|
||
) : (
|
||
<span className="font-medium">{j.status}</span>
|
||
)
|
||
|
||
return (
|
||
<div
|
||
key={keyFor(j)}
|
||
role="button"
|
||
tabIndex={0}
|
||
className="cursor-pointer"
|
||
onClick={() => onOpenPlayer(j)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||
}}
|
||
onContextMenu={(e) => openCtx(j, e)}
|
||
>
|
||
<Card
|
||
header={
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||
{model}
|
||
</div>
|
||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
||
{file || '—'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex items-center gap-1">
|
||
{/* 🗑️ Direkt-Löschen */}
|
||
<Button
|
||
aria-label="Video löschen"
|
||
title="Video löschen"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
void deleteVideo(j)
|
||
}}
|
||
>
|
||
🗑
|
||
</Button>
|
||
|
||
{/* ✅ Menü-Button für Touch/Small Devices */}
|
||
<button
|
||
type="button"
|
||
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
|
||
aria-label="Aktionen"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
openCtxAt(j, r.left, r.bottom + 6)
|
||
}}
|
||
>
|
||
⋯
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="flex gap-3">
|
||
<div
|
||
className="shrink-0"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
openCtx(j, e)
|
||
}}
|
||
>
|
||
<FinishedVideoPreview
|
||
job={j}
|
||
getFileName={baseName}
|
||
durationSeconds={durations[keyFor(j)]}
|
||
onDuration={handleDuration}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Status: {statusNode}
|
||
<span className="mx-2 opacity-60">•</span>
|
||
Dauer: <span className="font-medium">{dur}</span>
|
||
</div>
|
||
|
||
{j.output ? (
|
||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
||
{j.output}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* ✅ Desktop/Tablet: Tabelle */}
|
||
<div className="hidden sm:block">
|
||
<Table
|
||
rows={visibleRows}
|
||
columns={columns}
|
||
getRowKey={(j) => keyFor(j)}
|
||
striped
|
||
fullWidth
|
||
sort={sort}
|
||
onSortChange={setSort}
|
||
onRowClick={onOpenPlayer}
|
||
onRowContextMenu={(job, e) => openCtx(job, e)}
|
||
/>
|
||
</div>
|
||
|
||
<ContextMenu
|
||
open={!!ctx}
|
||
x={ctx?.x ?? 0}
|
||
y={ctx?.y ?? 0}
|
||
items={items}
|
||
onClose={() => setCtx(null)}
|
||
/>
|
||
|
||
{rows.length > visibleCount ? (
|
||
<div className="mt-3 flex justify-center">
|
||
<button
|
||
type="button"
|
||
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
|
||
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
|
||
>
|
||
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|