nsfwapp/frontend/src/components/ui/RunningDownloads.tsx
2025-12-30 23:35:00 +01:00

248 lines
7.5 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.

// RunningDownloads.tsx
'use client'
import { useMemo } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable'
import type { RecordJob } from '../../types'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
}
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
blurPreviews?: boolean
}
const baseName = (p: string) =>
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
// <model>_MM_DD_YYYY__HH-MM-SS
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
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : Date.now()
if (!Number.isFinite(end)) return '—'
return formatDuration(end - start)
}
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob, blurPreviews }: Props) {
const columns = useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => <ModelPreview jobId={j.id} blur={blurPreviews} />,
},
{
key: 'model',
header: 'Modelname',
cell: (j) => {
const name = modelNameFromOutput(j.output)
return (
<span className="truncate" title={name}>
{name}
</span>
)
},
},
{
key: 'sourceUrl',
header: 'Source',
cell: (j) => (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
),
},
{
key: 'output',
header: 'Datei',
cell: (j) => baseName(j.output || ''),
},
{ key: 'status', header: 'Status' },
{
key: 'runtime',
header: 'Dauer',
cell: (j) => runtimeOf(j),
},
{
key: 'actions',
header: 'Aktion',
srOnlyHeader: true,
align: 'right',
cell: (j) => (
<Button
size="md"
variant="primary"
onClick={(e) => {
e.stopPropagation()
onStopJob(j.id)
}}
>
Stop
</Button>
),
},
]
}, [onStopJob])
if (jobs.length === 0 && pending.length === 0) {
return (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">Keine laufenden Downloads.</div>
</Card>
)
}
return (
<>
{pending.length > 0 && (
<Card
grayBody
header={
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Online (wartend Download startet nur bei <span className="font-semibold">public</span>)
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">{pending.length}</div>
</div>
}
>
<WaitingModelsTable models={pending} />
</Card>
)}
{jobs.length === 0 && pending.length > 0 && (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Kein aktiver Download wartet auf public.
</div>
</Card>
)}
{jobs.length > 0 && (
<>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{jobs.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
return (
<div
key={j.id}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<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>
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation()
onStopJob(j.id)
}}
>
Stop
</Button>
</div>
}
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} blur={blurPreviews} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{j.status}</span>
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
</Card>
</div>
)
})}
</div>
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={jobs}
columns={columns}
getRowKey={(r) => r.id}
striped
fullWidth
onRowClick={onOpenPlayer}
/>
</div>
</>
)}
</>
)
}