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" />
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user