nsfwapp/frontend/src/components/ui/Downloads.tsx
2026-01-13 14:00:05 +01:00

1110 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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\Downloads.tsx
'use client'
import { useMemo, useState, useCallback, useEffect } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { subscribeSSE } from '../../lib/sseSingleton'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
}
type WaitingModelRow = {
id: string
modelKey: string
url: string
imageUrl?: string
currentShow?: string // public / private / hidden / away / unknown
}
type AutostartState = { paused?: boolean }
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
}
type DownloadRow =
| { kind: 'job'; job: RecordJob }
| { kind: 'pending'; pending: PendingWatchedRoom }
const pendingModelName = (p: PendingWatchedRoom) => {
const anyP = p as any
return (
anyP.modelName ??
anyP.model ??
anyP.modelKey ??
anyP.username ??
anyP.name ??
'—'
)
}
const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
}
const pendingImageUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.imageUrl ?? anyP.image_url ?? '').trim()
}
const pendingRowKey = (p: PendingWatchedRoom) => {
const anyP = p as any
return String(anyP.key ?? anyP.id ?? pendingModelName(p))
}
const toMs = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) {
// Heuristik: 10-stellige Unix-Sekunden -> ms
return v < 1_000_000_000_000 ? v * 1000 : v
}
if (typeof v === 'string') {
const ms = Date.parse(v)
return Number.isFinite(ms) ? ms : 0
}
if (v instanceof Date) return v.getTime()
return 0
}
const addedAtMsOf = (r: DownloadRow): number => {
if (r.kind === 'job') {
const j = r.job as any
return (
toMs(j.addedAt) ||
toMs(j.createdAt) ||
toMs(j.enqueuedAt) ||
toMs(j.queuedAt) ||
toMs(j.requestedAt) ||
toMs(j.startedAt) || // Fallback
0
)
}
const p = r.pending as any
return (
toMs(p.addedAt) ||
toMs(p.createdAt) ||
toMs(p.enqueuedAt) ||
toMs(p.queuedAt) ||
toMs(p.requestedAt) ||
0
)
}
const phaseLabel = (p?: string) => {
switch (p) {
case 'stopping':
return 'Stop wird angefordert…'
case 'remuxing':
return 'Remux zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau…'
default:
return ''
}
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
function StatusCell({ job }: { job: RecordJob }) {
const phaseRaw = String((job as any)?.phase ?? '').trim()
const progress = Number((job as any)?.progress ?? 0)
const phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
// ✅ ProgressBar unabhängig vom Text
// ✅ determinate nur wenn sinnvoll (0..100)
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
// ✅ wenn wir in einer Phase sind, aber noch kein Progress da ist -> indeterminate
const showIndeterminate =
!showBar &&
Boolean(phaseRaw) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
return (
<div className="min-w-0">
{showBar ? (
<ProgressBar
label={text}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : showIndeterminate ? (
<ProgressBar
label={text}
indeterminate
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : (
<div className="truncate">
<span className="font-medium">{text}</span>
</div>
)}
</div>
)
}
function DownloadsCardRow({
r,
nowMs,
blurPreviews,
modelsByKey,
stopRequestedIds,
markStopRequested,
onOpenPlayer,
onStopJob,
onToggleFavorite,
onToggleLike,
onToggleWatch,
}: {
r: DownloadRow
nowMs: number
blurPreviews?: boolean
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
stopRequestedIds: Record<string, true>
markStopRequested: (ids: string | string[]) => void
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
}) {
// ---------- Pending ----------
if (r.kind === 'pending') {
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
const show = (p.currentShow || 'unknown').toLowerCase()
return (
<div
className="
relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
"
>
{/* subtle gradient */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="relative p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white" title={name}>
{name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<span className="inline-flex items-center gap-1 rounded-full bg-gray-900/5 px-2 py-0.5 font-medium dark:bg-white/10">
Wartend
</span>
<span className="inline-flex items-center rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:bg-white/10 dark:text-gray-200">
{show}
</span>
</div>
</div>
<span className="shrink-0 rounded-full bg-gray-900/5 px-2.5 py-1 text-xs font-medium text-gray-800 dark:bg-white/10 dark:text-gray-200">
</span>
</div>
<div className="mt-3">
{(() => {
const img = pendingImageUrl(p)
return (
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-xl bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
{img ? (
<img
src={img}
alt={name}
className={[
"h-full w-full object-cover",
blurPreviews ? "blur-md" : "",
].join(" ")}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
<div className="grid h-full w-full place-items-center">
<div className="text-sm text-gray-500 dark:text-gray-300">Waiting</div>
</div>
)}
</div>
)
})()}
</div>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-3 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={url}
>
{url}
</a>
) : null}
</div>
</div>
)
}
// ---------- Job ----------
const j = r.job
const name = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const phase = String((j as any).phase ?? '').trim()
const phaseText = phase ? (phaseLabel(phase) || phase) : ''
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
const rawStatus = String(j.status ?? '').toLowerCase()
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
// ✅ Badge hinter Modelname: IMMER Backend-Status
const statusText = rawStatus || 'unknown'
// ✅ Progressbar Label: Phase (gemappt), fallback auf Status
const progressLabel = phaseText || statusText
const progress = Number((j as any).progress ?? 0)
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
const showIndeterminate =
!showBar &&
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : ''
const flags = key ? modelsByKey[key] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<div
className="
group relative overflow-hidden rounded-2xl border border-white/40 bg-white/35 shadow-sm
backdrop-blur-xl supports-[backdrop-filter]:bg-white/25
ring-1 ring-black/5
transition-all hover:-translate-y-0.5 hover:shadow-md active:translate-y-0
dark:border-white/10 dark:bg-gray-950/35 dark:supports-[backdrop-filter]:bg-gray-950/25
"
onClick={() => onOpenPlayer(j)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
{/* subtle gradient */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 via-white/20 to-white/60 dark:from-white/10 dark:via-transparent dark:to-white/5" />
<div className="pointer-events-none absolute -inset-10 opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100 bg-white/40 dark:bg-white/10" />
<div className="relative p-3">
{/* Header: kleines Preview links + Infos rechts */}
<div className="flex items-start gap-3">
{/* Thumbnail */}
<div
className="
relative
shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
className="w-full h-full"
/>
</div>
{/* Infos */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
{name}
</div>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
'bg-gray-900/5 text-gray-800 dark:bg-white/10 dark:text-gray-200',
isStopping ? 'ring-1 ring-amber-500/30' : 'ring-1 ring-emerald-500/25',
].join(' ')}
title={statusText}
>
{statusText}
</span>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
{file || '—'}
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{runtimeOf(j, nowMs)}
</span>
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{formatBytes(sizeBytesOf(j))}
</span>
</div>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 hover:underline dark:text-indigo-400"
onClick={(e) => e.stopPropagation()}
title={j.sourceUrl}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
{(showBar || showIndeterminate) ? (
<div className="mt-3">
{showBar ? (
<ProgressBar
label={progressLabel}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full"
/>
) : (
<ProgressBar
label={progressLabel}
indeterminate
size="sm"
className="w-full"
/>
)}
</div>
) : null}
{/* Footer */}
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
busy={isStopping}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
showHot={false}
showKeep={false}
showDelete={false}
showFavorite
showLike
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
</div>
<Button
size="sm"
variant="primary"
disabled={isStopping}
className="shrink-0"
onClick={(e) => {
e.stopPropagation()
if (isStopping) return
markStopRequested(j.id)
onStopJob(j.id)
}}
>
{isStopping ? 'Stoppe…' : 'Stop'}
</Button>
</div>
</div>
</div>
)
}
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, nowMs: number) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs
if (!Number.isFinite(end)) return '—'
return formatDuration(end - start)
}
const sizeBytesOf = (job: RecordJob): number | null => {
const anyJob = job as any
const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}
const formatBytes = (bytes: number | null): string => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
const digits = i === 0 ? 0 : v >= 10 ? 1 : 2
const s = v.toFixed(digits).replace(/\.0+$/, '')
return `${s} ${units[i]}`
}
export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, modelsByKey = {}, blurPreviews }: Props) {
const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false)
const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => {
try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
setWatchedPaused(Boolean(s?.paused))
} catch {
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen
}
}, [])
useEffect(() => {
// initial: einmal fetchen (schneller first paint)
void refreshWatchedState()
// danach: Stream (Singleton)
const unsub = subscribeSSE<AutostartState>(
'/api/autostart/state/stream',
'autostart',
(data) => setWatchedPaused(Boolean((data as any)?.paused))
)
return () => {
unsub()
}
}, [refreshWatchedState])
const pauseWatched = useCallback(async () => {
if (watchedBusy || watchedPaused) return
setWatchedBusy(true)
try {
await fetch('/api/autostart/pause', { method: 'POST' })
setWatchedPaused(true)
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused])
const resumeWatched = useCallback(async () => {
if (watchedBusy || !watchedPaused) return
setWatchedBusy(true)
try {
await fetch('/api/autostart/resume', { method: 'POST' })
setWatchedPaused(false)
} catch {
// ignore
} finally {
setWatchedBusy(false)
}
}, [watchedBusy, watchedPaused])
// ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen")
const [stopRequestedIds, setStopRequestedIds] = useState<Record<string, true>>({})
// ✅ Merkt sich dauerhaft: Stop wurde vom User ausgelöst (damit wir später "Stopped" anzeigen können)
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
const markStopRequested = useCallback((ids: string | string[]) => {
const arr = Array.isArray(ids) ? ids : [ids]
// UI-Zwischenzustand ("Stoppe…")
setStopRequestedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
}
return next
})
// dauerhaft merken: User hat Stop ausgelöst
setStopInitiatedIds((prev) => {
const next = { ...prev }
for (const id of arr) {
if (id) next[id] = true
}
return next
})
}, [])
// Cleanup: sobald Job nicht mehr "running ohne phase" ist, brauchen wir das Flag nicht mehr
useEffect(() => {
setStopRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const next: Record<string, true> = {}
for (const id of keys) {
const j = jobs.find((x) => x.id === id)
if (!j) continue
const phase = String((j as any).phase ?? '').trim()
const isStopping = Boolean(phase) || j.status !== 'running'
if (!isStopping) next[id] = true
}
return next
})
}, [jobs])
const [nowMs, setNowMs] = useState(() => Date.now())
const hasActive = useMemo(() => {
// tickt solange mind. ein Job noch nicht beendet ist
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
useEffect(() => {
if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(t)
}, [hasActive])
const stoppableIds = useMemo(() => {
return jobs
.filter((j) => {
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
return !isStopping
})
.map((j) => j.id)
}, [jobs, stopRequestedIds])
const columns = useMemo<Column<DownloadRow>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[96px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
className="w-full h-full"
/>
</div>
)
}
// pending: kein jobId → Platzhalter
const p = r.pending
const name = pendingModelName(p)
const img = pendingImageUrl(p)
if (img) {
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
<img
src={img}
alt={name}
className={[
"h-full w-full object-cover",
blurPreviews ? "blur-md" : "",
].join(" ")}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
</div>
)
}
return (
<div className="h-[60px] w-[96px] rounded-md bg-gray-100 dark:bg-white/10 grid place-items-center text-sm text-gray-500">
</div>
)
},
},
{
key: 'model',
header: 'Modelname',
widthClassName: 'w-[170px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output)
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const stopInitiated = Boolean(stopInitiatedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
const statusText = rawStatus || 'unknown'
return (
<>
<div className="flex items-center gap-2 min-w-0">
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
// Status-Farben
isStopping
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: j.status === 'finished'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: j.status === 'failed'
? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25'
: isStoppedFinal
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')}
title={statusText}
>
{statusText}
</span>
</div>
<span className="block max-w-[220px] truncate" title={j.output}>
{f}
</span>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
title={j.sourceUrl}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
}
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
return (
<>
<span className="block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
title={url}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{url}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
},
},
{
key: 'status',
header: 'Status',
widthClassName: 'w-[260px] min-w-[240px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return <StatusCell job={j} />
}
const p = r.pending
const show = (p.currentShow || 'unknown').toLowerCase()
return (
<div className="min-w-0">
<div className="truncate">
<span className="font-medium">
{show}
</span>
</div>
</div>
)
},
},
{
key: 'runtime',
header: 'Dauer',
widthClassName: 'w-[90px]',
cell: (r) => {
if (r.kind === 'job') return runtimeOf(r.job, nowMs)
return '—'
},
},
{
key: 'size',
header: 'Größe',
align: 'right',
widthClassName: 'w-[110px]',
cell: (r) => {
if (r.kind === 'job') {
return (
<span className="tabular-nums text-sm text-gray-900 dark:text-white">
{formatBytes(sizeBytesOf(r.job))}
</span>
)
}
return (
<span className="tabular-nums text-sm text-gray-500 dark:text-gray-400">
</span>
)
},
},
{
key: 'actions',
header: 'Aktion',
srOnlyHeader: true,
align: 'right',
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
// actions nur für echte Jobs (Stop/Icons). Pending ist “read-only” in der Tabelle.
if (r.kind !== 'job') {
return <div className="pr-2 text-right text-sm text-gray-500 dark:text-gray-400"></div>
}
const j = r.job
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
const key = modelNameFromOutput(j.output || '')
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
return (
<div className="flex flex-wrap items-center justify-end gap-2 pr-2">
<RecordJobActions
job={j}
variant="table"
busy={isStopping}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
showHot={false}
showKeep={false}
showDelete={false}
showFavorite
showLike
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
{(() => {
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
return (
<Button
size="sm"
variant="primary"
disabled={isStopping}
className="shrink-0"
onClick={(e) => {
e.stopPropagation()
if (isStopping) return
markStopRequested(j.id)
onStopJob(j.id)
}}
>
{isStopping ? 'Stoppe…' : 'Stop'}
</Button>
)
})()}
</div>
)
},
},
]
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds])
const hasAnyPending = pending.length > 0
const hasJobs = jobs.length > 0
const rows = useMemo<DownloadRow[]>(() => {
const list: DownloadRow[] = [
...jobs.map((job) => ({ kind: 'job', job }) as const),
...pending.map((p) => ({ kind: 'pending', pending: p }) as const),
]
// ✅ Neueste zuerst (Hinzugefügt am DESC)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs, pending])
const stopAll = useCallback(async () => {
if (stopAllBusy) return
if (stoppableIds.length === 0) return
setStopAllBusy(true)
try {
// UI sofort auf "Stoppe…" stellen
markStopRequested(stoppableIds)
// Stop anfordern (parallel)
await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id))))
} finally {
setStopAllBusy(false)
}
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
return (
<div className="grid gap-3">
{(hasAnyPending || hasJobs) ? (
<>
{/* Toolbar (sticky) wie FinishedDownloads */}
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="flex items-center justify-between gap-2 p-3">
{/* Title + Count */}
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
</span>
</div>
{/* Actions + View */}
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
</div>
</div>
{/* Content */}
{/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden">
{rows.map((r) => (
<DownloadsCardRow
key={r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`}
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
))}
</div>
{/* Tablet/Desktop: Tabelle */}
<div className="mt-3 hidden sm:block overflow-x-auto">
<Table
rows={rows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
</>
) : null}
{!hasAnyPending && !hasJobs ? (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<span className="text-lg"></span>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine laufenden Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Starte oben eine URL hier siehst du Live-Status und kannst Jobs stoppen.
</div>
</div>
</div>
</Card>
) : null}
</div>
)
}