1929 lines
74 KiB
TypeScript
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 A→Z</option>
|
|
<option value="model_desc">Model Z→A</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 A→Z</option>
|
|
<option value="model_desc">Model Z→A</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>
|
|
)
|
|
}
|