nsfwapp/frontend/src/components/ui/RecordJobActions.tsx
2026-06-25 14:54:58 +02:00

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>
)
}