nsfwapp/frontend/src/components/ui/FinishedDownloads.tsx
2025-12-26 01:25:04 +01:00

486 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}
</>
)
}