updated UI
This commit is contained in:
parent
82cd87c92e
commit
bd6b2a50a6
Binary file not shown.
1
backend/web/dist/assets/index-BsHW0Op2.css
vendored
1
backend/web/dist/assets/index-BsHW0Op2.css
vendored
File diff suppressed because one or more lines are too long
257
backend/web/dist/assets/index-DFSqchi9.js
vendored
257
backend/web/dist/assets/index-DFSqchi9.js
vendored
File diff suppressed because one or more lines are too long
257
backend/web/dist/assets/index-DJeEzwKB.js
vendored
Normal file
257
backend/web/dist/assets/index-DJeEzwKB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
Normal file
1
backend/web/dist/assets/index-MWPLGKSF.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css">
|
||||
<script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -11,9 +11,17 @@ import ContextMenu, { type ContextMenuItem } from './ContextMenu'
|
||||
import { buildDownloadContextMenu } from './DownloadContextMenu'
|
||||
import Button from './Button'
|
||||
import ButtonGroup from './ButtonGroup'
|
||||
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline'
|
||||
import {
|
||||
TableCellsIcon,
|
||||
RectangleStackIcon,
|
||||
Squares2X2Icon,
|
||||
TrashIcon,
|
||||
EllipsisVerticalIcon,
|
||||
BookmarkSquareIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
|
||||
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
doneJobs: RecordJob[]
|
||||
@ -378,89 +386,194 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
}, [])
|
||||
|
||||
const columns: Column<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => (
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[keyFor(j)]}
|
||||
onDuration={handleDuration}
|
||||
/>
|
||||
),
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
srOnlyHeader: true,
|
||||
widthClassName: 'w-[140px]',
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
return (
|
||||
<div
|
||||
className="py-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openCtx(j, e)
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k]}
|
||||
onDuration={handleDuration}
|
||||
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||
showPopover={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
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: 'video',
|
||||
header: 'Video',
|
||||
sortable: true,
|
||||
sortValue: (j) => {
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = fileRaw.startsWith('HOT ')
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
header: 'Datei',
|
||||
sortable: true,
|
||||
sortValue: (j) => baseName(j.output || ''),
|
||||
cell: (j) => baseName(j.output || ''),
|
||||
cell: (j) => {
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = fileRaw.startsWith('HOT ')
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="truncate font-medium text-gray-900 dark:text-white" title={model}>
|
||||
{model}
|
||||
</div>
|
||||
{isHot ? (
|
||||
<span className="shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-500 dark:text-gray-400" title={file}>
|
||||
{file || '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
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
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
sortValue: (j) =>
|
||||
j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9,
|
||||
cell: (j) => {
|
||||
const base =
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset'
|
||||
if (j.status === 'failed') {
|
||||
const code = httpCodeFromError(j.error)
|
||||
const label = code ? `failed (${code})` : 'failed'
|
||||
return (
|
||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||
<span
|
||||
className={`${base} bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/30`}
|
||||
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)
|
||||
}
|
||||
if (j.status === 'finished') {
|
||||
return (
|
||||
<Button
|
||||
<span
|
||||
className={`${base} bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30`}
|
||||
>
|
||||
finished
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (j.status === 'stopped') {
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30`}
|
||||
>
|
||||
stopped
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`${base} bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10`}>
|
||||
{j.status}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'runtime',
|
||||
header: 'Dauer',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
sortValue: (j) => runtimeSecondsForSort(j),
|
||||
cell: (j) => <span className="font-medium text-gray-900 dark:text-white">{runtimeOf(j)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktionen',
|
||||
align: 'right',
|
||||
srOnlyHeader: true,
|
||||
cell: (j) => {
|
||||
const k = keyFor(j)
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k)
|
||||
|
||||
const iconBtn =
|
||||
'inline-flex items-center justify-center rounded-md p-1.5 ' +
|
||||
'hover:bg-gray-100/70 dark:hover:bg-white/5 ' +
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Keep */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Behalten (nach keep verschieben)"
|
||||
aria-label="Behalten"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void keepVideo(j)
|
||||
}}
|
||||
>
|
||||
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
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>
|
||||
)
|
||||
},
|
||||
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
||||
</button>
|
||||
|
||||
{/* More */}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Mehr Aktionen"
|
||||
aria-label="Mehr Aktionen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
openCtxAt(j, r.left, r.bottom + 6)
|
||||
}}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@ -475,33 +588,43 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
return (
|
||||
<>
|
||||
{/* Toolbar */}
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<ButtonGroup
|
||||
value={view}
|
||||
onChange={(id) => setView(id as ViewMode)}
|
||||
size="sm"
|
||||
ariaLabel="Ansicht"
|
||||
items={[
|
||||
{
|
||||
id: 'table',
|
||||
icon: <TableCellsIcon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Tabelle</span>,
|
||||
srLabel: 'Tabelle',
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
icon: <RectangleStackIcon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Cards</span>,
|
||||
srLabel: 'Cards',
|
||||
},
|
||||
{
|
||||
id: 'gallery',
|
||||
icon: <Squares2X2Icon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Galerie</span>,
|
||||
srLabel: 'Galerie',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
{/* Mobile title */}
|
||||
<div className="sm:hidden min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Abgeschlossene Downloads <span className="text-gray-500 dark:text-gray-400">({rows.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<div className="shrink-0">
|
||||
<ButtonGroup
|
||||
value={view}
|
||||
onChange={(id) => setView(id as ViewMode)}
|
||||
size="sm"
|
||||
ariaLabel="Ansicht"
|
||||
items={[
|
||||
{
|
||||
id: 'table',
|
||||
icon: <TableCellsIcon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Tabelle</span>,
|
||||
srLabel: 'Tabelle',
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
icon: <RectangleStackIcon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Cards</span>,
|
||||
srLabel: 'Cards',
|
||||
},
|
||||
{
|
||||
id: 'gallery',
|
||||
icon: <Squares2X2Icon className="size-5" />,
|
||||
label: <span className="hidden sm:inline">Galerie</span>,
|
||||
srLabel: 'Galerie',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Cards */}
|
||||
@ -556,80 +679,121 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
}}
|
||||
onContextMenu={(e) => openCtx(j, e)}
|
||||
>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Card noBodyPadding className="overflow-hidden">
|
||||
{/* Preview */}
|
||||
<div className="relative aspect-video bg-black/5 dark:bg-white/5">
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k]}
|
||||
onDuration={handleDuration}
|
||||
className="w-full h-full"
|
||||
showPopover={false}
|
||||
/>
|
||||
|
||||
{/* dunkler Verlauf unten für Text */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 to-transparent" />
|
||||
|
||||
{/* Overlay bottom */}
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-end 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 className="truncate text-sm font-semibold text-white">{model}</div>
|
||||
<div className="truncate text-[11px] text-white/80">{stripHotPrefix(file) || '—'}</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="Video löschen"
|
||||
title="Video löschen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const h = swipeRefs.current.get(k)
|
||||
if (h) {
|
||||
void h.swipeLeft() // ✅ führt Swipe + deleteVideo aus
|
||||
} else {
|
||||
void deleteVideo(j)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
|
||||
<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 className="shrink-0 flex items-center gap-2">
|
||||
{file.startsWith('HOT ') ? (
|
||||
<span className="rounded-md bg-amber-500/25 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md bg-black/40 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{dur}
|
||||
</span>
|
||||
</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[k]}
|
||||
onDuration={handleDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{/* Actions top-right */}
|
||||
<div className="absolute right-2 top-2 flex items-center gap-2">
|
||||
{(() => {
|
||||
const iconBtn =
|
||||
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Behalten"
|
||||
aria-label="Behalten"
|
||||
disabled={busy}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const h = swipeRefs.current.get(k)
|
||||
if (h) void h.swipeRight()
|
||||
else void keepVideo(j)
|
||||
}}
|
||||
>
|
||||
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
disabled={busy}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const h = swipeRefs.current.get(k)
|
||||
if (h) void h.swipeLeft()
|
||||
else void deleteVideo(j)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Aktionen"
|
||||
aria-label="Aktionen"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
openCtxAt(j, r.left, r.bottom + 6)
|
||||
}}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div className="min-w-0 truncate">
|
||||
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>
|
||||
|
||||
{j.output ? (
|
||||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400" title={j.output}>
|
||||
{j.output}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@ -648,6 +812,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
striped
|
||||
fullWidth
|
||||
stickyHeader
|
||||
compact
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onRowClick={onOpenPlayer}
|
||||
@ -655,13 +820,13 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
rowClassName={(j) => {
|
||||
const k = keyFor(j)
|
||||
return [
|
||||
'transition-opacity duration-300',
|
||||
'transition-all duration-300',
|
||||
(deletingKeys.has(k) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
|
||||
deletingKeys.has(k) && 'animate-pulse',
|
||||
(keepingKeys.has(k) || removingKeys.has(k)) && 'pointer-events-none',
|
||||
keepingKeys.has(k) && 'bg-emerald-50/60 dark:bg-emerald-500/10 animate-pulse',
|
||||
removingKeys.has(k) && 'opacity-0',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
].filter(Boolean).join(' ')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -670,67 +835,150 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
|
||||
{view === 'gallery' && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{visibleRows.map((j) => {
|
||||
const k = keyFor(j)
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const deleted = deletedKeys.has(k)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyFor(j)}
|
||||
key={k}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'bg-white dark:bg-gray-900/40',
|
||||
'transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
deletingKeys.has(k) && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
deleted && 'hidden',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
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-2">
|
||||
<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
|
||||
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>
|
||||
}
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className="relative aspect-video bg-black/5 dark:bg-white/5"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openCtx(j, e)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={baseName}
|
||||
durationSeconds={durations[k]}
|
||||
onDuration={handleDuration}
|
||||
variant="fill"
|
||||
showPopover={false}
|
||||
inlineVideo="hover"
|
||||
/>
|
||||
|
||||
{/* Gradient overlay bottom */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/65 to-transparent" />
|
||||
|
||||
{/* Bottom text */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2 text-white">
|
||||
<div className="truncate text-xs font-semibold">{model}</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] opacity-90">
|
||||
<span className="truncate">{file || '—'}</span>
|
||||
<span className="shrink-0 rounded bg-black/40 px-1.5 py-0.5 font-medium">{dur}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 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">{runtimeOf(j)}</span>
|
||||
{/* Quick keep */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'absolute right-12 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||
'bg-black/55 text-white hover:bg-black/70',
|
||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||
].join(' ')}
|
||||
aria-label="Behalten"
|
||||
title="Behalten (nach keep verschieben)"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void keepVideo(j)
|
||||
}}
|
||||
>
|
||||
<BookmarkSquareIcon className="size-5 text-emerald-600 dark:text-emerald-300" />
|
||||
</button>
|
||||
|
||||
{/* Quick delete */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'absolute right-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||
'bg-black/55 text-white hover:bg-black/70',
|
||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||
].join(' ')}
|
||||
aria-label="Video löschen"
|
||||
title="Video löschen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void deleteVideo(j)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-5 text-red-600 dark:text-red-300" />
|
||||
</button>
|
||||
|
||||
{/* More / Context */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs font-semibold',
|
||||
'bg-black/55 text-white hover:bg-black/70',
|
||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity',
|
||||
].join(' ')}
|
||||
aria-label="Aktionen"
|
||||
title="Aktionen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
openCtxAt(j, r.left, r.bottom + 6)
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Optional: status line (below thumb) */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className="truncate">
|
||||
Status:{' '}
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
{/* kurzer Hinweis: Hot Prefix */}
|
||||
{baseName(j.output || '').startsWith('HOT ') ? (
|
||||
<span className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -1,17 +1,35 @@
|
||||
// FinishedVideoPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import HoverPopover from './HoverPopover'
|
||||
|
||||
type Variant = 'thumb' | 'fill'
|
||||
type InlineVideoMode = false | true | 'always' | 'hover'
|
||||
|
||||
type Props = {
|
||||
job: RecordJob
|
||||
getFileName: (path: string) => string
|
||||
durationSeconds?: number
|
||||
onDuration?: (job: RecordJob, seconds: number) => void
|
||||
animated?: boolean // ✅ neu
|
||||
autoTickMs?: number // ✅ neu
|
||||
animated?: boolean
|
||||
autoTickMs?: number
|
||||
|
||||
/** neu: thumb = w-20 h-16, fill = w-full h-full */
|
||||
variant?: Variant
|
||||
|
||||
/** optionales Zusatz-Styling */
|
||||
className?: string
|
||||
|
||||
showPopover?: boolean
|
||||
|
||||
/**
|
||||
* inline video:
|
||||
* - false: nur Bild
|
||||
* - true/'always': immer inline abspielen (wenn inView)
|
||||
* - 'hover': nur bei Hover/Focus abspielen, sonst statisches Bild
|
||||
*/
|
||||
inlineVideo?: InlineVideoMode
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -21,10 +39,15 @@ export default function FinishedVideoPreview({
|
||||
onDuration,
|
||||
animated = false,
|
||||
autoTickMs = 15000,
|
||||
variant = 'thumb',
|
||||
className,
|
||||
showPopover = true,
|
||||
inlineVideo = false,
|
||||
}: Props) {
|
||||
const file = getFileName(job.output || '')
|
||||
|
||||
const [thumbOk, setThumbOk] = useState(true)
|
||||
const [videoOk, setVideoOk] = useState(true)
|
||||
const [metaLoaded, setMetaLoaded] = useState(false)
|
||||
|
||||
// ✅ nur animieren, wenn sichtbar (Viewport)
|
||||
@ -32,6 +55,9 @@ export default function FinishedVideoPreview({
|
||||
const [inView, setInView] = useState(false)
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
|
||||
// ✅ für hover-play
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
@ -94,9 +120,82 @@ export default function FinishedVideoPreview({
|
||||
}
|
||||
|
||||
if (!videoSrc) {
|
||||
return <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
}
|
||||
|
||||
const inlineMode: 'never' | 'always' | 'hover' =
|
||||
inlineVideo === true || inlineVideo === 'always'
|
||||
? 'always'
|
||||
: inlineVideo === 'hover'
|
||||
? 'hover'
|
||||
: 'never'
|
||||
|
||||
const showingInlineVideo =
|
||||
inlineMode !== 'never' &&
|
||||
inView &&
|
||||
videoOk &&
|
||||
(inlineMode === 'always' || (inlineMode === 'hover' && hovered))
|
||||
|
||||
const sizeClass = variant === 'fill' ? 'w-full h-full' : 'w-20 h-16'
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={[
|
||||
'rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative',
|
||||
sizeClass,
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
// ✅ hover only relevant for inlineMode==='hover'
|
||||
onMouseEnter={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
onFocus={inlineMode === 'hover' ? () => setHovered(true) : undefined}
|
||||
onBlur={inlineMode === 'hover' ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{/* ✅ Gallery: inline video nur bei Hover/Focus (oder always) */}
|
||||
{showingInlineVideo ? (
|
||||
<video
|
||||
src={videoSrc}
|
||||
className="w-full h-full object-cover bg-black pointer-events-none"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
autoPlay
|
||||
loop
|
||||
poster={thumbSrc || undefined}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onError={() => setVideoOk(false)}
|
||||
/>
|
||||
) : thumbSrc && thumbOk ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
loading="lazy"
|
||||
alt={file}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setThumbOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) und wir gerade NICHT inline-video anzeigen */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && !showingInlineVideo && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="hidden"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ✅ Gallery: kein HoverPopover
|
||||
if (!showPopover) return previewNode
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={(open) =>
|
||||
@ -120,31 +219,7 @@ export default function FinishedVideoPreview({
|
||||
)
|
||||
}
|
||||
>
|
||||
<div ref={rootRef} className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative">
|
||||
{thumbSrc && thumbOk ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
loading="lazy"
|
||||
alt={file}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setThumbOk(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-black" />
|
||||
)}
|
||||
|
||||
{/* ✅ Metadaten nur laden, wenn sichtbar (inView) */}
|
||||
{inView && onDuration && !hasDuration && !metaLoaded && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="hidden"
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{previewNode}
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
import * as React from 'react'
|
||||
import type { RecordJob } from '../../types'
|
||||
import Card from './Card'
|
||||
import Button from './Button'
|
||||
|
||||
import videojs from 'video.js'
|
||||
import type VideoJsPlayer from 'video.js/dist/types/player'
|
||||
import 'video.js/dist/video-js.css'
|
||||
@ -207,172 +205,265 @@ export default function Player({
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const mini = !expanded
|
||||
|
||||
const [miniHover, setMiniHover] = React.useState(false)
|
||||
const [canHover, setCanHover] = React.useState(false)
|
||||
|
||||
const [progressActive, setProgressActive] = React.useState(false)
|
||||
const progressTimerRef = React.useRef<number | null>(null)
|
||||
|
||||
const stopProgress = React.useCallback(() => {
|
||||
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
|
||||
progressTimerRef.current = null
|
||||
setProgressActive(false)
|
||||
}, [])
|
||||
|
||||
const kickProgress = React.useCallback(() => {
|
||||
setProgressActive(true)
|
||||
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
|
||||
progressTimerRef.current = window.setTimeout(() => {
|
||||
setProgressActive(false)
|
||||
progressTimerRef.current = null
|
||||
}, 500) // <- hier kannst du 300..800ms tunen
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded && canHover) kickProgress()
|
||||
}, [expanded, canHover, kickProgress])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (progressTimerRef.current) window.clearTimeout(progressTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) setProgressActive(false)
|
||||
}, [expanded])
|
||||
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia?.('(hover: hover) and (pointer: fine)')
|
||||
const update = () => setCanHover(Boolean(mq?.matches))
|
||||
update()
|
||||
mq?.addEventListener?.('change', update)
|
||||
return () => mq?.removeEventListener?.('change', update)
|
||||
}, [])
|
||||
|
||||
// optional: beim Expand Hover-State zurücksetzen
|
||||
React.useEffect(() => {
|
||||
if (!mini) setMiniHover(false)
|
||||
}, [mini])
|
||||
|
||||
const liftMiniOverlay = mini && (canHover ? miniHover : true)
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const mini = !expanded
|
||||
const overlayBtn =
|
||||
'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-black/55 transition ' +
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const overlayBtnDanger =
|
||||
'inline-flex items-center justify-center rounded-md bg-red-600/35 p-2 text-white ' +
|
||||
'backdrop-blur hover:bg-red-600/55 transition ' +
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const footerRight = (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={isHot ? 'soft' : 'secondary'}
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={overlayBtn}
|
||||
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||
onClick={async () => {
|
||||
// 1) Stream freigeben (wichtig für Windows Rename)
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
releaseMedia()
|
||||
|
||||
// 2) kurz warten, bis Browser/HTTP wirklich zu ist
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
|
||||
// 3) Rename (App aktualisiert danach job.output -> media.src ändert sich)
|
||||
await onToggleHot?.(job)
|
||||
|
||||
// 4) Optional: nach Rename wieder starten (falls du willst)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
const p = playerRef.current
|
||||
if (p && !(p as any).isDisposed?.()) {
|
||||
const ret = p.play?.()
|
||||
if (ret && typeof (ret as any).catch === 'function') {
|
||||
;(ret as Promise<void>).catch(() => {})
|
||||
}
|
||||
if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
|
||||
}
|
||||
}}
|
||||
disabled={!onToggleHot}
|
||||
>
|
||||
<FireIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<FireIcon className={cn('h-5 w-5', isHot ? 'text-amber-300' : 'text-white')} />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant={isFavorite ? 'soft' : 'secondary'}
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={overlayBtn}
|
||||
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
|
||||
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
|
||||
onClick={() => onToggleFavorite?.(job)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFavorite?.(job)
|
||||
}}
|
||||
disabled={!onToggleFavorite}
|
||||
>
|
||||
<HeartIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<HeartIcon className={cn('h-5 w-5', isFavorite ? 'text-pink-300' : 'text-white')} />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant={isLiked ? 'soft' : 'secondary'}
|
||||
size="md"
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={overlayBtn}
|
||||
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
|
||||
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
|
||||
onClick={() => onToggleLike?.(job)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleLike?.(job)
|
||||
}}
|
||||
disabled={!onToggleLike}
|
||||
>
|
||||
<HandThumbUpIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<HandThumbUpIcon className={cn('h-5 w-5', isLiked ? 'text-indigo-200' : 'text-white')} />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
|
||||
<button
|
||||
type="button"
|
||||
className={overlayBtnDanger}
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
onClick={async () => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
releaseMedia()
|
||||
// optional: Player schließen -> dispose() läuft im Cleanup und gibt endgültig frei
|
||||
onClose()
|
||||
|
||||
// kurzer Moment, bis der Browser den Stream wirklich abbricht
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
|
||||
await onDelete?.(job)
|
||||
}}
|
||||
disabled={!onDelete}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
return createPortal(
|
||||
<>
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40"
|
||||
className="fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px] transition-opacity"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
edgeToEdgeMobile
|
||||
noBodyPadding
|
||||
className={cn(
|
||||
'fixed z-50 shadow-xl border flex flex-col',
|
||||
'fixed z-50 flex flex-col shadow-2xl ring-1 ring-black/10 dark:ring-white/10',
|
||||
expanded
|
||||
? 'inset-6'
|
||||
: 'left-0 right-0 bottom-0 w-full rounded-none sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[380px]',
|
||||
? 'inset-4 sm:inset-6'
|
||||
: 'left-0 right-0 bottom-0 w-full rounded-none pb-[env(safe-area-inset-bottom)] sm:rounded-lg sm:left-auto sm:right-4 sm:bottom-4 sm:w-[420px]',
|
||||
className ?? ''
|
||||
)}
|
||||
noBodyPadding
|
||||
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
>
|
||||
{/* Video Stage */}
|
||||
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full h-full',
|
||||
mini && 'vjs-mini',
|
||||
|
||||
// ✅ activity-based controls für expanded UND mini (nur wenn Hover möglich)
|
||||
canHover && 'vjs-controls-on-activity',
|
||||
canHover && progressActive && 'vjs-controls-active'
|
||||
)}
|
||||
onMouseEnter={
|
||||
mini
|
||||
? () => {
|
||||
setMiniHover(true)
|
||||
if (canHover) kickProgress()
|
||||
}
|
||||
: expanded && canHover
|
||||
? kickProgress
|
||||
: undefined
|
||||
}
|
||||
onMouseMove={canHover ? kickProgress : undefined}
|
||||
onMouseLeave={() => {
|
||||
if (mini) setMiniHover(false)
|
||||
if (canHover) stopProgress()
|
||||
}}
|
||||
onFocusCapture={expanded && canHover ? () => setProgressActive(true) : undefined}
|
||||
onBlurCapture={
|
||||
expanded && canHover
|
||||
? (e) => {
|
||||
const next = e.relatedTarget as Node | null
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) stopProgress()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{/* Top overlay: title + window controls */}
|
||||
<div className="absolute inset-x-2 top-2 z-20 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="player-ui max-w-[70vw] sm:max-w-[320px] truncate rounded-md bg-black/45 px-2.5 py-1.5 text-xs font-semibold text-white backdrop-blur">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
title={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowsPointingInIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white backdrop-blur hover:bg-black/55 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
title="Schließen"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
className="px-2 py-1"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
title={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowsPointingInIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
color='red'
|
||||
className="px-2 py-1"
|
||||
onClick={onClose}
|
||||
title="Schließen"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div className="min-w-0 truncate">
|
||||
Status: <span className="font-medium">{job.status}</span>
|
||||
{job.output ? <span className="ml-2 opacity-70">• {job.output}</span> : null}
|
||||
</div>
|
||||
{/* Bottom overlay: mini actions + status */}
|
||||
{!expanded && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
|
||||
'transition-all duration-200 ease-out',
|
||||
liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Buttons primär im Mini-Player */}
|
||||
{mini ? footerRight : null}
|
||||
</div>
|
||||
}
|
||||
grayBody={false}
|
||||
>
|
||||
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
|
||||
<div className={cn('w-full h-full min-h-0', mini && 'vjs-mini')}>
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
<div
|
||||
className={cn(
|
||||
'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2',
|
||||
'transition-all duration-200 ease-out',
|
||||
liftMiniOverlay ? 'bottom-7' : 'bottom-2'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur">
|
||||
<span className="font-semibold text-white">{job.status}</span>
|
||||
{job.output ? <span className="ml-2 opacity-80">• {job.output}</span> : null}
|
||||
</div>
|
||||
|
||||
{footerRight}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
@ -62,11 +63,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
onSwipeRight,
|
||||
className,
|
||||
leftAction = {
|
||||
label: <span className="inline-flex items-center gap-2 font-semibold">✓ Behalten</span>,
|
||||
label: (
|
||||
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
|
||||
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
|
||||
<span>Behalten</span>
|
||||
</span>
|
||||
),
|
||||
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
|
||||
},
|
||||
rightAction = {
|
||||
label: <span className="inline-flex items-center gap-2 font-semibold">✕ Löschen</span>,
|
||||
label: (
|
||||
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
|
||||
<TrashIcon className="h-6 w-6" aria-hidden="true" />
|
||||
<span>Löschen</span>
|
||||
</span>
|
||||
),
|
||||
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
|
||||
},
|
||||
thresholdPx = 120,
|
||||
@ -87,11 +98,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
}>({ id: null, x: 0, y: 0, dragging: false })
|
||||
|
||||
const [dx, setDx] = React.useState(0)
|
||||
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
||||
const [animMs, setAnimMs] = React.useState<number>(0)
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setAnimMs(snapMs)
|
||||
setDx(0)
|
||||
setArmedDir(null)
|
||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||
}, [snapMs])
|
||||
|
||||
@ -102,6 +115,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
|
||||
// rausfliegen lassen
|
||||
setAnimMs(commitMs)
|
||||
setArmedDir(dir === 'right' ? 'right' : 'left')
|
||||
setDx(dir === 'right' ? w + 40 : -(w + 40))
|
||||
|
||||
let ok: boolean | void = true
|
||||
@ -116,6 +130,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
// wenn Aktion fehlschlägt => zurücksnappen
|
||||
if (ok === false) {
|
||||
setAnimMs(snapMs)
|
||||
setArmedDir(null)
|
||||
setDx(0)
|
||||
window.setTimeout(() => setAnimMs(0), snapMs)
|
||||
return false
|
||||
@ -138,19 +153,30 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
|
||||
return (
|
||||
<div className={cn('relative overflow-hidden rounded-lg', className)}>
|
||||
{/* Background actions (100% je Richtung) */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{dx !== 0 ? (
|
||||
{/* Background actions (100% je Richtung, animiert) */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full flex items-center',
|
||||
dx > 0 ? leftAction.className : rightAction.className,
|
||||
dx > 0 ? 'justify-start pl-4' : 'justify-end pr-4'
|
||||
)}
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity duration-200 ease-out',
|
||||
dx === 0 ? 'opacity-0' : 'opacity-100',
|
||||
dx > 0 ? leftAction.className : rightAction.className
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
|
||||
)}
|
||||
style={{
|
||||
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
|
||||
opacity: dx === 0 ? 0 : 1,
|
||||
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
|
||||
paddingLeft: dx > 0 ? 16 : 0,
|
||||
paddingRight: dx > 0 ? 0 : 16,
|
||||
}}
|
||||
>
|
||||
{dx > 0 ? leftAction.label : rightAction.label}
|
||||
{dx > 0 ? leftAction.label : rightAction.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Foreground (moves) */}
|
||||
@ -184,6 +210,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
|
||||
setAnimMs(0)
|
||||
setDx(ddx)
|
||||
|
||||
const el = cardRef.current
|
||||
const w = el?.offsetWidth || 360
|
||||
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
||||
|
||||
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!enabled || disabled) return
|
||||
@ -215,7 +247,19 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
||||
reset()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className="relative">
|
||||
<div className="relative z-10">{children}</div>
|
||||
|
||||
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg',
|
||||
armedDir === 'right' && 'bg-emerald-500/20 opacity-100',
|
||||
armedDir === 'left' && 'bg-red-500/20 opacity-100',
|
||||
armedDir === null && 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -206,11 +206,12 @@ export default function Table<T>({
|
||||
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10'
|
||||
)}
|
||||
>
|
||||
<table className="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
|
||||
<table className="relative min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead
|
||||
className={cn(
|
||||
card && 'bg-gray-50 dark:bg-gray-800/75',
|
||||
stickyHeader && 'sticky top-0 z-10 backdrop-blur-sm backdrop-filter'
|
||||
card && 'bg-gray-50/90 dark:bg-gray-800/70',
|
||||
stickyHeader &&
|
||||
'sticky top-0 z-10 backdrop-blur-sm shadow-sm'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
@ -240,7 +241,7 @@ export default function Table<T>({
|
||||
aria-sort={ariaSort as any}
|
||||
className={cn(
|
||||
headY,
|
||||
'px-3 text-sm font-semibold text-gray-900 dark:text-gray-200',
|
||||
'px-3 text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
|
||||
alignTd(col.align),
|
||||
col.widthClassName,
|
||||
col.headerClassName
|
||||
@ -253,7 +254,7 @@ export default function Table<T>({
|
||||
type="button"
|
||||
onClick={nextSort}
|
||||
className={cn(
|
||||
'group inline-flex w-full items-center gap-2 select-none',
|
||||
'group inline-flex w-full items-center gap-2 select-none rounded-md px-1.5 py-1 -my-1 hover:bg-gray-100/70 dark:hover:bg-white/5',
|
||||
justifyForAlign(col.align)
|
||||
)}
|
||||
>
|
||||
@ -313,6 +314,7 @@ export default function Table<T>({
|
||||
className={cn(
|
||||
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
onRowClick && 'cursor-pointer',
|
||||
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
|
||||
rowClassName?.(row, rowIndex)
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
|
||||
@ -15,14 +15,56 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.plyr-mini .plyr__controls [data-plyr="rewind"],
|
||||
.plyr-mini .plyr__controls [data-plyr="fast-forward"],
|
||||
.plyr-mini .plyr__controls [data-plyr="volume"],
|
||||
.plyr-mini .plyr__controls [data-plyr="settings"],
|
||||
.plyr-mini .plyr__controls [data-plyr="pip"],
|
||||
.plyr-mini .plyr__controls [data-plyr="airplay"],
|
||||
.plyr-mini .plyr__time--duration {
|
||||
display: none !important;
|
||||
/* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
|
||||
.vjs-mini .video-js .vjs-control-bar{
|
||||
z-index: 40; /* über Overlays */
|
||||
background: rgba(0,0,0,.65);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Progressbar deutlich höher */
|
||||
.vjs-mini .video-js .vjs-progress-control .vjs-progress-holder{
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-play-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(99,102,241,.95);
|
||||
}
|
||||
.vjs-mini .video-js .vjs-load-progress{
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,.25);
|
||||
}
|
||||
|
||||
/* Expanded Player: komplette Controlbar nur kurz nach Aktivität sichtbar */
|
||||
.vjs-controls-on-activity .video-js .vjs-control-bar{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .video-js .vjs-control-bar,
|
||||
.vjs-controls-on-activity:focus-within .video-js .vjs-control-bar{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Expanded Player: unsere Info-Overlays wie Controlbar ein-/ausblenden */
|
||||
.vjs-controls-on-activity .player-ui{
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vjs-controls-on-activity.vjs-controls-active .player-ui,
|
||||
.vjs-controls-on-activity:focus-within .player-ui{
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user