diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index 8882d9c..f99771c 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -473,16 +473,20 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) { fetchedAtNow := cbApplySnapshot(rooms) if cbModelStore != nil { - // ✅ bekannten Store sofort auf aktuellen Snapshot ziehen + copiedRooms := append([]ChaturbateRoom(nil), rooms...) + + // Statusfelder pflegen (online/offline, room_status, last_online_at, ...) syncChaturbateRoomStateIntoModelStore( cbModelStore, - append([]ChaturbateRoom(nil), rooms...), + copiedRooms, fetchedAtNow, ) - // optional / best effort - if len(rooms) > 0 { - go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms) + // Vollständigen Snapshot pflegen, aber offline NICHT überschreiben + _ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow) + + if len(copiedRooms) > 0 { + go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms) } } @@ -547,14 +551,16 @@ func startChaturbateOnlinePoller(store *ModelStore) { // ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren if cbModelStore != nil { - go syncChaturbateRoomStateIntoModelStore( - cbModelStore, - append([]ChaturbateRoom(nil), rooms...), - fetchedAtNow, - ) + copiedRooms := append([]ChaturbateRoom(nil), rooms...) - if len(rooms) > 0 { - cbModelStore.FillMissingTagsFromChaturbateOnline(rooms) + go func(roomsCopy []ChaturbateRoom, ts time.Time) { + syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts) + + _ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts) + }(copiedRooms, fetchedAtNow) + + if len(copiedRooms) > 0 { + cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms) } } @@ -686,17 +692,12 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us return "", err } - master, err := ParseStream(body) // -> hls_source + master, err := ParseStream(body) // hls_source if err != nil { return "", err } - pl, err := FetchPlaylist(ctx, hc, master, cookie) // -> beste Variant - if err != nil { - return "", err - } - - return strings.TrimSpace(pl.PlaylistURL), nil + return strings.TrimSpace(master), nil } // refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs. diff --git a/backend/live.go b/backend/live.go index ecf891b..cef75ec 100644 --- a/backend/live.go +++ b/backend/live.go @@ -202,17 +202,6 @@ func servePreviewHLSFileWithBase(w http.ResponseWriter, r *http.Request, id, fil return } - // activation: hover or play=1 - active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1" - if !active { - if isIndex { - serveLiveNotReady(w, r) - return - } - http.Error(w, "preview not active", http.StatusNotFound) - return - } - if !ok || job == nil { if isIndex { serveLiveNotReady(w, r) @@ -362,16 +351,10 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h commonIn = append(commonIn, "-i", m3u8URL) hqArgs := append(commonIn, - "-vf", "scale=480:-2", - "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", - "-pix_fmt", "yuv420p", - "-profile:v", "main", - "-level", "3.1", - "-threads", "4", - "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", "-map", "0:v:0", "-map", "0:a:0?", - "-c:a", "aac", "-b:a", "128k", "-ac", "2", + "-c:v", "copy", + "-c:a", "copy", "-f", "hls", "-hls_time", "2", "-hls_list_size", "6", @@ -485,7 +468,7 @@ func rewriteM3U8WithBase(raw []byte, id string, basePath string) []byte { } name := path.Base(u) - out.WriteString(base + url.QueryEscape(name) + "&play=1") + out.WriteString(base + url.QueryEscape(name)) out.WriteByte('\n') } if err := sc.Err(); err != nil { @@ -518,7 +501,7 @@ func rewriteAttrURIWithBase(line, base string, basePath string) string { } name := path.Base(valTrim) - repl := base + url.QueryEscape(name) + "&play=1" + repl := base + url.QueryEscape(name) return line[:start] + repl + line[end:] } @@ -591,13 +574,6 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) { return } - // activation: hover or play=1 (wie bei HLS) - active := isHover(r) || strings.TrimSpace(r.URL.Query().Get("play")) == "1" - if !active { - http.Error(w, "preview not active", http.StatusNotFound) - return - } - jobsMu.Lock() job, ok := jobs[id] state := "" @@ -701,20 +677,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) { args = append(args, "-map", "0:v:0", "-map", "0:a:0?", - "-vf", "scale=480:-2", - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-pix_fmt", "yuv420p", - "-profile:v", "main", - "-level", "3.1", - "-g", "48", - "-keyint_min", "48", - "-sc_threshold", "0", - "-c:a", "aac", - "-b:a", "128k", - "-ac", "2", - "-ar", "48000", + "-c:v", "copy", + "-c:a", "copy", ) // Output: fMP4 fragmented to stdout (single HTTP response) diff --git a/backend/models_store.go b/backend/models_store.go index 18eee44..0c2f141 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -1191,8 +1191,8 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom, } defer func() { _ = tx.Rollback() }() - // q1 mit cb_online_last_error - stmt1, err1 := tx.Prepare(` + // ONLINE: voller Snapshot wird gespeichert + stmtOnline1, errOnline1 := tx.Prepare(` UPDATE models SET last_seen_online = $1, @@ -1213,10 +1213,9 @@ WHERE lower(trim(host)) = lower(trim($8)) AND lower(trim(model_key)) = lower(trim($9)); `) - // fallback ohne cb_online_last_error - var stmt2 *sql.Stmt - if err1 != nil { - stmt2, err = tx.Prepare(` + var stmtOnline2 *sql.Stmt + if errOnline1 != nil { + stmtOnline2, err = tx.Prepare(` UPDATE models SET last_seen_online = $1, @@ -1238,13 +1237,43 @@ WHERE lower(trim(host)) = lower(trim($7)) if err != nil { return err } - defer stmt2.Close() + defer stmtOnline2.Close() } else { - defer stmt1.Close() + defer stmtOnline1.Close() + } + + // OFFLINE: nur Status updaten, Snapshot NICHT überschreiben + stmtOffline1, errOffline1 := tx.Prepare(` +UPDATE models +SET + last_seen_online = $1, + last_seen_online_at = $2, + cb_online_last_error = $3, + updated_at = $4 +WHERE lower(trim(host)) = lower(trim($5)) + AND lower(trim(model_key)) = lower(trim($6)); +`) + + var stmtOffline2 *sql.Stmt + if errOffline1 != nil { + stmtOffline2, err = tx.Prepare(` +UPDATE models +SET + last_seen_online = $1, + last_seen_online_at = $2, + updated_at = $3 +WHERE lower(trim(host)) = lower(trim($4)) + AND lower(trim(model_key)) = lower(trim($5)); +`) + if err != nil { + return err + } + defer stmtOffline2.Close() + } else { + defer stmtOffline1.Close() } now := time.Now().UTC() - fetchedAtStr := fetchedAt.Format(time.RFC3339Nano) for _, key := range knownKeys { key = strings.TrimSpace(key) @@ -1252,18 +1281,10 @@ WHERE lower(trim(host)) = lower(trim($7)) continue } - var ( - online bool - snap *ChaturbateOnlineSnapshot - jsonStr string - imgURL string - ) - if rm, ok := roomsByUser[strings.ToLower(key)]; ok { - online = true - imgURL = strings.TrimSpace(selectBestRoomImageURL(rm)) + imgURL := strings.TrimSpace(selectBestRoomImageURL(rm)) - snap = &ChaturbateOnlineSnapshot{ + snap := &ChaturbateOnlineSnapshot{ Username: rm.Username, DisplayName: rm.DisplayName, CurrentShow: strings.TrimSpace(rm.CurrentShow), @@ -1284,29 +1305,52 @@ WHERE lower(trim(host)) = lower(trim($7)) ChatRoomURLRS: rm.ChatRoomURLRS, Tags: rm.Tags, } - } else { - online = false - snap = &ChaturbateOnlineSnapshot{ - Username: key, - CurrentShow: "offline", - } - } - - if snap != nil { + jsonStr := "" if b, err := json.Marshal(snap); err == nil { jsonStr = strings.TrimSpace(string(b)) } + + if stmtOnline1 != nil { + if _, err := stmtOnline1.Exec( + true, + fetchedAt, + nullableStringArg(jsonStr), + fetchedAt, + "", + imgURL, + now, + host, + key, + ); err != nil { + return err + } + } else { + if _, err := stmtOnline2.Exec( + true, + fetchedAt, + nullableStringArg(jsonStr), + fetchedAt, + imgURL, + now, + host, + key, + ); err != nil { + return err + } + } + + continue } - if stmt1 != nil { - if _, err := stmt1.Exec( - online, - fetchedAt, - nullableStringArg(jsonStr), + // OFFLINE: + // KEIN cb_online_json Update + // KEIN cb_online_fetched_at Update + if stmtOffline1 != nil { + if _, err := stmtOffline1.Exec( + false, fetchedAt, "", - imgURL, now, host, key, @@ -1314,12 +1358,9 @@ WHERE lower(trim(host)) = lower(trim($7)) return err } } else { - if _, err := stmt2.Exec( - online, + if _, err := stmtOffline2.Exec( + false, fetchedAt, - nullableStringArg(jsonStr), - fetchedAt, - imgURL, now, host, key, @@ -1327,8 +1368,6 @@ WHERE lower(trim(host)) = lower(trim($7)) return err } } - - _ = fetchedAtStr // falls du später Logging willst } return tx.Commit() diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 74bc7b4..475454a 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -84,7 +84,7 @@ func RecordStream( } jobsMu.Lock() - job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) + job.PreviewM3U8 = strings.TrimSpace(hlsURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent if previewDir != "" { diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index e1bdca9..8daa9d6 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,8 +1,8 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", - "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", + "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", + "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, "autoStartAddedDownloads": true, @@ -10,8 +10,8 @@ "maxConcurrentDownloads": 40, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": false, - "autoDeleteSmallDownloadsBelowMB": 50, + "autoDeleteSmallDownloads": true, + "autoDeleteSmallDownloadsBelowMB": 100, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/backend/settings.go b/backend/settings.go index 8d61cb4..17e26fb 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -321,9 +321,6 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { if in.MaxConcurrentDownloads < 1 { in.MaxConcurrentDownloads = 1 } - if in.MaxConcurrentDownloads > 100 { - in.MaxConcurrentDownloads = 100 - } // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- recAbs, err := resolvePathRelativeToApp(in.RecordDir) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d4bc3d..f221583 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,17 +1,17 @@ { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.18", - "hls.js": "^1.6.15", + "flag-icons": "^7.5.0", "prop-types": "^15.8.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2819,6 +2819,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2933,12 +2939,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", - "license": "Apache-2.0" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index fb69e25..559236b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.1.18", - "hls.js": "^1.6.15", + "flag-icons": "^7.5.0", "prop-types": "^15.8.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49e26d8..312ffaf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { useNotify } from './components/ui/notify' import CategoriesTab from './components/ui/CategoriesTab' import LoginPage from './components/ui/LoginPage' import VideoSplitModal from './components/ui/VideoSplitModal' +import TextInput from './components/ui/TextInput' const COOKIE_STORAGE_KEY = 'record_cookies' @@ -498,10 +499,6 @@ export default function App() { const doneCountInFlightRef = useRef(false) const doneCountLastAtRef = useRef(0) - // ✅ sagt FinishedDownloads: "bitte ALL neu laden" - const finishedReloadTimerRef = useRef(null) - const finishedReloadLastDispatchAtRef = useRef(0) - const [playerModel, setPlayerModel] = useState(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) @@ -563,14 +560,16 @@ export default function App() { const [modelsByKey, setModelsByKey] = useState>({}) - const selectSourceUrl = useCallback(() => { - const el = sourceUrlInputRef.current - if (!el) return - // Fokus sicherstellen, dann alles markieren - el.focus() - // rAF, damit der Fokus sicher "sitzt" (und für Mobile/Safari stabiler) - requestAnimationFrame(() => el.select()) - }, []) + const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2' + + const [includeKeep, setIncludeKeep] = useState(() => { + try { + const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY) + return raw === '1' || raw === 'true' || raw === 'yes' + } catch { + return false + } + }) const checkAuth = useCallback(async () => { try { @@ -684,7 +683,8 @@ export default function App() { } }, []) - const DONE_PAGE_SIZE = 8 + const BASE_DONE_PAGE_SIZE = 6 + const [donePageSize, setDonePageSize] = useState(BASE_DONE_PAGE_SIZE) type DoneSortMode = | 'completed_desc' @@ -714,6 +714,11 @@ export default function App() { const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}` + const getDoneTotalPages = (count: number, pageSize = donePageSize) => { + const safeCount = Number.isFinite(count) && count > 0 ? count : 0 + return Math.max(1, Math.ceil(safeCount / pageSize)) + } + const prefetchDonePage = useCallback(async (pageToFetch: number) => { if (pageToFetch < 1) return if (donePrefetchInFlightRef.current) return @@ -728,7 +733,9 @@ export default function App() { donePrefetchInFlightRef.current = true try { const res = await fetch( - `/api/record/done?page=${pageToFetch}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`, + `/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${ + includeKeep ? '&includeKeep=1' : '' + }`, { cache: 'no-store' as any } ) if (!res.ok) return @@ -744,7 +751,7 @@ export default function App() { } finally { donePrefetchInFlightRef.current = false } - }, [doneSort]) + }, [doneSort, includeKeep]) const loadDoneCount = useCallback(async () => { const now = Date.now() @@ -757,7 +764,10 @@ export default function App() { doneCountLastAtRef.current = now try { - const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any }) + const res = await fetch( + `/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`, + { cache: 'no-store' as any } + ) if (!res.ok) return const data = await res.json().catch(() => null) @@ -771,7 +781,7 @@ export default function App() { } finally { doneCountInFlightRef.current = false } - }, []) + }, [includeKeep]) const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => { try { @@ -783,12 +793,14 @@ export default function App() { setLastHeaderUpdateAtMs(Date.now()) donePrefetchRef.current = null - void prefetchDonePage(pageToLoad + 1) + return } const res = await fetch( - `/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`, + `/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}${ + includeKeep ? '&includeKeep=1' : '' + }`, { cache: 'no-store' as any } ) if (!res.ok) return @@ -804,63 +816,24 @@ export default function App() { setDoneJobs(items) setLastHeaderUpdateAtMs(Date.now()) - void prefetchDonePage(pageToLoad + 1) + const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount) + const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 + + if (count !== doneCount) { + setDoneCount(count) + } } catch { // ignore } - }, [donePage, doneSort, prefetchDonePage]) - - const requestFinishedReload = useCallback((reason = 'unknown') => { - const now = Date.now() - const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme - - // Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern. - // (Count kann separat aktualisiert werden.) - if (selectedTabRef.current !== 'finished') return - - // Bereits geplant -> nur koaleszieren - if (finishedReloadTimerRef.current != null) { - return - } - - // Harte Dedupe-Schranke - const sinceLast = now - finishedReloadLastDispatchAtRef.current - if (sinceLast < cooldownMs) { - finishedReloadTimerRef.current = window.setTimeout(() => { - finishedReloadTimerRef.current = null - finishedReloadLastDispatchAtRef.current = Date.now() - - window.dispatchEvent( - new CustomEvent('finished-downloads:reload', { - detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason }, - }) - ) - }, cooldownMs - sinceLast) - - return - } - - // Leichtes Coalescing für Burst im selben Moment - finishedReloadTimerRef.current = window.setTimeout(() => { - finishedReloadTimerRef.current = null - finishedReloadLastDispatchAtRef.current = Date.now() - - window.dispatchEvent( - new CustomEvent('finished-downloads:reload', { - detail: { source: 'App.requestFinishedReload', reason }, - }) - ) - }, 150) - }, []) + }, [donePage, doneSort, prefetchDonePage, includeKeep, doneCount]) useEffect(() => { - return () => { - if (finishedReloadTimerRef.current != null) { - window.clearTimeout(finishedReloadTimerRef.current) - finishedReloadTimerRef.current = null - } + try { + window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0') + } catch { + // ignore } - }, []) + }, [includeKeep]) const loadJobs = useCallback(async () => { try { @@ -2347,14 +2320,11 @@ export default function App() { useEffect(() => { if (selectedTab !== 'finished') return - // ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden void loadDoneCount() - requestFinishedReload('selectedTab effect') const onVis = () => { if (!document.hidden) { void loadDoneCount() - requestFinishedReload('selectedTab visibilitychange') } } document.addEventListener('visibilitychange', onVis) @@ -2362,7 +2332,7 @@ export default function App() { return () => { document.removeEventListener('visibilitychange', onVis) } - }, [selectedTab, loadDoneCount, requestFinishedReload]) + }, [selectedTab, loadDoneCount]) useEffect(() => { if (!authed) return @@ -2386,10 +2356,6 @@ export default function App() { void loadDoneCount() void loadJobs() void loadTaskStateOnce() - - if (selectedTabRef.current === 'finished') { - requestFinishedReload('sse fallback poll') - } }, document.hidden ? 60000 : 5000) } @@ -2469,11 +2435,6 @@ export default function App() { if (state === 'done' || state === 'error' || state === 'missing') { bumpAssets() } - - // ✅ Optional: Finished-Ansicht sichtbar? Dann Liste leicht anstoßen - if (selectedTabRef.current === 'finished') { - requestFinishedReload(`finishedPostwork:${state}`) - } } catch {} } @@ -2528,10 +2489,6 @@ export default function App() { void loadJobs() void loadDoneCount() void loadTaskStateOnce() - - if (selectedTabRef.current === 'finished') { - requestFinishedReload('visibilitychange') - } } document.addEventListener('visibilitychange', onVis) @@ -2559,7 +2516,7 @@ export default function App() { eventSourceRef.current = null modelEventNamesRef.current = new Set() } - }, [authed, loadJobs, loadDoneCount, requestFinishedReload]) + }, [authed, loadJobs, loadDoneCount]) useEffect(() => { const desired = new Set() @@ -2830,7 +2787,7 @@ export default function App() { setDoneJobs((prev) => { const filtered = prev.filter((j) => baseName(j.output || '') !== file) - const need = DONE_PAGE_SIZE - filtered.length + const need = donePageSize - filtered.length if (need <= 0) return filtered const prefetchKey = makePrefetchKey(donePage + 1, doneSort) @@ -2843,7 +2800,7 @@ export default function App() { const next: RecordJob[] = [...filtered] const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim())) - while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) { + while (next.length < donePageSize && buf.items.length > 0) { const cand = buf.items.shift()! const id = String(cand.id || baseName(cand.output || '')).trim() if (!id || used.has(id)) continue @@ -2862,11 +2819,14 @@ export default function App() { setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) - // ✅ Prefetch wieder nachfüllen - void prefetchDonePage(donePage + 1) + // ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt + const nextDoneCount = Math.max(0, doneCount - 1) + const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount) + + if (donePage < totalPagesAfterDelete) { + void prefetchDonePage(donePage + 1) + } - // ✅ wichtig - requestFinishedReload('delete success') void loadDoneCount() }, 320) }, @@ -2881,7 +2841,7 @@ export default function App() { return } }, - [runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage] + [runFinishedFileAction, notify, donePage, doneSort, prefetchDonePage, doneCount] ) const handleKeepJob = useCallback( @@ -2900,7 +2860,7 @@ export default function App() { setDoneJobs((prev) => { const filtered = prev.filter((j) => baseName(j.output || '') !== file) - const need = DONE_PAGE_SIZE - filtered.length + const need = donePageSize - filtered.length if (need <= 0) return filtered const prefetchKey = makePrefetchKey(donePage + 1, doneSort) @@ -2913,7 +2873,7 @@ export default function App() { const next: RecordJob[] = [...filtered] const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim())) - while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) { + while (next.length < donePageSize && buf.items.length > 0) { const cand = buf.items.shift()! const id = String(cand.id || baseName(cand.output || '')).trim() if (!id || used.has(id)) continue @@ -2932,11 +2892,14 @@ export default function App() { // ✅ Count runter setDoneCount((c) => Math.max(0, c - 1)) - // ✅ Prefetch wieder nachfüllen - void prefetchDonePage(donePage + 1) + // ✅ Prefetch wieder nachfüllen – aber nur wenn es noch eine nächste Seite gibt + const nextDoneCount = Math.max(0, doneCount - 1) + const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount) + + if (donePage < totalPagesAfterKeep) { + void prefetchDonePage(donePage + 1) + } - // ✅ Finished-Liste aktiv neu laden - requestFinishedReload('keep success') void loadDoneCount() }, 320) }, @@ -2954,8 +2917,8 @@ export default function App() { donePage, doneSort, prefetchDonePage, - requestFinishedReload, loadDoneCount, + doneCount, ] ) @@ -3469,25 +3432,14 @@ export default function App() {
- setSourceUrl(e.target.value)} - onMouseDown={(e) => { - // nur Linksklick - if (e.button !== 0) return - - // wenn schon fokussiert: Browser soll Caret nicht irgendwohin setzen - // und wir markieren gleich alles - e.preventDefault() - selectSourceUrl() - }} - onFocus={() => { - // z.B. Tab-Navigation ins Feld - selectSourceUrl() - }} + selectAllOnFocus + selectAllOnMouseDown placeholder="https://…" - className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10" />
@@ -3578,7 +3530,8 @@ export default function App() { doneJobs={doneJobs} doneTotal={doneCount} page={donePage} - pageSize={DONE_PAGE_SIZE} + pageSize={donePageSize} + onPageSizeChange={setDonePageSize} onPageChange={setDonePage} onOpenPlayer={openPlayer} onDeleteJob={handleDeleteJobWithUndo} @@ -3600,6 +3553,12 @@ export default function App() { setDonePage(1) }} loadMode="paged" + includeKeep={includeKeep} + onIncludeKeepChange={(checked) => { + setIncludeKeep(checked) + setDonePage(1) + donePrefetchRef.current = null + }} /> ) : null} diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index f84b339..41ebff4 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -178,6 +178,17 @@ function effectiveRoomStatusOfJob( return raw } +function previewAlignEndAtOfJob(job: RecordJob): string | null { + const postworkState = getEffectivePostworkState(job) + + // Während Nachbearbeitung Preview nicht "abschalten" + if (postworkState === 'running' || postworkState === 'queued') { + return null + } + + return job.endedAt ?? null +} + const roomStatusTone = (status: string): string => { switch (status) { case 'Public': @@ -951,7 +962,7 @@ export default function Downloads({ growingByJobId )} alignStartAt={j.startedAt} - alignEndAt={j.endedAt ?? null} + alignEndAt={previewAlignEndAtOfJob(j)} alignEveryMs={10_000} fastRetryMs={1000} fastRetryMax={25} @@ -1388,77 +1399,155 @@ export default function Downloads({ dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40 " > -
-
-
- Downloads -
- - {totalCount} - -
+
+ {/* Mobile */} +
+
+
+
+ Downloads +
+ + {totalCount} + +
-
- {concurrentLimitHintVisible ? ( -
+ Downloads: {activeDownloadCount}/{maxConcurrentDownloads} +
+ ) : null} +
+ +
+ + + +
+
+ + {/* Desktop */} +
+
+
+ Downloads
- ) : null} + + {totalCount} + +
- +
+ {concurrentLimitHintVisible ? ( +
+ Downloads: {activeDownloadCount}/{maxConcurrentDownloads} + {concurrentLimitReached ? ' · Limit erreicht' : ''} +
+ ) : null} - + + + +
diff --git a/frontend/src/components/ui/DownloadsCardRow.tsx b/frontend/src/components/ui/DownloadsCardRow.tsx index a05430d..84d72f7 100644 --- a/frontend/src/components/ui/DownloadsCardRow.tsx +++ b/frontend/src/components/ui/DownloadsCardRow.tsx @@ -184,6 +184,31 @@ function effectiveRoomStatusOfJob( return raw } +function previewRoomStatusOfJob( + job: RecordJob, + roomStatusByModelKey?: Record, + modelsByKey?: Record, + growingByJobId?: Record +): string { + const postworkState = getEffectivePostworkState(job) + + if (postworkState === 'running' || postworkState === 'queued') { + return 'Public' + } + + return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId) +} + +function previewAlignEndAtOfJob(job: RecordJob): string | null { + const postworkState = getEffectivePostworkState(job) + + if (postworkState === 'running' || postworkState === 'queued') { + return null + } + + return job.endedAt ?? null +} + const roomStatusTone = (status: string): string => { switch (status) { case 'Public': @@ -682,9 +707,14 @@ export default function DownloadsCardRow({ void onPageChange: (page: number) => void assetNonce?: number sortMode: DoneSortMode onSortModeChange: (m: DoneSortMode) => void loadMode?: 'paged' | 'all' + includeKeep: boolean + onIncludeKeepChange: (checked: boolean) => void } const norm = (p: string) => (p || '').replaceAll('\\', '/') @@ -1246,7 +1252,7 @@ export function renderFinishedPostworkBadge( ) } -export default function FinishedDownloads({ +export default function FinishedDownloads({ jobs, doneJobs, blurPreviews, @@ -1266,99 +1272,169 @@ export default function FinishedDownloads({ page, pageSize, onPageChange, + onPageSizeChange, assetNonce, sortMode, onSortModeChange, modelsByKey, loadMode = 'paged', - }: Props) { - + includeKeep, + onIncludeKeepChange, +}: Props) { + // ----------------------------------------------------------------------------- + // local constants + // ----------------------------------------------------------------------------- const VIEW_KEY = 'finishedDownloads_view' - const KEEP_KEY = 'finishedDownloads_includeKeep_v2' const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' + const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1' + const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' const allMode = loadMode === 'all' - const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' - const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode + const teaserPlaybackMode: TeaserPlaybackMode = + pauseTeasers ? 'still' : requestedTeaserPlaybackMode - // Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover - // Mobile: "hover" wie "all" behandeln + const notify = useNotify() + + // ----------------------------------------------------------------------------- + // media queries + simple derived flags + // ----------------------------------------------------------------------------- const canHover = useMediaQuery('(hover: hover) and (pointer: fine)') - - const effectiveTeaserPlaybackMode: TeaserPlaybackMode = - !canHover && teaserPlaybackMode === 'hover' - ? 'all' - : teaserPlaybackMode - const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)') const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)') + const isSmall = useMediaQuery('(max-width: 639px)') + + const effectiveTeaserPlaybackMode = + !canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller - + const previewMuted = !Boolean(teaserAudio) + + const cardsMobileOffsetTopClass = 'mt-10' + const cardsMobileOffsetBottomClass = 'mb-2' + + // ----------------------------------------------------------------------------- + // state + // ----------------------------------------------------------------------------- const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false) + const [teaserKey, setTeaserKey] = React.useState(null) + const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) + + const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) + const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) + const [keepingKeys, setKeepingKeys] = React.useState>(() => new Set()) + const [removingKeys, setRemovingKeys] = React.useState>(() => new Set()) + const [hiddenFiles, setHiddenFiles] = React.useState>(() => new Set()) + + const [isLoading, setIsLoading] = React.useState(false) + const [undoing, setUndoing] = React.useState(false) + const [bulkBusy, setBulkBusy] = React.useState(false) + + const [postworkByFile, setPostworkByFile] = + React.useState>({}) + + const [selectedItems, setSelectedItems] = + React.useState>({}) + + const [renamedFiles, setRenamedFiles] = React.useState>({}) + + const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) + const [overrideDoneJobsKey, setOverrideDoneJobsKey] = React.useState(null) + const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) + const [refillTick, setRefillTick] = React.useState(0) + + const [view, setView] = React.useState('table') + const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) + const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) + + const [galleryCardScale, setGalleryCardScale] = React.useState(1) + const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState(1) + const [galleryColumnCount, setGalleryColumnCount] = React.useState(1) + + const [tagFilter, setTagFilter] = React.useState([]) + const [searchQuery, setSearchQuery] = React.useState('') + + const [durations, setDurations] = React.useState>({}) + const [resolutions, setResolutions] = + React.useState>({}) + + const [inlinePlay, setInlinePlay] = + React.useState<{ key: string; nonce: number } | null>(null) + + const [hotBusyKeys, setHotBusyKeys] = React.useState>(() => new Set()) + + const [lastAction, setLastAction] = React.useState(() => { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(LAST_UNDO_KEY) + if (!raw) return null + + const parsed = JSON.parse(raw) as PersistedUndoState + if (!parsed || parsed.v !== 1 || !parsed.action) return null + + const ageMs = Date.now() - Number(parsed.ts || 0) + if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) { + localStorage.removeItem(LAST_UNDO_KEY) + return null + } + + return parsed.action + } catch { + return null + } + }) + + // ----------------------------------------------------------------------------- + // refs + // ----------------------------------------------------------------------------- + const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null) + + const teaserHostsRef = React.useRef>(new Map()) + const teaserIORef = React.useRef(null) + const elToKeyRef = React.useRef>(new WeakMap()) + const teaserKeyRafRef = React.useRef(null) + const pendingTeaserKeyRef = React.useRef(null) + + const refillInFlightRef = React.useRef(false) + const refillQueuedWhileInFlightRef = React.useRef(false) + const refillSessionRef = React.useRef(0) + const refillTimerRef = React.useRef(null) + const refillRetryRef = React.useRef(0) + + const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) + + const scaleLiveRafRef = React.useRef(null) + const scaleLivePendingRef = React.useRef(1) + + const swipeRefs = React.useRef>(new Map()) + const fileToKeyRef = React.useRef>(new Map()) + + const durationsRef = React.useRef>({}) + const durationsFlushTimerRef = React.useRef(null) + + const resolutionsRef = React.useRef>({}) + const resolutionsFlushTimerRef = React.useRef(null) + + const removeTimersRef = React.useRef>(new Map()) + + const galleryHydratedKeyRef = React.useRef(null) + + // ----------------------------------------------------------------------------- + // memoized derived data + // ----------------------------------------------------------------------------- const forcePreviewMuted = mobileNeedsTapForAudio && teaserAudio ? !mobileTeaserAudioUnlocked : false - const notify = useNotify() + const deferredSearchQuery = React.useDeferredValue(searchQuery) - const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null) - - const teaserHostsRef = React.useRef>(new Map()) - const [teaserKey, setTeaserKey] = React.useState(null) - const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) - - const teaserIORef = React.useRef(null) - const elToKeyRef = React.useRef>(new WeakMap()) - - // 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten - const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) - const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) - - const [hiddenFiles, setHiddenFiles] = React.useState>(() => new Set()) - - const [isLoading, setIsLoading] = React.useState(false) - - const refillInFlightRef = React.useRef(false) - const refillQueuedWhileInFlightRef = React.useRef(false) - - // ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions - const refillSessionRef = React.useRef(0) - - const [postworkByFile, setPostworkByFile] = React.useState>({}) - - const [undoing, setUndoing] = React.useState(false) - - const [selectedItems, setSelectedItems] = React.useState>({}) - const [bulkBusy, setBulkBusy] = React.useState(false) - - // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln - const [renamedFiles, setRenamedFiles] = React.useState>({}) - - // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" - const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) - const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) - const [refillTick, setRefillTick] = React.useState(0) - const refillTimerRef = React.useRef(null) - - const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) - - const [view, setView] = React.useState('table') - const [includeKeep, setIncludeKeep] = React.useState(false) - - const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) - const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) - - const swipeRefs = React.useRef>(new Map()) - - // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab) - const [tagFilter, setTagFilter] = React.useState([]) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) - const teaserState = React.useMemo( + const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems]) + + const teaserState = useMemo( () => ({ mode: effectiveTeaserPlaybackMode, activeKey: teaserKey, @@ -1368,9 +1444,299 @@ export default function FinishedDownloads({ [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio] ) - const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' + const modelTags = useMemo(() => { + const tagsByModelKey: Record = {} + const tagSetByModelKey: Record> = {} - const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems]) + for (const [k, flags] of Object.entries(modelsByKey ?? {})) { + const key = lower(k) + const arr = parseTags(flags?.tags) + tagsByModelKey[key] = arr + tagSetByModelKey[key] = new Set(arr.map(lower)) + } + + return { tagsByModelKey, tagSetByModelKey } + }, [modelsByKey]) + + const searchTokens = useMemo( + () => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean), + [deferredSearchQuery] + ) + + const searchActiveForGlobalFetch = + activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2) + + const globalFilterActive = searchActiveForGlobalFetch + const effectiveAllMode = globalFilterActive || allMode + + const galleryPageSize = Math.max(2, galleryColumnCount * 2) + + const serverPageSize = pageSize + const effectivePageSize = + view === 'gallery' + ? galleryPageSize + : pageSize + + const galleryHydrationKey = + view === 'gallery' && loadMode === 'paged' && !effectiveAllMode + ? `${page}__${sortMode}__${includeKeep ? 1 : 0}__${effectivePageSize}` + : null + + const hasMatchingGalleryOverride = + view === 'gallery' && + galleryHydrationKey != null && + overrideDoneJobsKey === galleryHydrationKey + + const doneJobsPage = + effectiveAllMode + ? (overrideDoneJobs ?? doneJobs) + : hasMatchingGalleryOverride && overrideDoneJobs + ? overrideDoneJobs + : doneJobs + + const doneTotalPage = + effectiveAllMode + ? (overrideDoneTotal ?? doneTotal) + : hasMatchingGalleryOverride && overrideDoneTotal != null + ? overrideDoneTotal + : doneTotal + + const applyRenamedOutputLocal = useCallback( + (job: RecordJob): RecordJob => { + const out = norm(job.output || '') + const file = baseName(out) + const override = renamedFiles[file] + if (!override) return job + + const idx = out.lastIndexOf('/') + const dir = idx >= 0 ? out.slice(0, idx + 1) : '' + return { ...job, output: dir + override } + }, + [renamedFiles] + ) + + const rows = useMemo(() => { + const map = new Map() + const doneBaseKeys = new Set() + + for (const j of doneJobsPage) { + const jj = applyRenamedOutputLocal(j) + const k = keyFor(jj) + + map.set(k, jj) + doneBaseKeys.add(k) + } + + for (const j of jobs) { + const jj = applyRenamedOutputLocal(j) + const k = keyFor(jj) + + if (map.has(k)) { + const existing = map.get(k)! + + map.set(k, { + ...existing, + ...jj, + meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta), + }) + continue + } + + const file = baseName(jj.output || '') + if (!file) continue + + for (const [existingKey, existingJob] of map.entries()) { + if (baseName(existingJob.output || '') === file) { + map.set(existingKey, { + ...existingJob, + ...jj, + meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta), + }) + break + } + } + } + + return Array.from(map.entries()) + .map(([k, j]) => ({ k, j })) + .filter(({ k, j }) => { + const file = baseName(j.output || '') + if (deletedKeys.has(k)) return false + if (file && hiddenFiles.has(file)) return false + if (isTrashOutput(j.output)) return false + + const anyJ = j as any + const status = String(anyJ?.status ?? '').trim().toLowerCase() + const endedAt = anyJ?.endedAt + + if (doneBaseKeys.has(k)) return true + + if ( + status === 'finished' || + status === 'failed' || + status === 'stopped' + ) { + return true + } + + if (endedAt) return true + + return false + }) + .map(({ j }) => j) + }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal]) + + const effectivePostworkSummaryByFile = useMemo>(() => { + const next: Record = {} + + for (const [file, badges] of Object.entries(postworkByFile)) { + const summary = summarizePostworkSteps(file, badges) + if (summary) { + next[file] = summary + } + } + + for (const job of rows) { + const fileRaw = baseName(job.output || '') + const filePlain = stripHotPrefix(fileRaw) + + if (!fileRaw) continue + + const rawMeta = (job as any)?.meta + const meta = parseMetaObject(rawMeta) + const assetId = fileStemFromOutput(job.output) + + const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta) + const summaryFromCompleted = buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta) + + const mergedMetaSummary = + mergePostworkSummaries( + summaryFromHistory, + summaryFromCompleted, + fileRaw, + assetId + ) + + const metaSummary = + mergedMetaSummary ?? + buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status') + + const liveSummary = next[fileRaw] ?? next[filePlain] + + const summary = + mergePostworkSummaries( + liveSummary ?? null, + metaSummary ?? null, + fileRaw, + assetId + ) ?? metaSummary ?? liveSummary ?? null + + if (summary) { + next[fileRaw] = summary + if (filePlain && filePlain !== fileRaw) { + next[filePlain] = summary + } + } + } + + return next + }, [postworkByFile, rows]) + + const viewRows = rows + + const visibleRows = useMemo(() => { + const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) + + const searched = searchTokens.length + ? base.filter((j) => { + const file = baseName(j.output || '') + const model = modelNameFromOutput(j.output) + const modelKey = lower(model) + const tags = modelTags.tagsByModelKey[modelKey] ?? [] + + const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' ')) + + for (const t of searchTokens) { + if (!hay.includes(t)) return false + } + return true + }) + : base + + if (activeTagSet.size === 0) return searched + + return searched.filter((j) => { + const modelKey = lower(modelNameFromOutput(j.output)) + const have = modelTags.tagSetByModelKey[modelKey] + if (!have || have.size === 0) return false + + for (const t of activeTagSet) { + if (!have.has(t)) return false + } + return true + }) + }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) + + const selectedFiles = useMemo(() => { + return Object.values(selectedItems) + .map((x) => String(x.file || '').trim()) + .filter(Boolean) + }, [selectedItems]) + + const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage + + const pageRows = useMemo(() => { + if (effectiveAllMode) { + const start = (page - 1) * effectivePageSize + const end = start + effectivePageSize + return visibleRows.slice(start, end) + } + + if (view === 'gallery') { + return visibleRows.slice(0, effectivePageSize) + } + + return visibleRows + }, [visibleRows, page, effectivePageSize, effectiveAllMode, view]) + + const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 + const emptyByFilter = globalFilterActive && visibleRows.length === 0 + const emptyAll = allMode && visibleRows.length === 0 + const isEmptyState = emptyFolder || emptyAll || emptyByFilter + const showLoadingCard = isLoading && !isEmptyState + + const selectedCount = selectedFiles.length + const allPageSelected = + pageRows.length > 0 && + pageRows.every((j) => selectedKeys.has(keyFor(j))) + + // ----------------------------------------------------------------------------- + // callbacks + // ----------------------------------------------------------------------------- + const handleGalleryScaleLiveChange = useCallback((next: number) => { + const clamped = Math.max(0.8, Math.min(1.35, next)) + scaleLivePendingRef.current = clamped + + if (scaleLiveRafRef.current != null) return + + scaleLiveRafRef.current = requestAnimationFrame(() => { + scaleLiveRafRef.current = null + setGalleryCardScaleLive(scaleLivePendingRef.current) + }) + }, []) + + const handleGalleryScaleCommit = useCallback((next: number) => { + const clamped = Math.max(0.8, Math.min(1.35, next)) + + if (scaleLiveRafRef.current != null) { + cancelAnimationFrame(scaleLiveRafRef.current) + scaleLiveRafRef.current = null + } + + scaleLivePendingRef.current = clamped + setGalleryCardScaleLive(clamped) + setGalleryCardScale(clamped) + }, []) const toggleSelected = useCallback((job: RecordJob) => { const k = keyFor(job) @@ -1386,7 +1752,7 @@ export default function FinishedDownloads({ } return next }) - }, [keyFor]) + }, []) const clearSelection = useCallback(() => { setSelectedItems({}) @@ -1403,7 +1769,7 @@ export default function FinishedDownloads({ } return next }) - }, [keyFor]) + }, []) const deselectPageRows = useCallback((rows: RecordJob[]) => { setSelectedItems((prev) => { @@ -1413,30 +1779,8 @@ export default function FinishedDownloads({ } return next }) - }, [keyFor]) + }, []) - const [lastAction, setLastAction] = React.useState(() => { - if (typeof window === 'undefined') return null - try { - const raw = localStorage.getItem(LAST_UNDO_KEY) - if (!raw) return null - - const parsed = JSON.parse(raw) as PersistedUndoState - if (!parsed || parsed.v !== 1 || !parsed.action) return null - - // optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird - const ageMs = Date.now() - Number(parsed.ts || 0) - if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) { - localStorage.removeItem(LAST_UNDO_KEY) - return null - } - - return parsed.action - } catch { - return null - } - }) - const refreshHoverPreviewFromPointer = useCallback(() => { if (!canHover) return @@ -1458,219 +1802,10 @@ export default function FinishedDownloads({ setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) }, [canHover]) - useEffect(() => { - if (!pauseTeasers) return - - setTeaserKey(null) - setHoverTeaserKey(null) - setInlinePlay(null) - }, [pauseTeasers]) - - useEffect(() => { - let alive = true - - const loadInitialPostworkState = async () => { - try { - const res = await fetch('/api/tasks/status', { cache: 'no-store' }) - if (!res.ok) return - - const data = await res.json().catch(() => null) - if (!alive || !data) return - - const jobs = Array.isArray(data?.regenerateAssetsJobs) - ? data.regenerateAssetsJobs - : [] - - if (jobs.length > 0) { - setPostworkByFile((prev) => { - const next = { ...prev } - - for (const item of jobs) { - const file = String(item?.file ?? '').trim() - if (!file) continue - - const assetId = - String(item?.assetId ?? '').trim() || fileStemFromOutput(file) - - const phases = Array.isArray(item?.phases) ? item.phases : [] - - // ✅ Bevorzugt: echte Phasen aus dem Backend-Snapshot übernehmen - if (phases.length > 0) { - const restored = phases - .map((p: any): FinishedPostworkBadge | null => { - const queueRaw = String(p?.queue ?? '').trim().toLowerCase() - const phaseRaw = String(p?.phase ?? '').trim().toLowerCase() - const rawState = String(p?.state ?? '').trim().toLowerCase() - - const state: FinishedPostworkState = - rawState === 'queued' || - rawState === 'running' || - rawState === 'done' || - rawState === 'error' || - rawState === 'missing' - ? rawState - : rawState === 'cancelled' - ? 'missing' - : 'missing' - - if ( - (queueRaw !== 'postwork' && queueRaw !== 'enrich') || - !phaseRaw - ) { - return null - } - - return { - file, - assetId, - queue: queueRaw as 'postwork' | 'enrich', - phase: phaseRaw, - state, - label: - String(p?.label ?? '').trim() || - labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), - position: - typeof p?.position === 'number' ? p.position : undefined, - waiting: - typeof p?.waiting === 'number' ? p.waiting : undefined, - running: - typeof p?.running === 'number' ? p.running : undefined, - maxParallel: - typeof p?.maxParallel === 'number' - ? p.maxParallel - : undefined, - ts: - typeof p?.ts === 'number' && Number.isFinite(p.ts) - ? p.ts - : Date.now(), - } - }) - .filter(Boolean) as FinishedPostworkBadge[] - - if (restored.length > 0) { - next[file] = restored - continue - } - } - - // ✅ Fallback nur für ältere Backends ohne phases[] - const rawState = String(item?.state ?? '').trim().toLowerCase() - - const state: FinishedPostworkState = - rawState === 'queued' || - rawState === 'running' || - rawState === 'done' || - rawState === 'error' || - rawState === 'missing' - ? rawState - : rawState === 'cancelled' - ? 'missing' - : 'missing' - - // Nur aktive / noch relevante Jobs beim Initial-Load wiederherstellen. - // "done" kommt danach ohnehin sauber über Meta/SSE rein. - if (state !== 'queued' && state !== 'running' && state !== 'error') { - continue - } - - next[file] = [ - { - file, - assetId, - queue: 'postwork', - state, - phase: 'meta', - label: 'Meta', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'thumb', - label: 'Vorschaubild', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'teaser', - label: 'Teaser', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'postwork', - state, - phase: 'sprites', - label: 'Sprites', - ts: Date.now(), - }, - { - file, - assetId, - queue: 'enrich', - state, - phase: 'analyze', - label: 'KI-Analyse', - ts: Date.now(), - }, - ] - } - - return next - }) - } - } catch { - // ignore - } - } - - void loadInitialPostworkState() - - return () => { - alive = false - } - }, []) - - useEffect(() => { - const onPointerMove = (e: PointerEvent) => { - pointerPosRef.current = { x: e.clientX, y: e.clientY } - } - - window.addEventListener('pointermove', onPointerMove, { passive: true }) - return () => window.removeEventListener('pointermove', onPointerMove) - }, []) - - useEffect(() => { - try { - if (!lastAction) { - localStorage.removeItem(LAST_UNDO_KEY) - return - } - - const payload: PersistedUndoState = { - v: 1, - action: lastAction, - ts: Date.now(), - } - - localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload)) - } catch { - // ignore - } - }, [lastAction]) - - const emitCountHint = React.useCallback((delta: number) => { + const emitCountHint = useCallback((delta: number) => { if (!delta || !Number.isFinite(delta)) return countHintRef.current.pending += delta - - // coalesce mehrere Aktionen sehr kurz hintereinander if (countHintRef.current.timer) return countHintRef.current.timer = window.setTimeout(() => { @@ -1685,34 +1820,11 @@ export default function FinishedDownloads({ }, 120) }, []) - const modelTags = useMemo(() => { - const tagsByModelKey: Record = {} - const tagSetByModelKey: Record> = {} - - for (const [k, flags] of Object.entries(modelsByKey ?? {})) { - const key = lower(k) - const arr = parseTags(flags?.tags) - tagsByModelKey[key] = arr - tagSetByModelKey[key] = new Set(arr.map(lower)) - } - - return { tagsByModelKey, tagSetByModelKey } - }, [modelsByKey]) - const jobForDetails = useCallback( (job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey), [modelsByKey] ) - // 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite) - const [searchQuery, setSearchQuery] = React.useState('') - const deferredSearchQuery = React.useDeferredValue(searchQuery) - - const searchTokens = useMemo( - () => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean), - [deferredSearchQuery] - ) - const clearSearch = useCallback(() => setSearchQuery(''), []) const toggleTagFilter = useCallback((tag: string) => { @@ -1723,19 +1835,12 @@ export default function FinishedDownloads({ }) }, []) - // ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern - const searchActiveForGlobalFetch = - activeTagSet.size > 0 || - searchTokens.some((t) => t.length >= 2) - - const globalFilterActive = searchActiveForGlobalFetch - const effectiveAllMode = globalFilterActive || allMode + const clearTagFilter = useCallback(() => setTagFilter([]), []) const fetchAllDoneJobs = useCallback( async (signal?: AbortSignal) => { - // ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben - const shouldShowLoading = overrideDoneJobs == null - if (shouldShowLoading) setIsLoading(true) + setIsLoading(true) + try { const res = await fetch( `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`, @@ -1753,316 +1858,22 @@ export default function FinishedDownloads({ setOverrideDoneJobs(items) setOverrideDoneTotal(Number.isFinite(count) ? count : items.length) } finally { - if (shouldShowLoading) setIsLoading(false) + setIsLoading(false) } }, [sortMode, includeKeep] ) - const clearTagFilter = useCallback(() => setTagFilter([]), []) - - useEffect(() => { - // ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage - try { - const raw = localStorage.getItem('finishedDownloads_pendingTags') - if (!raw) return - - const arr = JSON.parse(raw) - const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : [] - if (tags.length === 0) return - - flushSync(() => setTagFilter(tags)) - if (page !== 1) onPageChange(1) - } catch { - // ignore - } finally { - try { - localStorage.removeItem('finishedDownloads_pendingTags') - } catch {} - } - }, []) // nur einmal beim Mount - const queueRefill = useCallback(() => { - // ✅ Schon geplant? Dann nicht nochmal planen. if (refillTimerRef.current != null) return refillTimerRef.current = window.setTimeout(() => { refillTimerRef.current = null setRefillTick((n) => n + 1) }, 80) - }, [page, pageSize, sortMode, includeKeep]) - - useEffect(() => { - if (!effectiveAllMode) return - - const ac = new AbortController() - - const t = window.setTimeout(() => { - fetchAllDoneJobs(ac.signal).catch(() => {}) - }, 250) - - return () => { - window.clearTimeout(t) - ac.abort() - } - }, [effectiveAllMode, fetchAllDoneJobs]) - - // ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt - useEffect(() => { - if (refillTick === 0) return - - const ac = new AbortController() - let alive = true - let finished = false // ✅ pro Effect-Instanz idempotent - - // ✅ eigene Session-ID für diese Effect-Instanz - const mySession = ++refillSessionRef.current - - const finishRefill = () => { - if (finished) return - finished = true - - // ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern - if (refillSessionRef.current !== mySession) { - return - } - - refillInFlightRef.current = false - - if (refillQueuedWhileInFlightRef.current) { - refillQueuedWhileInFlightRef.current = false - queueRefill() - } - } - - // ✅ Refill läuft (nur wenn diese Session noch aktuell ist) - if (refillSessionRef.current === mySession) { - refillInFlightRef.current = true - } - - // ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all" - if (effectiveAllMode) { - ;(async () => { - try { - // fetchAllDoneJobs setzt isLoading selbst - await fetchAllDoneJobs(ac.signal) - if (alive) { - refillRetryRef.current = 0 - } - } catch { - // ignore (Abort/Netzwerk) - } finally { - if (alive) finishRefill() - } - })() - - return () => { - alive = false - ac.abort() - finishRefill() - } - } - - // ✅ paged refill → hier Loading setzen - setIsLoading(true) - - ;(async () => { - try { - // 1) Liste + optional count in EINEM Request holen - const listRes = await fetch( - `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${ - includeKeep ? '&includeKeep=1' : '' - }`, - { cache: 'no-store' as any, signal: ac.signal } - ) - - if (!alive || ac.signal.aborted) return - - const okAll = listRes.ok - - if (listRes.ok) { - const data = await listRes.json().catch(() => null) - if (!alive || ac.signal.aborted) return - - const items = Array.isArray(data?.items) - ? (data.items as RecordJob[]) - : Array.isArray(data) - ? data - : [] - - setOverrideDoneJobs(items) - - // ✅ Count direkt aus /done lesen (withCount=1) - const countRaw = Number(data?.count ?? data?.totalCount ?? items.length) - const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 - - setOverrideDoneTotal(count) - - const totalPages = Math.max(1, Math.ceil(count / pageSize)) - if (page > totalPages) { - onPageChange(totalPages) - setOverrideDoneJobs(null) - return - } - } - - if (okAll) { - refillRetryRef.current = 0 - } else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) { - refillRetryRef.current += 1 - const retryNo = refillRetryRef.current - - window.setTimeout(() => { - if (!ac.signal.aborted) { - setRefillTick((n) => n + 1) - } - }, 400 * retryNo) - } - } catch { - // Abort / Fehler ignorieren - } finally { - if (alive) { - setIsLoading(false) - finishRefill() - } - } - })() - - return () => { - alive = false - ac.abort() - finishRefill() - } - }, [ - refillTick, - effectiveAllMode, - fetchAllDoneJobs, - page, - pageSize, - sortMode, - includeKeep, - onPageChange, - ]) - - useEffect(() => { - // Override-Daten nur dann zurücksetzen, wenn wir wirklich wieder auf - // die normalen /done-Props zurückfallen wollen. - if (effectiveAllMode || includeKeep) return - - setOverrideDoneJobs(null) - setOverrideDoneTotal(null) - }, [page, pageSize, sortMode, includeKeep, effectiveAllMode]) - - useEffect(() => { - if (effectiveAllMode) return - - // Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen. - if (!includeKeep) return - - const ac = new AbortController() - let alive = true - - setIsLoading(true) - - ;(async () => { - try { - const res = await fetch( - `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`, - { cache: 'no-store' as any, signal: ac.signal } - ) - if (!res.ok || !alive || ac.signal.aborted) return - - const data = await res.json().catch(() => null) - if (!alive || ac.signal.aborted) return - - const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] - const countRaw = Number(data?.count ?? data?.totalCount ?? items.length) - const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length - - setOverrideDoneJobs(items) - setOverrideDoneTotal(count) - } finally { - if (alive) setIsLoading(false) - } - })() - - return () => { - alive = false - ac.abort() - } - }, [effectiveAllMode, includeKeep, page, pageSize, sortMode]) - - useEffect(() => { - try { - const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null - if (saved === 'table' || saved === 'cards' || saved === 'gallery') { - setView(saved) - } else { - // Default: Mobile -> Cards, sonst Tabelle - setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table') - } - } catch { - setView('table') - } }, []) - useEffect(() => { - try { - localStorage.setItem(VIEW_KEY, view) - } catch {} - }, [view]) - - useEffect(() => { - try { - const raw = localStorage.getItem(KEEP_KEY) - setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes') - } catch { - setIncludeKeep(false) - } - }, []) - - useEffect(() => { - try { - localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0') - } catch {} - }, [includeKeep]) - - useEffect(() => { - try { - const raw = localStorage.getItem(MOBILE_OPTS_KEY) - setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes') - } catch { - setMobileOptionsOpen(false) - } - }, []) - - useEffect(() => { - try { - localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0') - } catch {} - }, [mobileOptionsOpen]) - - // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden) - const [durations, setDurations] = React.useState>({}) - - // ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount) - const durationsRef = React.useRef>({}) - const durationsFlushTimerRef = React.useRef(null) - - // 🔹 hier sammeln wir die Videoauflösung pro Job/Datei - const [resolutions, setResolutions] = React.useState>({}) - - // ✅ Perf: resolutions gesammelt flushen (wie durations) - const resolutionsRef = React.useRef>({}) - const resolutionsFlushTimerRef = React.useRef(null) - - const refillRetryRef = React.useRef(0) - - React.useEffect(() => { - resolutionsRef.current = resolutions - }, [resolutions]) - - const flushResolutionsSoon = React.useCallback(() => { + const flushResolutionsSoon = useCallback(() => { if (resolutionsFlushTimerRef.current != null) return resolutionsFlushTimerRef.current = window.setTimeout(() => { resolutionsFlushTimerRef.current = null @@ -2070,41 +1881,14 @@ export default function FinishedDownloads({ }, 200) }, []) - React.useEffect(() => { - return () => { - if (resolutionsFlushTimerRef.current != null) { - window.clearTimeout(resolutionsFlushTimerRef.current) - resolutionsFlushTimerRef.current = null - } - } - }, []) - - React.useEffect(() => { - durationsRef.current = durations - }, [durations]) - - const flushDurationsSoon = React.useCallback(() => { + const flushDurationsSoon = useCallback(() => { if (durationsFlushTimerRef.current != null) return durationsFlushTimerRef.current = window.setTimeout(() => { durationsFlushTimerRef.current = null - // neue Objekt-Referenz, damit React aktualisiert setDurations({ ...durationsRef.current }) }, 200) }, []) - React.useEffect(() => { - return () => { - if (durationsFlushTimerRef.current != null) { - window.clearTimeout(durationsFlushTimerRef.current) - durationsFlushTimerRef.current = null - } - } - }, []) - - const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null) - - const previewMuted = !Boolean(teaserAudio) - const tryAutoplayInline = useCallback((domId: string) => { const host = document.getElementById(domId) const v = host?.querySelector('video') as HTMLVideoElement | null @@ -2113,21 +1897,25 @@ export default function FinishedDownloads({ applyInlineVideoPolicy(v, { muted: previewMuted }) const p = v.play?.() - if (p && typeof (p as any).catch === 'function') (p as Promise).catch(() => {}) + if (p && typeof (p as any).catch === 'function') { + (p as Promise).catch(() => {}) + } return true }, [previewMuted]) const startInline = useCallback((key: string) => { - setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 })) + setInlinePlay((prev) => ( + prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 } + )) }, []) const startInlineAt = useCallback((key: string, seconds: number, domId: string) => { const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0 - // Inline-Preview aktivieren / remount erzwingen - setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 })) + setInlinePlay((prev) => ( + prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 } + )) - // Nach dem Rendern das Video suchen, seeken und autoplay versuchen const trySeekAndPlay = (retriesLeft: number) => { const host = document.getElementById(domId) const v = host?.querySelector('video') as HTMLVideoElement | null @@ -2150,9 +1938,7 @@ export default function FinishedDownloads({ : safeSeconds v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek)) - } catch { - // ignore - } + } catch {} const p = v.play?.() if (p && typeof (p as any).catch === 'function') { @@ -2160,13 +1946,11 @@ export default function FinishedDownloads({ } } - // Wenn Metadaten schon da sind -> direkt seeken if (v.readyState >= 1) { applySeek() return } - // Sonst warten bis metadata da sind const onLoadedMetadata = () => { v.removeEventListener('loadedmetadata', onLoadedMetadata) applySeek() @@ -2174,7 +1958,6 @@ export default function FinishedDownloads({ v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true }) - // zusätzlich sofort play versuchen (hilft manchmal) const p = v.play?.() if (p && typeof (p as any).catch === 'function') { ;(p as Promise).catch(() => {}) @@ -2201,12 +1984,10 @@ export default function FinishedDownloads({ const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0 if (count <= 0) { - // Fallback: Player normal öffnen openPlayer(job) return } - // Dauer bevorzugt aus Preview-Metadaten, sonst aus Job const k = keyFor(job) const durationSec = durations[k] ?? @@ -2214,20 +1995,17 @@ export default function FinishedDownloads({ 0 if (!Number.isFinite(durationSec) || durationSec <= 0) { - // Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp) openPlayer(job) return } - // Segment-Index -> Startsekunde - // Beispiel: 10 Segmente, Klick auf Index 0..9 const clampedIdx = Math.max(0, Math.min(idx, count - 1)) const secPerSegment = durationSec / count const startAtSec = clampedIdx * secPerSegment openPlayerAt(job, startAtSec) }, - [durations, keyFor, openPlayer, openPlayerAt] + [durations, openPlayer, openPlayerAt] ) const markDeleting = useCallback((key: string, value: boolean) => { @@ -2247,8 +2025,6 @@ export default function FinishedDownloads({ }) }, []) - const [keepingKeys, setKeepingKeys] = React.useState>(() => new Set()) - const markKeeping = useCallback((key: string, value: boolean) => { setKeepingKeys((prev) => { const next = new Set(prev) @@ -2258,21 +2034,6 @@ export default function FinishedDownloads({ }) }, []) - // neben deletedKeys / deletingKeys - const [removingKeys, setRemovingKeys] = React.useState>(() => new Set()) - - // ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können - const removeTimersRef = React.useRef>(new Map()) - - useEffect(() => { - return () => { - for (const t of removeTimersRef.current.values()) { - window.clearTimeout(t) - } - removeTimersRef.current.clear() - } - }, []) - const markRemoving = useCallback((key: string, value: boolean) => { setRemovingKeys((prev) => { const next = new Set(prev) @@ -2362,19 +2123,13 @@ export default function FinishedDownloads({ const releasePlayingFile = useCallback( async (file: string, opts?: { close?: boolean }) => { - // 1) App-/Overlay-Player freigeben window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) if (opts?.close) { window.dispatchEvent(new CustomEvent('player:close', { detail: { file } })) } - // 2) Einmal auf den nächsten Frame warten (React/DOM cleanup) await new Promise((r) => requestAnimationFrame(() => r())) - - // 3) Nochmals release senden (hilft bei race zwischen close/unmount) window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) - - // 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind await new Promise((r) => window.setTimeout(r, 260)) }, [] @@ -2393,10 +2148,8 @@ export default function FinishedDownloads({ for (let attempt = 1; attempt <= attempts; attempt++) { try { - // vor JEDEM Versuch freigeben (nicht nur einmal) await releasePlayingFile(file, { close: opts?.close ?? true }) - // kurzer Tick extra (DOM/video cleanup, OS handle release) if (attempt > 1) { await sleep(baseDelayMs * attempt) } else { @@ -2406,18 +2159,16 @@ export default function FinishedDownloads({ return await run() } catch (e) { lastErr = e - - // nur bei "Datei in Verwendung" retryen if (!looksLikeFileInUseError(e) || attempt >= attempts) { throw e } - - // nächster Versuch continue } } - throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler')) + throw lastErr instanceof Error + ? lastErr + : new Error(String(lastErr ?? 'Unbekannter Fehler')) }, [releasePlayingFile] ) @@ -2456,7 +2207,6 @@ export default function FinishedDownloads({ return { ok: true, result } } catch (e: any) { - // Optimistik zurückrollen if (optimisticRemove) { restoreRow(rowKey, file) } @@ -2464,7 +2214,10 @@ export default function FinishedDownloads({ await onError?.(e) if (looksLikeFileInUseError(e)) { - notify.error(labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`) + notify.error( + labels.inUseTitle, + `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.` + ) } else { const suffix = e?.message ? ` — ${String(e.message)}` : '' notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`) @@ -2520,7 +2273,6 @@ export default function FinishedDownloads({ return (await r.json().catch(() => null)) as any }, onSuccess: async (result: any) => { - // Fall 1: externer Handler (App) liefert { undoToken } if (onDeleteJob) { const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' if (undoToken) { @@ -2531,7 +2283,6 @@ export default function FinishedDownloads({ return } - // Fall 2: lokaler API-Call (liefert from + undoToken) const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep' const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' @@ -2549,8 +2300,6 @@ export default function FinishedDownloads({ return res.ok }, [ - baseName, - keyFor, deletingKeys, markDeleting, onDeleteJob, @@ -2614,8 +2363,6 @@ export default function FinishedDownloads({ return res.ok }, [ - baseName, - keyFor, markKeeping, keepingKeys, deletingKeys, @@ -2629,6 +2376,7 @@ export default function FinishedDownloads({ const applyRename = useCallback((oldFile: string, newFile: string) => { if (!oldFile || !newFile || oldFile === newFile) return + setRenamedFiles((prev) => { const next: Record = { ...prev } for (const [k, v] of Object.entries(next)) { @@ -2637,6 +2385,7 @@ export default function FinishedDownloads({ next[oldFile] = newFile return next }) + setSelectedItems((prev) => { let changed = false const next: Record = {} @@ -2714,7 +2463,6 @@ export default function FinishedDownloads({ const restoredFile = String(data?.restoredFile || lastAction.originalFile) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) - // Fallbacks (falls alte lastAction ohne rowKey existiert) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) @@ -2738,7 +2486,6 @@ export default function FinishedDownloads({ const restoredFile = String(data?.newFile || lastAction.originalFile) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) - // Fallbacks (für ältere Actions / Sonderfälle) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) @@ -2785,8 +2532,6 @@ export default function FinishedDownloads({ applyRename, ]) - const [hotBusyKeys, setHotBusyKeys] = React.useState>(() => new Set()) - const markHotBusy = useCallback((key: string, value: boolean) => { setHotBusyKeys((prev) => { const next = new Set(prev) @@ -2821,7 +2566,6 @@ export default function FinishedDownloads({ failPrefix: oldFile, }, run: async () => { - // Optimistik sofort anwenden applyRename(oldFile, optimisticNew) if (onToggleHot) { @@ -2856,15 +2600,12 @@ export default function FinishedDownloads({ } }, onError: async () => { - // Rename-Optimistik rollback clearRenamePair(oldFile, optimisticNew) setLastAction(null) }, }) }, [ - baseName, - keyFor, hotBusyKeys, markHotBusy, runFileMutation, @@ -2969,455 +2710,15 @@ export default function FinishedDownloads({ } }, [notify]) - const applyRenamedOutput = useCallback( - (job: RecordJob): RecordJob => { - const out = norm(job.output || '') - const file = baseName(out) - const override = renamedFiles[file] - if (!override) return job - - const idx = out.lastIndexOf('/') - const dir = idx >= 0 ? out.slice(0, idx + 1) : '' - return { ...job, output: dir + override } - }, - [renamedFiles] - ) - - const doneJobsPage = overrideDoneJobs ?? doneJobs - const doneTotalPage = overrideDoneTotal ?? doneTotal - - const rows = useMemo(() => { - const map = new Map() - const doneBaseKeys = new Set() - - // Basis: Files aus dem Done-Ordner - for (const j of doneJobsPage) { - const jj = applyRenamedOutput(j) - const k = keyFor(jj) - - map.set(k, jj) - doneBaseKeys.add(k) - } - - // Jobs aus /list drübermergen, damit aktuelle Live-Felder - // (phase, postWork, progress, duration, size, etc.) sichtbar bleiben. - for (const j of jobs) { - const jj = applyRenamedOutput(j) - const k = keyFor(jj) - - if (map.has(k)) { - const existing = map.get(k)! - - map.set(k, { - ...existing, - ...jj, - meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta), - }) - continue - } - - // Fallback: gleicher Dateiname, aber evtl. anderer key/ID - const file = baseName(jj.output || '') - if (!file) continue - - for (const [existingKey, existingJob] of map.entries()) { - if (baseName(existingJob.output || '') === file) { - map.set(existingKey, { - ...existingJob, - ...jj, - meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta), - }) - break - } - } - } - - const list = Array.from(map.entries()) - .map(([k, j]) => ({ k, j })) - .filter(({ k, j }) => { - const file = baseName(j.output || '') - if (deletedKeys.has(k)) return false - if (file && hiddenFiles.has(file)) return false - - // .trash niemals anzeigen - if (isTrashOutput(j.output)) return false - - const anyJ = j as any - const status = String(anyJ?.status ?? '').trim().toLowerCase() - const endedAt = anyJ?.endedAt - - // ✅ Wichtig: - // Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben – - // auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist. - if (doneBaseKeys.has(k)) return true - - // Fallback: terminale Jobs ebenfalls anzeigen - if ( - status === 'finished' || - status === 'failed' || - status === 'stopped' - ) { - return true - } - - // Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist - if (endedAt) return true - - return false - }) - .map(({ j }) => j) - - return list - }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName]) - - - - const effectivePostworkSummaryByFile = useMemo>(() => { - const next: Record = {} - - // 1) Erst Live-Zustände sammeln - for (const [file, badges] of Object.entries(postworkByFile)) { - const summary = summarizePostworkSteps(file, badges) - if (summary) { - next[file] = summary - } - } - - // 2) Dann für jede sichtbare Datei Meta auswerten und ggf. Live-Zustand überschreiben - for (const job of rows) { - const fileRaw = baseName(job.output || '') - const filePlain = stripHotPrefix(fileRaw) - - if (!fileRaw) continue - - const rawMeta = (job as any)?.meta - const meta = parseMetaObject(rawMeta) - const assetId = fileStemFromOutput(job.output) - - const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta) - const summaryFromCompleted = - buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta) - - const mergedMetaSummary = - mergePostworkSummaries( - summaryFromHistory, - summaryFromCompleted, - fileRaw, - assetId - ) - - const metaSummary = - mergedMetaSummary ?? - buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status') - - const liveSummary = next[fileRaw] ?? next[filePlain] - - const summary = - mergePostworkSummaries( - liveSummary ?? null, - metaSummary ?? null, - fileRaw, - assetId - ) ?? metaSummary ?? liveSummary ?? null - - if (summary) { - next[fileRaw] = summary - if (filePlain && filePlain !== fileRaw) { - next[filePlain] = summary - } - } - } - - return next - }, [postworkByFile, rows]) - - const viewRows = rows - - const fileToKeyRef = React.useRef>(new Map()) - - useEffect(() => { - const m = new Map() - for (const j of viewRows) { - const f = baseName(j.output || '') - if (!f) continue - m.set(f, keyFor(j)) - } - fileToKeyRef.current = m - }, [viewRows]) - - useEffect(() => { - const onPostwork = (ev: Event) => { - const detail = (ev as CustomEvent).detail - const file = String(detail?.file ?? '').trim() - if (!file) return - - setPostworkByFile((prev) => { - const next = { ...prev } - - if (detail.state === 'missing') { - const cur = Array.isArray(prev[file]) ? prev[file] : [] - const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}` - const filtered = cur.filter((item) => { - const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` - return itemKey !== missingKey - }) - - if (filtered.length === 0) { - delete next[file] - } else { - next[file] = filtered - } - - return next - } - - const incoming: FinishedPostworkBadge = { - ...detail, - label: - String(detail?.label ?? '').trim() === file - ? undefined - : detail?.label, - } - - const cur = Array.isArray(prev[file]) ? prev[file] : [] - const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}` - - let replaced = false - const merged = cur.map((item) => { - const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` - if (itemKey !== key) return item - replaced = true - return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming - }) - - next[file] = replaced ? merged : [...merged, incoming] - return next - }) - } - - window.addEventListener('finished-downloads:postwork', onPostwork as EventListener) - return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener) - }, []) - - useEffect(() => { - const onExternalDelete = (ev: Event) => { - const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail - if (!detail?.file) return - - const key = fileToKeyRef.current.get(detail.file) || detail.file - - if (detail.phase === 'start') { - markDeleting(key, true) - - // ✅ Optimistik: überall gleich -> animiert raus - animateRemove(key, detail.file) - - return - } - - if (detail.phase === 'error') { - // ✅ alles zurückrollen -> wieder sichtbar - restoreRow(key, detail.file) - - // ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht) - swipeRefs.current.get(key)?.reset() - return - } - - if (detail.phase === 'success') { - markDeleting(key, false) - - // ✅ Nach erfolgreichem externen Delete aktuelle Seite nachziehen - if (refillInFlightRef.current) { - refillQueuedWhileInFlightRef.current = true - } else { - queueRefill() - } - - return - } - } - - window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) - return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) - }, [animateRemove, markDeleting, queueRefill, restoreRow]) - - useEffect(() => { - const onReload = () => { - // Nur reagieren, wenn FinishedDownloads wirklich sichtbar/mounted ist - if (document.hidden) return - - if (refillInFlightRef.current) { - if (!refillQueuedWhileInFlightRef.current) { - refillQueuedWhileInFlightRef.current = true - } - return - } - - if (refillTimerRef.current != null) return - - queueRefill() - } - - window.addEventListener('finished-downloads:reload', onReload as EventListener) - return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener) - }, [queueRefill]) - - useEffect(() => { - const onExternalRename = (ev: Event) => { - const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail - const oldFile = String(detail?.oldFile ?? '').trim() - const newFile = String(detail?.newFile ?? '').trim() - if (!oldFile || !newFile || oldFile === newFile) return - - // ✅ nutzt eure bestehende Logik inkl. Aufräumen + durations-move - applyRename(oldFile, newFile) - } - - window.addEventListener('finished-downloads:rename', onExternalRename as EventListener) - return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener) - }, [applyRename]) - - const visibleRows = useMemo(() => { - const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) - - // 🔎 Suche (AND über Tokens) - const searched = searchTokens.length - ? base.filter((j) => { - const file = baseName(j.output || '') - const model = modelNameFromOutput(j.output) - const modelKey = lower(model) - const tags = modelTags.tagsByModelKey[modelKey] ?? [] - - const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' ')) - - for (const t of searchTokens) { - if (!hay.includes(t)) return false - } - return true - }) - : base - - if (activeTagSet.size === 0) return searched - - // AND-Filter: alle ausgewählten Tags müssen vorhanden sein - return searched.filter((j) => { - const modelKey = lower(modelNameFromOutput(j.output)) - - const have = modelTags.tagSetByModelKey[modelKey] - if (!have || have.size === 0) return false - - for (const t of activeTagSet) { - if (!have.has(t)) return false - } - return true - }) - }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) - - const selectedFiles = useMemo(() => { - return Object.values(selectedItems) - .map((x) => String(x.file || '').trim()) - .filter(Boolean) - }, [selectedItems]) - - const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage - - const pageRows = useMemo(() => { - if (!effectiveAllMode) return visibleRows - const start = (page - 1) * pageSize - const end = start + pageSize - return visibleRows.slice(Math.max(0, start), Math.max(0, end)) - }, [effectiveAllMode, visibleRows, page, pageSize]) - - const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 - const emptyByFilter = globalFilterActive && visibleRows.length === 0 - - // Optional zusätzlich: wenn allMode und wirklich gar nix da: - const emptyAll = allMode && visibleRows.length === 0 - - const showLoadingCard = isLoading && (emptyFolder || emptyAll || emptyByFilter) - - useEffect(() => { - if (!globalFilterActive) return - const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize)) - if (page > totalPages) onPageChange(totalPages) - }, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange]) - - // 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter) - useEffect(() => { - const active = - !canHover && - effectiveTeaserPlaybackMode !== 'still' && - (view === 'cards' || view === 'gallery' || view === 'table') - - if (!active) { - setTeaserKey(null) - teaserIORef.current?.disconnect() - teaserIORef.current = null - return - } - - // Cards: Inline-Player soll “sticky” bleiben - if (view === 'cards' && inlinePlay?.key) { - setTeaserKey(inlinePlay.key) - return - } - - teaserIORef.current?.disconnect() - - const io = new IntersectionObserver( - (entries) => { - let bestKey: string | null = null - let bestRatio = 0 - - for (const ent of entries) { - if (!ent.isIntersecting) continue - const k = elToKeyRef.current.get(ent.target) - if (!k) continue - - if (ent.intersectionRatio > bestRatio) { - bestRatio = ent.intersectionRatio - bestKey = k - } - } - - if (bestKey) { - setTeaserKey((prev) => (prev === bestKey ? prev : bestKey)) - } - }, - { - // eher “welche Card ist wirklich sichtbar” - threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9], - rootMargin: '0px', - } - ) - - teaserIORef.current = io - - for (const [k, el] of teaserHostsRef.current) { - elToKeyRef.current.set(el, k) - io.observe(el) - } - - return () => { - io.disconnect() - if (teaserIORef.current === io) teaserIORef.current = null - } - }, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key]) - - // 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt - const runtimeOf = (job: RecordJob): string => { + const runtimeOf = useCallback((job: RecordJob): string => { const k = keyFor(job) - - // ✅ 1) echte Videodauer (Preview-Metadaten oder Backend durationSeconds) const meta = parseMetaObject((job as any)?.meta) const sec = durations[k] ?? metaDurationSeconds(meta) - + if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) { return formatDuration(sec * 1000) } - // 2) Fallback const start = Date.parse(String(job.startedAt || '')) const end = Date.parse(String(job.endedAt || '')) if (Number.isFinite(start) && Number.isFinite(end) && end > start) { @@ -3425,7 +2726,7 @@ export default function FinishedDownloads({ } return '—' - } + }, [durations]) const registerTeaserHost = useCallback( (key: string) => (el: HTMLDivElement | null) => { @@ -3444,7 +2745,6 @@ export default function FinishedDownloads({ [] ) - // Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind const handleDuration = useCallback((job: RecordJob, seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return const k = keyFor(job) @@ -3592,12 +2892,760 @@ export default function FinishedDownloads({ notify, ]) - // ✅ Hooks immer zuerst – unabhängig von rows - const isSmall = useMediaQuery('(max-width: 639px)') + // ----------------------------------------------------------------------------- + // effects + // ----------------------------------------------------------------------------- + useEffect(() => { + if (view !== 'gallery') return - // ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar) - const cardsMobileOffsetTopClass = 'mt-10' - const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4' + const wanted = Math.max(2, galleryColumnCount * 2) + + if (pageSize !== wanted) { + onPageSizeChange?.(wanted) + } + }, [view, galleryColumnCount, pageSize, onPageSizeChange]) + + useEffect(() => { + if (effectiveAllMode) return + setOverrideDoneJobs(null) + setOverrideDoneTotal(null) + setOverrideDoneJobsKey(null) + }, [effectiveAllMode]) + + useEffect(() => { + if (view === 'gallery') return + + setOverrideDoneJobs(null) + setOverrideDoneTotal(null) + setOverrideDoneJobsKey(null) + }, [view]) + + useEffect(() => { + try { + const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY) + const parsed = Number(raw) + const initial = Number.isFinite(parsed) + ? Math.max(0.8, Math.min(1.35, parsed)) + : 1 + + setGalleryCardScale(initial) + setGalleryCardScaleLive(initial) + scaleLivePendingRef.current = initial + } catch { + setGalleryCardScale(1) + setGalleryCardScaleLive(1) + scaleLivePendingRef.current = 1 + } + }, []) + + useEffect(() => { + try { + localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScale)) + } catch {} + }, [galleryCardScale]) + + useEffect(() => { + try { + const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null + if (saved === 'table' || saved === 'cards' || saved === 'gallery') { + setView(saved) + } else { + setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table') + } + } catch { + setView('table') + } + }, []) + + useEffect(() => { + try { + localStorage.setItem(VIEW_KEY, view) + } catch {} + }, [view]) + + useEffect(() => { + try { + const raw = localStorage.getItem(MOBILE_OPTS_KEY) + setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes') + } catch { + setMobileOptionsOpen(false) + } + }, []) + + useEffect(() => { + try { + localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0') + } catch {} + }, [mobileOptionsOpen]) + + useEffect(() => { + try { + const raw = localStorage.getItem('finishedDownloads_pendingTags') + if (!raw) return + + const arr = JSON.parse(raw) + const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : [] + if (tags.length === 0) return + + flushSync(() => setTagFilter(tags)) + if (page !== 1) onPageChange(1) + } catch { + } finally { + try { + localStorage.removeItem('finishedDownloads_pendingTags') + } catch {} + } + }, []) + + useEffect(() => { + return () => { + if (scaleLiveRafRef.current != null) { + cancelAnimationFrame(scaleLiveRafRef.current) + scaleLiveRafRef.current = null + } + } + }, []) + + useEffect(() => { + return () => { + if (teaserKeyRafRef.current != null) { + cancelAnimationFrame(teaserKeyRafRef.current) + teaserKeyRafRef.current = null + } + } + }, []) + + useEffect(() => { + resolutionsRef.current = resolutions + }, [resolutions]) + + useEffect(() => { + return () => { + if (resolutionsFlushTimerRef.current != null) { + window.clearTimeout(resolutionsFlushTimerRef.current) + resolutionsFlushTimerRef.current = null + } + } + }, []) + + useEffect(() => { + durationsRef.current = durations + }, [durations]) + + useEffect(() => { + return () => { + if (durationsFlushTimerRef.current != null) { + window.clearTimeout(durationsFlushTimerRef.current) + durationsFlushTimerRef.current = null + } + } + }, []) + + useEffect(() => { + return () => { + for (const t of removeTimersRef.current.values()) { + window.clearTimeout(t) + } + removeTimersRef.current.clear() + } + }, []) + + useEffect(() => { + if (!pauseTeasers) return + + setTeaserKey(null) + setHoverTeaserKey(null) + setInlinePlay(null) + }, [pauseTeasers]) + + useEffect(() => { + let alive = true + + const loadInitialPostworkState = async () => { + try { + const res = await fetch('/api/tasks/status', { cache: 'no-store' }) + if (!res.ok) return + + const data = await res.json().catch(() => null) + if (!alive || !data) return + + const jobs = Array.isArray(data?.regenerateAssetsJobs) + ? data.regenerateAssetsJobs + : [] + + if (jobs.length > 0) { + setPostworkByFile((prev) => { + const next = { ...prev } + + for (const item of jobs) { + const file = String(item?.file ?? '').trim() + if (!file) continue + + const assetId = + String(item?.assetId ?? '').trim() || fileStemFromOutput(file) + + const phases = Array.isArray(item?.phases) ? item.phases : [] + + if (phases.length > 0) { + const restored = phases + .map((p: any): FinishedPostworkBadge | null => { + const queueRaw = String(p?.queue ?? '').trim().toLowerCase() + const phaseRaw = String(p?.phase ?? '').trim().toLowerCase() + const rawState = String(p?.state ?? '').trim().toLowerCase() + + const state: FinishedPostworkState = + rawState === 'queued' || + rawState === 'running' || + rawState === 'done' || + rawState === 'error' || + rawState === 'missing' + ? rawState + : rawState === 'cancelled' + ? 'missing' + : 'missing' + + if ( + (queueRaw !== 'postwork' && queueRaw !== 'enrich') || + !phaseRaw + ) { + return null + } + + return { + file, + assetId, + queue: queueRaw as 'postwork' | 'enrich', + phase: phaseRaw, + state, + label: + String(p?.label ?? '').trim() || + labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), + position: + typeof p?.position === 'number' ? p.position : undefined, + waiting: + typeof p?.waiting === 'number' ? p.waiting : undefined, + running: + typeof p?.running === 'number' ? p.running : undefined, + maxParallel: + typeof p?.maxParallel === 'number' + ? p.maxParallel + : undefined, + ts: + typeof p?.ts === 'number' && Number.isFinite(p.ts) + ? p.ts + : Date.now(), + } + }) + .filter(Boolean) as FinishedPostworkBadge[] + + if (restored.length > 0) { + next[file] = restored + continue + } + } + + const rawState = String(item?.state ?? '').trim().toLowerCase() + + const state: FinishedPostworkState = + rawState === 'queued' || + rawState === 'running' || + rawState === 'done' || + rawState === 'error' || + rawState === 'missing' + ? rawState + : rawState === 'cancelled' + ? 'missing' + : 'missing' + + if (state !== 'queued' && state !== 'running' && state !== 'error') { + continue + } + + next[file] = [ + { + file, + assetId, + queue: 'postwork', + state, + phase: 'meta', + label: 'Meta', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'thumb', + label: 'Vorschaubild', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'teaser', + label: 'Teaser', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'postwork', + state, + phase: 'sprites', + label: 'Sprites', + ts: Date.now(), + }, + { + file, + assetId, + queue: 'enrich', + state, + phase: 'analyze', + label: 'KI-Analyse', + ts: Date.now(), + }, + ] + } + + return next + }) + } + } catch {} + } + + void loadInitialPostworkState() + + return () => { + alive = false + } + }, []) + + useEffect(() => { + const onPointerMove = (e: PointerEvent) => { + pointerPosRef.current = { x: e.clientX, y: e.clientY } + } + + window.addEventListener('pointermove', onPointerMove, { passive: true }) + return () => window.removeEventListener('pointermove', onPointerMove) + }, []) + + useEffect(() => { + try { + if (!lastAction) { + localStorage.removeItem(LAST_UNDO_KEY) + return + } + + const payload: PersistedUndoState = { + v: 1, + action: lastAction, + ts: Date.now(), + } + + localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload)) + } catch {} + }, [lastAction]) + + useEffect(() => { + if (!effectiveAllMode) return + + const ac = new AbortController() + + const t = window.setTimeout(() => { + fetchAllDoneJobs(ac.signal).catch(() => {}) + }, 250) + + return () => { + window.clearTimeout(t) + ac.abort() + } + }, [effectiveAllMode, fetchAllDoneJobs]) + + useEffect(() => { + if (refillTick === 0) return + + const ac = new AbortController() + let alive = true + let finished = false + const mySession = ++refillSessionRef.current + + const finishRefill = () => { + if (finished) return + finished = true + + if (refillSessionRef.current !== mySession) return + + refillInFlightRef.current = false + + if (refillQueuedWhileInFlightRef.current) { + refillQueuedWhileInFlightRef.current = false + queueRefill() + } + } + + refillInFlightRef.current = true + + // ✅ paged-mode hier NICHT mehr laden + if (!effectiveAllMode) { + finishRefill() + return () => { + alive = false + ac.abort() + finishRefill() + } + } + + ;(async () => { + try { + await fetchAllDoneJobs(ac.signal) + if (alive) refillRetryRef.current = 0 + } catch { + } finally { + if (alive) finishRefill() + } + })() + + return () => { + alive = false + ac.abort() + finishRefill() + } + }, [refillTick, effectiveAllMode, fetchAllDoneJobs, queueRefill]) + + useEffect(() => { + if (isLoading) return + if (effectiveAllMode) return + if (totalItemsForPagination < 0) return + + const totalPages = Math.max(1, Math.ceil(totalItemsForPagination / effectivePageSize)) + + if (page > totalPages) { + onPageChange(totalPages) + } + }, [ + isLoading, + effectiveAllMode, + totalItemsForPagination, + effectivePageSize, + page, + onPageChange, + ]) + + useEffect(() => { + if (loadMode !== 'paged') return + if (effectiveAllMode) return + if (view !== 'gallery') return + if (!galleryHydrationKey) return + + const currentCount = hasMatchingGalleryOverride + ? (overrideDoneJobs?.length ?? 0) + : doneJobs.length + + const wantedCount = effectivePageSize + const totalCount = doneTotal + + const pageStart = (page - 1) * wantedCount + const maxItemsOnThisPage = Math.max(0, totalCount - pageStart) + + if (maxItemsOnThisPage <= 0) { + galleryHydratedKeyRef.current = galleryHydrationKey + return + } + + const targetCount = Math.min(wantedCount, maxItemsOnThisPage) + + if (currentCount >= targetCount) { + galleryHydratedKeyRef.current = galleryHydrationKey + return + } + + if (galleryHydratedKeyRef.current === galleryHydrationKey) { + return + } + + const ac = new AbortController() + let cancelled = false + + setIsLoading(true) + + ;(async () => { + try { + const res = await fetch( + `/api/record/done?page=${page}&pageSize=${targetCount}&sort=${encodeURIComponent(sortMode)}${ + includeKeep ? '&includeKeep=1' : '' + }`, + { cache: 'no-store' as any, signal: ac.signal } + ) + + if (!res.ok || cancelled || ac.signal.aborted) return + + const data = await res.json().catch(() => null) + if (cancelled || ac.signal.aborted) return + + const items = Array.isArray(data?.items) + ? (data.items as RecordJob[]) + : Array.isArray(data) + ? data + : [] + + const countRaw = Number(data?.count ?? data?.totalCount ?? items.length) + const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 + + setOverrideDoneJobs(items) + setOverrideDoneTotal(count) + setOverrideDoneJobsKey(galleryHydrationKey) + galleryHydratedKeyRef.current = galleryHydrationKey + } catch (err: any) { + if (err?.name !== 'AbortError') { + console.error('gallery hydration failed', err) + } + } finally { + if (!cancelled) { + setIsLoading(false) + } + } + })() + + return () => { + cancelled = true + ac.abort() + } + }, [ + loadMode, + effectiveAllMode, + view, + page, + sortMode, + includeKeep, + doneJobs, + doneTotal, + effectivePageSize, + serverPageSize, + galleryHydrationKey, + ]) + + useEffect(() => { + galleryHydratedKeyRef.current = null + }, [page, sortMode, includeKeep, effectivePageSize, view]) + + useEffect(() => { + const m = new Map() + for (const j of viewRows) { + const f = baseName(j.output || '') + if (!f) continue + m.set(f, keyFor(j)) + } + fileToKeyRef.current = m + }, [viewRows]) + + useEffect(() => { + const onPostwork = (ev: Event) => { + const detail = (ev as CustomEvent).detail + const file = String(detail?.file ?? '').trim() + if (!file) return + + setPostworkByFile((prev) => { + const next = { ...prev } + + if (detail.state === 'missing') { + const cur = Array.isArray(prev[file]) ? prev[file] : [] + const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}` + const filtered = cur.filter((item) => { + const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` + return itemKey !== missingKey + }) + + if (filtered.length === 0) { + delete next[file] + } else { + next[file] = filtered + } + + return next + } + + const incoming: FinishedPostworkBadge = { + ...detail, + label: + String(detail?.label ?? '').trim() === file + ? undefined + : detail?.label, + } + + const cur = Array.isArray(prev[file]) ? prev[file] : [] + const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}` + + let replaced = false + const merged = cur.map((item) => { + const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` + if (itemKey !== key) return item + replaced = true + return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming + }) + + next[file] = replaced ? merged : [...merged, incoming] + return next + }) + } + + window.addEventListener('finished-downloads:postwork', onPostwork as EventListener) + return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener) + }, []) + + useEffect(() => { + const onExternalDelete = (ev: Event) => { + const detail = (ev as CustomEvent<{ file: string; phase: 'start' | 'success' | 'error' }>).detail + if (!detail?.file) return + + const key = fileToKeyRef.current.get(detail.file) || detail.file + + if (detail.phase === 'start') { + markDeleting(key, true) + animateRemove(key, detail.file) + return + } + + if (detail.phase === 'error') { + restoreRow(key, detail.file) + swipeRefs.current.get(key)?.reset() + return + } + + if (detail.phase === 'success') { + markDeleting(key, false) + + if (refillInFlightRef.current) { + refillQueuedWhileInFlightRef.current = true + } else { + queueRefill() + } + } + } + + window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) + return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) + }, [animateRemove, markDeleting, queueRefill, restoreRow]) + + useEffect(() => { + const onReload = () => { + if (document.hidden) return + + if (refillInFlightRef.current) { + if (!refillQueuedWhileInFlightRef.current) { + refillQueuedWhileInFlightRef.current = true + } + return + } + + if (refillTimerRef.current != null) return + queueRefill() + } + + window.addEventListener('finished-downloads:reload', onReload as EventListener) + return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener) + }, [queueRefill]) + + useEffect(() => { + const onExternalRename = (ev: Event) => { + const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail + const oldFile = String(detail?.oldFile ?? '').trim() + const newFile = String(detail?.newFile ?? '').trim() + if (!oldFile || !newFile || oldFile === newFile) return + + applyRename(oldFile, newFile) + } + + window.addEventListener('finished-downloads:rename', onExternalRename as EventListener) + return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener) + }, [applyRename]) + + useEffect(() => { + const shouldClampFromVisibleRows = effectiveAllMode + + if (!shouldClampFromVisibleRows) return + + const totalPages = Math.max(1, Math.ceil(visibleRows.length / effectivePageSize)) + + if (page > totalPages) { + onPageChange(totalPages) + } + }, [ + effectiveAllMode, + visibleRows.length, + page, + effectivePageSize, + onPageChange, + ]) + + useEffect(() => { + const active = + !canHover && + effectiveTeaserPlaybackMode !== 'still' && + (view === 'cards' || view === 'gallery' || view === 'table') + + if (!active) { + setTeaserKey(null) + teaserIORef.current?.disconnect() + teaserIORef.current = null + return + } + + if (view === 'cards' && inlinePlay?.key) { + setTeaserKey(inlinePlay.key) + return + } + + teaserIORef.current?.disconnect() + + const io = new IntersectionObserver( + (entries) => { + let bestKey: string | null = null + let bestRatio = 0 + + for (const ent of entries) { + if (!ent.isIntersecting) continue + const k = elToKeyRef.current.get(ent.target) + if (!k) continue + + if (ent.intersectionRatio > bestRatio) { + bestRatio = ent.intersectionRatio + bestKey = k + } + } + + if (bestKey) { + pendingTeaserKeyRef.current = bestKey + + if (teaserKeyRafRef.current == null) { + teaserKeyRafRef.current = requestAnimationFrame(() => { + teaserKeyRafRef.current = null + const nextKey = pendingTeaserKeyRef.current + setTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) + }) + } + } + }, + { + threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9], + rootMargin: '0px', + } + ) + + teaserIORef.current = io + + for (const [k, el] of teaserHostsRef.current) { + elToKeyRef.current.set(el, k) + io.observe(el) + } + + return () => { + io.disconnect() + if (teaserIORef.current === io) teaserIORef.current = null + } + }, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key]) useEffect(() => { if (!isSmall || view !== 'cards') return @@ -3614,33 +3662,23 @@ export default function FinishedDownloads({ const topKey = keyFor(top) setTeaserKey((prev) => (prev === topKey ? prev : topKey)) - }, [isSmall, view, pageRows, keyFor, effectiveTeaserPlaybackMode]) + }, [isSmall, view, pageRows, effectiveTeaserPlaybackMode]) useEffect(() => { if (!isSmall) { - // dein Cleanup (z.B. swipeRefs reset) wie gehabt swipeRefs.current = new Map() } }, [isSmall]) - - useEffect(() => { - // Nicht während eines Seitenwechsels / Refills zurückspringen. - if (isLoading) return - - if (emptyFolder && page !== 1) onPageChange(1) - }, [emptyFolder, isLoading, page, onPageChange]) - - const selectedCount = selectedFiles.length - const allPageSelected = - pageRows.length > 0 && - pageRows.every((j) => selectedKeys.has(keyFor(j))) - + useEffect(() => { if (selectedCount === 0 && selectionActionsOpen) { setSelectionActionsOpen(false) } }, [selectedCount, selectionActionsOpen]) + // ----------------------------------------------------------------------------- + // render + // ----------------------------------------------------------------------------- return ( <> {/* Toolbar (sticky) */} @@ -3654,7 +3692,6 @@ export default function FinishedDownloads({ > {/* Desktop */}
- {/* Zeile 1: Titel */}
@@ -3677,18 +3714,13 @@ export default function FinishedDownloads({
- {/* Zeile 2: Controls */}
- setSearchQuery(e.target.value)} placeholder="Suchen…" - className=" - h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm - focus:outline-none focus:ring-2 focus:ring-indigo-500 - dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark] - " /> {(searchQuery || '').trim() !== '' ? (
{view !== 'table' ? ( -
- - +
+ {view === 'gallery' ? ( +
+ +
+ ) : null} + +
+ + +
) : null}
- {/* Auswahl-Aktionen */} {selectedCount > 0 ? (
@@ -3924,7 +3969,6 @@ export default function FinishedDownloads({ selectionActionsOpen ? 'translate-y-0' : '-translate-y-1 pointer-events-none', ].join(' ')} > - {/* Mobile */}
{bulkBusy ? (
@@ -3938,7 +3982,11 @@ export default function FinishedDownloads({ size="sm" variant="soft" color={allPageSelected ? 'amber' : 'indigo'} - leadingIcon={allPageSelected ? : } + leadingIcon={ + allPageSelected + ? + : + } disabled={bulkBusy} onClick={() => { if (allPageSelected) deselectPageRows(pageRows) @@ -3986,7 +4034,6 @@ export default function FinishedDownloads({
- {/* Desktop */}
{bulkBusy ? (
@@ -3999,7 +4046,11 @@ export default function FinishedDownloads({ size="sm" variant="soft" color={allPageSelected ? 'amber' : 'indigo'} - leadingIcon={allPageSelected ? : } + leadingIcon={ + allPageSelected + ? + : + } disabled={bulkBusy} onClick={() => { if (allPageSelected) deselectPageRows(pageRows) @@ -4047,7 +4098,6 @@ export default function FinishedDownloads({
) : null} - {/* Desktop: aktive Tag-Filter anzeigen */} {tagFilter.length > 0 ? (
@@ -4074,7 +4124,6 @@ export default function FinishedDownloads({
) : null} - {/* Mobile Optionen */}
- setSearchQuery(e.target.value)} placeholder="Suchen…" - className=" - h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 - focus:outline-none focus:ring-2 focus:ring-indigo-500 - dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark] - " /> {(searchQuery || '').trim() !== '' ? (
{view !== 'table' ? ( -
-
- Sortierung +
+ {view === 'gallery' ? ( +
+ +
+ ) : null} + +
+
+ Sortierung +
+
-
) : null}
@@ -4395,6 +4458,7 @@ export default function FinishedDownloads({ {view === 'gallery' && ( { @@ -4436,6 +4500,7 @@ export default function FinishedDownloads({ postworkByFile={effectivePostworkSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} + cardScale={galleryCardScaleLive} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, @@ -4457,7 +4522,7 @@ export default function FinishedDownloads({
{ flushSync(() => { @@ -4478,5 +4543,4 @@ export default function FinishedDownloads({
) - } diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx new file mode 100644 index 0000000..05512fe --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -0,0 +1,521 @@ +// frontend\src\components\ui\FinishedDownloadsGalleryCard.tsx + +'use client' + +import * as React from 'react' +import type { + RecordJob, + GalleryModelFlags, + FinishedPostworkSummary, +} from '../../types' +import FinishedVideoPreview from './FinishedVideoPreview' +import RecordJobActions from './RecordJobActions' +import TagOverflowRow from './TagOverflowRow' +import { isHotName, stripHotPrefix } from './hotName' +import { formatResolution } from './formatters' +import PreviewScrubber from './PreviewScrubber' +import type { FinishedDownloadsTeaserState } from './teaserPlayback' +import { + shouldAnimateTeaser, + shouldEnableTeaserAudio, +} from './teaserPlayback' +import Checkbox from './Checkbox' +import { + buildSpriteFrameStyle, + parseJobMeta, + firstNonEmptyString, + readPreviewSpriteInfo, + scrubProgressRatioFromIndex, +} from './previewSprite' + +function normalizeDurationSeconds(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return value > 24 * 60 * 60 ? value / 1000 : value +} + +type Props = { + job: RecordJob + checked: boolean + hasAnySelection: boolean + isPreviewActive: boolean + forcePreviewMuted?: boolean + blurPreviews?: boolean + + postworkByFile: Record + getPostworkSummaryForOutput: ( + output: string | undefined, + summaries: Record + ) => FinishedPostworkSummary | undefined + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode + + durations: Record + teaserState: FinishedDownloadsTeaserState + + keyFor: (j: RecordJob) => string + baseName: (p: string) => string + modelNameFromOutput: (output?: string) => string + runtimeOf: (job: RecordJob) => string + sizeBytesOf: (job: RecordJob) => number | null + formatBytes: (bytes?: number | null) => string + lower: (s: string) => string + + modelsByKey: Record + activeTagSet: Set + + deletingKeys: Set + keepingKeys: Set + removingKeys: Set + + registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void + + onToggleSelected: (job: RecordJob) => void + onHoverPreviewKeyChange?: (key: string | null) => void + onToggleTagFilter: (tag: string) => void + onOpenPlayer: (job: RecordJob) => void + handleDuration: (job: RecordJob, seconds: number) => void + handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void + deleteVideo: (job: RecordJob) => Promise + keepVideo: (job: RecordJob) => Promise + onToggleHot: (job: RecordJob) => void | Promise + onToggleFavorite?: (job: RecordJob) => void | Promise + onToggleLike?: (job: RecordJob) => void | Promise + onToggleWatch?: (job: RecordJob) => void | Promise + onSplit?: (job: RecordJob) => void | Promise + onAddToDownloads?: (job: RecordJob) => void | Promise + jobForDetails: (job: RecordJob) => RecordJob + + contentPaddingClass: string + footerMinHeightClass: string + titleClass: string + sublineClass: string +} + +function FinishedDownloadsGalleryCardInner({ + job: j, + checked, + hasAnySelection, + isPreviewActive, + forcePreviewMuted, + blurPreviews, + postworkByFile, + getPostworkSummaryForOutput, + renderPostworkBadge, + durations, + teaserState, + keyFor, + baseName, + modelNameFromOutput, + runtimeOf, + sizeBytesOf, + formatBytes, + lower, + modelsByKey, + activeTagSet, + deletingKeys, + keepingKeys, + removingKeys, + registerTeaserHost, + onToggleSelected, + onHoverPreviewKeyChange, + onToggleTagFilter, + onOpenPlayer, + handleDuration, + handleScrubberClickIndex, + deleteVideo, + keepVideo, + onToggleHot, + onToggleFavorite, + onToggleLike, + onToggleWatch, + onSplit, + onAddToDownloads, + jobForDetails, + contentPaddingClass, + footerMinHeightClass, + titleClass, + sublineClass, +}: Props) { + const k = keyFor(j) + + const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState(null) + const [scrubIndexByKey, setScrubIndexByKey] = React.useState>({}) + const [hoveredThumbKey, setHoveredThumbKey] = React.useState(null) + + const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { + setScrubIndexByKey((prev) => { + if (index === undefined) { + if (!(key in prev)) return prev + const next = { ...prev } + delete next[key] + return next + } + if (prev[key] === index) return prev + return { ...prev, [key]: index } + }) + }, []) + + const clearScrubIndex = React.useCallback((key: string) => { + setScrubIndexForKey(key, undefined) + }, [setScrubIndexForKey]) + + const allowSound = shouldEnableTeaserAudio({ + state: teaserState, + itemKey: k, + }) + const previewMuted = forcePreviewMuted ? true : !allowSound + + const model = modelNameFromOutput(j.output) + const modelKey = lower(model) + const flags = modelsByKey[modelKey] + + const tags = React.useMemo(() => { + const s = String(flags?.tags ?? '').trim() + if (!s) return [] as string[] + const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean) + const seen = new Set() + const out: string[] = [] + for (const p of parts) { + const kk = p.toLowerCase() + if (seen.has(kk)) continue + seen.add(kk) + out.push(p) + } + return out + }, [flags?.tags]) + + const isFav = Boolean(flags?.favorite) + const isLiked = flags?.liked === true + const isWatching = Boolean(flags?.watching) + + const fileRaw = baseName(j.output || '') + const isHot = isHotName(fileRaw) + const file = stripHotPrefix(fileRaw) + + const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) + const showPostworkBadge = !!postworkBadge + + const dur = runtimeOf(j) + const size = formatBytes(sizeBytesOf(j)) + + const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta]) + + const resObj = React.useMemo(() => { + const w = meta?.media?.video?.width ?? meta?.file?.video?.width + const h = meta?.media?.video?.height ?? meta?.file?.video?.height + if ( + typeof w === 'number' && Number.isFinite(w) && w > 0 && + typeof h === 'number' && Number.isFinite(h) && h > 0 + ) { + return { w, h } + } + return null + }, [meta]) + + const resLabel = formatResolution(resObj) + + const isRemoving = removingKeys.has(k) + const isDeleting = deletingKeys.has(k) + const isKeeping = keepingKeys.has(k) + const busy = isDeleting || isKeeping || isRemoving + + const modelImageSrc = firstNonEmptyString( + flags?.image, + flags?.imageUrl, + (j as any)?.meta?.modelImage, + (j as any)?.meta?.modelImageUrl + ) + + const sprite = React.useMemo( + () => + readPreviewSpriteInfo((j as any)?.meta, { + fallbackPath: flags?.previewScrubberPath, + fallbackCount: flags?.previewScrubberCount, + }), + [(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount] + ) + + const spriteUrl = sprite.url + const hasScrubberUi = sprite.hasScrubberUi + const hasSpriteScrubber = sprite.hasSpriteScrubber + const scrubberCount = sprite.count + const scrubberStepSeconds = sprite.stepSeconds + const hasScrubber = hasScrubberUi + + const activeScrubIndex = scrubIndexByKey[k] + const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) + + const spriteFrameStyle = React.useMemo( + () => + buildSpriteFrameStyle({ + spriteUrl, + spriteCols: sprite.cols, + spriteRows: sprite.rows, + spriteCount: sprite.count, + activeIndex: activeScrubIndex, + }), + [spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex] + ) + + const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) + const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) + const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb + + const previewDurationSeconds = + durations[k] ?? + normalizeDurationSeconds(meta?.media?.durationSeconds) ?? + normalizeDurationSeconds(meta?.file?.durationSeconds) + + return ( +
+
{ + if (hasAnySelection) { + e.preventDefault() + e.stopPropagation() + onToggleSelected(j) + return + } + onOpenPlayer(j) + }} + onMouseEnter={() => onHoverPreviewKeyChange?.(k)} + onMouseLeave={() => { + onHoverPreviewKeyChange?.(null) + setHoveredThumbKey((prev) => (prev === k ? null : prev)) + clearScrubIndex(k) + setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + }} + > +
+
{ + e.preventDefault() + e.stopPropagation() + }} + onMouseDown={(e) => e.stopPropagation()} + > + onToggleSelected(j)} + /> +
+ +
setHoveredThumbKey(k)} + onMouseLeave={() => { + setHoveredThumbKey((prev) => (prev === k ? null : prev)) + clearScrubIndex(k) + setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) + }} + > +
+
+ stripHotPrefix(baseName(p))} + durationSeconds={previewDurationSeconds} + onDuration={handleDuration} + variant="fill" + showPopover={false} + blur={blurPreviews} + animated={shouldAnimateTeaser({ + state: teaserState, + itemKey: k, + disabled: hideTeaserUnderOverlay, + })} + animatedMode="teaser" + animatedTrigger="always" + muted={previewMuted} + popoverMuted={previewMuted} + scrubProgressRatio={scrubProgressRatio} + preferScrubProgress={typeof activeScrubIndex === 'number'} + forceActive={isPreviewActive} + teaserPreloadEnabled + /> +
+ + {hasSpriteScrubber && spriteUrl ? ( + + ) : null} + + {showScrubberSpriteInThumb && spriteFrameStyle ? ( + +
+ +
{ + e.preventDefault() + e.stopPropagation() + onOpenPlayer(j) + }} + > +
+
+
+ {model} +
+ + {showPostworkBadge && renderPostworkBadge ? ( +
+ {renderPostworkBadge(postworkBadge)} +
+ ) : null} +
+ +
+ {isHot ? ( + + HOT + + ) : null} + + + {stripHotPrefix(file) || '—'} + +
+
+ +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > +
+
+ +
+
+
+ +
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + +
+
+
+
+
+ ) +} + +const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner) +export default FinishedDownloadsGalleryCard \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 418bbc0..d46c786 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -7,32 +7,18 @@ import type { GalleryModelFlags, FinishedPostworkSummary, } from '../../types' -import FinishedVideoPreview from './FinishedVideoPreview' -import RecordJobActions from './RecordJobActions' -import TagOverflowRow from './TagOverflowRow' -import { isHotName, stripHotPrefix } from './hotName' -import { formatResolution } from './formatters' -import PreviewScrubber from './PreviewScrubber' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { - shouldAnimateTeaser, - shouldEnableTeaserAudio, shouldObserveTeasers, } from './teaserPlayback' -import Checkbox from './Checkbox' -import { - buildSpriteFrameStyle, - parseJobMeta, - firstNonEmptyString, - readPreviewSpriteInfo, - scrubProgressRatioFromIndex, -} from './previewSprite' +import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard' type Props = { rows: RecordJob[] selectedKeys: Set onToggleSelected: (job: RecordJob) => void onToggleSelectAllPage: (checked: boolean) => void + onColumnCountChange?: (count: number) => void isLoading?: boolean blurPreviews?: boolean durations: Record @@ -43,6 +29,7 @@ type Props = { ) => FinishedPostworkSummary | undefined teaserState: FinishedDownloadsTeaserState renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode + cardScale?: number handleDuration: (job: RecordJob, seconds: number) => void @@ -83,10 +70,15 @@ type Props = { forcePreviewMuted?: boolean } -function normalizeDurationSeconds(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined - // ms -> s Heuristik wie in FinishedVideoPreview - return value > 24 * 60 * 60 ? value / 1000 : value +function snapTo(value: number, step: number, base = 0) { + if (!Number.isFinite(value)) return base + if (!Number.isFinite(step) || step <= 0) return value + const snapped = Math.round((value - base) / step) * step + base + return Number(snapped.toFixed(4)) +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)) } export default function FinishedDownloadsGalleryView({ @@ -98,9 +90,10 @@ export default function FinishedDownloadsGalleryView({ getPostworkSummaryForOutput, teaserState, renderPostworkBadge, + cardScale, + onColumnCountChange, selectedKeys, onToggleSelected, - handleDuration, handleScrubberClickIndex, jobForDetails, @@ -110,13 +103,10 @@ export default function FinishedDownloadsGalleryView({ runtimeOf, sizeBytesOf, formatBytes, - deletingKeys, keepingKeys, removingKeys, - registerTeaserHost, - onHoverPreviewKeyChange, onOpenPlayer, deleteVideo, @@ -133,11 +123,8 @@ export default function FinishedDownloadsGalleryView({ onAddToDownloads, forcePreviewMuted, }: Props) { - // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll const observeTeasers = shouldObserveTeasers(teaserState.mode) - const hasAnySelection = selectedKeys.size > 0 - // ✅ Wrapper: bei still unregistrieren / nicht registrieren const registerTeaserHostIfNeeded = React.useCallback( (key: string) => (el: HTMLDivElement | null) => { if (!observeTeasers) { @@ -149,444 +136,137 @@ export default function FinishedDownloadsGalleryView({ [registerTeaserHost, observeTeasers] ) - const parseTags = (raw?: string): string[] => { - const s = String(raw ?? '').trim() - if (!s) return [] - const parts = s - .split(/[\n,;|]+/g) - .map((p) => p.trim()) - .filter(Boolean) + const effectiveScaleRaw = + typeof cardScale === 'number' + ? cardScale + : 1 - const seen = new Set() - const out: string[] = [] - for (const p of parts) { - const k = p.toLowerCase() - if (seen.has(k)) continue - seen.add(k) - out.push(p) - } - return out - } + const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35) - // ✅ Auflösung als {w,h} aus meta.json bevorzugen - const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => { - const meta = parseJobMeta((j as any)?.meta) + const layoutScale = React.useMemo( + () => snapTo(effectiveScale, 0.05, 0.8), + [effectiveScale] + ) - const w = - meta?.media?.video?.width ?? - meta?.file?.video?.width + const containerRef = React.useRef(null) + const [containerWidth, setContainerWidth] = React.useState(0) - const h = - meta?.media?.video?.height ?? - meta?.file?.video?.height + const gapPx = 12 - if ( - typeof w === 'number' && - Number.isFinite(w) && - w > 0 && - typeof h === 'number' && - Number.isFinite(h) && - h > 0 - ) { - return { w, h } + const preferredCardWidth = React.useMemo(() => { + const base = 240 + return Math.round(base * layoutScale) + }, [layoutScale]) + + const columnCount = React.useMemo(() => { + if (!containerWidth || containerWidth <= 0) { + return 1 } - return null + const columnsThatFit = Math.floor( + (containerWidth + gapPx) / (preferredCardWidth + gapPx) + ) + + return Math.max(1, columnsThatFit) + }, [containerWidth, preferredCardWidth]) + + React.useEffect(() => { + onColumnCountChange?.(columnCount) + }, [columnCount, onColumnCountChange]) + + const cardMinWidth = React.useMemo(() => { + return preferredCardWidth + }, [preferredCardWidth]) + + const gridStyle = React.useMemo(() => { + return { + ['--gallery-card-min' as any]: `${cardMinWidth}px`, + } + }, [cardMinWidth]) + + const contentPaddingClass = 'px-3.5 py-3' + const footerMinHeightClass = 'min-h-[118px]' + const titleClass = 'text-sm' + const sublineClass = 'text-xs' + + React.useLayoutEffect(() => { + const el = containerRef.current + if (!el) return + + const update = () => { + setContainerWidth(el.clientWidth || 0) + } + + update() + + const ro = new ResizeObserver(() => update()) + ro.observe(el) + + return () => { + ro.disconnect() + } }, []) - // ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet) - const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState(null) - - // ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte) - const [scrubIndexByKey, setScrubIndexByKey] = React.useState>({}) - - const [hoveredThumbKey, setHoveredThumbKey] = React.useState(null) - - const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { - setScrubIndexByKey((prev) => { - if (index === undefined) { - if (!(key in prev)) return prev - const next = { ...prev } - delete next[key] - return next - } - - if (prev[key] === index) return prev - return { ...prev, [key]: index } - }) - }, []) - - const clearScrubIndex = React.useCallback((key: string) => { - setScrubIndexForKey(key, undefined) - }, [setScrubIndexForKey]) - return ( -
-
+
+
{rows.map((j) => { const k = keyFor(j) - const checked = selectedKeys.has(k) - - const isPreviewActive = - teaserState.activeKey === k || teaserState.hoverKey === k - - // Sound nur bei Hover auf genau diesem Teaser - const allowSound = shouldEnableTeaserAudio({ - state: teaserState, - itemKey: k, - }) - const previewMuted = forcePreviewMuted ? true : !allowSound - - const model = modelNameFromOutput(j.output) - const modelKey = lower(model) - const flags = modelsByKey[modelKey] - const isFav = Boolean(flags?.favorite) - const isLiked = flags?.liked === true - const isWatching = Boolean(flags?.watching) - - const tags = parseTags(flags?.tags) - - const fileRaw = baseName(j.output || '') - const isHot = isHotName(fileRaw) - const file = stripHotPrefix(fileRaw) - - const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) - const showPostworkBadge = !!postworkBadge - - const dur = runtimeOf(j) - const size = formatBytes(sizeBytesOf(j)) - - const resObj = resolutionObjOf(j) - const resLabel = formatResolution(resObj) - - const isRemoving = removingKeys.has(k) - const isDeleting = deletingKeys.has(k) - const isKeeping = keepingKeys.has(k) - const busy = isDeleting || isKeeping || isRemoving - - // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat) - const modelImageSrc = firstNonEmptyString( - flags?.image, - flags?.imageUrl, - (j as any)?.meta?.modelImage, - (j as any)?.meta?.modelImageUrl - ) - - // meta robust lesen (Objekt oder JSON-String) - const meta = parseJobMeta((j as any)?.meta) - - // ------------------------------------------------------------ - // ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position) - // Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds } - // ------------------------------------------------------------ - const sprite = readPreviewSpriteInfo((j as any)?.meta, { - fallbackPath: flags?.previewScrubberPath, - fallbackCount: flags?.previewScrubberCount, - }) - - const spriteUrl = sprite.url - const hasScrubberUi = sprite.hasScrubberUi - const hasSpriteScrubber = sprite.hasSpriteScrubber - const scrubberCount = sprite.count - const scrubberStepSeconds = sprite.stepSeconds - const hasScrubber = hasScrubberUi - - const activeScrubIndex = scrubIndexByKey[k] - - const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) - - // Sprite-Overlay-Frame (kein Request pro Move) - const spriteFrameStyle = buildSpriteFrameStyle({ - spriteUrl, - spriteCols: sprite.cols, - spriteRows: sprite.rows, - spriteCount: sprite.count, - activeIndex: activeScrubIndex, - }) - - const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc) - const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle) - - const hideTeaserUnderOverlay = - showModelPreviewInThumb || showScrubberSpriteInThumb - - const previewDurationSeconds = - durations[k] ?? - normalizeDurationSeconds(meta?.media?.durationSeconds) ?? - normalizeDurationSeconds(meta?.file?.durationSeconds) return ( -
-
{ - if (hasAnySelection) { - e.preventDefault() - e.stopPropagation() - onToggleSelected(j) - return - } - - onOpenPlayer(j) - }} - onMouseEnter={() => { - onHoverPreviewKeyChange?.(k) - }} - onMouseLeave={() => { - onHoverPreviewKeyChange?.(null) - setHoveredThumbKey((prev) => (prev === k ? null : prev)) - clearScrubIndex(k) - setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) - }} - > -
-
{ - e.preventDefault() - e.stopPropagation() - }} - onMouseDown={(e) => e.stopPropagation()} - > - onToggleSelected(j)} - /> -
- - {/* Thumb */} -
{ - setHoveredThumbKey(k) - }} - onMouseLeave={() => { - setHoveredThumbKey((prev) => (prev === k ? null : prev)) - clearScrubIndex(k) - setHoveredModelPreviewKey((prev) => (prev === k ? null : prev)) - }} - > - {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */} -
-
- stripHotPrefix(baseName(p))} - durationSeconds={previewDurationSeconds} - onDuration={handleDuration} - variant="fill" - showPopover={false} - blur={blurPreviews} - animated={shouldAnimateTeaser({ - state: teaserState, - itemKey: k, - disabled: hideTeaserUnderOverlay, - })} - animatedMode="teaser" - animatedTrigger="always" - muted={previewMuted} - popoverMuted={previewMuted} - scrubProgressRatio={scrubProgressRatio} - preferScrubProgress={typeof activeScrubIndex === 'number'} - forceActive={isPreviewActive} - teaserPreloadEnabled - /> -
- - {/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */} - {hasSpriteScrubber && spriteUrl ? ( - - ) : null} - - {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */} - {showScrubberSpriteInThumb && spriteFrameStyle ? ( - -
- - {/* Footer / Meta */} -
{ - e.preventDefault() - e.stopPropagation() - onOpenPlayer(j) - }} - > - {/* Model oben + Badge direkt dahinter */} -
-
-
- {model} -
- - {showPostworkBadge && renderPostworkBadge ? ( -
- {renderPostworkBadge(postworkBadge)} -
- ) : null} -
- -
- {isHot ? ( - - HOT - - ) : null} - - - {stripHotPrefix(file) || '—'} - -
-
- - {/* Actions (wie CardView: im Footer statt im Video) */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > -
-
- -
-
-
- - {/* Tags */} -
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> - -
-
-
-
-
+ 0} + isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k} + forcePreviewMuted={forcePreviewMuted} + blurPreviews={blurPreviews} + postworkByFile={postworkByFile} + getPostworkSummaryForOutput={getPostworkSummaryForOutput} + renderPostworkBadge={renderPostworkBadge} + durations={durations} + teaserState={teaserState} + keyFor={keyFor} + baseName={baseName} + modelNameFromOutput={modelNameFromOutput} + runtimeOf={runtimeOf} + sizeBytesOf={sizeBytesOf} + formatBytes={formatBytes} + lower={lower} + modelsByKey={modelsByKey} + activeTagSet={activeTagSet} + deletingKeys={deletingKeys} + keepingKeys={keepingKeys} + removingKeys={removingKeys} + registerTeaserHost={registerTeaserHostIfNeeded} + onToggleSelected={onToggleSelected} + onHoverPreviewKeyChange={onHoverPreviewKeyChange} + onToggleTagFilter={onToggleTagFilter} + onOpenPlayer={onOpenPlayer} + handleDuration={handleDuration} + handleScrubberClickIndex={handleScrubberClickIndex} + deleteVideo={deleteVideo} + keepVideo={keepVideo} + onToggleHot={onToggleHot} + onToggleFavorite={onToggleFavorite} + onToggleLike={onToggleLike} + onToggleWatch={onToggleWatch} + onSplit={onSplit} + onAddToDownloads={onAddToDownloads} + jobForDetails={jobForDetails} + contentPaddingClass={contentPaddingClass} + footerMinHeightClass={footerMinHeightClass} + titleClass={titleClass} + sublineClass={sublineClass} + /> ) })}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 2d577d3..03225e2 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -5,6 +5,7 @@ import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' import { XMarkIcon } from '@heroicons/react/24/outline' +import Button from './Button' type ModalLayout = 'single' | 'split' type ModalScroll = 'body' | 'right' | 'none' @@ -12,7 +13,7 @@ type ModalScroll = 'body' | 'right' | 'none' type ModalProps = { open: boolean onClose: () => void - title?: string + title?: ReactNode titleRight?: ReactNode children?: ReactNode footer?: ReactNode @@ -201,20 +202,17 @@ export default function Modal({
{titleRight ?
{titleRight}
: null} - +
@@ -272,7 +270,7 @@ export default function Modal({ {mobileCollapsedImageSrc ? ( {mobileCollapsedImageAlt {titleRight ?
{titleRight}
: null} - +
diff --git a/frontend/src/components/ui/ModelDetails.tsx b/frontend/src/components/ui/ModelDetails.tsx index 0786872..5c1d085 100644 --- a/frontend/src/components/ui/ModelDetails.tsx +++ b/frontend/src/components/ui/ModelDetails.tsx @@ -7,15 +7,15 @@ import * as React from 'react' import type { RecordJob } from '../../types' import Modal from './Modal' import Button from './Button' -import TagBadge from './TagBadge' import RecordJobActions from './RecordJobActions' import Tabs, { type TabItem } from './Tabs' import { ArrowTopRightOnSquareIcon, - CalendarDaysIcon, ArrowPathIcon, HeartIcon as HeartOutlineIcon, StarIcon as StarOutlineIcon, + VideoCameraIcon, + CakeIcon, IdentificationIcon, LanguageIcon, LinkIcon, @@ -23,7 +23,6 @@ import { PhotoIcon, SparklesIcon, UsersIcon, - ClockIcon, EyeIcon as EyeOutlineIcon, } from '@heroicons/react/24/outline' import { @@ -43,6 +42,7 @@ import { readPreviewSpriteInfo, scrubProgressRatioFromIndex, } from './previewSprite' +import ModelGenderIcon from './ModelGenderIcon' function cn(...parts: Array) { return parts.filter(Boolean).join(' ') @@ -166,6 +166,127 @@ function shortDate(v: string | Date | null | undefined) { return d.toLocaleDateString('de-DE', { year: '2-digit', month: '2-digit', day: '2-digit' }) } +function fmtBirthDate(v: string | Date | null | undefined) { + if (!v) return '' + const raw = String(v).trim() + if (!raw) return '' + + const d = typeof v === 'string' ? new Date(v) : v + if (!Number.isNaN(d.getTime())) { + return d.toLocaleDateString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + } + + return raw +} + +function countryCodeFromInput(input?: string | null) { + const raw = String(input ?? '').trim() + if (!raw) return '' + + const aliases: Record = { + usa: 'US', + us: 'US', + 'united states': 'US', + 'united states of america': 'US', + uk: 'GB', + gb: 'GB', + 'united kingdom': 'GB', + 'great britain': 'GB', + england: 'GB', + germany: 'DE', + deutschland: 'DE', + austria: 'AT', + österreich: 'AT', + switzerland: 'CH', + schweiz: 'CH', + netherlands: 'NL', + holland: 'NL', + france: 'FR', + italy: 'IT', + spain: 'ES', + poland: 'PL', + romania: 'RO', + hungary: 'HU', + czechia: 'CZ', + 'czech republic': 'CZ', + slovakia: 'SK', + ukraine: 'UA', + russia: 'RU', + ru: 'RU', + turkey: 'TR', + brazil: 'BR', + colombia: 'CO', + mexico: 'MX', + canada: 'CA', + japan: 'JP', + china: 'CN', + 'south korea': 'KR', + 'north korea': 'KP', + } + + let code = '' + + if (/^[a-z]{2}$/i.test(raw)) { + code = raw.toUpperCase() + } else { + code = aliases[raw.toLowerCase()] ?? '' + } + + return /^[A-Z]{2}$/.test(code) ? code : '' +} + +function normalizeCountryName(input?: string | null) { + const raw = String(input ?? '').trim() + if (!raw) return '' + + const aliases: Record = { + usa: 'United States', + us: 'United States', + 'united states': 'United States', + 'united states of america': 'United States', + uk: 'United Kingdom', + gb: 'United Kingdom', + 'united kingdom': 'United Kingdom', + 'great britain': 'United Kingdom', + england: 'United Kingdom', + germany: 'Germany', + deutschland: 'Germany', + austria: 'Austria', + österreich: 'Austria', + switzerland: 'Switzerland', + schweiz: 'Switzerland', + netherlands: 'Netherlands', + holland: 'Netherlands', + france: 'France', + italy: 'Italy', + spain: 'Spain', + poland: 'Poland', + romania: 'Romania', + hungary: 'Hungary', + czechia: 'Czechia', + 'czech republic': 'Czechia', + slovakia: 'Slovakia', + ukraine: 'Ukraine', + russia: 'Russia', + ru: 'Russia', + turkey: 'Turkey', + brazil: 'Brazil', + colombia: 'Colombia', + mexico: 'Mexico', + canada: 'Canada', + japan: 'Japan', + china: 'China', + 'south korea': 'South Korea', + 'north korea': 'North Korea', + } + + return aliases[raw.toLowerCase()] ?? raw +} + function stripHotPrefix(name: string) { return name.startsWith('HOT ') ? name.slice(4) : name } @@ -506,8 +627,7 @@ export default function ModelDetails({ type TabKey = 'info' | 'downloads' | 'running' const [tab, setTab] = React.useState('info') - const bioReqRef = React.useRef(null) - + const bioReqRef = React.useRef(null) // ===== Gallery UI State (wie FinishedDownloadsGalleryView) ===== const [durations, setDurations] = React.useState>({}) @@ -526,6 +646,36 @@ export default function ModelDetails({ const removingKeys = React.useMemo(() => new Set(), []) const deletedKeys = React.useMemo(() => new Set(), []) + React.useEffect(() => { + if (!open) { + bioReqRef.current?.abort() + bioReqRef.current = null + return + } + + // nur beim Öffnen / Modelwechsel zurücksetzen + setRoom(null) + setRoomMeta(null) + + setBio(null) + setBioMeta(null) + setBioLoading(false) + + setDone([]) + setDoneTotalCount(0) + setDoneLoading(false) + + if (!Array.isArray(runningJobs)) { + setRunning([]) + setRunningLoading(false) + } + + setStopPending(false) + + bioReqRef.current?.abort() + bioReqRef.current = null + }, [open, key]) + // ✅ 1) Beim Öffnen sofort aus Cache rendern React.useEffect(() => { if (!open || !key) return @@ -784,31 +934,194 @@ export default function ModelDetails({ }, [runningList, key]) const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model' + + const titleUsername = React.useMemo(() => { + const raw = String( + effectiveRoom?.username || + model?.modelKey || + key || + '—' + ).trim() + + if (!raw) return '—' + return raw.startsWith('@') ? raw : `@${raw}` + }, [effectiveRoom?.username, model?.modelKey, key]) + const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || '' const heroImgFull = effectiveRoom?.image_url || heroImg const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || '' + const profileUsername = React.useMemo(() => { + const raw = String( + effectiveRoom?.username || + model?.modelKey || + key || + '' + ).trim() + + return raw.replace(/^@/, '') + }, [effectiveRoom?.username, model?.modelKey, key]) + + const profileHref = React.useMemo(() => { + if (roomUrl) return roomUrl + if (!profileUsername) return '' + return absCbUrl(`/${profileUsername}/`) + }, [roomUrl, profileUsername]) + const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase() - const showPill = showLabel - ? showLabel === 'public' + + const showPill = + showLabel === 'public' ? 'Public' : showLabel === 'private' ? 'Private' - : showLabel - : '' + : showLabel === 'hidden' + ? 'Hidden' + : showLabel === 'away' + ? 'Away' + : showLabel || '' const bioLocation = (bio?.location || '').trim() const bioFollowers = bio?.follower_count const bioAge = bio?.display_age - const bioStatus = (bio?.room_status || '').trim() const bioLast = bio?.last_broadcast ? fmtDateTime(bio.last_broadcast) : '—' const about = stripHtmlToText(bio?.about_me) const wish = stripHtmlToText(bio?.wish_list) - const storedPresenceLabel = - model?.lastSeenOnline == null ? '' : model.lastSeenOnline ? 'online' : 'offline' + const effectiveLocationParts = React.useMemo(() => { + const rawLocation = String(effectiveRoom?.location ?? bioLocation ?? '').trim() + const rawCountry = normalizeCountryName(effectiveRoom?.country) - const effectivePresenceLabel = (bioStatus || showLabel || storedPresenceLabel || '').trim() + if (!rawLocation && !rawCountry) { + return { + locationText: '', + countryText: '', + } + } + + const locationParts = rawLocation + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + + const lastPartRaw = locationParts[locationParts.length - 1] ?? '' + const lastPartCountry = normalizeCountryName(lastPartRaw) + + // Fall 1: country separat vorhanden + if (rawCountry) { + if (locationParts.length >= 2 && lastPartCountry.toLowerCase() === rawCountry.toLowerCase()) { + return { + locationText: locationParts.slice(0, -1).join(', ').trim(), + countryText: rawCountry, + } + } + + if (rawLocation.toLowerCase() === rawCountry.toLowerCase()) { + return { + locationText: rawLocation, + countryText: '', + } + } + + return { + locationText: rawLocation, + countryText: rawCountry, + } + } + + // Fall 2: country NICHT separat vorhanden -> aus letztem location-Teil ableiten + if (locationParts.length >= 2 && lastPartCountry && lastPartCountry !== lastPartRaw ? true : [ + 'United States', + 'United Kingdom', + 'Germany', + 'Austria', + 'Switzerland', + 'Netherlands', + 'France', + 'Italy', + 'Spain', + 'Poland', + 'Romania', + 'Hungary', + 'Czechia', + 'Slovakia', + 'Ukraine', + 'Russia', + 'Turkey', + 'Brazil', + 'Colombia', + 'Mexico', + 'Canada', + 'Japan', + 'China', + 'South Korea', + 'North Korea', + ].includes(lastPartCountry)) { + return { + locationText: locationParts.slice(0, -1).join(', ').trim(), + countryText: lastPartCountry, + } + } + + return { + locationText: rawLocation, + countryText: '', + } + }, [effectiveRoom?.location, bioLocation, effectiveRoom?.country]) + + const effectiveLocationText = effectiveLocationParts.locationText + const effectiveCountryText = effectiveLocationParts.countryText + + const effectiveCountryCode = React.useMemo(() => { + return countryCodeFromInput(effectiveCountryText) + }, [effectiveCountryText]) + + const effectiveLocationSubText = React.useMemo(() => { + return effectiveCountryText || '' + }, [effectiveCountryText]) + + const effectiveLocationTitle = React.useMemo(() => { + if (effectiveLocationText && effectiveLocationSubText) { + return `${effectiveLocationText} · ${effectiveLocationSubText}` + } + if (effectiveLocationText) return effectiveLocationText + if (effectiveLocationSubText) return effectiveLocationSubText + return '—' + }, [effectiveLocationText, effectiveLocationSubText]) + + const effectiveAge = + typeof bioAge === 'number' && Number.isFinite(bioAge) + ? bioAge + : typeof effectiveRoom?.age === 'number' && Number.isFinite(effectiveRoom.age) + ? effectiveRoom.age + : null + + const effectiveBirthday = React.useMemo(() => { + const bioBirthday = String(bio?.display_birthday ?? '').trim() + if (bioBirthday) return bioBirthday + + const roomBirthday = String(effectiveRoom?.birthday ?? '').trim() + if (roomBirthday) return roomBirthday + + return '' + }, [bio?.display_birthday, effectiveRoom?.birthday]) + + const effectiveAgeText = React.useMemo(() => { + return effectiveAge != null ? String(effectiveAge) : '' + }, [effectiveAge]) + + const effectiveBirthdayText = React.useMemo(() => { + return fmtBirthDate(effectiveBirthday) + }, [effectiveBirthday]) + + const effectiveAgeTitle = React.useMemo(() => { + if (effectiveAgeText && effectiveBirthdayText) { + return `${effectiveAgeText} · ${effectiveBirthdayText}` + } + if (effectiveAgeText) return effectiveAgeText + if (effectiveBirthdayText) return effectiveBirthdayText + return '—' + }, [effectiveAgeText, effectiveBirthdayText]) const socials = Array.isArray(bio?.social_medias) ? bio!.social_medias! : [] const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : [] @@ -1034,18 +1347,90 @@ export default function ModelDetails({ { id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading }, ] + const mobileFlagBtnBase = + 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm transition hover:scale-[1.03] active:scale-[0.98]' + + const mobileFlagBtnInactive = + 'bg-white/95 text-gray-700 ring-gray-300 shadow-md hover:bg-white dark:bg-black/35 dark:text-white dark:ring-white/20 dark:hover:bg-black/45' + + const hasLocationText = Boolean(effectiveLocationText) + const hasLocationSubText = Boolean(effectiveLocationSubText) + const showLocationTwoLines = hasLocationText && hasLocationSubText + + const effectiveLocationDisplayText = + effectiveLocationText || + effectiveLocationSubText || + effectiveLocationTitle || + '—' + return ( + {profileHref ? ( + e.stopPropagation()} + > + {titleUsername} + + + + + + ) : ( +
+ {titleUsername} + + +
+ )} + +
+ {showPill ? ( + + {showPill} + + ) : null} + + {effectiveRoom?.is_new ? ( + + NEW + + ) : null} +
+
+ } width="max-w-6xl" layout="split" scroll="right" className="z-[120]" leftWidthClass="lg:w-[320px]" - mobileCollapsedImageSrc={heroImg || undefined} - mobileCollapsedImageAlt={titleName} rightBodyClassName="pt-0 sm:pt-2" titleRight={ tab === 'info' ? ( @@ -1097,25 +1482,15 @@ export default function ModelDetails({ {/* Pills */}
- {effectivePresenceLabel ? ( + {showPill ? ( - {effectivePresenceLabel} - - ) : null} - - {effectiveRoom?.is_hd ? ( - - HD + {showPill} ) : null} @@ -1132,12 +1507,55 @@ export default function ModelDetails({ {/* Title */}
-
- {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} -
-
- {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''} +
+
+ {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} +
+ + {profileHref ? ( + e.stopPropagation()} + > + + {effectiveRoom?.username + ? `@${effectiveRoom.username}` + : model?.modelKey + ? `@${model.modelKey}` + : ''} + + + + + + + ) : ( +
+ + {effectiveRoom?.username + ? `@${effectiveRoom.username}` + : model?.modelKey + ? `@${model.modelKey}` + : ''} + + + +
+ )}
{/* Local flags icons */} @@ -1151,11 +1569,10 @@ export default function ModelDetails({ handleToggleWatchModel() }} className={cn( - 'inline-flex items-center justify-center rounded-full p-1.5 ring-1 backdrop-blur shadow-sm', - 'cursor-pointer transition hover:scale-[1.03] active:scale-[0.98]', + mobileFlagBtnBase, model?.watching ? 'bg-sky-100/95 text-sky-700 ring-sky-200 hover:bg-sky-200/95 dark:bg-sky-500/25 dark:text-sky-200 dark:ring-sky-200/30 dark:hover:bg-sky-500/30' - : 'bg-white/90 text-gray-700 ring-gray-200 hover:bg-white dark:bg-black/20 dark:text-white dark:ring-white/15 dark:hover:bg-black/30' + : mobileFlagBtnInactive )} title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'} aria-pressed={Boolean(model?.watching)} @@ -1166,7 +1583,7 @@ export default function ModelDetails({ className={cn( 'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none', model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0', - 'text-gray-600 dark:text-white/70' + 'text-gray-600 dark:text-white/80' )} /> } label="Viewer" value={fmtInt(effectiveRoom?.num_users)} /> } + icon={} label="Follower" value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} /> @@ -1272,8 +1687,35 @@ export default function ModelDetails({ Location -
- {effectiveRoom?.location || bioLocation || '—'} +
+
+
+
+ + {effectiveLocationText || '—'} + + + {effectiveLocationSubText ? ( + + {effectiveLocationSubText} + + ) : null} +
+
+ + {effectiveCountryCode ? ( + + ) : null} +
@@ -1289,7 +1731,7 @@ export default function ModelDetails({
- + Online
@@ -1299,17 +1741,30 @@ export default function ModelDetails({
- + Alter
-
- {bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'} +
+ {effectiveBirthdayText ? ( +
+ + {effectiveAgeText || '—'} + + + {effectiveBirthdayText} + +
+ ) : ( + + {effectiveAgeText || '—'} + + )}
- + Last broadcast
@@ -1353,14 +1808,22 @@ export default function ModelDetails({ {/* Tags (desktop) */}
Tags
+ {allTags.length ? ( -
- {allTags.map((t) => ( - - ))} +
+ {}} + className="w-full" + />
) : ( -
Keine Tags vorhanden.
+
+ Keine Tags vorhanden. +
)}
@@ -1380,7 +1843,7 @@ export default function ModelDetails({ {/* HERO Header (mobile) */}
{/* Hero Background */} -
+
{heroImg ? ( +
+
+ {/* Watched */} + - {/* Favorite */} - + {/* Favorite */} + - {/* Like */} - + {/* Like */} + +
- {/* Pills bottom-left */} -
- {showPill ? ( - - {showPill} - - ) : null} + {/* Bottom overlay content */} +
+
+ {/* Quick stats im Hero */} +
+ + - {effectivePresenceLabel ? ( - - {effectivePresenceLabel} - - ) : null} + + {showLocationTwoLines ? ( + + + {effectiveLocationText} + + + {effectiveLocationSubText} + + + ) : ( + + {effectiveLocationDisplayText} + + )} + - {effectiveRoom?.is_hd ? HD : null} - {effectiveRoom?.is_new ? NEW : null} -
+ {effectiveCountryCode ? ( + + ) : null} + - {/* Room link bottom-right */} - {roomUrl ? ( - - ) : null} -
+ + - {/* Quick stats row (unter dem Hero) */} -
-
- - - {fmtInt(effectiveRoom?.num_users)} - - - - {fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} - - - - {fmtHms(effectiveRoom?.seconds_online)} - - - - - {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'} - - -
+ {effectiveBirthdayText ? ( + + + {effectiveAgeText || '—'} + + + {effectiveBirthdayText} + + + ) : ( + + {effectiveAgeText || '—'} + + )} + - {/* Meta warnings (mobile) */} - {effectiveRoomMeta?.enabled === false ? ( -
- Chaturbate-Online ist aktuell deaktiviert. -
- ) : effectiveRoomMeta?.lastError ? ( -
- Online-Info: {effectiveRoomMeta.lastError} -
- ) : null} + + + + {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'} + + - {bioMeta?.enabled === false ? ( -
- BioContext ist aktuell deaktiviert. -
- ) : bioMeta?.lastError ? ( -
-
-
BioContext: {errorSummary(bioMeta.lastError)}
+ + + + {fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} + +
-
- - Details - -
-                        {errorDetails(bioMeta.lastError)}
-                      
-
- ) : null} +
{/* Tags (mobile, compact row) */} {allTags.length ? ( -
-
-
+
+
+
Tags ({allTags.length})
-
-
- {allTags.slice(0, 20).map((t) => ( - - ))} - {allTags.length > 20 ? ( - - +{allTags.length - 20} - - ) : null} -
+
+ {}} + className="w-full" + expandMode="dropdown" + dropdownMaxHeightClassName="max-h-[50vh]" + />
@@ -1647,27 +2196,6 @@ export default function ModelDetails({ )}
- - {/* Actions */} -
- {roomUrl ? ( - - - Room öffnen - - ) : null} -
{/* Row 2: Tabs */} diff --git a/frontend/src/components/ui/ModelGenderIcon.tsx b/frontend/src/components/ui/ModelGenderIcon.tsx new file mode 100644 index 0000000..98c989a --- /dev/null +++ b/frontend/src/components/ui/ModelGenderIcon.tsx @@ -0,0 +1,171 @@ +// frontend\src\components\ui\ModelGenderIcon.tsx + +'use client' + +import * as React from 'react' + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +type GenderKind = 'female' | 'male' | 'trans' | 'couple' | null + +function normalizeGender( + gender?: string | null, + sex?: string | null, + subgender?: string | null +): GenderKind { + const raw = `${gender || ''} ${sex || ''} ${subgender || ''}`.trim().toLowerCase() + + if (!raw) return null + + if (raw.includes('couple') || raw.includes('pair')) { + return 'couple' + } + + if ( + raw.includes('trans') || + raw.includes('transgender') || + raw.includes('ts') || + raw.includes('shemale') + ) { + return 'trans' + } + + if ( + raw.includes('female') || + raw.includes('girl') || + raw.includes('woman') + ) { + return 'female' + } + + if ( + raw.includes('male') || + raw.includes('boy') || + raw.includes('man') + ) { + return 'male' + } + + return null +} + +type IconProps = { + className?: string +} + +function BaseIcon({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( + + ) +} + +function FemaleIcon({ className }: IconProps) { + return ( + + + + + + ) +} + +function MaleIcon({ className }: IconProps) { + return ( + + + + + + ) +} + +function TransIcon({ className }: IconProps) { + return ( + + + + + + + + + + ) +} + +function CoupleIcon({ className }: IconProps) { + return ( + + + + + + + ) +} + +type Props = { + gender?: string | null + sex?: string | null + subgender?: string | null + variant?: 'hero' | 'default' + className?: string + iconClassName?: string +} + +export default function ModelGenderIcon({ + gender, + sex, + subgender, + variant = 'default', + className, + iconClassName, +}: Props) { + const kind = normalizeGender(gender, sex, subgender) + + if (!kind) return null + + const map: Record, { label: string; Icon: React.ComponentType }> = { + female: { label: 'Female', Icon: FemaleIcon }, + male: { label: 'Male', Icon: MaleIcon }, + trans: { label: 'Trans', Icon: TransIcon }, + couple: { label: 'Couple', Icon: CoupleIcon }, + } + + const { label, Icon } = map[kind] + + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/ModelPreview.tsx b/frontend/src/components/ui/ModelPreview.tsx index e6091a1..dbc55ce 100644 --- a/frontend/src/components/ui/ModelPreview.tsx +++ b/frontend/src/components/ui/ModelPreview.tsx @@ -290,7 +290,7 @@ export default function ModelPreview({ setPopupVolume(nextVolume) setPopupMuted(nextMuted) }} - className="w-full h-full object-contain object-bottom relative z-0" + className="w-full h-full object-contain object-center relative z-0" /> {showLiveBadge ? ( @@ -303,7 +303,7 @@ export default function ModelPreview({
@@ -2120,7 +2051,7 @@ export default function Player({ {resolutionLabel !== '—' ? ( {resolutionLabel} ) : null} - {!isRunning && runtimeLabel !== '—' ? ( + {!isLive && runtimeLabel !== '—' ? ( {runtimeLabel} ) : null}
diff --git a/frontend/src/components/ui/Slider.tsx b/frontend/src/components/ui/Slider.tsx new file mode 100644 index 0000000..ba1e5a0 --- /dev/null +++ b/frontend/src/components/ui/Slider.tsx @@ -0,0 +1,460 @@ +// frontend\src\components\ui\Slider.tsx + +'use client' + +import * as React from 'react' + +type Props = { + id?: string + + value: number + onChange: (value: number) => void + onChangeEnd?: (value: number) => void + + min?: number + max?: number + step?: number + disabled?: boolean + + label?: string + hint?: string + + valueLabel?: string + valueFormatter?: (value: number) => string + showValueLabel?: boolean + + showMinMaxLabels?: boolean + minLabel?: string + maxLabel?: string + + showReset?: boolean + resetValue?: number + resetLabel?: string + + compact?: boolean + softStep?: boolean + + className?: string + inputClassName?: string +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)) +} + +function countDecimals(value: number) { + if (!Number.isFinite(value)) return 0 + const s = String(value) + const i = s.indexOf('.') + return i >= 0 ? s.length - i - 1 : 0 +} + +function roundToStep(value: number, step: number, min: number) { + if (!Number.isFinite(step) || step <= 0) return value + const precision = Math.max(countDecimals(step), countDecimals(min)) + const rounded = Math.round((value - min) / step) * step + min + return Number(rounded.toFixed(precision)) +} + +export default function Slider({ + id, + value, + onChange, + onChangeEnd, + min = 0, + max = 100, + step = 1, + disabled = false, + label, + hint, + valueLabel, + valueFormatter, + showValueLabel = false, + showMinMaxLabels = false, + minLabel, + maxLabel, + showReset = false, + resetValue, + resetLabel = 'Reset', + compact = false, + softStep = true, + className = '', + inputClassName = '', +}: Props) { + const reactId = React.useId() + const inputId = id ?? `slider-${reactId}` + + const safeValue = Number.isFinite(value) ? value : min + const clamped = clamp(safeValue, min, max) + const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0 + + const emitCommit = () => { + onChangeEnd?.(clamped) + } + + const resolvedValueLabel = + valueLabel ?? + (valueFormatter ? valueFormatter(clamped) : String(clamped)) + + const resolvedMinLabel = + minLabel ?? (valueFormatter ? valueFormatter(min) : String(min)) + + const resolvedMaxLabel = + maxLabel ?? (valueFormatter ? valueFormatter(max) : String(max)) + + const canReset = + showReset && + typeof resetValue === 'number' && + Number.isFinite(resetValue) + + const trackH = compact ? 4 : 8 + const thumbSize = compact ? 14 : 16 + const inputH = compact ? 14 : 16 + const thumbOffset = -((thumbSize - trackH) / 2) + + const handleChange = (next: number) => { + const raw = clamp(next, min, max) + const finalValue = softStep ? raw : roundToStep(raw, step, min) + onChange(finalValue) + } + + return ( +
+ {compact ? ( + <> + {label ? ( + + ) : null} + +
+
+
+
+
+ + handleChange(Number(e.target.value))} + onPointerUp={emitCommit} + onMouseUp={emitCommit} + onTouchEnd={emitCommit} + onKeyUp={(e) => { + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' || + e.key === 'PageUp' || + e.key === 'PageDown' + ) { + onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value)) + } + }} + /> + + +
+ + {showValueLabel ? ( +
+ {resolvedValueLabel} +
+ ) : null} + + {canReset ? ( + + ) : null} + + ) : ( + <> + {(label || hint || showValueLabel || canReset) ? ( +
+ {(label || hint) ? ( +
+ {label ? ( + + ) : null} + + {hint ? ( +
+ {hint} +
+ ) : null} +
+ ) : null} + + {(showValueLabel || canReset) ? ( +
+ {showValueLabel ? ( +
+ {resolvedValueLabel} +
+ ) : null} + + {canReset ? ( + + ) : null} +
+ ) : null} +
+ ) : null} + +
+
+
+
+
+ + handleChange(Number(e.target.value))} + onPointerUp={emitCommit} + onMouseUp={emitCommit} + onTouchEnd={emitCommit} + onKeyUp={(e) => { + if ( + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' || + e.key === 'ArrowUp' || + e.key === 'ArrowDown' || + e.key === 'Home' || + e.key === 'End' || + e.key === 'PageUp' || + e.key === 'PageDown' + ) { + onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value)) + } + }} + /> + + +
+ + {showMinMaxLabels ? ( +
+ {resolvedMinLabel} + {resolvedMaxLabel} +
+ ) : null} + + )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/TagOverflowRow.tsx b/frontend/src/components/ui/TagOverflowRow.tsx index 8edcd80..6f1f6b7 100644 --- a/frontend/src/components/ui/TagOverflowRow.tsx +++ b/frontend/src/components/ui/TagOverflowRow.tsx @@ -15,6 +15,8 @@ type Props = { maxWidthClassName?: string gapPx?: number className?: string + expandMode?: 'cover' | 'dropdown' + dropdownMaxHeightClassName?: string } export default function TagOverflowRow({ @@ -27,6 +29,8 @@ export default function TagOverflowRow({ maxWidthClassName, gapPx = 6, className, + expandMode = 'cover', + dropdownMaxHeightClassName = 'max-h-[40vh]', }: Props) { const [open, setOpen] = React.useState(false) @@ -141,6 +145,25 @@ export default function TagOverflowRow({ }) }, []) + React.useLayoutEffect(() => { + if (!open || expandMode !== 'cover') return + + recalcOverlay() + + const host = hostRef.current + const parent = host?.offsetParent as HTMLElement | null + if (!parent) return + + const ro = new ResizeObserver(() => { + recalcOverlay() + }) + + ro.observe(parent) + if (host) ro.observe(host) + + return () => ro.disconnect() + }, [open, expandMode, recalcOverlay]) + React.useLayoutEffect(() => { if (!open) return @@ -181,11 +204,15 @@ export default function TagOverflowRow({ // ✅ Hinweis: auch wenn cap < tags.length, zählt "rest" nur gegen totalTags (capped) // willst du immer gegen alle tags: rest = tags.length - visibleTags.length const restAll = sortedTags.length - visibleTags.length + + const keepInlineRowVisible = expandMode === 'dropdown' + const showInlineRow = !open || keepInlineRowVisible return (
+ {/* collapsed row (in footer) */} - {!open ? ( + {showInlineRow ? (
{ e.preventDefault() e.stopPropagation() - setOpen(true) + setOpen((prev) => !prev) }} - title="Alle Tags anzeigen" + title={open ? 'Tag-Liste schließen' : 'Alle Tags anzeigen'} aria-haspopup="dialog" - aria-expanded={false} + aria-expanded={open} > +{restAll} @@ -240,56 +265,107 @@ export default function TagOverflowRow({ {/* overlay that covers the whole footer host */} {open ? ( -
- + -
-
-
- Alle Tags +
+
+
+ Alle Tags +
+
+ +
+ {sortedTags.map((t) => ( + + ))}
+
+ ) : ( +
+ -
- {sortedTags.map((t) => ( - - ))} +
+
+
+ Alle Tags +
+
+ +
+ {sortedTags.map((t) => ( + + ))} +
-
+ ) ) : null} {/* hidden measure area */} diff --git a/frontend/src/index.css b/frontend/src/index.css index faf8bad..9f6b0bd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import 'flag-icons/css/flag-icons.min.css'; :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;