This commit is contained in:
Linrador 2026-04-14 15:18:33 +02:00
parent 3a6e1d0f48
commit 691c24e899
24 changed files with 2993 additions and 1650 deletions

View File

@ -166,18 +166,25 @@ func nsfwLabelPriority(label string) int {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case
"anus_exposed",
"female_genitalia_exposed",
"male_genitalia_exposed",
"female_breast_exposed",
"buttocks_exposed":
return 300
case "female_genitalia_exposed":
return 1000
case "female_breast_exposed":
return 900
case "anus_exposed":
return 800
case "male_genitalia_exposed":
return 700
case "buttocks_exposed":
return 600
case
"female_genitalia_covered",
"male_genitalia_covered",
"female_breast_covered",
"male_genitalia_covered",
"buttocks_covered",
"male_breast_exposed",
"male_breast_covered":

View File

@ -1,114 +1,114 @@
{
"items": [
{
"modelKey": "jam_cream",
"url": "https://www.myfreecams.com/#Jam_cream",
"modelKey": "sw33t_g1rl",
"url": "https://www.myfreecams.com/#Sw33t_g1rl",
"mode": "probe_retry",
"nextProbeAtMs": 1775899166952,
"nextProbeAtMs": 1776172330458,
"currentShow": "offline",
"source": "watched"
},
{
"modelKey": "milk_bunny_",
"url": "https://chaturbate.com/milk_bunny_/",
"modelKey": "angel_from_sky",
"url": "https://chaturbate.com/angel_from_sky/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg",
"source": "watched"
},
{
"modelKey": "vanicha_007",
"url": "https://chaturbate.com/vanicha_007/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg",
"source": "watched"
},
{
"modelKey": "kali_hiill",
"url": "https://chaturbate.com/kali_hiill/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg",
"source": "watched"
},
{
"modelKey": "passiondesire_",
"url": "https://chaturbate.com/passiondesire_/",
"mode": "wait_public",
"currentShow": "away",
"imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg",
"source": "watched"
},
{
"modelKey": "tsukuyami_mi",
"url": "https://chaturbate.com/tsukuyami_mi/",
"modelKey": "tomokosturgeon",
"url": "https://chaturbate.com/tomokosturgeon/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg",
"source": "watched"
},
{
"modelKey": "swtty_meivi",
"url": "https://chaturbate.com/swtty_meivi/",
"modelKey": "tammieprotano",
"url": "https://chaturbate.com/tammieprotano/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg",
"source": "watched"
},
{
"modelKey": "amandatalk",
"url": "https://chaturbate.com/amandatalk/",
"modelKey": "troybailly",
"url": "https://chaturbate.com/troybailly/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg",
"source": "watched"
},
{
"modelKey": "vonen_ray",
"url": "https://chaturbate.com/vonen_ray/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg",
"source": "watched"
},
{
"modelKey": "oliver_and_megan",
"url": "https://chaturbate.com/oliver_and_megan/",
"modelKey": "sharitaklemme",
"url": "https://chaturbate.com/sharitaklemme/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
"source": "watched"
},
{
"modelKey": "aurora_asti_",
"url": "https://chaturbate.com/aurora_asti_/",
"modelKey": "sosaguillypleasure",
"url": "https://chaturbate.com/sosaguillypleasure/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg",
"source": "watched"
},
{
"modelKey": "wasianbby",
"url": "https://chaturbate.com/wasianbby/",
"modelKey": "kittymeow_xxx",
"url": "https://chaturbate.com/kittymeow_xxx/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/wasianbby.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg",
"source": "watched"
},
{
"modelKey": "gimbobar",
"url": "https://chaturbate.com/gimbobar/",
"modelKey": "superstar090",
"url": "https://chaturbate.com/superstar090/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg",
"source": "watched"
},
{
"modelKey": "lizbethbiers",
"url": "https://chaturbate.com/lizbethbiers/",
"modelKey": "victoryglow",
"url": "https://chaturbate.com/victoryglow/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg",
"imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg",
"source": "watched"
},
{
"modelKey": "bonnettbonett",
"url": "https://chaturbate.com/bonnettbonett/",
"modelKey": "palmiratownzen",
"url": "https://chaturbate.com/palmiratownzen/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg",
"source": "watched"
},
{
"modelKey": "pazrapacki",
"url": "https://chaturbate.com/pazrapacki/",
"modelKey": "rafaelaloghry",
"url": "https://chaturbate.com/rafaelaloghry/",
"mode": "probe_retry",
"nextProbeAtMs": 1775899181131,
"nextProbeAtMs": 1776172331673,
"currentShow": "unknown",
"source": "watched"
}

View File

@ -145,10 +145,10 @@ type modelsOverviewModel struct {
Input string `json:"input"`
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"`
Tags string `json:"tags,omitempty"`
Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"`
Subgender string `json:"subgender,omitempty"`
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
Watching bool `json:"watching"`
Favorite bool `json:"favorite,omitempty"`

View File

@ -24,9 +24,8 @@ type StoredModel struct {
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"`
Subgender string `json:"subgender,omitempty"`
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
@ -1053,27 +1052,25 @@ ORDER BY updated_at DESC;
continue
}
var gender, sex, subgender string
var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
}
}
m := StoredModel{
ID: id,
Input: input,
IsURL: isURL,
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
ID: id,
Input: input,
IsURL: isURL,
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Country: country,
Tags: tags,
LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
@ -1889,12 +1886,13 @@ WHERE id=$1;
}
}
var gender, sex, subgender string
var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
}
}
@ -1907,8 +1905,7 @@ WHERE id=$1;
ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
Country: country,
Tags: tags,
LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,246 @@
// frontend\src\components\ui\CountryFlag.tsx
'use client'
import {type RecordJob } from '../../types'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const COUNTRY_CODE_ALIASES: Record<string, string> = {
usa: 'US',
us: 'US',
'united states': 'US',
'united states of america': 'US',
uk: 'GB',
gb: 'GB',
'united kingdom': 'GB',
'great britain': 'GB',
england: 'GB',
germany: 'DE',
deutschland: 'DE',
de: 'DE',
austria: 'AT',
österreich: 'AT',
at: 'AT',
switzerland: 'CH',
schweiz: 'CH',
ch: 'CH',
netherlands: 'NL',
holland: 'NL',
nl: 'NL',
france: 'FR',
fr: 'FR',
italy: 'IT',
it: 'IT',
spain: 'ES',
es: 'ES',
poland: 'PL',
pl: 'PL',
romania: 'RO',
ro: 'RO',
hungary: 'HU',
hu: 'HU',
czechia: 'CZ',
'czech republic': 'CZ',
cz: 'CZ',
slovakia: 'SK',
sk: 'SK',
ukraine: 'UA',
ua: 'UA',
russia: 'RU',
ru: 'RU',
turkey: 'TR',
tr: 'TR',
brazil: 'BR',
br: 'BR',
colombia: 'CO',
co: 'CO',
mexico: 'MX',
mx: 'MX',
canada: 'CA',
ca: 'CA',
japan: 'JP',
jp: 'JP',
china: 'CN',
cn: 'CN',
'south korea': 'KR',
kr: 'KR',
'north korea': 'KP',
kp: 'KP',
}
const COUNTRY_NAME_DE_FALLBACK: Record<string, string> = {
US: 'Vereinigte Staaten',
GB: 'Vereinigtes Königreich',
DE: 'Deutschland',
AT: 'Österreich',
CH: 'Schweiz',
NL: 'Niederlande',
FR: 'Frankreich',
IT: 'Italien',
ES: 'Spanien',
PL: 'Polen',
RO: 'Rumänien',
HU: 'Ungarn',
CZ: 'Tschechien',
SK: 'Slowakei',
UA: 'Ukraine',
RU: 'Russland',
TR: 'Türkei',
BR: 'Brasilien',
CO: 'Kolumbien',
MX: 'Mexiko',
CA: 'Kanada',
JP: 'Japan',
CN: 'China',
KR: 'Südkorea',
KP: 'Nordkorea',
}
export function countryCodeFromInput(input?: string | null) {
const raw = String(input ?? '').trim()
if (!raw) return ''
if (/^[a-z]{2}$/i.test(raw)) {
return raw.toUpperCase()
}
const code = COUNTRY_CODE_ALIASES[raw.toLowerCase()] ?? ''
return /^[A-Z]{2}$/.test(code) ? code : ''
}
export function resolveFinishedCountry(flags: unknown, job: RecordJob): string {
const anyFlags = flags as any
const anyJob = job as any
const candidates = [
anyFlags?.country,
anyFlags?.countryCode,
anyFlags?.country_code,
anyFlags?.countryName,
anyFlags?.country_name,
anyJob?.country,
anyJob?.countryCode,
anyJob?.country_code,
anyJob?.model?.country,
anyJob?.model?.countryCode,
anyJob?.model?.country_code,
anyJob?.meta?.country,
anyJob?.meta?.countryCode,
anyJob?.meta?.country_code,
anyJob?.meta?.model?.country,
anyJob?.meta?.model?.countryCode,
anyJob?.meta?.model?.country_code,
]
for (const value of candidates) {
const raw = String(value ?? '').trim()
if (!raw) continue
if (countryCodeFromInput(raw)) return raw
}
return ''
}
export function countryNameDeFromCode(code?: string | null) {
const normalizedCode = String(code ?? '').trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalizedCode)) return ''
try {
const display = new Intl.DisplayNames(['de'], { type: 'region' })
const name = display.of(normalizedCode)
if (typeof name === 'string' && name.trim()) {
return name.trim()
}
} catch {
// fallback below
}
return COUNTRY_NAME_DE_FALLBACK[normalizedCode] ?? normalizedCode
}
export function normalizeCountryName(input?: string | null) {
const code = countryCodeFromInput(input)
if (!code) return String(input ?? '').trim()
return countryNameDeFromCode(code)
}
type CountryFlagProps = {
country?: string | null
code?: string | null
className?: string
title?: string
ariaLabel?: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'hero'
}
const SIZE_MAP: Record<NonNullable<CountryFlagProps['size']>, { width: number; height: number }> = {
xs: { width: 14, height: 10 },
sm: { width: 18, height: 13 },
md: { width: 20, height: 15 },
lg: { width: 24, height: 18 },
hero: { width: 16, height: 12 },
}
export default function CountryFlag({
country,
code,
className,
title,
ariaLabel,
size = 'sm',
}: CountryFlagProps) {
const normalizedCode = countryCodeFromInput(code || country)
if (!normalizedCode) return null
const localizedCountryName = countryNameDeFromCode(normalizedCode)
const dims = SIZE_MAP[size]
const finalTitle = title || localizedCountryName || 'Flagge'
const finalAriaLabel =
ariaLabel || (localizedCountryName ? `${localizedCountryName} Flagge` : 'Flagge')
return (
<span
className={cn(
'fi',
`fi-${normalizedCode.toLowerCase()}`,
'inline-block shrink-0 rounded-[2px] ring-1 ring-black/5 dark:ring-white/10',
className
)}
aria-label={finalAriaLabel}
title={finalTitle}
style={{ width: dims.width, height: dims.height }}
/>
)
}

View File

@ -1,75 +0,0 @@
import type { RecordJob } from '../../types'
import type { ContextMenuItem } from './ContextMenu'
export type DownloadMenuState = {
watching?: boolean
liked?: boolean | null // true = like, false = dislike, null/undefined = neutral
favorite?: boolean
hot?: boolean
keep?: boolean
}
export type DownloadMenuActions = {
onPlay: (job: RecordJob) => void
onToggleWatch: (job: RecordJob) => void
onSetLike: (job: RecordJob, liked: boolean | null) => void
onToggleFavorite: (job: RecordJob) => void
onMoreFromModel: (modelName: string, job: RecordJob) => void
onRevealInExplorer: (job: RecordJob) => void
onAddToDownloadList: (job: RecordJob) => void
onToggleHot: (job: RecordJob) => void
onToggleKeep: (job: RecordJob) => void
onDelete: (job: RecordJob) => void
}
type BuildArgs = {
job: RecordJob
modelName: string
state: DownloadMenuState
actions: DownloadMenuActions
}
export function buildDownloadContextMenu({ job, modelName, state, actions }: BuildArgs): ContextMenuItem[] {
const watching = !!state.watching
const favorite = !!state.favorite
const hot = !!state.hot
const keep = !!state.keep
const liked = state.liked // true / false / null
return [
{ label: 'Abspielen', onClick: () => actions.onPlay(job) },
// optionaler Separator-Style: wenn du willst, kann ContextMenu auch "separator" Items rendern
{ label: watching ? 'Nicht beobachten' : 'Beobachten', onClick: () => actions.onToggleWatch(job) },
{
label: liked === true ? 'Gefällt mir entfernen' : 'Gefällt mir',
onClick: () => actions.onSetLike(job, liked === true ? null : true),
},
{
label: liked === false ? 'Gefällt mir nicht entfernen' : 'Gefällt mir nicht',
onClick: () => actions.onSetLike(job, liked === false ? null : false),
},
{ label: favorite ? 'Von Favoriten entfernen' : 'Als Favorit markieren', onClick: () => actions.onToggleFavorite(job) },
{
label: `Mehr von ${modelName} anzeigen`,
onClick: () => actions.onMoreFromModel(modelName, job),
disabled: !modelName || modelName === '—',
},
{ label: 'Dateipfad im Explorer öffnen', onClick: () => actions.onRevealInExplorer(job), disabled: !job.output },
{ label: 'Zur Downloadliste hinzufügen', onClick: () => actions.onAddToDownloadList(job) },
{ label: hot ? 'Nicht mehr als HOT markieren' : 'Als HOT markieren', onClick: () => actions.onToggleHot(job) },
{ label: keep ? 'Nicht behalten' : 'Behalten', onClick: () => actions.onToggleKeep(job) },
{ label: 'Löschen', onClick: () => actions.onDelete(job), danger: true, disabled: keep },
]
}

View File

@ -20,6 +20,7 @@ import DownloadsCardRow, {
import SwipeActionRow from './SwipeActionRow'
import RecordJobActions from './RecordJobActions'
import LazyMountWhenVisible from './LazyMountWhenVisible'
import CountryFlag from './CountryFlag'
type AutostartState = {
paused?: boolean
@ -41,8 +42,7 @@ type Props = {
roomStatus?: string
imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
country?: string | null
}
>
roomStatusByModelKey?: Record<string, string>
@ -61,6 +61,39 @@ type Props = {
maxConcurrentDownloads?: number
}
const countryOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { country?: string | null }>
): string => {
const anyJ = job as any
const modelKey = modelKeyFromJob(job)
return String(
modelsByKey?.[modelKey]?.country ??
anyJ?.country ??
anyJ?.model?.country ??
anyJ?.room?.country ??
anyJ?.meta?.country ??
anyJ?.meta?.model?.country ??
''
).trim()
}
const countryOfPending = (
p: PendingWatchedRoom,
modelsByKey?: Record<string, { country?: string | null }>
): string => {
const anyP = p as any
const pendingKey = String(anyP?.modelKey ?? pendingModelName(p)).trim().toLowerCase()
return String(
modelsByKey?.[pendingKey]?.country ??
anyP?.country ??
anyP?.room?.country ??
''
).trim()
}
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
@ -1044,8 +1077,12 @@ export default function Downloads({
<ModelGenderIcon
gender={modelMeta?.gender}
sex={modelMeta?.sex}
subgender={modelMeta?.subgender}
className="shrink-0"
/>
<CountryFlag
country={countryOfJob(j, modelsByKey)}
size="sm"
className="shrink-0"
/>
@ -1098,8 +1135,12 @@ export default function Downloads({
<ModelGenderIcon
gender={pendingFlags?.gender}
sex={pendingFlags?.sex}
subgender={pendingFlags?.subgender}
className="shrink-0"
/>
<CountryFlag
country={countryOfPending(p, modelsByKey)}
size="sm"
className="shrink-0"
/>
</div>

View File

@ -10,6 +10,7 @@ import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
import LazyMountWhenVisible from './LazyMountWhenVisible'
import CountryFlag from './CountryFlag'
export type PendingWatchedRoom = WaitingModelRow & {
currentShow: string
@ -34,8 +35,7 @@ type ModelFlags = {
roomStatus?: string
imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
country?: string | null
}
type Props = {
@ -649,8 +649,12 @@ export default function DownloadsCardRow({
<ModelGenderIcon
gender={pendingFlags?.gender}
sex={pendingFlags?.sex}
subgender={pendingFlags?.subgender}
className="shrink-0"
/>
<CountryFlag
country={pendingFlags?.country}
size="sm"
className="shrink-0"
/>
</div>
@ -868,8 +872,12 @@ export default function DownloadsCardRow({
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
<CountryFlag
country={flags?.country}
size="sm"
className="shrink-0"
/>
</div>

View File

@ -7,7 +7,6 @@ import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type {
ViewMode,
SelectedItem,
UndoAction,
PersistedUndoState,
RunFileMutationOptions,
@ -43,7 +42,6 @@ import FinishedDownloadsTableView from './FinishedDownloadsTableView'
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy'
import TagBadge from './TagBadge'
import Button from './Button'
import { useNotify } from './notify'
import { isHotName, stripHotPrefix } from './hotName'
@ -54,6 +52,13 @@ import HoverPopover from './HoverPopover'
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
import Slider from './Slider'
import TextInput from './TextInput'
import TagFilterBar from './TagFilterBar'
import { parseTagList, lowerTag } from './tagUtils'
import {
createFinishedSelectionStore,
useSelectionVersion,
type FinishedSelectionStore,
} from './finishedSelectionStore'
type Props = {
jobs: RecordJob[]
@ -73,7 +78,9 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (job: RecordJob) => void | Promise<void>
onKeepJob?: (
job: RecordJob
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
onSplitJob?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
doneTotal: number
@ -113,6 +120,36 @@ const isTrashOutput = (output?: string) => {
return p.includes('/.trash/') || p.endsWith('/.trash')
}
const GALLERY_CARD_SCALE_PRESETS = [
{ label: 'Klein', scale: 0.8 },
{ label: 'Normal', scale: 1.0 },
{ label: 'XL', scale: 1.2 },
] as const
const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2
function clampPresetIndex(value: number) {
return Math.max(
0,
Math.min(GALLERY_CARD_SCALE_PRESETS.length - 1, Math.round(value))
)
}
function nearestPresetIndex(value: number) {
let bestIndex = 0
let bestDist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[0].scale)
for (let i = 1; i < GALLERY_CARD_SCALE_PRESETS.length; i++) {
const dist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[i].scale)
if (dist < bestDist) {
bestDist = dist
bestIndex = i
}
}
return bestIndex
}
function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalSec = Math.floor(ms / 1000)
@ -193,44 +230,28 @@ function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredM
}
}
const reModelFromFilename = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(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 m = stem.match(reModelFromFilename)
if (m?.[1]?.trim()) {
return m[1].trim()
}
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const lower = (s: string) => (s || '').trim().toLowerCase()
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const parseTags = parseTagList
// stable dedupe (case-insensitive), aber original casing behalten
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
// wie in ModelsTab: alphabetisch (case-insensitive)
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}
const lower = lowerTag
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
const sizeBytesOf = (job: RecordJob): number | null => {
@ -507,7 +528,7 @@ function metaDurationSeconds(meta: any): number | undefined {
function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
const state = String(summary?.state ?? '').toLowerCase()
const label = String(summary?.label ?? '').toLowerCase()
const isPartialDone = state === 'done' && label.startsWith('teilweise fertig')
const isPartialDone = state === 'done' && label.startsWith('fertig (')
if (isPartialDone) {
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
@ -860,7 +881,7 @@ function buildCompletedPostworkSummaryFromMeta(
label: allDone
? 'Fertig'
: doneCount > 0
? `Teilweise fertig (${doneCount}/${steps.length})`
? `Fertig (${doneCount}/${steps.length})`
: 'Kein Status',
steps,
ts: Number(meta?.updatedAtUnix ?? 0),
@ -1043,7 +1064,7 @@ function summarizePostworkSteps(
: allDone
? 'Fertig'
: partiallyDone
? `Teilweise fertig (${doneCount}/${steps.length})`
? `Fertig (${doneCount}/${steps.length})`
: 'Kein Status'
const ts = Math.max(0, ...steps.map((s) => s.ts || 0))
@ -1156,7 +1177,7 @@ function mergePostworkSummaries(
: allDone
? 'Fertig'
: partiallyDone
? `Teilweise fertig (${doneCount}/${orderedSteps.length})`
? `Fertig (${doneCount}/${orderedSteps.length})`
: 'Kein Status'
const ts = Math.max(
@ -1428,18 +1449,30 @@ export default function FinishedDownloads({
const [postworkByFile, setPostworkByFile] =
React.useState<Record<string, FinishedPostworkBadge[]>>({})
const [selectedItems, setSelectedItems] =
React.useState<Record<string, SelectedItem>>({})
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
const [view, setView] = React.useState<ViewMode>('table')
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
const [galleryCardScale, setGalleryCardScale] = React.useState<number>(1)
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
const [galleryColumnCount, setGalleryColumnCount] = React.useState<number>(1)
const [galleryCardScaleIndex, setGalleryCardScaleIndex] = React.useState<number>(
DEFAULT_GALLERY_CARD_SCALE_INDEX
)
const selectionStoreRef = React.useRef<FinishedSelectionStore | null>(null)
const selectionStore =
selectionStoreRef.current ??
(selectionStoreRef.current = createFinishedSelectionStore())
const selectionVersion = useSelectionVersion(selectionStore)
const galleryCardScale =
GALLERY_CARD_SCALE_PRESETS[galleryCardScaleIndex]?.scale ??
GALLERY_CARD_SCALE_PRESETS[DEFAULT_GALLERY_CARD_SCALE_INDEX].scale
const handleGalleryScalePresetChange = useCallback((nextIndex: number) => {
setGalleryCardScaleIndex(clampPresetIndex(nextIndex))
}, [])
const [allDoneJobs, setAllDoneJobs] = React.useState<RecordJob[] | null>(null)
const [allDoneJobsLoading, setAllDoneJobsLoading] = React.useState(false)
@ -1492,9 +1525,6 @@ export default function FinishedDownloads({
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const scaleLiveRafRef = React.useRef<number | null>(null)
const scaleLivePendingRef = React.useRef<number>(1)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
const fileToKeyRef = React.useRef<Map<string, string>>(new Map())
@ -1518,9 +1548,24 @@ export default function FinishedDownloads({
const deferredSearchQuery = React.useDeferredValue(searchQuery)
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])
const allKnownTags = useMemo(() => {
const seen = new Set<string>()
const out: string[] = []
for (const flags of Object.values(modelsByKey ?? {})) {
for (const tag of parseTags(flags?.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
}, [modelsByKey])
const teaserState = useMemo<FinishedDownloadsTeaserState>(
() => ({
@ -1537,17 +1582,17 @@ export default function FinishedDownloads({
const tagSetByModelKey: Record<string, Set<string>> = {}
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
const key = lower(k)
const key = lowerTag(k)
const arr = parseTags(flags?.tags)
tagsByModelKey[key] = arr
tagSetByModelKey[key] = new Set(arr.map(lower))
tagSetByModelKey[key] = new Set(arr.map(lowerTag))
}
return { tagsByModelKey, tagSetByModelKey }
}, [modelsByKey])
const searchTokens = useMemo(
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
[deferredSearchQuery]
)
@ -1557,7 +1602,10 @@ export default function FinishedDownloads({
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || loadMode === 'all'
const galleryPageSize = Math.max(2, galleryColumnCount * 2)
const DEFAULT_GALLERY_PAGE_SIZE = 8
const [galleryPageSize, setGalleryPageSize] = React.useState<number>(
DEFAULT_GALLERY_PAGE_SIZE
)
const effectivePageSize =
view === 'gallery'
@ -1724,10 +1772,10 @@ export default function FinishedDownloads({
? base.filter((j) => {
const file = baseName(j.output || '')
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const modelKey = lowerTag(model)
const tags = modelTags.tagsByModelKey[modelKey] ?? []
const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
for (const t of searchTokens) {
if (!hay.includes(t)) return false
@ -1739,7 +1787,7 @@ export default function FinishedDownloads({
if (activeTagSet.size === 0) return searched
return searched.filter((j) => {
const modelKey = lower(modelNameFromOutput(j.output))
const modelKey = lowerTag(modelNameFromOutput(j.output))
const have = modelTags.tagSetByModelKey[modelKey]
if (!have || have.size === 0) return false
@ -1750,12 +1798,6 @@ export default function FinishedDownloads({
})
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
const selectedFiles = useMemo(() => {
return Object.values(selectedItems)
.map((x) => String(x.file || '').trim())
.filter(Boolean)
}, [selectedItems])
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
const pageRows = useMemo(() => {
@ -1775,84 +1817,69 @@ export default function FinishedDownloads({
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyByFilter = globalFilterActive && visibleRows.length === 0
const selectedCount = selectedFiles.length
const allPageSelected =
pageRows.length > 0 &&
pageRows.every((j) => selectedKeys.has(keyFor(j)))
const selectedCount = React.useMemo(() => {
return selectionStore.getCount()
}, [selectionStore, selectionVersion])
const selectedFiles = React.useMemo(() => {
return selectionStore.getSelectedFiles()
}, [selectionStore, selectionVersion])
const allPageSelected = React.useMemo(() => {
return (
pageRows.length > 0 &&
pageRows.every((j) => selectionStore.isSelected(keyFor(j)))
)
}, [pageRows, keyFor, selectionStore, selectionVersion])
const hasSearchQuery = (searchQuery || '').trim() !== ''
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
const selectionModeActive = selectedCount > 0
const selectedKeys = React.useMemo(() => {
const next = new Set<string>()
for (const j of pageRows) {
const k = keyFor(j)
if (selectionStore.isSelected(k)) {
next.add(k)
}
}
return next
}, [pageRows, keyFor, selectionStore, selectionVersion])
// -----------------------------------------------------------------------------
// callbacks
// -----------------------------------------------------------------------------
const handleGalleryScaleLiveChange = useCallback((next: number) => {
const clamped = Math.max(0.8, Math.min(1.35, next))
scaleLivePendingRef.current = clamped
if (scaleLiveRafRef.current != null) return
scaleLiveRafRef.current = requestAnimationFrame(() => {
scaleLiveRafRef.current = null
setGalleryCardScaleLive(scaleLivePendingRef.current)
})
}, [])
const handleGalleryScaleCommit = useCallback((next: number) => {
const clamped = Math.max(0.8, Math.min(1.35, next))
if (scaleLiveRafRef.current != null) {
cancelAnimationFrame(scaleLiveRafRef.current)
scaleLiveRafRef.current = null
}
scaleLivePendingRef.current = clamped
setGalleryCardScaleLive(clamped)
setGalleryCardScale(clamped)
}, [])
const toggleSelected = useCallback((job: RecordJob) => {
const k = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
setSelectedItems((prev) => {
const next = { ...prev }
if (next[k]) {
delete next[k]
} else {
next[k] = { key: k, file }
}
return next
})
}, [])
selectionStore.toggle({ key: k, file })
}, [selectionStore])
const clearSelection = useCallback(() => {
setSelectedItems({})
}, [])
selectionStore.clear()
}, [selectionStore])
const selectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
const k = keyFor(j)
const items = rows
.map((j) => {
const key = keyFor(j)
const file = baseName(j.output || '')
if (!file) continue
next[k] = { key: k, file }
}
return next
})
}, [])
if (!file) return null
return { key, file }
})
.filter((x): x is { key: string; file: string } => Boolean(x))
selectionStore.selectMany(items)
}, [selectionStore])
const deselectPageRows = useCallback((rows: RecordJob[]) => {
setSelectedItems((prev) => {
const next = { ...prev }
for (const j of rows) {
delete next[keyFor(j)]
}
return next
})
}, [])
selectionStore.deselectMany(rows.map((j) => keyFor(j)))
}, [selectionStore, keyFor])
const refreshHoverPreviewFromPointer = useCallback(() => {
if (!canHover) return
@ -1901,14 +1928,40 @@ export default function FinishedDownloads({
const clearSearch = useCallback(() => setSearchQuery(''), [])
const toggleTagFilter = useCallback((tag: string) => {
const k = lower(tag)
const k = lowerTag(tag)
setTagFilter((prev) => {
const has = prev.some((t) => lower(t) === k)
return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
const has = prev.some((t) => lowerTag(t) === k)
return has ? prev.filter((t) => lowerTag(t) !== k) : [...prev, tag]
})
}, [])
const clearTagFilter = useCallback(() => setTagFilter([]), [])
const clearTagFilter = useCallback(() => {
setTagFilter([])
}, [])
const addTagFilter = useCallback((tag: string) => {
const clean = String(tag ?? '').trim()
if (!clean) return
setTagFilter((prev) =>
prev.some((t) => lowerTag(t) === lowerTag(clean)) ? prev : [...prev, clean]
)
}, [])
const removeTagFilter = useCallback((tag: string) => {
setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag)))
}, [])
const renderTagFilterControls = (opts?: { mobile?: boolean }): React.ReactNode => (
<TagFilterBar
mobile={Boolean(opts?.mobile)}
activeTags={tagFilter}
allTags={allKnownTags}
onAddTag={addTagFilter}
onRemoveTag={removeTagFilter}
onClear={clearTagFilter}
/>
)
const loadAllDoneJobs = useCallback(async (): Promise<RecordJob[]> => {
const res = await fetch(
@ -2371,6 +2424,8 @@ export default function FinishedDownloads({
return (await r.json().catch(() => null)) as any
},
onSuccess: async (result: any) => {
selectionStore.deselect(key)
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
if (undoToken) {
@ -2392,6 +2447,7 @@ export default function FinishedDownloads({
withFileReleaseRetry,
runFileMutation,
emitCountHint,
selectionStore,
]
)
@ -2436,6 +2492,8 @@ export default function FinishedDownloads({
return (await r.json().catch(() => null)) as any
},
onSuccess: async (data: any) => {
selectionStore.deselect(key)
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
@ -2454,9 +2512,12 @@ export default function FinishedDownloads({
runFileMutation,
emitCountHint,
includeKeep,
selectionStore,
]
)
const applyRename = useCallback((oldFile: string, newFile: string) => {
if (!oldFile || !newFile || oldFile === newFile) return
@ -2469,22 +2530,8 @@ export default function FinishedDownloads({
return next
})
setSelectedItems((prev) => {
let changed = false
const next: Record<string, SelectedItem> = {}
for (const [k, item] of Object.entries(prev)) {
if (item.file === oldFile) {
next[k] = { ...item, file: newFile }
changed = true
} else {
next[k] = item
}
}
return changed ? next : prev
})
}, [])
selectionStore.replaceFile(oldFile, newFile)
}, [selectionStore])
const clearRenamePair = useCallback((a: string, b: string) => {
if (!a && !b) return
@ -2497,6 +2544,15 @@ export default function FinishedDownloads({
})
}, [])
const selectOnly = useCallback((job: RecordJob) => {
const key = keyFor(job)
const file = baseName(job.output || '')
if (!file) return
selectionStore.clear()
selectionStore.selectMany([{ key, file }])
}, [selectionStore])
const undoLastAction = useCallback(async () => {
if (!lastAction || undoing) return
setUndoing(true)
@ -2676,6 +2732,7 @@ export default function FinishedDownloads({
},
onError: async () => {
clearRenamePair(oldFile, optimisticNew)
selectionStore.replaceFile(optimisticNew, oldFile)
setLastAction(null)
},
})
@ -2688,6 +2745,7 @@ export default function FinishedDownloads({
clearRenamePair,
onToggleHot,
withFileReleaseRetry,
selectionStore,
]
)
@ -2858,7 +2916,6 @@ export default function FinishedDownloads({
if (!file) continue
const key =
Object.values(selectedItems).find((x) => x.file === file)?.key ||
fileToKeyRef.current.get(file) ||
file
@ -2881,7 +2938,6 @@ export default function FinishedDownloads({
}, [
bulkBusy,
selectedFiles,
selectedItems,
animateRemove,
clearSelection,
emitCountHint,
@ -2918,7 +2974,6 @@ export default function FinishedDownloads({
if (!file) continue
const key =
Object.values(selectedItems).find((x) => x.file === file)?.key ||
fileToKeyRef.current.get(file) ||
file
@ -2941,7 +2996,6 @@ export default function FinishedDownloads({
}, [
bulkBusy,
selectedFiles,
selectedItems,
animateRemove,
clearSelection,
emitCountHint,
@ -2995,36 +3049,45 @@ export default function FinishedDownloads({
useEffect(() => {
if (view !== 'gallery') return
const wanted = Math.max(2, galleryColumnCount * 2)
const wanted = galleryPageSize
if (pageSize !== wanted) {
onPageSizeChange?.(wanted)
}
}, [view, galleryColumnCount, pageSize, onPageSizeChange])
}, [view, pageSize, galleryPageSize, onPageSizeChange])
useEffect(() => {
try {
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
const parsed = Number(raw)
const initial = Number.isFinite(parsed)
? Math.max(0.8, Math.min(1.35, parsed))
: 1
setGalleryCardScale(initial)
setGalleryCardScaleLive(initial)
scaleLivePendingRef.current = initial
if (!Number.isFinite(parsed)) {
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
return
}
// neuer Fall: gespeicherter Index
if (
Number.isInteger(parsed) &&
parsed >= 0 &&
parsed <= GALLERY_CARD_SCALE_PRESETS.length - 1
) {
setGalleryCardScaleIndex(clampPresetIndex(parsed))
return
}
// alter Fall: gespeicherter Scale-Wert wie 0.8 / 0.9 / 1 / 1.2
setGalleryCardScaleIndex(nearestPresetIndex(parsed))
} catch {
setGalleryCardScale(1)
setGalleryCardScaleLive(1)
scaleLivePendingRef.current = 1
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScale))
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScaleIndex))
} catch {}
}, [galleryCardScale])
}, [galleryCardScaleIndex])
useEffect(() => {
try {
@ -3079,15 +3142,6 @@ export default function FinishedDownloads({
}
}, [])
useEffect(() => {
return () => {
if (scaleLiveRafRef.current != null) {
cancelAnimationFrame(scaleLiveRafRef.current)
scaleLiveRafRef.current = null
}
}
}, [])
useEffect(() => {
return () => {
if (teaserKeyRafRef.current != null) {
@ -3169,6 +3223,8 @@ export default function FinishedDownloads({
const file = String(item?.file ?? '').trim()
if (!file) continue
syncedFiles.add(file)
let nextBadges: FinishedPostworkBadge[] | undefined
const assetId =
@ -3564,24 +3620,29 @@ export default function FinishedDownloads({
}, [selectedCount, selectionActionsOpen])
const desktopToolbarControlsClass = [
'flex items-center gap-3',
'flex-nowrap',
'md:flex-wrap',
'xl:flex-nowrap',
'flex flex-wrap items-start gap-3',
'min-[1800px]:flex-nowrap min-[1800px]:items-center',
].join(' ')
const desktopToolbarLeftClass = [
'flex min-w-0 shrink-0 items-center gap-2',
'flex-nowrap',
'flex min-w-0 flex-wrap items-center gap-2',
'min-[1800px]:min-w-max min-[1800px]:shrink-0 min-[1800px]:flex-nowrap',
].join(' ')
const desktopToolbarRightClass = [
'flex min-w-0 items-center gap-2',
'flex-nowrap',
'md:ml-0 md:basis-full md:flex-wrap',
'xl:ml-auto xl:basis-auto xl:flex-nowrap xl:justify-end',
'flex w-full min-w-0 flex-wrap items-center gap-2',
'min-[1800px]:ml-auto min-[1800px]:w-auto min-[1800px]:min-w-max min-[1800px]:flex-nowrap min-[1800px]:justify-end',
].join(' ')
const handleToggleSelectAllPage = useCallback((checked: boolean) => {
if (checked) {
selectPageRows(pageRows)
return
}
deselectPageRows(pageRows)
}, [pageRows, selectPageRows, deselectPageRows])
const renderClickablePostworkBadge = useCallback(
(badge?: FinishedPostworkSummary) =>
renderFinishedPostworkBadge(badge, {
@ -3602,7 +3663,7 @@ export default function FinishedDownloads({
return (
<>
{/* Toolbar (sticky) */}
<div className="sticky top-[56px] z-20">
<div className="sticky top-[56px] z-[80]">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
@ -3627,7 +3688,10 @@ export default function FinishedDownloads({
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
<LoadingSpinner
size="lg"
className="text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none"
className={[
'text-indigo-500 transition-opacity duration-150 pointer-events-none',
allDoneJobsLoading ? 'opacity-100' : 'opacity-0',
].join(' ')}
srLabel="Lade Downloads…"
/>
</div>
@ -3699,23 +3763,28 @@ export default function FinishedDownloads({
{/* rechts */}
<div className={desktopToolbarRightClass}>
{view === 'gallery' ? (
<div className="w-[160px] shrink-0 md:w-[180px]">
<div className="w-[200px] shrink-0 md:w-[220px]">
<Slider
id="finished-gallery-card-scale"
label="Größe"
min={0.8}
max={1.35}
value={galleryCardScaleLive}
onChange={handleGalleryScaleLiveChange}
onChangeEnd={handleGalleryScaleCommit}
min={0}
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
step={1}
value={galleryCardScaleIndex}
onChange={handleGalleryScalePresetChange}
onChangeEnd={handleGalleryScalePresetChange}
valueFormatter={(v) => {
const idx = clampPresetIndex(v)
return GALLERY_CARD_SCALE_PRESETS[idx].label
}}
showValueLabel
compact
softStep
/>
</div>
) : null}
{view !== 'table' ? (
<div className="w-[190px] shrink-0 md:w-[220px]">
<div className="w-[150px] shrink-0 md:w-[165px] xl:w-[175px]">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
@ -4044,28 +4113,16 @@ export default function FinishedDownloads({
</div>
</div>
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex items-start gap-2">
<span className="pt-1 text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
<div className="min-w-0 flex-1">
{renderTagFilterControls()}
</div>
</div>
</div>
) : null}
@ -4125,13 +4182,17 @@ export default function FinishedDownloads({
<Slider
id="finished-gallery-card-scale-mobile"
label="Kartengröße"
min={0.8}
max={1.35}
value={galleryCardScaleLive}
onChange={handleGalleryScaleLiveChange}
onChangeEnd={handleGalleryScaleCommit}
softStep
showValueLabel={false}
min={0}
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
step={1}
value={galleryCardScaleIndex}
onChange={handleGalleryScalePresetChange}
onChangeEnd={handleGalleryScalePresetChange}
valueFormatter={(v) => {
const idx = clampPresetIndex(v)
return GALLERY_CARD_SCALE_PRESETS[idx].label
}}
showValueLabel
/>
</div>
) : null}
@ -4168,31 +4229,13 @@ export default function FinishedDownloads({
</div>
</div>
{tagFilter.length > 0 ? (
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
Tag-Filter
</span>
<div className="flex flex-wrap items-center gap-1.5">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
))}
<Button
className="
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
"
size="sm"
variant="soft"
onClick={clearTagFilter}
>
Zurücksetzen
</Button>
</div>
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">
Tag-Filter
</div>
{renderTagFilterControls({ mobile: true })}
</div>
) : null}
</div>
@ -4251,12 +4294,10 @@ export default function FinishedDownloads({
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
<FinishedDownloadsCardsView
rows={pageRows}
selectedKeys={selectedKeys}
selectionStore={selectionStore}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
onToggleSelectAllPage={handleToggleSelectAllPage}
onSelectOnly={selectOnly}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={allDoneJobsLoading}
@ -4274,7 +4315,7 @@ export default function FinishedDownloads({
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
lower={lowerTag}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
onOpenPlayerAt={openPlayerAt}
@ -4313,6 +4354,7 @@ export default function FinishedDownloads({
rows={pageRows}
isLoading={allDoneJobsLoading}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
@ -4321,7 +4363,7 @@ export default function FinishedDownloads({
jobForDetails={jobForDetails}
keyFor={keyFor}
baseName={baseName}
lower={lower}
lower={lowerTag}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
@ -4365,13 +4407,10 @@ export default function FinishedDownloads({
{view === 'gallery' && (
<FinishedDownloadsGalleryView
rows={pageRows}
onColumnCountChange={setGalleryColumnCount}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
onSelectOnly={selectOnly}
isLoading={allDoneJobsLoading}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
@ -4387,14 +4426,13 @@ export default function FinishedDownloads({
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
deletedKeys={deletedKeys}
registerTeaserHost={registerTeaserHost}
onOpenPlayer={onOpenPlayer}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={toggleHotVideo}
lower={lower}
lower={lowerTag}
modelsByKey={modelsByKey}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
@ -4407,8 +4445,9 @@ export default function FinishedDownloads({
postworkByFile={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
cardScale={galleryCardScaleLive}
cardScale={galleryCardScale}
renderPostworkBadge={renderClickablePostworkBadge}
onRecommendedPageSizeChange={setGalleryPageSize}
/>
)}
</div>

View File

@ -1,8 +1,17 @@
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client'
import * as React from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from 'react'
import Card from './Card'
import Button from './Button'
import type {
RecordJob,
FinishedPostworkSummary,
@ -30,14 +39,22 @@ import {
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
import React from 'react'
import {
type FinishedSelectionStore,
useSelectionChecked,
} from './finishedSelectionStore'
type Props = {
rows: RecordJob[]
isLoading?: boolean
isSmall: boolean
selectedKeys: Set<string>
selectionStore: FinishedSelectionStore
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
onSelectOnly: (job: RecordJob) => void
blurPreviews?: boolean
durations: Record<string, number>
@ -48,7 +65,7 @@ type Props = {
keepingKeys: Set<string>
removingKeys: Set<string>
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
swipeRefs: MutableRefObject<Map<string, SwipeCardHandle>>
assetNonce?: number
@ -97,32 +114,13 @@ type Props = {
summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void
}
const parseTags = (raw?: string): string[] => {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
return out
}
function CardBlurWrapper({
blurred,
animateUnblurOnMount,
@ -130,11 +128,11 @@ function CardBlurWrapper({
}: {
blurred?: boolean
animateUnblurOnMount?: boolean
children: React.ReactNode
children: ReactNode
}) {
const [entered, setEntered] = React.useState(!animateUnblurOnMount)
const [entered, setEntered] = useState(!animateUnblurOnMount)
React.useEffect(() => {
useEffect(() => {
if (!animateUnblurOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
@ -161,11 +159,11 @@ function PromoteToFrontWrapper({
children,
}: {
animateOnMount?: boolean
children: React.ReactNode
children: ReactNode
}) {
const [entered, setEntered] = React.useState(!animateOnMount)
const [entered, setEntered] = useState(!animateOnMount)
React.useEffect(() => {
useEffect(() => {
if (!animateOnMount) return
const raf = requestAnimationFrame(() => {
setEntered(true)
@ -288,14 +286,14 @@ function MobileLoadingStack({
</div>
)
})}
</div>
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
<LoadingCardSkeleton />
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade</div>
</div>
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
<LoadingCardSkeleton />
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade</div>
</div>
</div>
</div>
@ -303,11 +301,434 @@ function MobileLoadingStack({
)
}
export default function FinishedDownloadsCardsView({
type FinishedDownloadsDesktopCardProps = {
job: RecordJob
selectionStore: FinishedSelectionStore
isDeleting: boolean
isKeeping: boolean
isRemoving: boolean
postworkBadge?: FinishedPostworkSummary
blurPreviews?: boolean
durationSeconds?: number
teaserState: FinishedDownloadsTeaserState
inlinePlay: InlinePlayState
assetNonce?: number
forcePreviewMuted?: boolean
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null
formatBytes: (bytes?: number | null) => string
lower: (s: string) => string
jobForDetails: (job: RecordJob) => RecordJob
handleDuration: (job: RecordJob, seconds: number) => void
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
onToggleSelected: (job: RecordJob) => void
onSelectOnly: (job: RecordJob) => void
onHoverPreviewKeyChange?: (key: string | null) => void
onOpenPlayer: (job: RecordJob) => void
openPlayer: (job: RecordJob) => void
startInline: (key: string) => void
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
modelsByKey: Record<string, GalleryModelFlags>
activeTagSet: Set<string>
onToggleTagFilter: (tag: string) => void
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
deleteVideo: (job: RecordJob) => Promise<boolean>
keepVideo: (job: RecordJob) => Promise<boolean>
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
}
function samePostworkSummaryLite(
a?: FinishedPostworkSummary,
b?: FinishedPostworkSummary
) {
if (a === b) return true
if (!a || !b) return !a && !b
return (
a.file === b.file &&
a.state === b.state &&
a.label === b.label &&
a.ts === b.ts
)
}
function isPreviewActiveForKey(
state: FinishedDownloadsTeaserState,
key: string
) {
return state.activeKey === key || state.hoverKey === key
}
function inlineStateForKey(
inlinePlay: InlinePlayState,
key: string
) {
const active = inlinePlay?.key === key
return {
active,
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
}
}
function SelectionCheckboxOverlay({
selectionStore,
rowKey,
label,
onToggle,
}: {
selectionStore: FinishedSelectionStore
rowKey: string
label: string
onToggle: () => void
}) {
const checked = useSelectionChecked(selectionStore, rowKey)
return (
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={label}
onChange={onToggle}
/>
</div>
)
}
const FinishedDownloadsDesktopCard = React.memo(
function FinishedDownloadsDesktopCard({
job: j,
selectionStore,
isDeleting,
isKeeping,
isRemoving,
postworkBadge,
blurPreviews,
durationSeconds,
teaserState,
inlinePlay,
assetNonce,
forcePreviewMuted,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
sizeBytesOf,
formatBytes,
lower,
jobForDetails,
handleDuration,
onToggleSelected,
onHoverPreviewKeyChange,
onOpenPlayer,
openPlayer,
startInline,
registerTeaserHost,
modelsByKey,
activeTagSet,
onToggleTagFilter,
onToggleHot,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onSplit,
onAddToDownloads,
deleteVideo,
keepVideo,
renderPostworkBadge,
}: FinishedDownloadsDesktopCardProps) {
const k = keyFor(j)
const busy = isDeleting || isKeeping || isRemoving
const inlineActive = inlinePlay?.key === k
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
const allowSound = shouldEnableTeaserAudio({
state: teaserState,
itemKey: k,
inlineActive,
disabled: false,
})
const handleSelectOrOpen = React.useCallback(() => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}, [selectionStore, onToggleSelected, j])
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const tags = parseFinishedTags(flags?.tags)
const modelCountry = resolveFinishedCountry(flags, j)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
const meta = parseJobMeta((j as any)?.meta)
const resObj =
typeof meta?.file?.video?.width === 'number' &&
typeof meta?.file?.video?.height === 'number' &&
meta.file.video.width > 0 &&
meta.file.video.height > 0
? { w: meta.file.video.width, h: meta.file.video.height }
: null
const resLabel = formatResolution(resObj)
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
return (
<div
role="button"
tabIndex={0}
data-finished-hover-key={k}
className={[
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
'bg-white dark:bg-gray-900/40',
'transition-all duration-200',
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
busy && 'pointer-events-none opacity-70',
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
].filter(Boolean).join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j)
}
}}
>
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
<Card noBodyPadding className="overflow-hidden bg-transparent">
<div
id={inlineDomId}
ref={registerTeaserHost(k)}
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
startInline(k)
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
{!inlineActive ? (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
>
<span>{dur}</span>
{resLabel ? <span aria-hidden="true"></span> : null}
{resLabel ? <span>{resLabel}</span> : null}
<span aria-hidden="true"></span>
<span>{size}</span>
</div>
</div>
) : null}
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
variant="fill"
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
onDuration={handleDuration}
showPopover={false}
blur={inlineActive ? false : Boolean(blurPreviews)}
animated={shouldAnimateTeaser({
state: teaserState,
itemKey: k,
disabled: false,
})}
animatedMode="teaser"
animatedTrigger="always"
inlineVideo={inlineActive ? 'always' : false}
inlineNonce={inlineNonce}
inlineControls={inlineActive}
inlineLoop={false}
muted={previewMuted}
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</div>
</div>
<div
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (handleSelectOrOpen()) return
openPlayer(j)
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
</div>
{renderPostworkBadge && postworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-start gap-2 min-w-0">
{isHot ? (
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
HOT
</span>
) : null}
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
{stripHotPrefix(fileRaw) || '—'}
</span>
</div>
</div>
</div>
<div
className="mt-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<RecordJobActions
job={jobForDetails(j)}
variant="table"
busy={busy}
collapseToMenu
compact={false}
isHot={isHot}
isFavorite={Boolean(flags?.favorite)}
isLiked={flags?.liked === true}
isWatching={Boolean(flags?.watching)}
onToggleWatch={onToggleWatch}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
className="w-full gap-1.5"
/>
</div>
</div>
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<TagOverflowRow
rowKey={k}
tags={tags}
activeTagSet={activeTagSet}
lower={lower}
onToggleTagFilter={onToggleTagFilter}
/>
</div>
</div>
</Card>
</div>
)
},
(prev, next) => {
const prevKey = prev.keyFor(prev.job)
const nextKey = next.keyFor(next.job)
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
return (
prev.job === next.job &&
prev.isDeleting === next.isDeleting &&
prev.isKeeping === next.isKeeping &&
prev.isRemoving === next.isRemoving &&
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
prev.durationSeconds === next.durationSeconds &&
prev.forcePreviewMuted === next.forcePreviewMuted &&
prev.blurPreviews === next.blurPreviews &&
prev.teaserState.mode === next.teaserState.mode &&
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
prevPreviewActive === nextPreviewActive &&
prevInline.active === nextInline.active &&
prevInline.nonce === nextInline.nonce &&
prev.modelsByKey === next.modelsByKey &&
prev.activeTagSet === next.activeTagSet &&
prev.renderPostworkBadge === next.renderPostworkBadge
)
}
)
function FinishedDownloadsCardsView({
rows,
isSmall,
selectedKeys,
selectionStore,
onToggleSelected,
onSelectOnly,
isLoading,
blurPreviews,
durations,
@ -360,14 +781,13 @@ export default function FinishedDownloadsCardsView({
onUnlockMobileTeaserAudio,
}: Props) {
const hasAnySelection = selectedKeys.size > 0
const footerTapRef = React.useRef<Record<string, boolean>>({})
const footerTapRef = useRef<Record<string, boolean>>({})
const teaserPlayFnsRef = React.useRef<Record<string, (() => void) | null>>({})
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
const [speedHoldKey, setSpeedHoldKey] = React.useState<string | null>(null)
const [speedHoldKey, setSpeedHoldKey] = useState<string | null>(null)
const registerTeaserPlayForKey = React.useCallback(
const registerTeaserPlayForKey = useCallback(
(key: string) => (fn: (() => void) | null) => {
if (fn) teaserPlayFnsRef.current[key] = fn
else delete teaserPlayFnsRef.current[key]
@ -375,11 +795,11 @@ export default function FinishedDownloadsCardsView({
[]
)
const parseMeta = React.useCallback((j: RecordJob): any | null => {
const parseMeta = useCallback((j: RecordJob): any | null => {
return parseJobMeta((j as any)?.meta)
}, [])
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
const resolutionObjOf = useCallback((j: RecordJob): { w: number; h: number } | null => {
const meta = parseMeta(j)
const w =
@ -396,12 +816,12 @@ export default function FinishedDownloadsCardsView({
return null
}, [parseMeta])
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({})
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => {
setScrubActiveByKey((prev) => {
if (index === undefined) {
if (!(key in prev)) return prev
@ -415,11 +835,11 @@ export default function FinishedDownloadsCardsView({
})
}, [])
const clearScrubActiveIndex = React.useCallback((key: string) => {
const clearScrubActiveIndex = useCallback((key: string) => {
setScrubActiveIndex(key, undefined)
}, [setScrubActiveIndex])
const setScrubHovering = React.useCallback((key: string, hovering: boolean | undefined) => {
const setScrubHovering = useCallback((key: string, hovering: boolean | undefined) => {
setScrubHoveringByKey((prev) => {
if (hovering === undefined) {
if (!(key in prev)) return prev
@ -450,6 +870,14 @@ export default function FinishedDownloadsCardsView({
) => {
const k = keyFor(j)
const handleSelectOrOpen = () => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}
const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive
@ -488,13 +916,18 @@ export default function FinishedDownloadsCardsView({
})
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const checked = selectedKeys.has(k)
const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '')
const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const scrubActiveIndex = scrubActiveByKey[k]
const scrubHovering = scrubHoveringByKey[k] === true
@ -525,7 +958,9 @@ export default function FinishedDownloadsCardsView({
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const tags = parseTags(flags?.tags)
const tags = parseFinishedTags(flags?.tags)
const modelCountry = resolveFinishedCountry(flags, j)
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
@ -567,12 +1002,8 @@ export default function FinishedDownloadsCardsView({
data-finished-hover-key={k}
className={shellCls}
onClick={(e) => {
if (hasAnySelection) {
e.preventDefault()
e.stopPropagation()
onToggleSelected(j)
return
}
e.preventDefault()
e.stopPropagation()
if (!isSmall) {
openPlayer(j)
@ -598,6 +1029,8 @@ export default function FinishedDownloadsCardsView({
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j)
}
}}
@ -607,24 +1040,12 @@ export default function FinishedDownloadsCardsView({
animateUnblurOnMount={opts?.animateUnblurOnMount}
>
{!isSmall ? (
<div
className={[
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
onChange={() => onToggleSelected(j)}
/>
</div>
<SelectionCheckboxOverlay
selectionStore={selectionStore}
rowKey={k}
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
onToggle={() => onToggleSelected(j)}
/>
) : null}
{/* Card shell keeps backgrounds consistent */}
@ -654,11 +1075,6 @@ export default function FinishedDownloadsCardsView({
e.preventDefault()
e.stopPropagation()
if (hasAnySelection) {
onToggleSelected(j)
return
}
if (isSmall || opts?.disableInline) return
startInline(k)
}}
@ -846,6 +1262,12 @@ export default function FinishedDownloadsCardsView({
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return
}
openPlayer(j)
}}
>
@ -859,8 +1281,12 @@ export default function FinishedDownloadsCardsView({
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
<CountryFlag
country={modelCountry}
size="sm"
className="shrink-0"
/>
</div>
@ -947,30 +1373,78 @@ export default function FinishedDownloadsCardsView({
allowTeaserAnimation,
}
}
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
const mobileOrderedRows = useMemo(() => {
if (!isSmall || skippedMobileKeys.length === 0) return rows
const skippedSet = new Set(skippedMobileKeys)
const front = rows.filter((row) => !skippedSet.has(keyFor(row)))
const byKey = new Map(rows.map((row) => [keyFor(row), row] as const))
const back = skippedMobileKeys
.map((key) => byKey.get(key))
.filter((row): row is RecordJob => Boolean(row))
return [...front, ...back]
}, [isSmall, rows, skippedMobileKeys, keyFor])
const skipTopMobileCard = useCallback(() => {
if (!isSmall || mobileOrderedRows.length <= 1) return
const topRow = mobileOrderedRows[0]
if (!topRow) return
const topKey = keyFor(topRow)
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
setSkippedMobileKeys((prev) => [
...prev.filter((key) => key !== topKey),
topKey,
])
}, [isSmall, mobileOrderedRows, keyFor])
const mobileStackDepth = 3
const mobilePreloadDepth = mobileStackDepth + 3
// ✅ 4 Karten mounten, aber nur 3 sichtbar stacken
const mobileLoadedStackRows = isSmall ? rows.slice(0, mobilePreloadDepth) : []
const mobileLoadedStackRows = isSmall ? mobileOrderedRows.slice(0, mobilePreloadDepth) : []
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
const mobileTopRow = mobileVisibleStackRows[0] ?? null
const mobileTopKey = isSmall && mobileVisibleStackRows[0]
? keyFor(mobileVisibleStackRows[0])
: ''
const [mobileTopActivationNonce, setMobileTopActivationNonce] = React.useState(0)
const [mobileTopActivationNonce, setMobileTopActivationNonce] = useState(0)
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = React.useState(false)
const mobileTopTeaserEnableTimerRef = React.useRef<number | null>(null)
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = useState(false)
const mobileTopTeaserEnableTimerRef = useRef<number | null>(null)
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true)
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null)
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState<string | null>(null)
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.useState<string | null>(null)
const [mobileTopTapEnabled, setMobileTopTapEnabled] = useState(true)
const mobileTopTapEnableTimerRef = useRef<number | null>(null)
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = useState<string | null>(null)
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = useState<string | null>(null)
React.useEffect(() => {
useEffect(() => {
if (!isSmall) {
setSkippedMobileKeys([])
return
}
const validKeys = new Set(rows.map((row) => keyFor(row)))
setSkippedMobileKeys((prev) => {
const next = prev.filter((key) => validKeys.has(key))
return next.length === prev.length ? prev : next
})
}, [isSmall, rows, keyFor])
useEffect(() => {
if (!isSmall) return
if (!mobileTopKey) return
if (teaserAudioEnabledForKey !== mobileTopKey) return
@ -983,7 +1457,7 @@ export default function FinishedDownloadsCardsView({
})
}, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked])
React.useEffect(() => {
useEffect(() => {
if (mobileTopTeaserEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
mobileTopTeaserEnableTimerRef.current = null
@ -1049,18 +1523,59 @@ export default function FinishedDownloadsCardsView({
{!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' })
const k = keyFor(j)
return (
<React.Fragment key={`${k}__desktop`}>
{cardInner}
</React.Fragment>
<FinishedDownloadsDesktopCard
key={`${k}__desktop`}
job={j}
selectionStore={selectionStore}
isDeleting={deletingKeys.has(k)}
isKeeping={keepingKeys.has(k)}
isRemoving={removingKeys.has(k)}
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
blurPreviews={blurPreviews}
durationSeconds={durations[k]}
teaserState={teaserState}
inlinePlay={inlinePlay}
assetNonce={assetNonce}
forcePreviewMuted={forcePreviewMuted}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
jobForDetails={jobForDetails}
handleDuration={handleDuration}
handleScrubberClickIndex={handleScrubberClickIndex}
onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onOpenPlayer={onOpenPlayer}
openPlayer={openPlayer}
startInline={startInline}
registerTeaserHost={registerTeaserHost}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
onToggleTagFilter={onToggleTagFilter}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
renderPostworkBadge={renderPostworkBadge}
/>
)
})}
</div>
) : (
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? (
{mobileOrderedRows.length === 0 ? (
isLoading ? (
<MobileLoadingStack
stackPeekOffsetPx={stackPeekOffsetPx}
@ -1194,14 +1709,14 @@ export default function FinishedDownloadsCardsView({
onTap={() => {
if (!mobileTopTapEnabled) return
if (footerTapRef.current[k]) {
delete footerTapRef.current[k]
openPlayer(j)
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return
}
if (hasAnySelection) {
onToggleSelected(j)
if (footerTapRef.current[k]) {
delete footerTapRef.current[k]
openPlayer(j)
return
}
@ -1240,6 +1755,19 @@ export default function FinishedDownloadsCardsView({
</div>
</div>
)}
{mobileTopRow && mobileOrderedRows.length > 1 ? (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="soft"
onClick={skipTopMobileCard}
className="w-full"
>
Überspringen
</Button>
</div>
) : null}
</div>
)}
@ -1254,3 +1782,6 @@ export default function FinishedDownloadsCardsView({
</div>
)
}
const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView)
export default MemoFinishedDownloadsCardsView

View File

@ -23,11 +23,12 @@ import Checkbox from './Checkbox'
import {
buildSpriteFrameStyle,
parseJobMeta,
firstNonEmptyString,
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -36,8 +37,9 @@ function normalizeDurationSeconds(value: unknown): number | undefined {
type Props = {
job: RecordJob
checked: boolean
hasAnySelection: boolean
selectedKeys: Set<string>
selectionModeActive: boolean
rowKey: string
isPreviewActive: boolean
forcePreviewMuted?: boolean
blurPreviews?: boolean
@ -52,7 +54,6 @@ type Props = {
durationSeconds?: number
teaserState: FinishedDownloadsTeaserState
keyFor: (j: RecordJob) => string
baseName: (p: string) => string
modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string
@ -70,6 +71,7 @@ type Props = {
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
onToggleSelected: (job: RecordJob) => void
onSelectOnly: (job: RecordJob) => void
onHoverPreviewKeyChange?: (key: string | null) => void
onToggleTagFilter: (tag: string) => void
onOpenPlayer: (job: RecordJob) => void
@ -93,8 +95,9 @@ type Props = {
function FinishedDownloadsGalleryCardInner({
job: j,
checked,
hasAnySelection,
selectedKeys,
selectionModeActive,
rowKey,
isPreviewActive,
forcePreviewMuted,
blurPreviews,
@ -103,7 +106,6 @@ function FinishedDownloadsGalleryCardInner({
renderPostworkBadge,
durationSeconds,
teaserState,
keyFor,
baseName,
modelNameFromOutput,
runtimeOf,
@ -117,6 +119,7 @@ function FinishedDownloadsGalleryCardInner({
removingKeys,
registerTeaserHost,
onToggleSelected,
onSelectOnly,
onHoverPreviewKeyChange,
onToggleTagFilter,
onOpenPlayer,
@ -136,12 +139,27 @@ function FinishedDownloadsGalleryCardInner({
titleClass,
sublineClass,
}: Props) {
const k = keyFor(j)
const k = rowKey
const checked = selectedKeys.has(rowKey)
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
const handlePrimaryClick = React.useCallback(
(e?: React.SyntheticEvent) => {
e?.preventDefault()
e?.stopPropagation()
if (selectionModeActive) {
onToggleSelected(j)
return
}
onOpenPlayer(j)
},
[selectionModeActive, onSelectOnly, onOpenPlayer, j]
)
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
setScrubIndexByKey((prev) => {
if (index === undefined) {
@ -166,8 +184,13 @@ function FinishedDownloadsGalleryCardInner({
const previewMuted = forcePreviewMuted ? true : !allowSound
const model = modelNameFromOutput(j.output)
const modelKey = lower(model)
const flags = modelsByKey[modelKey]
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const tags = React.useMemo(() => {
const s = String(flags?.tags ?? '').trim()
@ -188,6 +211,8 @@ function FinishedDownloadsGalleryCardInner({
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const country = resolveFinishedCountry(flags, j)
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
@ -219,13 +244,6 @@ function FinishedDownloadsGalleryCardInner({
const isKeeping = keepingKeys.has(k)
const busy = isDeleting || isKeeping || isRemoving
const modelImageSrc = firstNonEmptyString(
flags?.image,
flags?.imageUrl,
(j as any)?.meta?.modelImage,
(j as any)?.meta?.modelImageUrl
)
const sprite = React.useMemo(
() =>
readPreviewSpriteInfo((j as any)?.meta, {
@ -257,9 +275,8 @@ function FinishedDownloadsGalleryCardInner({
[spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex]
)
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
const previewDurationSeconds =
durationSeconds ??
@ -274,23 +291,17 @@ function FinishedDownloadsGalleryCardInner({
data-finished-hover-key={k}
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
onClick={(e) => {
if (hasAnySelection) {
e.preventDefault()
e.stopPropagation()
onToggleSelected(j)
return
}
onOpenPlayer(j)
handlePrimaryClick(e)
}}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
onHoverPreviewKeyChange?.(null)
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
if (e.key !== 'Enter' && e.key !== ' ') return
handlePrimaryClick(e)
}}
>
<div
@ -311,11 +322,8 @@ function FinishedDownloadsGalleryCardInner({
'absolute left-3 top-3 z-20 transition-opacity duration-150',
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={checked}
@ -331,7 +339,6 @@ function FinishedDownloadsGalleryCardInner({
onMouseLeave={() => {
setHoveredThumbKey((prev) => (prev === k ? null : prev))
clearScrubIndex(k)
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
}}
>
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
@ -377,22 +384,6 @@ function FinishedDownloadsGalleryCardInner({
</div>
) : null}
{showModelPreviewInThumb && modelImageSrc ? (
<div className="absolute inset-0 z-[6]">
<img
src={modelImageSrc}
alt={model ? `${model} preview` : 'Model preview'}
className="h-full w-full object-cover"
draggable={false}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
<div className="text-[10px] font-semibold tracking-wide text-white/95">
MODEL PREVIEW
</div>
</div>
</div>
) : null}
{hasScrubber && hoveredThumbKey === k ? (
<div
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
@ -435,9 +426,7 @@ function FinishedDownloadsGalleryCardInner({
contentPaddingClass,
].join(' ')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onOpenPlayer(j)
handlePrimaryClick(e)
}}
>
<div className="min-w-0">
@ -449,8 +438,12 @@ function FinishedDownloadsGalleryCardInner({
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
<CountryFlag
country={country}
size="sm"
className="shrink-0"
/>
</div>

View File

@ -16,9 +16,9 @@ import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
type Props = {
rows: RecordJob[]
selectedKeys: Set<string>
selectionModeActive: boolean
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
onColumnCountChange?: (count: number) => void
onSelectOnly: (job: RecordJob) => void
isLoading?: boolean
blurPreviews?: boolean
durations: Record<string, number>
@ -46,7 +46,6 @@ type Props = {
deletingKeys: Set<string>
keepingKeys: Set<string>
removingKeys: Set<string>
deletedKeys: Set<string>
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
@ -66,6 +65,7 @@ type Props = {
onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
onRecommendedPageSizeChange?: (size: number) => void
forcePreviewMuted?: boolean
}
@ -81,7 +81,7 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
export default function FinishedDownloadsGalleryView({
function FinishedDownloadsGalleryView({
rows,
isLoading,
blurPreviews,
@ -91,9 +91,10 @@ export default function FinishedDownloadsGalleryView({
teaserState,
renderPostworkBadge,
cardScale,
onColumnCountChange,
selectedKeys,
selectionModeActive,
onToggleSelected,
onSelectOnly,
handleDuration,
handleScrubberClickIndex,
jobForDetails,
@ -121,6 +122,7 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch,
onSplit,
onAddToDownloads,
onRecommendedPageSizeChange,
forcePreviewMuted,
}: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode)
@ -148,73 +150,105 @@ export default function FinishedDownloadsGalleryView({
[effectiveScale]
)
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [containerWidth, setContainerWidth] = React.useState(0)
const gapPx = 12
const preferredCardWidth = React.useMemo(() => {
const base = 240
return Math.round(base * layoutScale)
const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
if (layoutScale >= 1.14) return 'xl'
if (layoutScale >= 0.99) return 'lg'
if (layoutScale >= 0.88) return 'md'
return 'sm'
}, [layoutScale])
const columnCount = React.useMemo(() => {
if (!containerWidth || containerWidth <= 0) {
return 1
}
const minCardWidth = React.useMemo(() => {
return Math.round(270 * layoutScale)
}, [layoutScale])
const columnsThatFit = Math.floor(
(containerWidth + gapPx) / (preferredCardWidth + gapPx)
)
const rootRef = React.useRef<HTMLDivElement | null>(null)
const lastRecommendedPageSizeRef = React.useRef<number | null>(null)
return Math.max(1, columnsThatFit)
}, [containerWidth, preferredCardWidth])
const recommendPageSize = React.useCallback(() => {
if (!onRecommendedPageSizeChange) return
React.useEffect(() => {
onColumnCountChange?.(columnCount)
}, [columnCount, onColumnCountChange])
const cardMinWidth = React.useMemo(() => {
return preferredCardWidth
}, [preferredCardWidth])
const gridStyle = React.useMemo<React.CSSProperties>(() => {
return {
['--gallery-card-min' as any]: `${cardMinWidth}px`,
}
}, [cardMinWidth])
const contentPaddingClass = 'px-3.5 py-3'
const footerMinHeightClass = 'min-h-[118px]'
const titleClass = 'text-sm'
const sublineClass = 'text-xs'
React.useLayoutEffect(() => {
const el = containerRef.current
const el = rootRef.current
if (!el) return
const update = () => {
setContainerWidth(el.clientWidth || 0)
}
const containerWidth = el.clientWidth
if (!Number.isFinite(containerWidth) || containerWidth <= 0) return
update()
const gapPx = 12 // entspricht Tailwind gap-3
const columns = Math.max(
1,
Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx))
)
// mobil etwas mehr vertikale Füllung, desktop wie bisher ca. 2 Reihen
const rowsPerPage = containerWidth < 640 ? 3 : 2
const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24)
if (lastRecommendedPageSizeRef.current === nextPageSize) return
lastRecommendedPageSizeRef.current = nextPageSize
onRecommendedPageSizeChange(nextPageSize)
}, [minCardWidth, onRecommendedPageSizeChange])
React.useEffect(() => {
recommendPageSize()
const el = rootRef.current
if (!el) return
const ro = new ResizeObserver(() => {
recommendPageSize()
})
const ro = new ResizeObserver(() => update())
ro.observe(el)
window.addEventListener('resize', recommendPageSize)
return () => {
ro.disconnect()
window.removeEventListener('resize', recommendPageSize)
}
}, [])
}, [recommendPageSize])
const contentPaddingClass =
sizePreset === 'xl'
? 'px-4 py-3.5'
: sizePreset === 'lg'
? 'px-3.5 py-3'
: sizePreset === 'md'
? 'px-3 py-2.5'
: 'px-2.5 py-2'
const footerMinHeightClass =
sizePreset === 'xl'
? 'min-h-[132px]'
: sizePreset === 'lg'
? 'min-h-[124px]'
: sizePreset === 'md'
? 'min-h-[118px]'
: 'min-h-[110px]'
const titleClass =
sizePreset === 'xl'
? 'text-[15px]'
: sizePreset === 'lg'
? 'text-sm'
: sizePreset === 'md'
? 'text-[13px]'
: 'text-xs'
const sublineClass =
sizePreset === 'xl'
? 'text-[13px]'
: sizePreset === 'lg'
? 'text-xs'
: 'text-[11px]'
return (
<div className="relative w-full" ref={containerRef}>
<div ref={rootRef} className="relative w-full">
<div
className="grid w-full gap-3"
className="grid w-full justify-start gap-3"
style={{
...gridStyle,
gridAutoFlow: 'row',
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
}}
>
{rows.map((j) => {
@ -222,52 +256,57 @@ export default function FinishedDownloadsGalleryView({
const durationForRow = durations[k]
return (
<FinishedDownloadsGalleryCard
<div
key={k}
job={j}
checked={selectedKeys.has(k)}
hasAnySelection={selectedKeys.size > 0}
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
forcePreviewMuted={forcePreviewMuted}
blurPreviews={blurPreviews}
postworkByFile={postworkByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
renderPostworkBadge={renderPostworkBadge}
durationSeconds={durationForRow}
teaserState={teaserState}
keyFor={keyFor}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded}
onToggleSelected={onToggleSelected}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer}
handleDuration={handleDuration}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
jobForDetails={jobForDetails}
contentPaddingClass={contentPaddingClass}
footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass}
sublineClass={sublineClass}
/>
className="min-w-0 w-full"
>
<FinishedDownloadsGalleryCard
job={j}
selectedKeys={selectedKeys}
selectionModeActive={selectionModeActive}
rowKey={k}
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
forcePreviewMuted={forcePreviewMuted}
blurPreviews={blurPreviews}
postworkByFile={postworkByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
renderPostworkBadge={renderPostworkBadge}
durationSeconds={durationForRow}
teaserState={teaserState}
baseName={baseName}
modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf}
formatBytes={formatBytes}
lower={lower}
modelsByKey={modelsByKey}
activeTagSet={activeTagSet}
deletingKeys={deletingKeys}
keepingKeys={keepingKeys}
removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded}
onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly}
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer}
handleDuration={handleDuration}
handleScrubberClickIndex={handleScrubberClickIndex}
deleteVideo={deleteVideo}
keepVideo={keepVideo}
onToggleHot={onToggleHot}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onSplit={onSplit}
onAddToDownloads={onAddToDownloads}
jobForDetails={jobForDetails}
contentPaddingClass={contentPaddingClass}
footerMinHeightClass={footerMinHeightClass}
titleClass={titleClass}
sublineClass={sublineClass}
/>
</div>
)
})}
</div>
@ -282,4 +321,7 @@ export default function FinishedDownloadsGalleryView({
) : null}
</div>
)
}
}
const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView)
export default MemoFinishedDownloadsGalleryView

View File

@ -23,11 +23,14 @@ import {
import Checkbox from './Checkbox'
import { parseJobMeta } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
import { resolveFinishedModelFlags } from './finishedModelFlags'
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
type Props = {
rows: RecordJob[]
isLoading?: boolean
selectedKeys: Set<string>
selectionModeActive: boolean
onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void
@ -91,6 +94,7 @@ export default function FinishedDownloadsTableView({
rows,
isLoading,
selectedKeys,
selectionModeActive,
onToggleSelected,
onToggleSelectAllPage,
keyFor,
@ -223,6 +227,18 @@ export default function FinishedDownloadsTableView({
const someSelectedOnPage =
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
const handlePrimaryAction = React.useCallback(
(job: RecordJob) => {
if (selectionModeActive) {
onToggleSelected(job)
return
}
onOpenPlayer(job)
},
[selectionModeActive, onToggleSelected, onOpenPlayer]
)
const columns = React.useMemo<Column<RecordJob>[]>(() => {
return [
{
@ -298,7 +314,11 @@ export default function FinishedDownloadsTableView({
<div
ref={registerTeaserHost(k)}
className="group relative py-1"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handlePrimaryAction(j)
}}
onMouseDown={(e) => e.stopPropagation()}
onMouseLeave={() => {
setScrubActiveIndex(k, undefined)
@ -357,12 +377,20 @@ export default function FinishedDownloadsTableView({
const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags)
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const tags = parseTags(flags?.tags)
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge = !!postworkBadge
const country = resolveFinishedCountry(flags, j)
return (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
@ -372,9 +400,13 @@ export default function FinishedDownloadsTableView({
</div>
<ModelGenderIcon
gender={modelsByKey[modelKey]?.gender}
sex={modelsByKey[modelKey]?.sex}
subgender={modelsByKey[modelKey]?.subgender}
gender={flags?.gender}
className="shrink-0"
/>
<CountryFlag
country={country}
size="sm"
className="shrink-0"
/>
</div>
@ -493,8 +525,13 @@ export default function FinishedDownloadsTableView({
const fileRaw = baseName(j.output || '')
const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey]
const { flags } = resolveFinishedModelFlags(
j,
modelsByKey,
modelNameFromOutput,
lower
)
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
@ -541,6 +578,7 @@ export default function FinishedDownloadsTableView({
modelsByKey,
activeTagSet,
onToggleTagFilter,
handlePrimaryAction,
resolutions,
deletingKeys,
keepingKeys,
@ -557,7 +595,6 @@ export default function FinishedDownloadsTableView({
sizeBytesOf,
formatBytes,
runtimeOf,
parseTags,
runtimeSecondsForSort,
forcePreviewMuted,
renderPostworkBadge,
@ -623,7 +660,7 @@ export default function FinishedDownloadsTableView({
card
sort={sort}
onSortChange={handleSortChange}
onRowClick={onOpenPlayer}
onRowClick={handlePrimaryAction}
rowAttrs={(j) => ({
'data-finished-hover-key': keyFor(j),
})}

View File

@ -43,6 +43,7 @@ import {
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
import CountryFlag, { normalizeCountryName } from './CountryFlag'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
@ -183,110 +184,6 @@ function fmtBirthDate(v: string | Date | null | undefined) {
return raw
}
function countryCodeFromInput(input?: string | null) {
const raw = String(input ?? '').trim()
if (!raw) return ''
const aliases: Record<string, string> = {
usa: 'US',
us: 'US',
'united states': 'US',
'united states of america': 'US',
uk: 'GB',
gb: 'GB',
'united kingdom': 'GB',
'great britain': 'GB',
england: 'GB',
germany: 'DE',
deutschland: 'DE',
austria: 'AT',
österreich: 'AT',
switzerland: 'CH',
schweiz: 'CH',
netherlands: 'NL',
holland: 'NL',
france: 'FR',
italy: 'IT',
spain: 'ES',
poland: 'PL',
romania: 'RO',
hungary: 'HU',
czechia: 'CZ',
'czech republic': 'CZ',
slovakia: 'SK',
ukraine: 'UA',
russia: 'RU',
ru: 'RU',
turkey: 'TR',
brazil: 'BR',
colombia: 'CO',
mexico: 'MX',
canada: 'CA',
japan: 'JP',
china: 'CN',
'south korea': 'KR',
'north korea': 'KP',
}
let code = ''
if (/^[a-z]{2}$/i.test(raw)) {
code = raw.toUpperCase()
} else {
code = aliases[raw.toLowerCase()] ?? ''
}
return /^[A-Z]{2}$/.test(code) ? code : ''
}
function normalizeCountryName(input?: string | null) {
const raw = String(input ?? '').trim()
if (!raw) return ''
const aliases: Record<string, string> = {
usa: 'United States',
us: 'United States',
'united states': 'United States',
'united states of america': 'United States',
uk: 'United Kingdom',
gb: 'United Kingdom',
'united kingdom': 'United Kingdom',
'great britain': 'United Kingdom',
england: 'United Kingdom',
germany: 'Germany',
deutschland: 'Germany',
austria: 'Austria',
österreich: 'Austria',
switzerland: 'Switzerland',
schweiz: 'Switzerland',
netherlands: 'Netherlands',
holland: 'Netherlands',
france: 'France',
italy: 'Italy',
spain: 'Spain',
poland: 'Poland',
romania: 'Romania',
hungary: 'Hungary',
czechia: 'Czechia',
'czech republic': 'Czechia',
slovakia: 'Slovakia',
ukraine: 'Ukraine',
russia: 'Russia',
ru: 'Russia',
turkey: 'Turkey',
brazil: 'Brazil',
colombia: 'Colombia',
mexico: 'Mexico',
canada: 'Canada',
japan: 'Japan',
china: 'China',
'south korea': 'South Korea',
'north korea': 'North Korea',
}
return aliases[raw.toLowerCase()] ?? raw
}
function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name
}
@ -493,7 +390,6 @@ type BioContext = {
interested_in?: string[]
display_age?: number
sex?: string
subgender?: string
room_status?: string // offline/online
photo_sets?: BioPhotoSet[]
social_medias?: BioSocial[]
@ -550,7 +446,9 @@ type Props = {
| { ok?: boolean; oldFile?: string; newFile?: string }
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onDelete?: (job: RecordJob) => void | Promise<void>
onKeep?: (job: RecordJob) => void | Promise<void>
onKeep?: (
job: RecordJob
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
onStopJob?: (id: string) => void | Promise<void>
}
@ -1100,10 +998,6 @@ export default function ModelDetails({
const effectiveLocationText = effectiveLocationParts.locationText
const effectiveCountryText = effectiveLocationParts.countryText
const effectiveCountryCode = React.useMemo(() => {
return countryCodeFromInput(effectiveCountryText)
}, [effectiveCountryText])
const effectiveLocationSubText = React.useMemo(() => {
return effectiveCountryText || ''
}, [effectiveCountryText])
@ -1407,14 +1301,18 @@ export default function ModelDetails({
>
<span className="truncate">{titleUsername}</span>
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" />
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" />
<CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</a>
) : (
<div className="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-gray-900 dark:text-white">
@ -1422,10 +1320,14 @@ export default function ModelDetails({
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
<CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</div>
)}
</div>
@ -1523,14 +1425,18 @@ export default function ModelDetails({
: ''}
</span>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
<CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</a>
) : (
<div className="inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow">
@ -1544,8 +1450,6 @@ export default function ModelDetails({
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
</div>
@ -1697,18 +1601,11 @@ export default function ModelDetails({
</div>
</div>
{effectiveCountryCode ? (
<span
className={cn(
'fi',
`fi-${effectiveCountryCode.toLowerCase()}`,
'inline-block shrink-0 self-center rounded-[2px] ring-1 ring-black/5 dark:ring-white/10'
)}
aria-label={effectiveCountryText ? `${effectiveCountryText} Flagge` : 'Flagge'}
title={effectiveCountryText || 'Flagge'}
style={{ width: 24, height: 18 }}
/>
) : null}
<CountryFlag
country={effectiveCountryText}
size="lg"
className="self-center"
/>
</div>
</dd>
</div>
@ -1910,14 +1807,12 @@ export default function ModelDetails({
: ''}
</span>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
</a>
) : (
<div className="mt-0.5 inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow">
@ -1931,8 +1826,6 @@ export default function ModelDetails({
<ModelGenderIcon
gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero"
/>
</div>
@ -2089,19 +1982,11 @@ export default function ModelDetails({
)}
</span>
{effectiveCountryCode ? (
<span
className={cn(
'fi',
`fi-${effectiveCountryCode.toLowerCase()}`,
'inline-block shrink-0 rounded-[2px] ring-1 ring-white/15',
showLocationTwoLines ? 'self-start mt-0.5' : 'self-center'
)}
aria-label={effectiveCountryText ? `${effectiveCountryText} Flagge` : 'Flagge'}
title={effectiveCountryText || 'Flagge'}
style={{ width: 20, height: 15 }}
/>
) : null}
<CountryFlag
country={effectiveCountryText}
size="md"
className={showLocationTwoLines ? 'self-start mt-0.5' : 'self-center'}
/>
</span>
<span

View File

@ -12,10 +12,8 @@ type GenderKind = 'female' | 'male' | 'trans' | 'couple' | null
function normalizeGender(
gender?: string | null,
sex?: string | null,
subgender?: string | null
): GenderKind {
const raw = `${gender || ''} ${sex || ''} ${subgender || ''}`.trim().toLowerCase()
const raw = `${gender || ''}`.trim().toLowerCase()
if (!raw) return null
@ -127,8 +125,6 @@ function CoupleIcon({ className }: IconProps) {
type Props = {
gender?: string | null
sex?: string | null
subgender?: string | null
variant?: 'hero' | 'default'
className?: string
iconClassName?: string
@ -136,13 +132,11 @@ type Props = {
export default function ModelGenderIcon({
gender,
sex,
subgender,
variant = 'default',
className,
iconClassName,
}: Props) {
const kind = normalizeGender(gender, sex, subgender)
const kind = normalizeGender(gender)
if (!kind) return null

View File

@ -15,6 +15,25 @@ 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
@ -53,8 +72,7 @@ export type StoredModel = {
profileImageUpdatedAt?: string
gender?: string | null
sex?: string | null
subgender?: string | null
country?: string | null
watching: boolean
favorite: boolean
@ -104,16 +122,7 @@ function normalizeHttpUrl(raw: string): string | null {
}
function splitTags(raw?: string): string[] {
if (!raw) return []
const tags = raw
.split(/[\n,;|]+/g)
.map((t) => t.trim())
.filter(Boolean)
const uniq = Array.from(new Set(tags))
uniq.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return uniq
return parseTagList(raw)
}
function canonicalHost(raw?: string): string {
@ -265,25 +274,11 @@ function sortFromGallerySortMode(mode: GallerySortMode): { key: string; directio
}
function GridIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
)
return <Squares2X2Icon className="size-4" />
}
function TableIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 10h18" />
<path d="M9 10v10" />
<path d="M15 10v10" />
</svg>
)
return <TableCellsIcon className="size-4" />
}
type ModelsTabProps = {
@ -305,6 +300,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const [tagFilter, setTagFilter] = React.useState<string[]>([])
type WatchFilterMode = 'all' | 'watched' | 'unwatched'
type StatusFilterKey = 'favorite' | 'liked'
const [watchFilter, setWatchFilter] = React.useState<WatchFilterMode>('all')
const [statusFilter, setStatusFilter] = React.useState<StatusFilterKey[]>([])
const activeStatusSet = React.useMemo(
() => new Set(statusFilter),
[statusFilter]
)
const toggleStatusFilter = React.useCallback((key: StatusFilterKey) => {
setStatusFilter((prev) =>
prev.includes(key)
? prev.filter((x) => x !== key)
: [...prev, key]
)
}, [])
const clearStatusFilter = React.useCallback(() => {
setWatchFilter('all')
setStatusFilter([])
}, [])
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
@ -342,7 +361,24 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
}, [])
const activeTagSet = React.useMemo(() => new Set(tagFilter.map((t) => t.toLowerCase())), [tagFilter])
const activeTagSet = React.useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
const allKnownTags = React.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 = React.useCallback((tag: string) => {
const k = tag.toLowerCase()
@ -672,14 +708,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
patch(m.id, { watched: !watch })
}}
>
<span
className={clsx(
'text-base leading-none',
watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'
)}
>
👁
</span>
{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>
@ -692,7 +725,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: false })
}}
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}></span>}
icon={
fav ? (
<StarIconSolid className="size-5 text-amber-500" />
) : (
<StarIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)
}
/>
<IconToggle
@ -704,7 +743,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: false })
}}
icon={<span className={liked ? 'text-rose-500' : 'text-gray-400 dark:text-gray-500'}></span>}
icon={
liked ? (
<HeartIconSolid className="size-5 text-rose-500" />
) : (
<HeartIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)
}
/>
</div>
)
@ -748,26 +793,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<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}
sex={m.sex}
subgender={m.subgender}
className="shrink-0"
/>
<CountryFlag
country={m.country}
size="sm"
className="shrink-0"
/>
</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')
}}
>
</span>
) : null}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
@ -890,10 +939,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const filtered = React.useMemo(() => {
const needle = deferredQ.trim().toLowerCase()
const base = !needle
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) => {
@ -905,7 +974,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}
return true
})
}, [models, modelsWithHay, deferredQ, activeTagSet])
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
const sortedAll = React.useMemo(() => {
if (!sort) return filtered
@ -943,7 +1012,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
React.useEffect(() => {
setPage(1)
}, [q, tagFilter, viewMode, sort])
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
@ -1207,25 +1276,106 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
</div>
</div>
{tagFilter.length > 0 ? (
<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">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Tag-Filter:
</span>
<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" />,
},
]}
/>
<div className="flex flex-wrap items-center gap-2">
{tagFilter.map((t) => (
<TagBadge key={t} tag={t} active onClick={toggleTagFilter} title={t} />
))}
<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="sm"
size="md"
variant="soft"
onClick={clearStatusFilter}
className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300"
onClick={clearTagFilter}
>
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}
@ -1344,11 +1494,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<ModelGenderIcon
gender={m.gender}
sex={m.sex}
subgender={m.subgender}
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}
@ -1363,7 +1518,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
title="Videos"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🎬</span>
<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>
@ -1374,7 +1529,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
title="Tags"
aria-hidden="true"
>
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🏷</span>
<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>
@ -1409,31 +1564,31 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<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-xl leading-none text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
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-xl leading-none text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
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-xl leading-none text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
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>
@ -1481,9 +1636,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}}
>
<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>
{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>
@ -1504,9 +1661,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}}
>
<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>
{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>
@ -1527,9 +1686,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}}
>
<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>
{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>

View File

@ -256,7 +256,9 @@ export type PlayerProps = {
isWatching?: boolean
// actions
onKeep?: (job: RecordJob) => void | Promise<void>
onKeep?: (
job: RecordJob
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (
job: RecordJob

View File

@ -32,7 +32,6 @@ type Props = {
resetLabel?: string
compact?: boolean
softStep?: boolean
className?: string
inputClassName?: string
@ -42,20 +41,6 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function countDecimals(value: number) {
if (!Number.isFinite(value)) return 0
const s = String(value)
const i = s.indexOf('.')
return i >= 0 ? s.length - i - 1 : 0
}
function roundToStep(value: number, step: number, min: number) {
if (!Number.isFinite(step) || step <= 0) return value
const precision = Math.max(countDecimals(step), countDecimals(min))
const rounded = Math.round((value - min) / step) * step + min
return Number(rounded.toFixed(precision))
}
export default function Slider({
id,
value,
@ -77,7 +62,6 @@ export default function Slider({
resetValue,
resetLabel = 'Reset',
compact = false,
softStep = true,
className = '',
inputClassName = '',
}: Props) {
@ -88,13 +72,8 @@ export default function Slider({
const clamped = clamp(safeValue, min, max)
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
const emitCommit = () => {
onChangeEnd?.(clamped)
}
const resolvedValueLabel =
valueLabel ??
(valueFormatter ? valueFormatter(clamped) : String(clamped))
valueLabel ?? (valueFormatter ? valueFormatter(clamped) : String(clamped))
const resolvedMinLabel =
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
@ -113,9 +92,18 @@ export default function Slider({
const thumbOffset = -((thumbSize - trackH) / 2)
const handleChange = (next: number) => {
const raw = clamp(next, min, max)
const finalValue = softStep ? raw : roundToStep(raw, step, min)
onChange(finalValue)
onChange(clamp(next, min, max))
}
const handleCommit = (e: React.SyntheticEvent<HTMLInputElement>) => {
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
}
const handleReset = () => {
if (!canReset) return
const next = clamp(resetValue!, min, max)
onChange(next)
onChangeEnd?.(next)
}
return (
@ -164,7 +152,7 @@ export default function Slider({
type="range"
min={min}
max={max}
step={softStep ? 'any' : step}
step={step}
value={clamped}
disabled={disabled}
aria-label={label ?? 'Slider'}
@ -213,9 +201,9 @@ export default function Slider({
} as React.CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit}
onMouseUp={emitCommit}
onTouchEnd={emitCommit}
onPointerUp={handleCommit}
onMouseUp={handleCommit}
onTouchEnd={handleCommit}
onKeyUp={(e) => {
if (
e.key === 'ArrowLeft' ||
@ -227,7 +215,7 @@ export default function Slider({
e.key === 'PageUp' ||
e.key === 'PageDown'
) {
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
}
}}
/>
@ -266,7 +254,7 @@ export default function Slider({
<button
type="button"
disabled={disabled}
onClick={() => handleChange(resetValue!)}
onClick={handleReset}
className="
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium
text-gray-700 ring-1 ring-gray-200 transition-colors
@ -313,7 +301,7 @@ export default function Slider({
<button
type="button"
disabled={disabled}
onClick={() => handleChange(resetValue!)}
onClick={handleReset}
className="
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
text-gray-700 ring-1 ring-gray-200 transition-colors
@ -355,7 +343,7 @@ export default function Slider({
type="range"
min={min}
max={max}
step={softStep ? 'any' : step}
step={step}
value={clamped}
disabled={disabled}
aria-label={label ?? 'Slider'}
@ -404,9 +392,9 @@ export default function Slider({
} as React.CSSProperties
}
onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit}
onMouseUp={emitCommit}
onTouchEnd={emitCommit}
onPointerUp={handleCommit}
onMouseUp={handleCommit}
onTouchEnd={handleCommit}
onKeyUp={(e) => {
if (
e.key === 'ArrowLeft' ||
@ -418,7 +406,7 @@ export default function Slider({
e.key === 'PageUp' ||
e.key === 'PageDown'
) {
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
}
}}
/>

View File

@ -15,9 +15,12 @@ type Props = {
/** Wenn aktiv (z.B. in Filter), etwas "stärker" highlighten */
active?: boolean
/** Wenn gesetzt, wird ein <button> gerendert und der Tag ist klickbar */
/** Tag-basierter Click */
onClick?: (tag: string) => void
/** Generischer Click, z.B. für Plus-Button */
onPress?: () => void
/** optional: default ist max-w-[11rem] wie in ModelsTab */
maxWidthClassName?: string
className?: string
@ -32,6 +35,7 @@ export default function TagBadge({
title,
active,
onClick,
onPress,
maxWidthClassName = 'max-w-[11rem]',
className,
stopPropagation = true,
@ -50,7 +54,9 @@ export default function TagBadge({
: 'bg-white text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-200 dark:ring-sky-400/20 dark:shadow-none'
)
const clickableCls = onClick
const isClickable = Boolean(onClick || onPress)
const clickableCls = isClickable
? clsx(
'cursor-pointer transition-colors',
active
@ -71,7 +77,7 @@ export default function TagBadge({
}
: {}
if (!onClick) {
if (!isClickable) {
return (
<span
className={cls}
@ -96,7 +102,13 @@ export default function TagBadge({
e.preventDefault()
e.stopPropagation()
}
if (!label) return
if (onPress) {
onPress()
return
}
if (!label || !onClick) return
onClick(label)
}}
>

View File

@ -0,0 +1,265 @@
// frontend\src\components\ui\TagFilterBar.tsx
'use client'
import * as React from 'react'
import clsx from 'clsx'
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline'
import Button from './Button'
import TagBadge from './TagBadge'
type TagFilterBarProps = {
activeTags: string[]
allTags: string[]
onAddTag: (tag: string) => void
onRemoveTag: (tag: string) => void
onClear: () => void
mobile?: boolean
emptyText?: string
className?: string
}
const lower = (s: string) => String(s ?? '').trim().toLowerCase()
export default function TagFilterBar({
activeTags,
allTags,
onAddTag,
onRemoveTag,
onClear,
mobile = false,
emptyText = 'Keine passenden Tags gefunden.',
className = '',
}: TagFilterBarProps) {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState('')
const rootRef = React.useRef<HTMLDivElement | null>(null)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const activeTagSet = React.useMemo(
() => new Set(activeTags.map(lower)),
[activeTags]
)
const addableTags = React.useMemo(() => {
return allTags.filter((tag) => !activeTagSet.has(lower(tag)))
}, [allTags, activeTagSet])
const normalizedQuery = React.useMemo(() => lower(query), [query])
const filteredOptions = React.useMemo(() => {
if (!normalizedQuery) return addableTags.slice(0, 40)
return addableTags
.filter((tag) => lower(tag).includes(normalizedQuery))
.slice(0, 40)
}, [addableTags, normalizedQuery])
const close = React.useCallback(() => {
setOpen(false)
setQuery('')
}, [])
const openPicker = React.useCallback(() => {
if (addableTags.length === 0) return
setOpen(true)
setQuery('')
}, [addableTags.length])
const selectTag = React.useCallback(
(tag: string) => {
onAddTag(tag)
setQuery('')
setOpen(false)
},
[onAddTag]
)
React.useEffect(() => {
if (!open) return
const t = window.setTimeout(() => inputRef.current?.focus(), 0)
return () => window.clearTimeout(t)
}, [open])
React.useEffect(() => {
if (!open) return
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return
if (rootRef.current?.contains(target)) return
close()
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open, close])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.key === 'Enter') {
e.preventDefault()
const exact = addableTags.find((tag) => lower(tag) === lower(query))
if (exact) {
selectTag(exact)
return
}
const first = filteredOptions[0]
if (first) {
selectTag(first)
}
}
},
[addableTags, query, filteredOptions, close, selectTag]
)
const renderInlineMobileMenu = mobile && open
return (
<div className={`flex flex-wrap items-center gap-1.5 overflow-visible ${className}`}>
{activeTags.map((tag) => (
<TagBadge
key={tag}
tag={tag}
active
onClick={onRemoveTag}
title={`Tag-Filter entfernen: ${tag}`}
maxWidthClassName="max-w-[14rem]"
className="min-h-6 pr-1 leading-tight"
>
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="truncate leading-tight">{tag}</span>
<XMarkIcon className="size-3.5 shrink-0 opacity-80" />
</span>
</TagBadge>
))}
<div
ref={rootRef}
className={[
'relative shrink-0 z-[90] leading-none',
renderInlineMobileMenu
? 'flex w-full min-w-0 flex-col items-stretch'
: 'flex h-6 items-center',
mobile
? renderInlineMobileMenu
? 'w-full min-w-0'
: 'w-auto min-w-0'
: '',
].join(' ')}
>
{!open ? (
addableTags.length === 0 ? (
<TagBadge
title="Keine weiteren Tags verfügbar"
maxWidthClassName=""
className="min-h-6 px-1.5 text-gray-400 ring-gray-200 dark:text-gray-500 dark:ring-white/10"
stopPropagation={false}
>
<PlusIcon className="size-3.5 shrink-0" />
</TagBadge>
) : (
<TagBadge
active
onPress={openPicker}
title="Tag-Filter hinzufügen"
maxWidthClassName=""
className={clsx(
'min-h-6 justify-center px-1.5'
)}
stopPropagation={false}
>
<PlusIcon className="size-3.5 shrink-0" />
</TagBadge>
)
) : (
<>
<div className={mobile ? 'relative w-full' : 'relative min-w-[220px]'}>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Tag suchen…"
className="
h-6 w-full rounded-md border border-gray-200 bg-white pl-2 pr-7 text-xs leading-none text-gray-900 shadow-sm outline-none
placeholder:text-gray-400 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200
dark:border-white/10 dark:bg-gray-950/70 dark:text-gray-100 dark:placeholder:text-gray-500
dark:focus:border-indigo-500 dark:focus:ring-indigo-500/20
"
/>
<button
type="button"
onClick={close}
className="
absolute right-0.5 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded
text-gray-500 hover:bg-gray-100 hover:text-gray-700
dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200
"
aria-label="Tag-Auswahl schließen"
title="Schließen"
>
<XMarkIcon className="size-3.5" />
</button>
</div>
<div
className={[
renderInlineMobileMenu
? 'mt-2 overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-lg dark:border-white/10 dark:bg-gray-900'
: 'absolute left-0 top-full z-[100] mt-2 overflow-hidden rounded-lg border border-gray-200/70 bg-white shadow-lg dark:border-white/10 dark:bg-gray-900',
mobile ? 'w-full' : 'min-w-[220px]',
].join(' ')}
>
<div className="max-h-56 overflow-auto py-1">
{filteredOptions.length > 0 ? (
filteredOptions.map((tag) => (
<button
key={tag}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectTag(tag)}
className="
flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm
text-gray-800 transition-colors hover:bg-sky-50
dark:text-gray-100 dark:hover:bg-sky-400/10
"
title={tag}
>
<span className="truncate">{tag}</span>
<PlusIcon className="size-4 shrink-0 text-sky-600 dark:text-sky-300" />
</button>
))
) : (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{emptyText}
</div>
)}
</div>
</div>
</>
)}
</div>
{activeTags.length > 0 ? (
<Button
size="md"
variant="soft"
onClick={onClear}
className="h-6 px-2 text-xs leading-none"
>
Zurücksetzen
</Button>
) : null}
</div>
)
}

View File

@ -0,0 +1,143 @@
// frontend\src\components\ui\finishedSelectionStore.ts
'use client'
import type {
RecordJob,
GalleryModelFlags,
} from '../../types'
function cleanString(value: unknown): string {
return String(value ?? '').trim()
}
function parseMetaObject(metaRaw: unknown): Record<string, any> | null {
if (!metaRaw) return null
if (typeof metaRaw === 'string') {
try {
const parsed = JSON.parse(metaRaw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: null
} catch {
return null
}
}
return typeof metaRaw === 'object' && !Array.isArray(metaRaw)
? (metaRaw as Record<string, any>)
: null
}
export function parseFinishedTags(value?: unknown): string[] {
if (Array.isArray(value)) {
const seen = new Set<string>()
const out: string[] = []
for (const item of value) {
const s = cleanString(item)
if (!s) continue
const key = s.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(s)
}
return out
}
const raw = cleanString(value)
if (!raw) return []
const parts = raw
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const part of parts) {
const key = part.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(part)
}
return out
}
function candidateModelNames(
job: RecordJob,
modelNameFromOutput: (output?: string) => string
): string[] {
const anyJob = job as any
const meta = parseMetaObject(anyJob?.meta)
const rawCandidates = [
modelNameFromOutput(job.output),
anyJob?.model,
anyJob?.modelName,
anyJob?.performer,
anyJob?.creator,
meta?.model?.name,
meta?.modelName,
meta?.performer,
meta?.creator,
meta?.source?.modelName,
]
const seen = new Set<string>()
const out: string[] = []
for (const value of rawCandidates) {
const s = cleanString(value)
if (!s) continue
const key = s.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(s)
}
return out
}
export function resolveFinishedModelFlags(
job: RecordJob,
modelsByKey: Record<string, GalleryModelFlags>,
modelNameFromOutput: (output?: string) => string,
lower: (s: string) => string
): {
model: string
modelKey: string
flags?: GalleryModelFlags
} {
const candidates = candidateModelNames(job, modelNameFromOutput)
for (const candidate of candidates) {
const key = lower(candidate)
const flags = modelsByKey[key]
if (flags) {
return {
model: candidate,
modelKey: key,
flags,
}
}
}
const fallbackModel = candidates[0] ?? modelNameFromOutput(job.output) ?? '—'
const fallbackKey = lower(cleanString(fallbackModel))
return {
model: fallbackModel,
modelKey: fallbackKey,
flags: modelsByKey[fallbackKey],
}
}

View File

@ -0,0 +1,24 @@
export const lowerTag = (s: string) => String(s ?? '').trim().toLowerCase()
export function parseTagList(raw?: string): string[] {
const s = String(raw ?? '').trim()
if (!s) return []
const parts = s
.split(/[\n,;|]+/g)
.map((p) => p.trim())
.filter(Boolean)
const seen = new Set<string>()
const out: string[] = []
for (const p of parts) {
const k = p.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(p)
}
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return out
}

View File

@ -229,8 +229,6 @@ export type StoredModelFlags = {
watching?: boolean | null
tags?: string
gender?: string | null
sex?: string | null
subgender?: string | null
}
export type GalleryModelFlags = StoredModelFlags & {
@ -307,9 +305,9 @@ export type StoredModel = {
watching: boolean
favorite?: boolean
liked?: boolean | null
tags?: string
isUrl?: boolean
path?: string
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string