1014 lines
31 KiB
TypeScript
1014 lines
31 KiB
TypeScript
// frontend\src\components\ui\RecordJobActions.tsx
|
|
|
|
'use client'
|
|
|
|
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
useMemo,
|
|
useLayoutEffect,
|
|
Fragment,
|
|
type ReactNode,
|
|
type MouseEvent as ReactMouseEvent,
|
|
} from 'react'
|
|
import type { RecordJob } from '../../types'
|
|
import {
|
|
FireIcon as FireOutlineIcon,
|
|
TrashIcon,
|
|
BookmarkSquareIcon,
|
|
MagnifyingGlassIcon,
|
|
EllipsisVerticalIcon,
|
|
StarIcon as StarOutlineIcon,
|
|
HeartIcon as HeartOutlineIcon,
|
|
EyeIcon as EyeOutlineIcon,
|
|
ArrowDownTrayIcon,
|
|
ScissorsIcon,
|
|
AcademicCapIcon,
|
|
} from '@heroicons/react/24/outline'
|
|
import {
|
|
FireIcon as FireSolidIcon,
|
|
StarIcon as StarSolidIcon,
|
|
HeartIcon as HeartSolidIcon,
|
|
EyeIcon as EyeSolidIcon,
|
|
CheckIcon,
|
|
} from '@heroicons/react/24/solid'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
|
|
type Variant = 'overlay' | 'table'
|
|
|
|
type ActionKey =
|
|
| 'details'
|
|
| 'add'
|
|
| 'training'
|
|
| 'split'
|
|
| 'hot'
|
|
| 'favorite'
|
|
| 'like'
|
|
| 'watch'
|
|
| 'keep'
|
|
| 'delete'
|
|
|
|
type ActionResult = void | boolean
|
|
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
|
|
|
|
type Props = {
|
|
job: RecordJob
|
|
variant?: Variant
|
|
collapseToMenu?: boolean
|
|
inlineCount?: number
|
|
|
|
busy?: boolean
|
|
compact?: boolean
|
|
|
|
isHot?: boolean
|
|
isFavorite?: boolean
|
|
isLiked?: boolean
|
|
isWatching?: boolean
|
|
|
|
onToggleFavorite?: ActionFn
|
|
onToggleLike?: ActionFn
|
|
onToggleHot?: ActionFn
|
|
onKeep?: ActionFn
|
|
onDelete?: ActionFn
|
|
onToggleWatch?: ActionFn
|
|
onAddToDownloads?: ActionFn
|
|
onSplit?: ActionFn
|
|
onAddToTraining?: ActionFn
|
|
|
|
order?: ActionKey[]
|
|
|
|
className?: string
|
|
}
|
|
|
|
function cn(...parts: Array<string | false | null | undefined>) {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
const baseName = (p: string) =>
|
|
(p || '').replaceAll('\\', '/').trim().split('/').pop() || ''
|
|
|
|
const stripHotPrefix = (name: string) => (name.startsWith('HOT ') ? name.slice(4) : name)
|
|
|
|
// wie backend models.go / App.tsx
|
|
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
|
|
|
|
function modelKeyFromOutput(output?: string): string | null {
|
|
const file = stripHotPrefix(baseName(output || ''))
|
|
const base = file.replace(/\.[^.]+$/, '') // ext weg
|
|
|
|
const m = base.match(reModel)
|
|
if (m?.[1]?.trim()) return m[1].trim()
|
|
|
|
const i = base.lastIndexOf('_')
|
|
if (i > 0) return base.slice(0, i)
|
|
|
|
return base ? base : null
|
|
}
|
|
|
|
export default function RecordJobActions({
|
|
job,
|
|
variant = 'overlay',
|
|
collapseToMenu = false,
|
|
inlineCount,
|
|
busy = false,
|
|
compact = false,
|
|
isHot = false,
|
|
isFavorite = false,
|
|
isLiked = false,
|
|
isWatching = false,
|
|
onToggleFavorite,
|
|
onToggleLike,
|
|
onToggleHot,
|
|
onKeep,
|
|
onDelete,
|
|
onToggleWatch,
|
|
onAddToDownloads,
|
|
onSplit,
|
|
onAddToTraining,
|
|
order,
|
|
className,
|
|
}: Props) {
|
|
|
|
const pad = variant === 'overlay' ? (compact ? 'p-1.5' : 'p-2') : 'p-1.5'
|
|
|
|
const iconBox = compact ? 'size-4' : 'size-5'
|
|
const iconFill = 'h-full w-full'
|
|
|
|
const btnBase =
|
|
variant === 'table'
|
|
? [
|
|
'inline-flex items-center justify-center rounded-md',
|
|
pad,
|
|
'bg-gray-50/90 text-gray-700 shadow-sm ring-1 ring-gray-200/80',
|
|
'hover:bg-white hover:text-gray-950 hover:ring-gray-300',
|
|
'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white',
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60',
|
|
'disabled:cursor-not-allowed disabled:opacity-50 transition-transform active:scale-95',
|
|
].join(' ')
|
|
: 'inline-flex items-center justify-center rounded-md ' +
|
|
`bg-white/75 ${pad} text-gray-900 backdrop-blur ring-1 ring-black/10 ` +
|
|
'hover:bg-white/90 ' +
|
|
'dark:bg-black/40 dark:text-white dark:ring-white/10 dark:hover:bg-black/60 ' +
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:focus-visible:ring-white/40 ' +
|
|
'disabled:opacity-50 disabled:cursor-not-allowed transition-transform active:scale-95'
|
|
|
|
const colors =
|
|
variant === 'table'
|
|
? {
|
|
favOn: 'text-amber-600 dark:text-amber-300',
|
|
likeOn: 'text-rose-600 dark:text-rose-300',
|
|
hotOn: 'text-amber-600 dark:text-amber-300',
|
|
off: 'text-gray-800 dark:text-gray-100',
|
|
keep: 'text-emerald-600 dark:text-emerald-300',
|
|
del: 'text-red-600 dark:text-red-300',
|
|
watchOn: 'text-sky-600 dark:text-sky-300',
|
|
}
|
|
: {
|
|
favOn: 'text-amber-600 dark:text-amber-300',
|
|
likeOn: 'text-rose-600 dark:text-rose-300',
|
|
hotOn: 'text-amber-600 dark:text-amber-300',
|
|
off: 'text-gray-800/90 dark:text-white/90',
|
|
keep: 'text-emerald-600 dark:text-emerald-200',
|
|
del: 'text-red-600 dark:text-red-300',
|
|
watchOn: 'text-sky-600 dark:text-sky-200',
|
|
}
|
|
|
|
|
|
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
|
|
const outlineStrokeWidth = variant === 'table' ? 1.75 : undefined
|
|
|
|
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'training', 'split', 'hot', 'keep', 'delete', 'details']
|
|
const inOrder = (k: ActionKey) => actionOrder.includes(k)
|
|
|
|
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
|
const detailsKey = modelKeyFromOutput(job.output || '')
|
|
const detailsLabel = detailsKey ? `Mehr zu ${detailsKey} anzeigen` : 'Mehr anzeigen'
|
|
|
|
// Sichtbarkeit NUR über `order`
|
|
const wantFavorite = inOrder('favorite')
|
|
const wantLike = inOrder('like')
|
|
const wantHot = inOrder('hot')
|
|
const wantWatch = inOrder('watch')
|
|
const wantKeep = inOrder('keep')
|
|
const wantDelete = inOrder('delete')
|
|
const wantSplit = inOrder('split') && Boolean(baseName(job.output || ''))
|
|
const wantTraining = inOrder('training') && Boolean(baseName(job.output || ''))
|
|
|
|
// Details: wenn du ihn auch ohne detailsKey zeigen willst, nimm nur inOrder('details').
|
|
// (Aktuell macht Details ohne detailsKey wenig Sinn, daher: nur anzeigen wenn key existiert.)
|
|
const wantDetails = inOrder('details') && Boolean(detailsKey)
|
|
|
|
// Add: nur über order steuern (disabled wenn keine URL/Handler)
|
|
const wantAdd = inOrder('add')
|
|
|
|
const [addState, setAddState] = useState<'idle' | 'busy' | 'ok'>('idle')
|
|
const addTimerRef = useRef<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
|
|
}
|
|
}, [])
|
|
|
|
const flashAddOk = () => {
|
|
setAddState('ok')
|
|
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
|
|
addTimerRef.current = window.setTimeout(() => setAddState('idle'), 800)
|
|
}
|
|
|
|
const doAdd = async (): Promise<boolean> => {
|
|
if (busy) return false
|
|
if (addState === 'busy') return false
|
|
|
|
const hasUrl = Boolean(addUrl)
|
|
if (!onAddToDownloads && !hasUrl) return false
|
|
|
|
setAddState('busy')
|
|
try {
|
|
let ok = true
|
|
|
|
if (onAddToDownloads) {
|
|
const r = await onAddToDownloads(job)
|
|
ok = r !== false
|
|
} else if (addUrl) {
|
|
window.dispatchEvent(new CustomEvent('downloads:add-url', { detail: { url: addUrl } }))
|
|
ok = true
|
|
} else {
|
|
ok = false
|
|
}
|
|
|
|
if (ok) flashAddOk()
|
|
else setAddState('idle')
|
|
|
|
return ok
|
|
} catch {
|
|
setAddState('idle')
|
|
return false
|
|
}
|
|
}
|
|
|
|
const doTrainingImport = async (): Promise<boolean> => {
|
|
if (busy) return false
|
|
|
|
const output = String(job.output || '').trim()
|
|
if (!output) return false
|
|
|
|
try {
|
|
if (onAddToTraining) {
|
|
const r = await onAddToTraining(job)
|
|
return r !== false
|
|
}
|
|
|
|
const detail = {
|
|
jobId: String((job as any)?.id ?? ''),
|
|
output,
|
|
sourceFile: baseName(output),
|
|
count: 10,
|
|
}
|
|
|
|
// Falls TrainingTab gerade nicht gemountet ist.
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
'training:pending-import-video',
|
|
JSON.stringify(detail)
|
|
)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// App kann darauf reagieren und zum Training-Tab wechseln.
|
|
window.dispatchEvent(
|
|
new CustomEvent('app:navigate-tab', {
|
|
detail: { tab: 'training' },
|
|
})
|
|
)
|
|
|
|
// Einen Frame warten, damit der TrainingTab nach dem Tab-Wechsel sicher gemountet/aktiv ist.
|
|
// sessionStorage bleibt trotzdem der Fallback.
|
|
window.requestAnimationFrame(() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent('training:import-video', {
|
|
detail,
|
|
})
|
|
)
|
|
})
|
|
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
|
|
const [rootW, setRootW] = useState(0)
|
|
const [gapPx, setGapPx] = useState(4)
|
|
|
|
const run = (fn?: ActionFn) => (e: ReactMouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (busy || !fn) return
|
|
void Promise.resolve(fn(job)).catch(() => {})
|
|
}
|
|
|
|
const DetailsBtn =
|
|
wantDetails ? (
|
|
<button
|
|
type="button"
|
|
className={cn(btnBase)}
|
|
title={detailsLabel}
|
|
aria-label={detailsLabel}
|
|
disabled={busy}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (busy) return
|
|
window.dispatchEvent(new CustomEvent('open-model-details', { detail: { modelKey: detailsKey } }))
|
|
}}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<MagnifyingGlassIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
<span className="sr-only">{detailsLabel}</span>
|
|
</button>
|
|
) : null
|
|
|
|
const AddBtn = wantAdd ? (
|
|
<button
|
|
type="button"
|
|
className={cn(btnBase)}
|
|
title={addUrl ? 'URL zu Downloads hinzufügen' : 'Keine URL vorhanden'}
|
|
aria-label="Zu Downloads hinzufügen"
|
|
disabled={busy || addState === 'busy' || (!onAddToDownloads && !addUrl)}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
await doAdd()
|
|
}}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
{addState === 'ok' ? (
|
|
<CheckIcon className={cn(iconFill, 'text-emerald-600 dark:text-emerald-300')} />
|
|
) : (
|
|
<ArrowDownTrayIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
)}
|
|
</span>
|
|
<span className="sr-only">Zu Downloads</span>
|
|
</button>
|
|
) : null
|
|
|
|
const TrainingBtn = wantTraining ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title="Video ins Training übernehmen"
|
|
aria-label="Video ins Training übernehmen"
|
|
disabled={busy || (!onAddToTraining && !job.output)}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
await doTrainingImport()
|
|
}}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<AcademicCapIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
<span className="sr-only">Ins Training übernehmen</span>
|
|
</button>
|
|
) : null
|
|
|
|
const FavoriteBtn = wantFavorite ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
|
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
|
aria-pressed={isFavorite}
|
|
disabled={busy || !onToggleFavorite}
|
|
onClick={run(onToggleFavorite)}
|
|
>
|
|
<span className={cn('relative inline-block', iconBox)}>
|
|
<StarOutlineIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
isFavorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
iconFill,
|
|
colors.off
|
|
)}
|
|
strokeWidth={outlineStrokeWidth}
|
|
/>
|
|
<StarSolidIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
isFavorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
iconFill,
|
|
colors.favOn
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const LikeBtn = wantLike ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
|
aria-label={isLiked ? 'Gefällt mir entfernen' : 'Als Gefällt mir markieren'}
|
|
aria-pressed={isLiked}
|
|
disabled={busy || !onToggleLike}
|
|
onClick={run(onToggleLike)}
|
|
>
|
|
<span className={cn('relative inline-block', iconBox)}>
|
|
<HeartOutlineIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
isLiked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
iconFill,
|
|
colors.off
|
|
)}
|
|
strokeWidth={outlineStrokeWidth}
|
|
/>
|
|
<HeartSolidIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out motion-reduce:transition-none',
|
|
isLiked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
iconFill,
|
|
colors.likeOn
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const HotBtn = wantHot ? (
|
|
<button
|
|
type="button"
|
|
data-hot-target
|
|
className={btnBase}
|
|
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
|
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
|
aria-pressed={isHot}
|
|
disabled={busy || !onToggleHot}
|
|
onClick={run(onToggleHot)}
|
|
>
|
|
<span className={cn('relative inline-block', iconBox)}>
|
|
<FireOutlineIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out',
|
|
isHot ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
iconFill,
|
|
colors.off
|
|
)}
|
|
strokeWidth={outlineStrokeWidth}
|
|
/>
|
|
<FireSolidIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out',
|
|
isHot ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
iconFill,
|
|
colors.hotOn
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const WatchBtn = wantWatch ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title={isWatching ? 'Watched entfernen' : 'Als Watched markieren'}
|
|
aria-label={isWatching ? 'Watched entfernen' : 'Als Watched markieren'}
|
|
aria-pressed={isWatching}
|
|
disabled={busy || !onToggleWatch}
|
|
onClick={run(onToggleWatch)}
|
|
>
|
|
<span className={cn('relative inline-block', iconBox)}>
|
|
<EyeOutlineIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out',
|
|
isWatching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
|
|
iconFill,
|
|
colors.off
|
|
)}
|
|
strokeWidth={outlineStrokeWidth}
|
|
/>
|
|
<EyeSolidIcon
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-200 ease-out',
|
|
isWatching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
|
|
iconFill,
|
|
colors.watchOn
|
|
)}
|
|
/>
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const SplitBtn = wantSplit ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title="Video schneiden"
|
|
aria-label="Video schneiden"
|
|
disabled={busy || !onSplit}
|
|
onClick={run(onSplit)}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<ScissorsIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const KeepBtn = wantKeep ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title="Behalten (nach keep verschieben)"
|
|
aria-label="Behalten"
|
|
disabled={busy || !onKeep}
|
|
onClick={run(onKeep)}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<BookmarkSquareIcon className={cn(iconFill, colors.keep)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const DeleteBtn = wantDelete ? (
|
|
<button
|
|
type="button"
|
|
className={btnBase}
|
|
title="Löschen"
|
|
aria-label="Löschen"
|
|
disabled={busy || !onDelete}
|
|
onClick={run(onDelete)}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<TrashIcon className={cn(iconFill, colors.del)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
</button>
|
|
) : null
|
|
|
|
const byKey: Record<ActionKey, ReactNode> = {
|
|
details: DetailsBtn,
|
|
add: AddBtn,
|
|
training: TrainingBtn,
|
|
split: SplitBtn,
|
|
favorite: FavoriteBtn,
|
|
like: LikeBtn,
|
|
watch: WatchBtn,
|
|
hot: HotBtn,
|
|
keep: KeepBtn,
|
|
delete: DeleteBtn,
|
|
}
|
|
|
|
const collapse = collapseToMenu
|
|
|
|
// Nur Keys, die wirklich einen Node haben
|
|
const presentKeys = actionOrder.filter((k) => Boolean(byKey[k]))
|
|
|
|
// ✅ Wenn inlineCount NICHT gesetzt ist -> Auto-Fit (so viele wie passen + Menü-Icon)
|
|
const autoFit = collapse && typeof inlineCount !== 'number'
|
|
|
|
// grobe, aber sehr stabile Button-Breite in px (Icon + Padding links/rechts)
|
|
const padPx = variant === 'overlay' ? (compact ? 6 : 8) : 6 // p-1.5=6px, p-2=8px
|
|
const iconPx = compact ? 16 : 20 // size-4 / size-5
|
|
const btnW = iconPx + padPx * 2
|
|
|
|
const fallbackLimit = compact ? 2 : 3
|
|
|
|
const fitLimit = useMemo(() => {
|
|
if (!autoFit) return (inlineCount ?? fallbackLimit)
|
|
|
|
// rootW kann initial 0 sein -> brauchbarer Fallback
|
|
const w = rootW || 0
|
|
if (w <= 0) return Math.min(presentKeys.length, fallbackLimit)
|
|
|
|
// Wir reservieren IMMER Platz für das Menü-Icon (btnW).
|
|
// Gaps: zwischen den inline Buttons + zwischen letztem inline und Menü => inlineCount Gaps.
|
|
for (let i = presentKeys.length; i >= 0; i--) {
|
|
const total = i * btnW + btnW + (i > 0 ? i * gapPx : 0)
|
|
if (total <= w) return i
|
|
}
|
|
return 0
|
|
}, [autoFit, inlineCount, fallbackLimit, rootW, presentKeys.length, btnW, gapPx])
|
|
|
|
const inlineKeys = collapse ? presentKeys.slice(0, fitLimit) : presentKeys
|
|
const menuKeys = collapse ? presentKeys.slice(fitLimit) : []
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
useLayoutEffect(() => {
|
|
const el = menuRef.current
|
|
if (!el || typeof window === 'undefined') return
|
|
|
|
const read = () => {
|
|
const self = menuRef.current
|
|
if (!self) return
|
|
|
|
// ✅ WICHTIG: verfügbare Breite = eigene Breite (so stimmt es auch mit Geschwistern wie "Stop")
|
|
const w = Math.floor(self.getBoundingClientRect().width || 0)
|
|
if (w > 0) setRootW(w)
|
|
|
|
const cs = window.getComputedStyle(self)
|
|
const gRaw = (cs.columnGap || (cs as any).gap || '0') as string
|
|
const g = parseFloat(gRaw)
|
|
if (Number.isFinite(g) && g >= 0) setGapPx(g)
|
|
}
|
|
|
|
read()
|
|
|
|
const ro = new ResizeObserver(() => read())
|
|
ro.observe(el)
|
|
|
|
window.addEventListener('resize', read)
|
|
return () => {
|
|
window.removeEventListener('resize', read)
|
|
ro.disconnect()
|
|
}
|
|
}, [])
|
|
|
|
// ✅ neu
|
|
const menuBtnRef = useRef<HTMLButtonElement | null>(null)
|
|
const menuPopupRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const MENU_W = 208 // entspricht Tailwind w-52 (13rem)
|
|
const GAP = 4
|
|
const MARGIN = 8
|
|
|
|
const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxH: number } | null>(null)
|
|
const [menuPortalTarget, setMenuPortalTarget] = useState<HTMLElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!menuOpen) return
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setMenuOpen(false)
|
|
}
|
|
|
|
const onDown = (e: MouseEvent) => {
|
|
const root = menuRef.current
|
|
const popup = menuPopupRef.current
|
|
const target = e.target as Node
|
|
|
|
// ✅ Click innerhalb der Action-Root ODER innerhalb des Portal-Menüs -> offen lassen
|
|
if ((root && root.contains(target)) || (popup && popup.contains(target))) return
|
|
setMenuOpen(false)
|
|
}
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
window.addEventListener('mousedown', onDown)
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey)
|
|
window.removeEventListener('mousedown', onDown)
|
|
}
|
|
}, [menuOpen])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!menuOpen) {
|
|
setMenuStyle(null)
|
|
setMenuPortalTarget(null)
|
|
return
|
|
}
|
|
|
|
const portalTarget =
|
|
(menuBtnRef.current?.closest('dialog[open]') as HTMLElement | null) ??
|
|
document.body
|
|
|
|
setMenuPortalTarget(portalTarget)
|
|
|
|
const update = () => {
|
|
const btn = menuBtnRef.current
|
|
if (!btn) return
|
|
|
|
const r = btn.getBoundingClientRect()
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
|
|
let left = r.right - MENU_W
|
|
left = Math.max(MARGIN, Math.min(left, vw - MENU_W - MARGIN))
|
|
|
|
let top = r.bottom + GAP
|
|
|
|
const spaceBelow = vh - top - MARGIN
|
|
const spaceAbove = r.top - MARGIN
|
|
|
|
// Falls unten zu wenig Platz ist, über dem Button öffnen
|
|
if (spaceBelow < 120 && spaceAbove > spaceBelow) {
|
|
const maxH = Math.max(120, spaceAbove)
|
|
top = Math.max(MARGIN, r.top - GAP - Math.min(maxH, 320))
|
|
setMenuStyle({ top, left, maxH })
|
|
return
|
|
}
|
|
|
|
top = Math.max(MARGIN, Math.min(top, vh - MARGIN))
|
|
const maxH = Math.max(120, vh - top - MARGIN)
|
|
|
|
setMenuStyle({ top, left, maxH })
|
|
}
|
|
|
|
update()
|
|
|
|
window.addEventListener('resize', update)
|
|
window.addEventListener('scroll', update, true)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', update)
|
|
window.removeEventListener('scroll', update, true)
|
|
}
|
|
}, [menuOpen])
|
|
|
|
const fireDetails = () => {
|
|
if (!detailsKey) return
|
|
window.dispatchEvent(new CustomEvent('open-model-details', { detail: { modelKey: detailsKey } }))
|
|
}
|
|
|
|
const menuItem = (k: ActionKey) => {
|
|
if (k === 'details') {
|
|
return (
|
|
<button
|
|
key="details"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
fireDetails()
|
|
}}
|
|
>
|
|
<MagnifyingGlassIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Details</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'add') {
|
|
return (
|
|
<button
|
|
key="add"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || (!onAddToDownloads && !addUrl)}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await doAdd()
|
|
}}
|
|
>
|
|
<ArrowDownTrayIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Zu Downloads</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'training') {
|
|
return (
|
|
<button
|
|
key="training"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || (!onAddToTraining && !job.output)}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await doTrainingImport()
|
|
}}
|
|
>
|
|
<AcademicCapIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Ins Training übernehmen</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'split') {
|
|
return (
|
|
<button
|
|
key="split"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onSplit}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await onSplit?.(job)
|
|
}}
|
|
>
|
|
<ScissorsIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Video schneiden</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'favorite') {
|
|
return (
|
|
<button
|
|
key="favorite"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onToggleFavorite}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await onToggleFavorite?.(job)
|
|
}}
|
|
>
|
|
{isFavorite ? (
|
|
<StarSolidIcon className={cn('size-4', colors.favOn)} />
|
|
) : (
|
|
<StarOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
)}
|
|
<span className="truncate">{isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'like') {
|
|
return (
|
|
<button
|
|
key="like"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onToggleLike}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await onToggleLike?.(job)
|
|
}}
|
|
>
|
|
{isLiked ? (
|
|
<HeartSolidIcon className={cn('size-4', colors.favOn)} />
|
|
) : (
|
|
<HeartOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
)}
|
|
<span className="truncate">{isLiked ? 'Gefällt mir entfernen' : 'Gefällt mir'}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'watch') {
|
|
return (
|
|
<button
|
|
key="watch"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onToggleWatch}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await onToggleWatch?.(job)
|
|
}}
|
|
>
|
|
{isWatching ? (
|
|
<EyeSolidIcon className={cn('size-4', colors.favOn)} />
|
|
) : (
|
|
<EyeOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
)}
|
|
<span className="truncate">{isWatching ? 'Watched entfernen' : 'Watched'}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'hot') {
|
|
return (
|
|
<button
|
|
key="hot"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onToggleHot}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
await onToggleHot?.(job)
|
|
}}
|
|
>
|
|
{isHot ? (
|
|
<FireSolidIcon className={cn('size-4', colors.favOn)} />
|
|
) : (
|
|
<FireOutlineIcon className={cn('size-4', colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
)}
|
|
<span className="truncate">{isHot ? 'HOT entfernen' : 'Als HOT markieren'}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'keep') {
|
|
return (
|
|
<button
|
|
key="keep"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onKeep}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
try {
|
|
await onKeep?.(job)
|
|
} catch {
|
|
// The app-level handler already reports delete/keep failures.
|
|
}
|
|
}}
|
|
>
|
|
<BookmarkSquareIcon className={cn('size-4', colors.keep)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Behalten</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
if (k === 'delete') {
|
|
return (
|
|
<button
|
|
key="delete"
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
|
|
disabled={busy || !onDelete}
|
|
onClick={async (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setMenuOpen(false)
|
|
try {
|
|
await onDelete?.(job)
|
|
} catch {
|
|
// The app-level handler already reports delete/keep failures.
|
|
}
|
|
}}
|
|
>
|
|
<TrashIcon className={cn('size-4', colors.del)} strokeWidth={outlineStrokeWidth} />
|
|
<span className="truncate">Löschen</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div ref={menuRef} className={cn('relative flex items-center flex-nowrap', className ?? 'gap-2')}>
|
|
{inlineKeys.map((k) => {
|
|
const node = byKey[k]
|
|
return node ? <Fragment key={k}>{node}</Fragment> : null
|
|
})}
|
|
|
|
{collapse && menuKeys.length > 0 ? (
|
|
<div className="relative">
|
|
<button
|
|
ref={menuBtnRef}
|
|
type="button"
|
|
className={btnBase}
|
|
aria-label="Mehr"
|
|
title="Mehr"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
disabled={busy}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (busy) return
|
|
setMenuOpen((v) => !v)
|
|
}}
|
|
>
|
|
<span className={cn('inline-flex items-center justify-center', iconBox)}>
|
|
<EllipsisVerticalIcon className={cn(iconFill, colors.off)} strokeWidth={outlineStrokeWidth} />
|
|
</span>
|
|
</button>
|
|
|
|
{menuOpen && menuStyle && menuPortalTarget
|
|
? createPortal(
|
|
<div
|
|
ref={menuPopupRef}
|
|
role="menu"
|
|
className="overflow-auto rounded-md bg-white/95 p-1 text-gray-900 shadow-2xl ring-1 ring-black/10 backdrop-blur dark:bg-gray-900/95 dark:text-gray-100 dark:ring-white/10"
|
|
style={{
|
|
position: 'fixed',
|
|
top: menuStyle.top,
|
|
left: menuStyle.left,
|
|
width: MENU_W,
|
|
maxHeight: menuStyle.maxH,
|
|
zIndex: 2147483647,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{menuKeys.map(menuItem)}
|
|
</div>,
|
|
menuPortalTarget
|
|
)
|
|
: null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|