// 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 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) { 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(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 => { 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 => { 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) => { e.preventDefault() e.stopPropagation() if (busy || !fn) return void Promise.resolve(fn(job)).catch(() => {}) } const DetailsBtn = wantDetails ? ( ) : null const AddBtn = wantAdd ? ( ) : null const TrainingBtn = wantTraining ? ( ) : null const FavoriteBtn = wantFavorite ? ( ) : null const LikeBtn = wantLike ? ( ) : null const HotBtn = wantHot ? ( ) : null const WatchBtn = wantWatch ? ( ) : null const SplitBtn = wantSplit ? ( ) : null const KeepBtn = wantKeep ? ( ) : null const DeleteBtn = wantDelete ? ( ) : null const byKey: Record = { 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(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(null) const menuPopupRef = useRef(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(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 ( ) } if (k === 'add') { return ( ) } if (k === 'training') { return ( ) } if (k === 'split') { return ( ) } if (k === 'favorite') { return ( ) } if (k === 'like') { return ( ) } if (k === 'watch') { return ( ) } if (k === 'hot') { return ( ) } if (k === 'keep') { return ( ) } if (k === 'delete') { return ( ) } return null } return (
{inlineKeys.map((k) => { const node = byKey[k] return node ? {node} : null })} {collapse && menuKeys.length > 0 ? (
{menuOpen && menuStyle && menuPortalTarget ? createPortal(
e.stopPropagation()} > {menuKeys.map(menuItem)}
, menuPortalTarget ) : null}
) : null}
) }