updated UI

This commit is contained in:
Linrador 2025-12-27 00:54:17 +01:00
parent 82cd87c92e
commit bd6b2a50a6
12 changed files with 1130 additions and 628 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>frontend</title>
<script type="module" crossorigin src="/assets/index-DFSqchi9.js"></script> <script type="module" crossorigin src="/assets/index-DJeEzwKB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsHW0Op2.css"> <link rel="stylesheet" crossorigin href="/assets/index-MWPLGKSF.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -11,9 +11,17 @@ import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu' import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button' import Button from './Button'
import ButtonGroup from './ButtonGroup' 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' import SwipeCard, { type SwipeCardHandle } from './SwipeCard'
type Props = { type Props = {
jobs: RecordJob[] jobs: RecordJob[]
doneJobs: RecordJob[] doneJobs: RecordJob[]
@ -378,89 +386,194 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}, []) }, [])
const columns: Column<RecordJob>[] = [ const columns: Column<RecordJob>[] = [
{ {
key: 'preview', key: 'preview',
header: 'Vorschau', header: 'Vorschau',
cell: (j) => ( srOnlyHeader: true,
<FinishedVideoPreview widthClassName: 'w-[140px]',
job={j} cell: (j) => {
getFileName={baseName} const k = keyFor(j)
durationSeconds={durations[keyFor(j)]} return (
onDuration={handleDuration} <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', key: 'video',
sortable: true, header: 'Video',
sortValue: (j) => modelNameFromOutput(j.output), sortable: true,
cell: (j) => { sortValue: (j) => {
const name = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '')
return ( const isHot = fileRaw.startsWith('HOT ')
<span className="truncate" title={name}> const model = modelNameFromOutput(j.output)
{name} const file = stripHotPrefix(fileRaw)
</span> return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
)
},
}, },
{ cell: (j) => {
key: 'output', const fileRaw = baseName(j.output || '')
header: 'Datei', const isHot = fileRaw.startsWith('HOT ')
sortable: true, const model = modelNameFromOutput(j.output)
sortValue: (j) => baseName(j.output || ''), const file = stripHotPrefix(fileRaw)
cell: (j) => baseName(j.output || ''),
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', key: 'status',
sortable: true, header: 'Status',
sortValue: (j) => (j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9), sortable: true,
cell: (j) => { sortValue: (j) =>
if (j.status !== 'failed') return j.status 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 code = httpCodeFromError(j.error)
const label = code ? `failed (${code})` : 'failed' const label = code ? `failed (${code})` : 'failed'
return ( 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} {label}
</span> </span>
) )
}, }
}, if (j.status === 'finished') {
{
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 ( 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} disabled={busy}
variant='soft'
color='red'
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
void deleteVideo(j) void deleteVideo(j)
}} }}
aria-label="Video löschen"
title="Video löschen"
> >
{busy ? '…' : 'Löschen'} <TrashIcon className="size-5 text-red-600 dark:text-red-300" />
</Button> </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) { if (rows.length === 0) {
return ( return (
@ -475,33 +588,43 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
return ( return (
<> <>
{/* Toolbar */} {/* Toolbar */}
<div className="mb-3 flex items-center justify-end"> <div className="mb-3 flex items-center justify-between gap-2">
<ButtonGroup {/* Mobile title */}
value={view} <div className="sm:hidden min-w-0">
onChange={(id) => setView(id as ViewMode)} <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
size="sm" Abgeschlossene Downloads <span className="text-gray-500 dark:text-gray-400">({rows.length})</span>
ariaLabel="Ansicht" </div>
items={[ </div>
{
id: 'table', {/* Views */}
icon: <TableCellsIcon className="size-5" />, <div className="shrink-0">
label: <span className="hidden sm:inline">Tabelle</span>, <ButtonGroup
srLabel: 'Tabelle', value={view}
}, onChange={(id) => setView(id as ViewMode)}
{ size="sm"
id: 'cards', ariaLabel="Ansicht"
icon: <RectangleStackIcon className="size-5" />, items={[
label: <span className="hidden sm:inline">Cards</span>, {
srLabel: 'Cards', id: 'table',
}, icon: <TableCellsIcon className="size-5" />,
{ label: <span className="hidden sm:inline">Tabelle</span>,
id: 'gallery', srLabel: 'Tabelle',
icon: <Squares2X2Icon className="size-5" />, },
label: <span className="hidden sm:inline">Galerie</span>, {
srLabel: 'Galerie', 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> </div>
{/* ✅ Cards */} {/* ✅ Cards */}
@ -556,80 +679,121 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
}} }}
onContextMenu={(e) => openCtx(j, e)} onContextMenu={(e) => openCtx(j, e)}
> >
<Card <Card noBodyPadding className="overflow-hidden">
header={ {/* Preview */}
<div className="flex items-start justify-between gap-3"> <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="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div> <div className="truncate text-sm font-semibold text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div> <div className="truncate text-[11px] text-white/80">{stripHotPrefix(file) || '—'}</div>
</div> </div>
<div className="shrink-0 flex items-center gap-1"> <div className="shrink-0 flex items-center gap-2">
<Button {file.startsWith('HOT ') ? (
aria-label="Video löschen" <span className="rounded-md bg-amber-500/25 px-2 py-1 text-[11px] font-semibold text-white">
title="Video löschen" HOT
onClick={(e) => { </span>
e.preventDefault() ) : null}
e.stopPropagation() <span className="rounded-md bg-black/40 px-2 py-1 text-[11px] font-semibold text-white">
{dur}
const h = swipeRefs.current.get(k) </span>
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> </div>
</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"> {/* Actions top-right */}
<div className="text-xs text-gray-600 dark:text-gray-300"> <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} Status: {statusNode}
<span className="mx-2 opacity-60"></span> <span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span> Dauer: <span className="font-medium">{dur}</span>
</div> </div>
{j.output ? (
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">{j.output}</div>
) : null}
</div> </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> </div>
</Card> </Card>
</div> </div>
@ -648,6 +812,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
striped striped
fullWidth fullWidth
stickyHeader stickyHeader
compact
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
onRowClick={onOpenPlayer} onRowClick={onOpenPlayer}
@ -655,13 +820,13 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
rowClassName={(j) => { rowClassName={(j) => {
const k = keyFor(j) const k = keyFor(j)
return [ 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) || removingKeys.has(k)) && 'bg-red-50/60 dark:bg-red-500/10 pointer-events-none',
deletingKeys.has(k) && 'animate-pulse', 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', 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' && ( {view === 'gallery' && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{visibleRows.map((j) => { {visibleRows.map((j) => {
const k = keyFor(j)
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const file = baseName(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 ( return (
<div <div
key={keyFor(j)} key={k}
role="button" role="button"
tabIndex={0} 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)} onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}} }}
onContextMenu={(e) => openCtx(j, e)} onContextMenu={(e) => openCtx(j, e)}
> >
<Card {/* Thumb */}
header={ <div
<div className="flex items-start justify-between gap-2"> className="relative aspect-video bg-black/5 dark:bg-white/5"
<div className="min-w-0"> onContextMenu={(e) => {
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div> e.preventDefault()
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div> e.stopPropagation()
</div> openCtx(j, e)
<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 <FinishedVideoPreview
onClick={(e) => e.stopPropagation()} job={j}
onMouseDown={(e) => e.stopPropagation()} getFileName={baseName}
onContextMenu={(e) => { durationSeconds={durations[k]}
e.preventDefault() onDuration={handleDuration}
e.stopPropagation() variant="fill"
openCtx(j, e) showPopover={false}
}} inlineVideo="hover"
> />
<FinishedVideoPreview
job={j} {/* Gradient overlay bottom */}
getFileName={baseName} <div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/65 to-transparent" />
durationSeconds={durations[keyFor(j)]}
onDuration={handleDuration} {/* 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>
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300"> {/* Quick keep */}
Status: <span className="font-medium">{j.status}</span> <button
<span className="mx-2 opacity-60"></span> type="button"
Dauer: <span className="font-medium">{runtimeOf(j)}</span> 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> </div>
</Card> </div>
</div> </div>
) )
})} })}

View File

@ -1,17 +1,35 @@
// FinishedVideoPreview.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover'
type Props = { type Props = {
job: RecordJob job: RecordJob
getFileName: (path: string) => string getFileName: (path: string) => string
durationSeconds?: number durationSeconds?: number
onDuration?: (job: RecordJob, seconds: number) => void onDuration?: (job: RecordJob, seconds: number) => void
animated?: boolean // ✅ neu animated?: boolean
autoTickMs?: number // ✅ neu 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({ export default function FinishedVideoPreview({
@ -21,10 +39,15 @@ export default function FinishedVideoPreview({
onDuration, onDuration,
animated = false, animated = false,
autoTickMs = 15000, autoTickMs = 15000,
variant = 'thumb',
className,
showPopover = true,
inlineVideo = false,
}: Props) { }: Props) {
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const [thumbOk, setThumbOk] = useState(true) const [thumbOk, setThumbOk] = useState(true)
const [videoOk, setVideoOk] = useState(true)
const [metaLoaded, setMetaLoaded] = useState(false) const [metaLoaded, setMetaLoaded] = useState(false)
// ✅ nur animieren, wenn sichtbar (Viewport) // ✅ nur animieren, wenn sichtbar (Viewport)
@ -32,6 +55,9 @@ export default function FinishedVideoPreview({
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
// ✅ für hover-play
const [hovered, setHovered] = useState(false)
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
if (!el) return if (!el) return
@ -94,9 +120,82 @@ export default function FinishedVideoPreview({
} }
if (!videoSrc) { 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 ( return (
<HoverPopover <HoverPopover
content={(open) => 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"> {previewNode}
{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>
</HoverPopover> </HoverPopover>
) )
} }

View File

@ -4,8 +4,6 @@
import * as React from 'react' import * as React from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import Card from './Card' import Card from './Card'
import Button from './Button'
import videojs from 'video.js' import videojs from 'video.js'
import type VideoJsPlayer from 'video.js/dist/types/player' import type VideoJsPlayer from 'video.js/dist/types/player'
import 'video.js/dist/video-js.css' import 'video.js/dist/video-js.css'
@ -207,172 +205,265 @@ export default function Player({
} catch {} } 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 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 = ( const footerRight = (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <button
variant={isHot ? 'soft' : 'secondary'} type="button"
size="md" className={overlayBtn}
className={cn(
'px-2 py-1',
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'} title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'} aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
onClick={async () => { onClick={async (e) => {
// 1) Stream freigeben (wichtig für Windows Rename) e.stopPropagation()
releaseMedia() releaseMedia()
// 2) kurz warten, bis Browser/HTTP wirklich zu ist
await new Promise((r) => setTimeout(r, 150)) await new Promise((r) => setTimeout(r, 150))
// 3) Rename (App aktualisiert danach job.output -> media.src ändert sich)
await onToggleHot?.(job) await onToggleHot?.(job)
// 4) Optional: nach Rename wieder starten (falls du willst)
await new Promise((r) => setTimeout(r, 0)) await new Promise((r) => setTimeout(r, 0))
const p = playerRef.current const p = playerRef.current
if (p && !(p as any).isDisposed?.()) { if (p && !(p as any).isDisposed?.()) {
const ret = p.play?.() const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') { if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
;(ret as Promise<void>).catch(() => {})
}
} }
}} }}
disabled={!onToggleHot} disabled={!onToggleHot}
> >
<FireIcon className="h-4 w-4" /> <FireIcon className={cn('h-5 w-5', isHot ? 'text-amber-300' : 'text-white')} />
</Button> </button>
<Button <button
variant={isFavorite ? 'soft' : 'secondary'} type="button"
size="md" className={overlayBtn}
className={cn(
'px-2 py-1',
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'} title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
aria-label={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} disabled={!onToggleFavorite}
> >
<HeartIcon className="h-4 w-4" /> <HeartIcon className={cn('h-5 w-5', isFavorite ? 'text-pink-300' : 'text-white')} />
</Button> </button>
<Button <button
variant={isLiked ? 'soft' : 'secondary'} type="button"
size="md" className={overlayBtn}
className={cn(
'px-2 py-1',
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'} title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
aria-label={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} disabled={!onToggleLike}
> >
<HandThumbUpIcon className="h-4 w-4" /> <HandThumbUpIcon className={cn('h-5 w-5', isLiked ? 'text-indigo-200' : 'text-white')} />
</Button> </button>
<Button <button
variant="secondary" type="button"
size="md" className={overlayBtnDanger}
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"
title="Löschen" title="Löschen"
aria-label="Löschen" aria-label="Löschen"
onClick={async () => { onClick={async (e) => {
e.stopPropagation()
releaseMedia() releaseMedia()
// optional: Player schließen -> dispose() läuft im Cleanup und gibt endgültig frei
onClose() onClose()
// kurzer Moment, bis der Browser den Stream wirklich abbricht
await new Promise((r) => setTimeout(r, 150)) await new Promise((r) => setTimeout(r, 150))
await onDelete?.(job) await onDelete?.(job)
}} }}
disabled={!onDelete} disabled={!onDelete}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-5 w-5" />
</Button> </button>
</div> </div>
) )
return createPortal( return createPortal(
<> <>
{expanded && ( {expanded && (
<div <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} onClick={onClose}
aria-hidden="true" aria-hidden="true"
/> />
)} )}
<Card <Card
edgeToEdgeMobile
noBodyPadding
className={cn( 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 expanded
? 'inset-6' ? 'inset-4 sm: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]', : '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 ?? '' className ?? ''
)} )}
noBodyPadding
bodyClassName="flex flex-col flex-1 min-h-0 p-0" bodyClassName="flex flex-col flex-1 min-h-0 p-0"
header={ >
<div className="flex items-center justify-between gap-3"> {/* Video Stage */}
<div className="min-w-0"> <div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
<div className="truncate text-sm font-medium text-gray-900 dark:text-white"> <div
{title} 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> </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 {/* Bottom overlay: mini actions + status */}
variant="secondary" {!expanded && (
size="md" <>
color='red' <div
className="px-2 py-1" className={cn(
onClick={onClose} 'player-ui pointer-events-none absolute inset-x-0 bg-gradient-to-t from-black/70 to-transparent',
title="Schließen" 'transition-all duration-200 ease-out',
aria-label="Schließen" liftMiniOverlay ? 'bottom-7 h-24' : 'bottom-0 h-20'
> )}
<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>
{/* Buttons primär im Mini-Player */} <div
{mini ? footerRight : null} className={cn(
</div> 'player-ui absolute inset-x-2 z-20 flex items-end justify-between gap-2',
} 'transition-all duration-200 ease-out',
grayBody={false} liftMiniOverlay ? 'bottom-7' : 'bottom-2'
> )}
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}> >
<div className={cn('w-full h-full min-h-0', mini && 'vjs-mini')}> <div className="min-w-0 rounded-md bg-black/45 px-2.5 py-1.5 text-[11px] text-white/90 backdrop-blur">
<div ref={containerRef} className="w-full h-full" /> <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>
</div> </div>
</Card> </Card>

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -62,11 +63,21 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
onSwipeRight, onSwipeRight,
className, className,
leftAction = { 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', className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
}, },
rightAction = { 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', className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
}, },
thresholdPx = 120, thresholdPx = 120,
@ -87,11 +98,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}>({ id: null, x: 0, y: 0, dragging: false }) }>({ id: null, x: 0, y: 0, dragging: false })
const [dx, setDx] = React.useState(0) const [dx, setDx] = React.useState(0)
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
const [animMs, setAnimMs] = React.useState<number>(0) const [animMs, setAnimMs] = React.useState<number>(0)
const reset = React.useCallback(() => { const reset = React.useCallback(() => {
setAnimMs(snapMs) setAnimMs(snapMs)
setDx(0) setDx(0)
setArmedDir(null)
window.setTimeout(() => setAnimMs(0), snapMs) window.setTimeout(() => setAnimMs(0), snapMs)
}, [snapMs]) }, [snapMs])
@ -102,6 +115,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// rausfliegen lassen // rausfliegen lassen
setAnimMs(commitMs) setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
setDx(dir === 'right' ? w + 40 : -(w + 40)) setDx(dir === 'right' ? w + 40 : -(w + 40))
let ok: boolean | void = true let ok: boolean | void = true
@ -116,6 +130,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// wenn Aktion fehlschlägt => zurücksnappen // wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)
setArmedDir(null)
setDx(0) setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs) window.setTimeout(() => setAnimMs(0), snapMs)
return false return false
@ -138,19 +153,30 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return ( return (
<div className={cn('relative overflow-hidden rounded-lg', className)}> <div className={cn('relative overflow-hidden rounded-lg', className)}>
{/* Background actions (100% je Richtung) */} {/* Background actions (100% je Richtung, animiert) */}
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{dx !== 0 ? (
<div <div
className={cn( className={cn(
'h-full w-full flex items-center', 'absolute inset-0 transition-opacity duration-200 ease-out',
dx > 0 ? leftAction.className : rightAction.className, dx === 0 ? 'opacity-0' : 'opacity-100',
dx > 0 ? 'justify-start pl-4' : 'justify-end pr-4' 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> </div>
) : null}
</div> </div>
{/* Foreground (moves) */} {/* Foreground (moves) */}
@ -184,6 +210,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
setAnimMs(0) setAnimMs(0)
setDx(ddx) 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) => { onPointerUp={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
@ -215,7 +247,19 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
reset() 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>
</div> </div>
) )

View File

@ -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' '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 <thead
className={cn( className={cn(
card && 'bg-gray-50 dark:bg-gray-800/75', card && 'bg-gray-50/90 dark:bg-gray-800/70',
stickyHeader && 'sticky top-0 z-10 backdrop-blur-sm backdrop-filter' stickyHeader &&
'sticky top-0 z-10 backdrop-blur-sm shadow-sm'
)} )}
> >
<tr> <tr>
@ -240,7 +241,7 @@ export default function Table<T>({
aria-sort={ariaSort as any} aria-sort={ariaSort as any}
className={cn( className={cn(
headY, 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), alignTd(col.align),
col.widthClassName, col.widthClassName,
col.headerClassName col.headerClassName
@ -253,7 +254,7 @@ export default function Table<T>({
type="button" type="button"
onClick={nextSort} onClick={nextSort}
className={cn( 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) justifyForAlign(col.align)
)} )}
> >
@ -313,6 +314,7 @@ export default function Table<T>({
className={cn( className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50', striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && 'cursor-pointer', onRowClick && 'cursor-pointer',
onRowClick && 'hover:bg-gray-50 dark:hover:bg-white/5 transition-colors',
rowClassName?.(row, rowIndex) rowClassName?.(row, rowIndex)
)} )}
onClick={() => onRowClick?.(row)} onClick={() => onRowClick?.(row)}

View File

@ -15,14 +15,56 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.plyr-mini .plyr__controls [data-plyr="rewind"], /* MiniPlayer - Controlbar sichtbar, dicker, kontrastreich */
.plyr-mini .plyr__controls [data-plyr="fast-forward"], .vjs-mini .video-js .vjs-control-bar{
.plyr-mini .plyr__controls [data-plyr="volume"], z-index: 40; /* über Overlays */
.plyr-mini .plyr__controls [data-plyr="settings"], background: rgba(0,0,0,.65);
.plyr-mini .plyr__controls [data-plyr="pip"], backdrop-filter: blur(8px);
.plyr-mini .plyr__controls [data-plyr="airplay"], }
.plyr-mini .plyr__time--duration {
display: none !important; /* 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) { @media (prefers-color-scheme: light) {