// frontend\src\components\ui\ModelsTab.tsx 'use client' import { useEffect, useRef, useState, useMemo, useCallback, useDeferredValue, type ReactNode, type MouseEvent as ReactMouseEvent, } from 'react' import clsx from 'clsx' import Card from './Card' import Button from './Button' import ButtonGroup from './ButtonGroup' import Table, { type Column } from './Table' import Modal from './Modal' import Pagination from './Pagination' import TagBadge from './TagBadge' import RecordJobActions from './RecordJobActions' import type { RecordJob } from '../../types' import TextInput from './TextInput' import ModelGenderIcon from './ModelGenderIcon' import CountryFlag from './CountryFlag' import { ArrowTopRightOnSquareIcon, EyeIcon as EyeIconOutline, EyeSlashIcon, FilmIcon, HeartIcon as HeartIconOutline, Squares2X2Icon, StarIcon as StarIconOutline, TableCellsIcon, TagIcon, } from '@heroicons/react/24/outline' import { EyeIcon as EyeIconSolid, HeartIcon as HeartIconSolid, StarIcon as StarIconSolid, } from '@heroicons/react/24/solid' import TagFilterBar from './TagFilterBar' import { parseTagList, lowerTag } from './tagUtils' type ParsedModel = { input: string isUrl: boolean host?: string path?: string modelKey: string } type ImportKind = 'liked' | 'favorite' type ModelsViewMode = 'table' | 'gallery' type GallerySortMode = | 'created_desc' | 'created_asc' | 'model_asc' | 'model_desc' | 'videos_desc' | 'videos_asc' | 'tags_desc' | 'tags_asc' export type StoredModel = { id: string input: string isUrl: boolean host?: string path?: string modelKey: string tags?: string lastStream?: string // ✅ Profile image Felder vom Backend profileImageUrl?: string profileImageCached?: string profileImageUpdatedAt?: string gender?: string | null country?: string | null watching: boolean favorite: boolean hot: boolean keep: boolean liked?: boolean | null createdAt: string updatedAt: string } async function apiJSON(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } return res.json() as Promise } const badge = (on: boolean, label: string) => ( {label} ) /** Erlaubt nur http(s) URLs. Optional: ohne Scheme -> https:// */ function normalizeHttpUrl(raw: string): string | null { let v = (raw ?? '').trim() if (!v) return null if (!/^https?:\/\//i.test(v)) v = `https://${v}` try { const u = new URL(v) if (u.protocol !== 'http:' && u.protocol !== 'https:') return null return u.toString() } catch { return null } } function splitTags(raw?: string): string[] { return parseTagList(raw) } function canonicalHost(raw?: string): string { return String(raw ?? '') .trim() .toLowerCase() .replace(/^www\./, '') } function baseNameFromPath(p: string): string { const n = String(p || '').replaceAll('\\', '/') const parts = n.split('/') return parts[parts.length - 1] || '' } function stripHotPrefixLocal(file: string): string { const f = String(file || '') return f.toUpperCase().startsWith('HOT ') ? f.slice(4).trim() : f } // exakt wie in FinishedDownloads / backend: _MM_DD_YYYY__HH-MM-SS.ext function modelKeyFromOutput(output?: string): string { const fileRaw = baseNameFromPath(output || '') const file = stripHotPrefixLocal(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) if (m?.[1]) return m[1] const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } function modelHref(m: StoredModel): string | null { if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) { return String(m.input) } const host = canonicalHost(m.host) const key = String(m.modelKey ?? '').trim() if (!host || !key) return null if (host.includes('chaturbate.com') || host.includes('chaturbate')) { return `https://chaturbate.com/${encodeURIComponent(key)}/` } if (host.includes('myfreecams.com') || host.includes('myfreecams')) { return `https://www.myfreecams.com/#${encodeURIComponent(key)}` } return null } function modelProfileImageSrc(m: StoredModel): string | null { const cached = String(m.profileImageCached ?? '').trim() if (cached) return cached const remote = String(m.profileImageUrl ?? '').trim() if (/^https?:\/\//i.test(remote)) return remote return null } function IconToggle({ title, active, hiddenUntilHover, onClick, icon, }: { title: string active?: boolean hiddenUntilHover?: boolean onClick: (e: ReactMouseEvent) => void icon: ReactNode }) { return ( ) } function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } { if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' } if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() } if (typeof v === 'number') return { isNull: false, kind: 'number', value: v } if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 } if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) } return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() } } function gallerySortModeFromSort( sort: { key: string; direction: 'asc' | 'desc' } | null ): GallerySortMode { if (!sort) return 'created_desc' if (sort.key === 'createdAt') return sort.direction === 'asc' ? 'created_asc' : 'created_desc' if (sort.key === 'model') return sort.direction === 'asc' ? 'model_asc' : 'model_desc' if (sort.key === 'videos') return sort.direction === 'asc' ? 'videos_asc' : 'videos_desc' if (sort.key === 'tags') return sort.direction === 'asc' ? 'tags_asc' : 'tags_desc' return 'created_desc' } function sortFromGallerySortMode(mode: GallerySortMode): { key: string; direction: 'asc' | 'desc' } { switch (mode) { case 'created_asc': return { key: 'createdAt', direction: 'asc' } case 'created_desc': return { key: 'createdAt', direction: 'desc' } case 'model_asc': return { key: 'model', direction: 'asc' } case 'model_desc': return { key: 'model', direction: 'desc' } case 'videos_asc': return { key: 'videos', direction: 'asc' } case 'videos_desc': return { key: 'videos', direction: 'desc' } case 'tags_asc': return { key: 'tags', direction: 'asc' } case 'tags_desc': return { key: 'tags', direction: 'desc' } default: return { key: 'createdAt', direction: 'desc' } } } function GridIcon() { return } function TableIcon() { return } type ModelsTabProps = { onAddToDownloads?: (job: RecordJob) => void | Promise onModelsCountChange?: (count: number) => void } export default function ModelsTab({ onAddToDownloads, onModelsCountChange }: ModelsTabProps) { const [models, setModels] = useState([]) const modelsLoadedRef = useRef(false) const flagsInFlightRef = useRef>({}) const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) const [q, setQ] = useState('') const [page, setPage] = useState(1) const pageSize = 10 const [viewMode, setViewMode] = useState('gallery') const [tagFilter, setTagFilter] = useState([]) type WatchFilterMode = 'all' | 'watched' | 'unwatched' type StatusFilterKey = 'favorite' | 'liked' const [watchFilter, setWatchFilter] = useState('all') const [statusFilter, setStatusFilter] = useState([]) const activeStatusSet = useMemo( () => new Set(statusFilter), [statusFilter] ) const toggleStatusFilter = useCallback((key: StatusFilterKey) => { setStatusFilter((prev) => prev.includes(key) ? prev.filter((x) => x !== key) : [...prev, key] ) }, []) const clearStatusFilter = useCallback(() => { setWatchFilter('all') setStatusFilter([]) }, []) const [videoCounts, setVideoCounts] = useState>({}) const [videoCountsLoading, setVideoCountsLoading] = useState(false) const [sort, setSort] = useState<{ key: string; direction: 'asc' | 'desc' } | null>({ key: 'createdAt', direction: 'desc', }) const refreshVideoCounts = useCallback(async () => { setVideoCountsLoading(true) try { const res = await fetch('/api/record/done/model-counts?includeKeep=1', { cache: 'no-store' as any }) if (!res.ok) return const data = await res.json().catch(() => null) const rawCounts = data?.counts && typeof data.counts === 'object' ? data.counts : data const counts: Record = {} for (const [key, value] of Object.entries(rawCounts ?? {})) { const mk = String(key || '').trim().toLowerCase() if (!mk || mk === '—') continue const k = mk.trim().toLowerCase() if (!k) continue const n = typeof value === 'number' ? value : Number(value) counts[k] = Number.isFinite(n) && n > 0 ? n : 0 } setVideoCounts(counts) } finally { setVideoCountsLoading(false) } }, []) const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter]) const allKnownTags = useMemo(() => { const seen = new Set() const out: string[] = [] for (const m of models) { for (const tag of splitTags(m.tags)) { const k = lowerTag(tag) if (!k || seen.has(k)) continue seen.add(k) out.push(tag) } } out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) return out }, [models]) const toggleTagFilter = useCallback((tag: string) => { const k = tag.toLowerCase() setTagFilter((prev) => { const has = prev.some((t) => t.toLowerCase() === k) return has ? prev.filter((t) => t.toLowerCase() !== k) : [...prev, tag] }) }, []) const clearTagFilter = useCallback(() => setTagFilter([]), []) useEffect(() => { const onSet = (ev: Event) => { const e = ev as CustomEvent<{ tags?: string[] }> const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : [] setTagFilter(tags) } window.addEventListener('models:set-tag-filter', onSet as any) return () => window.removeEventListener('models:set-tag-filter', onSet as any) }, []) useEffect(() => { try { const raw = localStorage.getItem('models_pendingTags') if (!raw) return const tags = JSON.parse(raw) if (Array.isArray(tags)) setTagFilter(tags) localStorage.removeItem('models_pendingTags') } catch { // ignore } }, []) useEffect(() => { try { const saved = localStorage.getItem('models_viewMode') if (saved === 'table' || saved === 'gallery') setViewMode(saved) } catch { // ignore } }, []) useEffect(() => { try { localStorage.setItem('models_viewMode', viewMode) } catch { // ignore } }, [viewMode]) const [input, setInput] = useState('') const [parsed, setParsed] = useState(null) const [parseError, setParseError] = useState(null) const [adding, setAdding] = useState(false) const [importOpen, setImportOpen] = useState(false) const [importFile, setImportFile] = useState(null) const [importMsg, setImportMsg] = useState(null) const [importKind, setImportKind] = useState('favorite') const [importing, setImporting] = useState(false) const [importErr, setImportErr] = useState(null) async function doImport() { if (!importFile) return setImporting(true) setImportMsg(null) setErr(null) try { const fd = new FormData() fd.append('file', importFile) fd.append('kind', importKind) const res = await fetch('/api/models/import', { method: 'POST', body: fd }) if (!res.ok) throw new Error(await res.text()) const data = await res.json() setImportMsg(`✅ Import: ${data.inserted} neu, ${data.updated} aktualisiert, ${data.skipped} übersprungen`) setImportOpen(false) setImportFile(null) await refresh() window.dispatchEvent(new Event('models-changed')) } catch (e: any) { setImportErr(e?.message ?? String(e)) } finally { setImporting(false) } } function jobForDetails(m: StoredModel): RecordJob { const href = modelHref(m) ?? undefined const modelKey = modelKeyFromOutput(`${m.modelKey}_01_01_2000__00-00-00.mp4`) || m.modelKey return { output: `${modelKey}_01_01_2000__00-00-00.mp4`, sourceUrl: href, } as any } const openImport = () => { setImportErr(null) setImportMsg(null) setImportFile(null) setImportKind('favorite') setImportOpen(true) } const refresh = useCallback(async () => { setLoading(true) setErr(null) try { const res = await fetch('/api/models', { cache: 'no-store' as any }) if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)) const data = await res.json().catch(() => null) // ✅ akzeptiere beide Formen: Array ODER { items: [...] } const list: StoredModel[] = Array.isArray(data?.items) ? (data.items as StoredModel[]) : Array.isArray(data) ? (data as StoredModel[]) : [] setModels(list) modelsLoadedRef.current = true onModelsCountChange?.(list.length) void refreshVideoCounts() } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setLoading(false) } }, [onModelsCountChange, refreshVideoCounts]) useEffect(() => { void refresh() }, [refresh]) useEffect(() => { if (!modelsLoadedRef.current) return onModelsCountChange?.(models.length) }, [models.length, onModelsCountChange]) useEffect(() => { const onChanged = (ev: Event) => { const e = ev as CustomEvent const detail = e?.detail ?? {} if (detail?.model) { const updated = detail.model as StoredModel setModels((prev) => { const idx = prev.findIndex((m) => m.id === updated.id) if (idx === -1) return [updated, ...prev] const next = prev.slice() next[idx] = updated return next }) return } if (detail?.removed && detail?.id) { const rid = String(detail.id) setModels((prev) => prev.filter((m) => m.id !== rid)) return } void refresh() } window.addEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any) }, [refresh]) useEffect(() => { const onDbChanged = () => { setPage(1) modelsLoadedRef.current = false setModels([]) void refresh() } window.addEventListener('models-db-changed', onDbChanged as any) return () => window.removeEventListener('models-db-changed', onDbChanged as any) }, [refresh]) useEffect(() => { const raw = input.trim() if (!raw) { setParsed(null) setParseError(null) return } const normalized = normalizeHttpUrl(raw) if (!normalized) { setParsed(null) setParseError('Bitte nur gültige http(s) URLs einfügen (keine Modelnamen).') return } const t = window.setTimeout(async () => { try { const p = await apiJSON('/api/models/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: normalized }), }) if (!p?.isUrl) { setParsed(null) setParseError('Bitte nur URLs einfügen (keine Modelnamen).') return } setParsed(p) setParseError(null) } catch (e: any) { setParsed(null) setParseError(e?.message ?? String(e)) } }, 300) return () => window.clearTimeout(t) }, [input]) const modelsWithHay = useMemo(() => { return models.map((m) => ({ m, hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(), })) }, [models]) const deferredQ = useDeferredValue(q) async function patch(id: string, body: any) { setErr(null) if (flagsInFlightRef.current[id]) return flagsInFlightRef.current[id] = true const prevModel = models.find((m) => m.id === id) ?? null if (prevModel) { const optimistic: StoredModel = { ...prevModel, ...body } if (typeof body?.watched === 'boolean') optimistic.watching = body.watched if (body?.favorite === true) optimistic.liked = false if (body?.liked === true) optimistic.favorite = false setModels((prev) => prev.map((m) => (m.id === id ? optimistic : m))) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: optimistic } })) } try { const res = await fetch('/api/models/flags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, ...body }), }) if (res.status === 204) { setModels((prev) => prev.filter((m) => m.id !== id)) if (prevModel) { window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prevModel.id, modelKey: prevModel.modelKey }, }) ) } else { window.dispatchEvent(new CustomEvent('models-changed', { detail: { removed: true, id } })) } return } if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const updated = (await res.json()) as StoredModel setModels((prev) => { const idx = prev.findIndex((m) => m.id === updated.id) if (idx === -1) return prev const next = prev.slice() next[idx] = updated return next }) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { if (prevModel) { setModels((prev) => prev.map((m) => (m.id === id ? prevModel : m))) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: prevModel } })) } setErr(e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[id] } } const columns = useMemo[]>(() => { return [ { key: 'statusAll', header: 'Status', align: 'center', cell: (m) => { const liked = m.liked === true const fav = m.favorite === true const watch = m.watching === true const hideUntilHover = !watch && !fav && !liked return (
{ e.stopPropagation() if (fav) patch(m.id, { favorite: false }) else patch(m.id, { favorite: true, liked: false }) }} icon={ fav ? ( ) : ( ) } /> { e.stopPropagation() if (liked) patch(m.id, { liked: false }) else patch(m.id, { liked: true, favorite: false }) }} icon={ liked ? ( ) : ( ) } />
) }, }, { key: 'model', header: 'Model', sortable: true, sortValue: (m) => (m.modelKey || '').toLowerCase(), cell: (m) => { const href = modelHref(m) const imgSrc = modelProfileImageSrc(m) return (
{imgSrc ? ( {m.modelKey} { e.stopPropagation() if (href) window.open(href, '_blank', 'noreferrer') }} onError={(e) => { e.currentTarget.style.display = 'none' }} /> ) : (
{String(m.modelKey || '?').slice(0, 1).toUpperCase()}
)}
{m.modelKey}
{href ? ( { e.stopPropagation() window.open(href, '_blank', 'noreferrer') }} > ) : null}
{m.host ?? '—'}
{href ? (
{href}
) : null}
) }, }, { key: 'videos', header: 'Videos', sortable: true, sortValue: (m) => { const k = String(m.modelKey || '').trim().toLowerCase() const n = videoCounts[k] return typeof n === 'number' ? n : 0 }, align: 'right', cell: (m) => { const k = String(m.modelKey || '').trim().toLowerCase() const n = videoCounts[k] return ( {typeof n === 'number' ? n : videoCountsLoading ? '…' : 0} ) }, }, { key: 'tags', header: 'Tags', sortable: true, sortValue: (m) => splitTags(m.tags).length, cell: (m) => { const tags = splitTags(m.tags) const shown = tags.slice(0, 6) const rest = tags.length - shown.length const full = tags.join(', ') return (
{m.hot ? badge(true, '🔥 HOT') : null} {m.keep ? badge(true, '📌 Behalten') : null} {shown.map((t) => ( ))} {rest > 0 ? +{rest} : null} {!m.hot && !m.keep && tags.length === 0 ? ( ) : null}
) }, }, { key: 'createdAt', header: 'Hinzugefügt', sortable: true, sortValue: (m) => { const t = Date.parse(String(m.createdAt ?? '')) return Number.isFinite(t) ? t : 0 }, widthClassName: 'w-[160px]', cell: (m) => { const ts = Date.parse(String(m.createdAt ?? '')) if (!Number.isFinite(ts) || ts <= 0) { return } const d = new Date(ts) return (
{d.toLocaleDateString('de-DE')}
{d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
) }, }, { key: 'actions', header: '', align: 'right', cell: (m) => (
), }, ] }, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading]) const filtered = useMemo(() => { const needle = deferredQ.trim().toLowerCase() let base = !needle ? models : modelsWithHay.filter((x) => x.hay.includes(needle)).map((x) => x.m) // Watch-Filter: Alle / Watched / Unwatched if (watchFilter === 'watched') { base = base.filter((m) => m.watching === true) } else if (watchFilter === 'unwatched') { base = base.filter((m) => m.watching !== true) } // Favorite / Liked: OR-Logik if (activeStatusSet.size > 0) { base = base.filter((m) => { const fav = m.favorite === true const liked = m.liked === true return ( (activeStatusSet.has('favorite') && fav) || (activeStatusSet.has('liked') && liked) ) }) } if (activeTagSet.size === 0) return base return base.filter((m) => { const tags = splitTags(m.tags) if (tags.length === 0) return false const have = new Set(tags.map((t) => t.toLowerCase())) for (const t of activeTagSet) { if (!have.has(t)) return false } return true }) }, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet]) const sortedAll = useMemo(() => { if (!sort) return filtered const col = columns.find((c) => c.key === sort.key) if (!col) return filtered const dirMul = sort.direction === 'asc' ? 1 : -1 const decorated = filtered.map((r, i) => ({ r, i })) decorated.sort((x, y) => { let res = 0 if (col.sortFn) { res = col.sortFn(x.r, y.r) } else { const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key] const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key] const na = normalizeSortValue(ax) const nb = normalizeSortValue(by) if (na.isNull && !nb.isNull) res = 1 else if (!na.isNull && nb.isNull) res = -1 else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0 else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true }) } if (res === 0) return x.i - y.i return res * dirMul }) return decorated.map((d) => d.r) }, [filtered, sort, columns]) useEffect(() => { setPage(1) }, [q, tagFilter, watchFilter, statusFilter, viewMode, sort]) const totalItems = sortedAll.length const totalPages = useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) useEffect(() => { if (page > totalPages) setPage(totalPages) }, [page, totalPages]) const pageRows = useMemo(() => { const start = (page - 1) * pageSize return sortedAll.slice(start, start + pageSize) }, [sortedAll, page, pageSize]) const upsertFromParsed = async () => { if (!parsed) return if (!parsed.isUrl) { setParseError('Bitte nur URLs einfügen (keine Modelnamen).') return } setAdding(true) setErr(null) try { const saved = await apiJSON('/api/models/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsed), }) setModels((prev) => { const rest = prev.filter((x) => x.id !== saved.id) return [saved, ...rest] }) setInput('') setParsed(null) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: saved } })) } catch (e: any) { setErr(e?.message ?? String(e)) } finally { setAdding(false) } } return (
Model hinzufügen
} grayBody >
setInput(e.target.value)} placeholder="https://…" className="flex-1" />
{parseError ? (
{parseError}
) : parsed ? (
Gefunden: {parsed.modelKey} {parsed.host ? • {parsed.host} : null}
) : null} {err ?
{err}
: null}
Models ({filtered.length})
{/* Mobile */}
{/* Desktop */}
{/* Mobile Layout: 2 Zeilen */}
{/* Zeile 1: View + Sort */}
{ if (id === 'table' || id === 'gallery') setViewMode(id) }} items={[ { id: 'gallery', label: 'Gallery', icon: , }, { id: 'table', label: 'Tabelle', icon: , }, ]} />
{viewMode === 'gallery' ? ( <> ) : ( )}
{/* Zeile 2: Suche volle Breite */} setQ(e.target.value)} placeholder="Suchen…" />
{/* Desktop Layout: wie bisher */}
{viewMode === 'gallery' ? (
) : null}
{ if (id === 'table' || id === 'gallery') setViewMode(id) }} items={[ { id: 'gallery', label: 'Gallery', icon: , }, { id: 'table', label: 'Tabelle', icon: , }, ]} />
setQ(e.target.value)} placeholder="Suchen…" className="sm:w-[260px]" />
Status-Filter:
{ if (id === 'all' || id === 'watched' || id === 'unwatched') { setWatchFilter(id) } }} items={[ { id: 'all', label: 'Alle', }, { id: 'watched', label: 'Watched', icon: , }, { id: 'unwatched', label: 'Unwatched', icon: , }, ]} /> {watchFilter !== 'all' || statusFilter.length > 0 ? ( ) : null}
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
Tag-Filter:
{ setTagFilter((prev) => prev.some((t) => lowerTag(t) === lowerTag(tag)) ? prev : [...prev, tag] ) }} onRemoveTag={(tag) => { setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag))) }} onClear={clearTagFilter} />
) : null} } noBodyPadding > {viewMode === 'table' ? ( <>
m.id} striped compact fullWidth stickyHeader sort={sort} onSortChange={(next) => setSort(next)} onRowClick={(m) => { const href = modelHref(m) if (href) window.open(href, '_blank', 'noreferrer') }} /> ) : ( <>
{pageRows.length === 0 ? (
Keine Models gefunden.
) : (
{pageRows.map((m) => { const href = modelHref(m) const imgSrc = modelProfileImageSrc(m) const tags = splitTags(m.tags) const shownTags = tags.slice(0, 3) const liked = m.liked === true const fav = m.favorite === true const watch = m.watching === true const hideUntilHover = !watch && !fav && !liked const videoCount = videoCounts[String(m.modelKey || '').trim().toLowerCase()] ?? 0 const modelName = String(m.modelKey || '?') const len = modelName.length return (
{ if (href) window.open(href, '_blank', 'noreferrer') }} > {/* Bildbereich ähnlich Screenshot */}
{imgSrc ? ( {m.modelKey} { e.currentTarget.style.display = 'none' const fallback = e.currentTarget.nextElementSibling as HTMLElement | null if (fallback) fallback.style.display = 'flex' }} /> ) : null} {/* Fallback-Name im Bild */}
{modelName}
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
{m.modelKey}
{m.host ? (
{m.host}
) : null}
{/* Stats im Bild (unten rechts, untereinander, mit Icons) */}
{/* oben links: Record Actions Overlay */}
e.stopPropagation()} >
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
{watch ? ( ) : null} {fav ? ( ) : null} {liked ? ( ) : null}
{/* HOT / KEEP links oben */} {(m.hot || m.keep) && (
{m.hot ? ( HOT ) : null} {m.keep ? ( KEEP ) : null}
)}
{/* Footer */}
{/* Mobile: kompakter, aber nicht gequetscht */}
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
e.stopPropagation()} >
{/* Zeile 2: Tags */}
{shownTags.length > 0 ? ( shownTags.map((t) => ( )) ) : ( placeholder )}
{/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
{/* Zeile 1: Actions rechts, Style wie mobile */}
e.stopPropagation()}>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
{shownTags.length > 0 ? ( shownTags.map((t) => ( )) ) : ( placeholder )}
) })}
)}
)} !importing && setImportOpen(false)} title="Models importieren" footer={ <> } >
Wähle eine CSV-Datei zum Import aus.
Import-Typ
{ const f = e.target.files?.[0] ?? null setImportErr(null) setImportFile(f) }} className="block w-full text-sm text-gray-700 file:mr-4 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-gray-900 hover:file:bg-gray-200 dark:text-gray-200 dark:file:bg-white/10 dark:file:text-white dark:hover:file:bg-white/20" /> {importFile ? (
Ausgewählt: {importFile.name}
) : null} {importErr ? (
{importErr}
) : null} {importMsg ? (
{importMsg}
) : null}
) }