nsfwapp/frontend/src/components/ui/ModelsTab.tsx
2026-06-24 11:29:14 +02:00

1929 lines
74 KiB
TypeScript

// 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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>
}
const badge = (on: boolean, label: string) => (
<span
className={clsx(
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
on
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200'
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300'
)}
>
{label}
</span>
)
/** 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: <modelKey>_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<HTMLButtonElement>) => void
icon: ReactNode
}) {
return (
<span
className={clsx(
hiddenUntilHover && !active
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
)}
>
<Button
variant={active ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'px-2 py-1 leading-none',
active ? '' : 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={title}
onClick={onClick}
>
<span className="text-base leading-none">{icon}</span>
</Button>
</span>
)
}
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 <Squares2X2Icon className="size-4" />
}
function TableIcon() {
return <TableCellsIcon className="size-4" />
}
type ModelsTabProps = {
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
onModelsCountChange?: (count: number) => void
}
export default function ModelsTab({ onAddToDownloads, onModelsCountChange }: ModelsTabProps) {
const [models, setModels] = useState<StoredModel[]>([])
const modelsLoadedRef = useRef(false)
const flagsInFlightRef = useRef<Record<string, true>>({})
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [q, setQ] = useState('')
const [page, setPage] = useState(1)
const pageSize = 10
const [viewMode, setViewMode] = useState<ModelsViewMode>('gallery')
const [tagFilter, setTagFilter] = useState<string[]>([])
type WatchFilterMode = 'all' | 'watched' | 'unwatched'
type StatusFilterKey = 'favorite' | 'liked'
const [watchFilter, setWatchFilter] = useState<WatchFilterMode>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilterKey[]>([])
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<Record<string, number>>({})
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<string, number> = {}
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<string>()
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<ParsedModel | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importMsg, setImportMsg] = useState<string | null>(null)
const [importKind, setImportKind] = useState<ImportKind>('favorite')
const [importing, setImporting] = useState(false)
const [importErr, setImportErr] = useState<string | null>(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<any>
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<ParsedModel>('/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<Column<StoredModel>[]>(() => {
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 (
<div className="group flex items-center justify-center gap-1 w-[110px]">
<span
className={clsx(
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
)}
>
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'px-2 py-1 leading-none',
!watch && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
{watch ? (
<EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-400" />
) : (
<EyeIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)}
</Button>
</span>
<IconToggle
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
active={fav}
hiddenUntilHover={hideUntilHover}
onClick={(e) => {
e.stopPropagation()
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
icon={
fav ? (
<StarIconSolid className="size-5 text-amber-500" />
) : (
<StarIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)
}
/>
<IconToggle
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
active={liked}
hiddenUntilHover={hideUntilHover}
onClick={(e) => {
e.stopPropagation()
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
icon={
liked ? (
<HeartIconSolid className="size-5 text-rose-500" />
) : (
<HeartIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)
}
/>
</div>
)
},
},
{
key: 'model',
header: 'Model',
sortable: true,
sortValue: (m) => (m.modelKey || '').toLowerCase(),
cell: (m) => {
const href = modelHref(m)
const imgSrc = modelProfileImageSrc(m)
return (
<div className="min-w-0">
<div className="flex items-start gap-2 min-w-0">
<div className="shrink-0 mt-0.5">
{imgSrc ? (
<img
src={imgSrc}
alt={m.modelKey}
loading="lazy"
className="w-10 h-10 rounded-full object-cover bg-gray-100 dark:bg-white/10 ring-1 ring-gray-200 dark:ring-white/10"
onClick={(e) => {
e.stopPropagation()
if (href) window.open(href, '_blank', 'noreferrer')
}}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-white/10 ring-1 ring-gray-200 dark:ring-white/10 flex items-center justify-center text-xs text-gray-500 dark:text-gray-400">
{String(m.modelKey || '?').slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<div className="font-medium truncate">{m.modelKey}</div>
{href ? (
<span
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
title={href}
onClick={(e) => {
e.stopPropagation()
window.open(href, '_blank', 'noreferrer')
}}
>
<ArrowTopRightOnSquareIcon className="size-4" />
</span>
) : null}
<ModelGenderIcon
gender={m.gender}
className="shrink-0"
/>
<CountryFlag
country={m.country}
size="sm"
className="shrink-0"
/>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{m.host ?? '—'}
</div>
{href ? (
<div className="text-xs text-indigo-600 dark:text-indigo-400 truncate max-w-[520px]">
{href}
</div>
) : null}
</div>
</div>
</div>
)
},
},
{
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 (
<span className="tabular-nums text-sm text-gray-900 dark:text-white w-[64px] inline-block text-right">
{typeof n === 'number' ? n : videoCountsLoading ? '…' : 0}
</span>
)
},
},
{
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 (
<div className="flex flex-wrap gap-2 max-w-[520px]" title={full || undefined}>
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
{shown.map((t) => (
<TagBadge
key={t}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))}
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
{!m.hot && !m.keep && tags.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
) : null}
</div>
)
},
},
{
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 <span className="text-xs text-gray-400 dark:text-gray-500"></span>
}
const d = new Date(ts)
return (
<div className="text-xs text-gray-700 dark:text-gray-300 leading-tight">
<div className="tabular-nums">
{d.toLocaleDateString('de-DE')}
</div>
<div className="tabular-nums text-gray-500 dark:text-gray-400">
{d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
)
},
},
{
key: 'actions',
header: '',
align: 'right',
cell: (m) => (
<div className="flex justify-end w-[92px]">
<RecordJobActions
job={jobForDetails(m)}
variant="table"
onAddToDownloads={onAddToDownloads}
order={['add', 'details']}
className="flex items-center gap-2"
/>
</div>
),
},
]
}, [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<StoredModel>('/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 (
<div className="space-y-4">
<Card
header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>}
grayBody
>
<div className="grid gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<TextInput
value={input}
size='sm'
onChange={(e) => setInput(e.target.value)}
placeholder="https://…"
className="flex-1"
/>
<Button
className="px-3 py-2 text-sm"
onClick={upsertFromParsed}
disabled={!parsed || adding}
title={!parsed ? 'Bitte gültige URL einfügen' : 'In Models speichern'}
>
Hinzufügen
</Button>
<Button className="px-3 py-2 text-sm" onClick={refresh} disabled={loading}>
Aktualisieren
</Button>
</div>
{parseError ? (
<div className="text-xs text-red-600 dark:text-red-300">{parseError}</div>
) : parsed ? (
<div className="text-xs text-gray-600 dark:text-gray-300">
Gefunden: <span className="font-medium">{parsed.modelKey}</span>
{parsed.host ? <span className="opacity-70"> {parsed.host}</span> : null}
</div>
) : null}
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
</div>
</Card>
<Card
header={
<div className="space-y-2">
<div className="grid gap-2 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
Models <span className="text-gray-500 dark:text-gray-400">({filtered.length})</span>
</div>
{/* Mobile */}
<div className="sm:hidden shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Import
</Button>
</div>
{/* Desktop */}
<div className="hidden sm:block shrink-0">
<Button variant="secondary" size="md" onClick={openImport}>
Importieren
</Button>
</div>
</div>
<div className="w-full sm:w-auto min-w-0">
{/* Mobile Layout: 2 Zeilen */}
<div className="sm:hidden grid gap-2">
{/* Zeile 1: View + Sort */}
<div className="grid grid-cols-[auto,minmax(0,1fr)] gap-2 items-center min-w-0">
<div className="shrink-0">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
className="flex w-full [&>button]:flex-1 [&>button]:min-w-0"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<div className="min-w-0">
{viewMode === 'gallery' ? (
<>
<label className="sr-only" htmlFor="models-gallery-sort-mobile">
Sortierung
</label>
<select
id="models-gallery-sort-mobile"
value={gallerySortModeFromSort(sort)}
onChange={(e) => {
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-full min-w-0
rounded-md px-2 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 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</>
) : (
<div
aria-hidden="true"
className="
h-9 w-full min-w-0
rounded-md px-3 text-sm
inline-flex items-center
bg-gray-50 text-gray-400 ring-1 ring-gray-200
dark:bg-white/5 dark:text-white/30 dark:ring-white/10
select-none
"
title="Sortierung in der Tabellenansicht über Spaltenkopf"
>
Sortierung
</div>
)}
</div>
</div>
{/* Zeile 2: Suche volle Breite */}
<TextInput
value={q}
size='sm'
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
/>
</div>
{/* Desktop Layout: wie bisher */}
<div className="hidden sm:flex items-center gap-2 min-w-0 sm:justify-end">
{viewMode === 'gallery' ? (
<div className="shrink-0">
<label className="sr-only" htmlFor="models-gallery-sort">
Sortierung
</label>
<select
id="models-gallery-sort"
value={gallerySortModeFromSort(sort)}
onChange={(e) => {
const next = sortFromGallerySortMode(e.target.value as GallerySortMode)
setSort(next)
}}
className="
h-9 w-auto
rounded-md px-3 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 dark:[color-scheme:dark]
"
>
<option value="created_desc">Hinzugefügt </option>
<option value="created_asc">Hinzugefügt </option>
<option value="model_asc">Model AZ</option>
<option value="model_desc">Model ZA</option>
<option value="videos_desc">Videos </option>
<option value="videos_asc">Videos </option>
<option value="tags_desc">Tags </option>
<option value="tags_asc">Tags </option>
</select>
</div>
) : null}
<div className="shrink-0">
<ButtonGroup
ariaLabel="Ansicht umschalten"
size="lg"
value={viewMode}
onChange={(id) => {
if (id === 'table' || id === 'gallery') setViewMode(id)
}}
items={[
{
id: 'gallery',
label: 'Gallery',
icon: <GridIcon />,
},
{
id: 'table',
label: 'Tabelle',
icon: <TableIcon />,
},
]}
/>
</div>
<TextInput
value={q}
size='sm'
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="sm:w-[260px]"
/>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Status-Filter:
</span>
<div className="flex flex-wrap items-center gap-2">
<ButtonGroup
ariaLabel="Watched-Filter"
size="sm"
value={watchFilter}
onChange={(id) => {
if (id === 'all' || id === 'watched' || id === 'unwatched') {
setWatchFilter(id)
}
}}
items={[
{
id: 'all',
label: 'Alle',
},
{
id: 'watched',
label: 'Watched',
icon: <EyeIconSolid className="size-4" />,
},
{
id: 'unwatched',
label: 'Unwatched',
icon: <EyeSlashIcon className="size-4" />,
},
]}
/>
<Button
size="sm"
color="amber"
variant={activeStatusSet.has('favorite') ? 'soft' : 'secondary'}
onClick={() => toggleStatusFilter('favorite')}
title="Favoriten filtern"
leadingIcon={
activeStatusSet.has('favorite') ? (
<StarIconSolid className="size-4" />
) : (
<StarIconOutline className="size-4" />
)
}
>
Favorited
</Button>
<Button
size="sm"
color="red"
variant={activeStatusSet.has('liked') ? 'soft' : 'secondary'}
onClick={() => toggleStatusFilter('liked')}
title="Liked Models filtern"
leadingIcon={
activeStatusSet.has('liked') ? (
<HeartIconSolid className="size-4" />
) : (
<HeartIconOutline className="size-4" />
)
}
>
Liked
</Button>
{watchFilter !== 'all' || statusFilter.length > 0 ? (
<Button
size="md"
variant="soft"
onClick={clearStatusFilter}
className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300"
>
Zurücksetzen
</Button>
) : null}
</div>
</div>
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="flex flex-wrap items-start gap-2">
<span className="pt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
Tag-Filter:
</span>
<div className="min-w-0 flex-1">
<TagFilterBar
activeTags={tagFilter}
allTags={allKnownTags}
onAddTag={(tag) => {
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}
/>
</div>
</div>
) : null}
</div>
}
noBodyPadding
>
{viewMode === 'table' ? (
<>
<div className="overflow-x-auto">
<div className="min-w-[980px]">
<Table
rows={pageRows}
columns={columns}
getRowKey={(m) => 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')
}}
/>
</div>
</div>
<Pagination page={page} pageSize={pageSize} totalItems={totalItems} onPageChange={setPage} />
</>
) : (
<>
<div className="p-2 sm:p-3">
{pageRows.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Keine Models gefunden.
</div>
) : (
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-5">
{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 (
<div
key={m.id}
className="group flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-white/10 dark:bg-slate-900/80"
>
<div
className="relative cursor-pointer bg-gray-100 dark:bg-slate-950"
onClick={() => {
if (href) window.open(href, '_blank', 'noreferrer')
}}
>
{/* Bildbereich ähnlich Screenshot */}
<div className="relative aspect-[3/4] w-full overflow-hidden bg-gradient-to-br from-gray-100 to-slate-200 dark:from-[#12202c] dark:to-[#0b1620]">
{imgSrc ? (
<img
src={imgSrc}
alt={m.modelKey}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
onError={(e) => {
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = 'flex'
}}
/>
) : null}
{/* Fallback-Name im Bild */}
<div
className={clsx(
'absolute inset-0 items-center justify-center px-3 text-center font-semibold leading-tight text-slate-600 dark:text-slate-500',
imgSrc ? 'hidden' : 'flex'
)}
style={{
display: imgSrc ? 'none' : 'flex',
fontSize:
len <= 10
? 'clamp(1rem, 1.4vw + 0.4rem, 2.1rem)'
: len <= 18
? 'clamp(0.95rem, 1.15vw + 0.3rem, 1.7rem)'
: 'clamp(0.8rem, 0.9vw + 0.25rem, 1.25rem)',
lineHeight: 1.1,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
title={modelName}
>
{modelName}
</div>
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/15 to-transparent dark:from-black/70 dark:via-black/20" />
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
<div className="flex items-center gap-1.5 min-w-0">
<div
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title={m.modelKey}
>
{m.modelKey}
</div>
<ModelGenderIcon
gender={m.gender}
className="shrink-0 text-white"
/>
<CountryFlag
country={m.country}
size="sm"
className="shrink-0"
/>
</div>
{m.host ? (
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{m.host}
</div>
) : null}
</div>
{/* Stats im Bild (unten rechts, untereinander, mit Icons) */}
<div className="pointer-events-none absolute bottom-2 right-2 z-10 flex flex-col items-end gap-1">
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Videos"
aria-hidden="true"
>
<FilmIcon className="size-3.5 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]" />
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{videoCountsLoading ? '…' : videoCount}
</span>
</span>
<span
className="inline-flex items-center gap-1 px-0.5 py-0 text-[10px] font-semibold text-white/95 tabular-nums"
title="Tags"
aria-hidden="true"
>
<TagIcon className="size-3.5 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]" />
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{tags.length}
</span>
</span>
</div>
{/* oben links: Record Actions Overlay */}
<div
className={clsx(
'absolute left-2 z-10 flex items-center gap-1',
(m.hot || m.keep) ? 'top-12' : 'top-2'
)}
onClick={(e) => e.stopPropagation()}
>
<RecordJobActions
job={jobForDetails(m)}
variant="table"
onAddToDownloads={onAddToDownloads}
order={['add', 'details']}
className="
flex items-center gap-1
[&_button]:h-7 [&_button]:w-7 [&_button]:min-w-0 [&_button]:p-0
[&_button]:bg-transparent [&_button]:shadow-none
[&_button:hover]:bg-white/20 dark:[&_button:hover]:bg-white/10
[&_button]:border-0
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
"
/>
</div>
{/* oben rechts: Status-Icons (nur Anzeige, ohne Hintergrund) */}
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
{watch ? (
<span
className="inline-flex items-center justify-center text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Beobachtet"
aria-hidden="true"
>
<EyeIconSolid className="size-5" />
</span>
) : null}
{fav ? (
<span
className="inline-flex items-center justify-center text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Favorit"
aria-hidden="true"
>
<StarIconSolid className="size-5" />
</span>
) : null}
{liked ? (
<span
className="inline-flex items-center justify-center text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title="Gefällt mir"
aria-hidden="true"
>
<HeartIconSolid className="size-5" />
</span>
) : null}
</div>
{/* HOT / KEEP links oben */}
{(m.hot || m.keep) && (
<div className="absolute left-2 top-2 flex flex-col gap-1">
{m.hot ? (
<span className="rounded bg-amber-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
HOT
</span>
) : null}
{m.keep ? (
<span className="rounded bg-indigo-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
KEEP
</span>
) : null}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex-1 rounded-b-xl border-t border-gray-200 bg-gray-50 px-2.5 py-2 dark:border-white/5 dark:bg-slate-800/70">
{/* Mobile: kompakter, aber nicht gequetscht */}
<div className="sm:hidden">
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
<div
className="grid grid-cols-3 gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
watch
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span className="inline-flex items-center justify-center gap-1">
{watch ? (
<EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-300" />
) : (
<EyeIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span>
</Button>
<Button
variant={fav ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
fav
? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
e.stopPropagation()
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
{fav ? (
<StarIconSolid className="size-5 text-amber-600 dark:text-amber-300" />
) : (
<StarIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span>
</Button>
<Button
variant={liked ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
liked
? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => {
e.stopPropagation()
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
{liked ? (
<HeartIconSolid className="size-5 text-rose-600 dark:text-rose-300" />
) : (
<HeartIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span>
</Button>
</div>
{/* Zeile 2: Tags */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div>
{/* Desktop: ohne Footer-Stats (wie mobile Buttons) */}
<div className="hidden sm:block">
{/* Zeile 1: Actions rechts, Style wie mobile */}
<div onClick={(e) => e.stopPropagation()}>
<div className="grid grid-cols-3 gap-1.5 w-full">
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
watch
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 shadow-xs hover:bg-indigo-100 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-0 dark:shadow-none dark:hover:bg-indigo-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watched: !watch })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
👁
</span>
</span>
</Button>
<Button
variant={fav ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !fav
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
fav
? 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 shadow-xs hover:bg-amber-100 dark:bg-amber-500/20 dark:text-amber-200 dark:ring-0 dark:shadow-none dark:hover:bg-amber-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
onClick={(e) => {
e.stopPropagation()
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
</span>
</span>
</Button>
<Button
variant={liked ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'h-8 min-w-0 px-0 shadow-none',
hideUntilHover && !liked
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100',
liked
? 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 shadow-xs hover:bg-rose-100 dark:bg-rose-500/20 dark:text-rose-200 dark:ring-0 dark:shadow-none dark:hover:bg-rose-500/30'
: 'bg-white text-gray-700 ring-1 ring-gray-200 shadow-none hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
)}
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
onClick={(e) => {
e.stopPropagation()
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
>
<span className="inline-flex items-center justify-center gap-1">
<span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
</span>
</span>
</Button>
</div>
</div>
{/* Zeile 2: Tags (immer vorhanden für gleiche Kartenhöhe) */}
<div className="mt-2 min-h-[24px] flex flex-wrap items-start gap-1.5">
{shownTags.length > 0 ? (
shownTags.map((t) => (
<TagBadge
key={`${m.id}:${t}`}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))
) : (
<span className="opacity-0 select-none pointer-events-none text-[11px]">placeholder</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Pagination page={page} pageSize={pageSize} totalItems={totalItems} onPageChange={setPage} />
</>
)}
</Card>
<Modal
open={importOpen}
onClose={() => !importing && setImportOpen(false)}
title="Models importieren"
footer={
<>
<Button variant="secondary" onClick={() => setImportOpen(false)} disabled={importing}>
Abbrechen
</Button>
<Button
variant="primary"
onClick={doImport}
isLoading={importing}
disabled={!importFile || importing}
>
Import starten
</Button>
</>
}
>
<div className="space-y-3">
<div className="text-sm text-gray-700 dark:text-gray-300">
Wähle eine CSV-Datei zum Import aus.
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">Import-Typ</div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="import-kind"
value="favorite"
checked={importKind === 'favorite'}
onChange={() => setImportKind('favorite')}
disabled={importing}
/>
Favoriten ()
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="import-kind"
value="liked"
checked={importKind === 'liked'}
onChange={() => setImportKind('liked')}
disabled={importing}
/>
Gefällt mir ()
</label>
</div>
</div>
<input
type="file"
accept=".csv,text/csv"
onChange={(e) => {
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 ? (
<div className="text-xs text-gray-600 dark:text-gray-400">
Ausgewählt: <span className="font-medium">{importFile.name}</span>
</div>
) : null}
{importErr ? (
<div className="text-xs text-red-600 dark:text-red-300">{importErr}</div>
) : null}
{importMsg ? (
<div className="rounded-md bg-green-50 px-3 py-2 text-xs text-green-700 dark:bg-green-500/10 dark:text-green-200">
{importMsg}
</div>
) : null}
</div>
</Modal>
</div>
)
}