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)) label = strings.ToLower(strings.TrimSpace(label))
switch label { switch label {
case case "female_genitalia_exposed":
"anus_exposed", return 1000
"female_genitalia_exposed",
"male_genitalia_exposed", case "female_breast_exposed":
"female_breast_exposed", return 900
"buttocks_exposed":
return 300 case "anus_exposed":
return 800
case "male_genitalia_exposed":
return 700
case "buttocks_exposed":
return 600
case case
"female_genitalia_covered", "female_genitalia_covered",
"male_genitalia_covered",
"female_breast_covered", "female_breast_covered",
"male_genitalia_covered",
"buttocks_covered", "buttocks_covered",
"male_breast_exposed", "male_breast_exposed",
"male_breast_covered": "male_breast_covered":

View File

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

View File

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

View File

@ -25,8 +25,7 @@ type StoredModel struct {
ModelKey string `json:"modelKey"` // Display/Key ModelKey string `json:"modelKey"` // Display/Key
Gender string `json:"gender,omitempty"` Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"` Country string `json:"country,omitempty"`
Subgender string `json:"subgender,omitempty"`
Tags string `json:"tags,omitempty"` Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
@ -1053,12 +1052,13 @@ ORDER BY updated_at DESC;
continue continue
} }
var gender, sex, subgender string var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" { if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender) gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
} }
} }
@ -1069,11 +1069,8 @@ ORDER BY updated_at DESC;
Host: host, Host: host,
Path: path, Path: path,
ModelKey: modelKey, ModelKey: modelKey,
Gender: gender, Gender: gender,
Sex: sex, Country: country,
Subgender: subgender,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
@ -1889,12 +1886,13 @@ WHERE id=$1;
} }
} }
var gender, sex, subgender string var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" { if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender) gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
} }
} }
@ -1907,8 +1905,7 @@ WHERE id=$1;
ModelKey: modelKey, ModelKey: modelKey,
Gender: gender, Gender: gender,
Sex: sex, Country: country,
Subgender: subgender,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), 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 SwipeActionRow from './SwipeActionRow'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import LazyMountWhenVisible from './LazyMountWhenVisible' import LazyMountWhenVisible from './LazyMountWhenVisible'
import CountryFlag from './CountryFlag'
type AutostartState = { type AutostartState = {
paused?: boolean paused?: boolean
@ -41,8 +42,7 @@ type Props = {
roomStatus?: string roomStatus?: string
imageUrl?: string imageUrl?: string
gender?: string | null gender?: string | null
sex?: string | null country?: string | null
subgender?: string | null
} }
> >
roomStatusByModelKey?: Record<string, string> roomStatusByModelKey?: Record<string, string>
@ -61,6 +61,39 @@ type Props = {
maxConcurrentDownloads?: number 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 = ( const isChaturbateJob = (
job: RecordJob, job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }> modelsByKey?: Record<string, { imageUrl?: string }>
@ -1044,8 +1077,12 @@ export default function Downloads({
<ModelGenderIcon <ModelGenderIcon
gender={modelMeta?.gender} gender={modelMeta?.gender}
sex={modelMeta?.sex} className="shrink-0"
subgender={modelMeta?.subgender} />
<CountryFlag
country={countryOfJob(j, modelsByKey)}
size="sm"
className="shrink-0" className="shrink-0"
/> />
@ -1098,8 +1135,12 @@ export default function Downloads({
<ModelGenderIcon <ModelGenderIcon
gender={pendingFlags?.gender} gender={pendingFlags?.gender}
sex={pendingFlags?.sex} className="shrink-0"
subgender={pendingFlags?.subgender} />
<CountryFlag
country={countryOfPending(p, modelsByKey)}
size="sm"
className="shrink-0" className="shrink-0"
/> />
</div> </div>

View File

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

View File

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

View File

@ -1,8 +1,17 @@
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx // frontend\src\components\ui\FinishedDownloadsCardsView.tsx
'use client' 'use client'
import * as React from 'react' import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from 'react'
import Card from './Card' import Card from './Card'
import Button from './Button'
import type { import type {
RecordJob, RecordJob,
FinishedPostworkSummary, FinishedPostworkSummary,
@ -30,14 +39,22 @@ import {
scrubProgressRatioFromIndex, scrubProgressRatioFromIndex,
} from './previewSprite' } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon' 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 = { type Props = {
rows: RecordJob[] rows: RecordJob[]
isLoading?: boolean isLoading?: boolean
isSmall: boolean isSmall: boolean
selectedKeys: Set<string> selectionStore: FinishedSelectionStore
onToggleSelected: (job: RecordJob) => void onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void onToggleSelectAllPage: (checked: boolean) => void
onSelectOnly: (job: RecordJob) => void
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
@ -48,7 +65,7 @@ type Props = {
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>> swipeRefs: MutableRefObject<Map<string, SwipeCardHandle>>
assetNonce?: number assetNonce?: number
@ -97,32 +114,13 @@ type Props = {
summaries: Record<string, FinishedPostworkSummary> summaries: Record<string, FinishedPostworkSummary>
) => FinishedPostworkSummary | undefined ) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
mobileTeaserAudioUnlocked?: boolean mobileTeaserAudioUnlocked?: boolean
onUnlockMobileTeaserAudio?: () => void 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({ function CardBlurWrapper({
blurred, blurred,
animateUnblurOnMount, animateUnblurOnMount,
@ -130,11 +128,11 @@ function CardBlurWrapper({
}: { }: {
blurred?: boolean blurred?: boolean
animateUnblurOnMount?: 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 if (!animateUnblurOnMount) return
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
setEntered(true) setEntered(true)
@ -161,11 +159,11 @@ function PromoteToFrontWrapper({
children, children,
}: { }: {
animateOnMount?: boolean animateOnMount?: boolean
children: React.ReactNode children: ReactNode
}) { }) {
const [entered, setEntered] = React.useState(!animateOnMount) const [entered, setEntered] = useState(!animateOnMount)
React.useEffect(() => { useEffect(() => {
if (!animateOnMount) return if (!animateOnMount) return
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
setEntered(true) setEntered(true)
@ -288,6 +286,7 @@ function MobileLoadingStack({
</div> </div>
) )
})} })}
</div>
<div className="relative touch-pan-y" style={{ zIndex: 30 }}> <div className="relative touch-pan-y" style={{ zIndex: 30 }}>
<LoadingCardSkeleton /> <LoadingCardSkeleton />
@ -299,15 +298,437 @@ function MobileLoadingStack({
</div> </div>
</div> </div>
</div> </div>
)
}
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> </div>
) )
} }
export default function FinishedDownloadsCardsView({ 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, rows,
isSmall, isSmall,
selectedKeys, selectionStore,
onToggleSelected, onToggleSelected,
onSelectOnly,
isLoading, isLoading,
blurPreviews, blurPreviews,
durations, durations,
@ -360,14 +781,13 @@ export default function FinishedDownloadsCardsView({
onUnlockMobileTeaserAudio, onUnlockMobileTeaserAudio,
}: Props) { }: Props) {
const hasAnySelection = selectedKeys.size > 0 const footerTapRef = useRef<Record<string, boolean>>({})
const footerTapRef = React.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) => { (key: string) => (fn: (() => void) | null) => {
if (fn) teaserPlayFnsRef.current[key] = fn if (fn) teaserPlayFnsRef.current[key] = fn
else delete teaserPlayFnsRef.current[key] 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) 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 meta = parseMeta(j)
const w = const w =
@ -396,12 +816,12 @@ export default function FinishedDownloadsCardsView({
return null return null
}, [parseMeta]) }, [parseMeta])
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({}) const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | 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) => { setScrubActiveByKey((prev) => {
if (index === undefined) { if (index === undefined) {
if (!(key in prev)) return prev 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(key, undefined)
}, [setScrubActiveIndex]) }, [setScrubActiveIndex])
const setScrubHovering = React.useCallback((key: string, hovering: boolean | undefined) => { const setScrubHovering = useCallback((key: string, hovering: boolean | undefined) => {
setScrubHoveringByKey((prev) => { setScrubHoveringByKey((prev) => {
if (hovering === undefined) { if (hovering === undefined) {
if (!(key in prev)) return prev if (!(key in prev)) return prev
@ -450,6 +870,14 @@ export default function FinishedDownloadsCardsView({
) => { ) => {
const k = keyFor(j) const k = keyFor(j)
const handleSelectOrOpen = () => {
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return true
}
return false
}
const realInlineActive = inlinePlay?.key === k const realInlineActive = inlinePlay?.key === k
const inlineActive = opts?.disableInline ? false : realInlineActive 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 busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const checked = selectedKeys.has(k)
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile) 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 scrubActiveIndex = scrubActiveByKey[k]
const scrubHovering = scrubHoveringByKey[k] === true const scrubHovering = scrubHoveringByKey[k] === true
@ -525,7 +958,9 @@ export default function FinishedDownloadsCardsView({
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) 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 dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
@ -567,12 +1002,8 @@ export default function FinishedDownloadsCardsView({
data-finished-hover-key={k} data-finished-hover-key={k}
className={shellCls} className={shellCls}
onClick={(e) => { onClick={(e) => {
if (hasAnySelection) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onToggleSelected(j)
return
}
if (!isSmall) { if (!isSmall) {
openPlayer(j) openPlayer(j)
@ -598,6 +1029,8 @@ export default function FinishedDownloadsCardsView({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
if (handleSelectOrOpen()) return
onOpenPlayer(j) onOpenPlayer(j)
} }
}} }}
@ -607,24 +1040,12 @@ export default function FinishedDownloadsCardsView({
animateUnblurOnMount={opts?.animateUnblurOnMount} animateUnblurOnMount={opts?.animateUnblurOnMount}
> >
{!isSmall ? ( {!isSmall ? (
<div <SelectionCheckboxOverlay
className={[ selectionStore={selectionStore}
'absolute left-3 top-3 z-20 transition-opacity duration-150', rowKey={k}
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100', label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
].join(' ')} onToggle={() => onToggleSelected(j)}
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>
) : null} ) : null}
{/* Card shell keeps backgrounds consistent */} {/* Card shell keeps backgrounds consistent */}
@ -654,11 +1075,6 @@ export default function FinishedDownloadsCardsView({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (hasAnySelection) {
onToggleSelected(j)
return
}
if (isSmall || opts?.disableInline) return if (isSmall || opts?.disableInline) return
startInline(k) startInline(k)
}} }}
@ -846,6 +1262,12 @@ export default function FinishedDownloadsCardsView({
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (selectionStore.hasAnySelection()) {
onToggleSelected(j)
return
}
openPlayer(j) openPlayer(j)
}} }}
> >
@ -859,8 +1281,12 @@ export default function FinishedDownloadsCardsView({
<ModelGenderIcon <ModelGenderIcon
gender={flags?.gender} gender={flags?.gender}
sex={flags?.sex} className="shrink-0"
subgender={flags?.subgender} />
<CountryFlag
country={modelCountry}
size="sm"
className="shrink-0" className="shrink-0"
/> />
</div> </div>
@ -948,29 +1374,77 @@ export default function FinishedDownloadsCardsView({
} }
} }
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 mobileStackDepth = 3
const mobilePreloadDepth = mobileStackDepth + 3 const mobilePreloadDepth = mobileStackDepth + 3
// ✅ 4 Karten mounten, aber nur 3 sichtbar stacken const mobileLoadedStackRows = isSmall ? mobileOrderedRows.slice(0, mobilePreloadDepth) : []
const mobileLoadedStackRows = isSmall ? rows.slice(0, mobilePreloadDepth) : []
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth) const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
const mobileTopRow = mobileVisibleStackRows[0] ?? null
const mobileTopKey = isSmall && mobileVisibleStackRows[0] const mobileTopKey = isSmall && mobileVisibleStackRows[0]
? keyFor(mobileVisibleStackRows[0]) ? keyFor(mobileVisibleStackRows[0])
: '' : ''
const [mobileTopActivationNonce, setMobileTopActivationNonce] = React.useState(0) const [mobileTopActivationNonce, setMobileTopActivationNonce] = useState(0)
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = React.useState(false) const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = useState(false)
const mobileTopTeaserEnableTimerRef = React.useRef<number | null>(null) const mobileTopTeaserEnableTimerRef = useRef<number | null>(null)
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true) const [mobileTopTapEnabled, setMobileTopTapEnabled] = useState(true)
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null) const mobileTopTapEnableTimerRef = useRef<number | null>(null)
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState<string | null>(null) const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = useState<string | null>(null)
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.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 (!isSmall) return
if (!mobileTopKey) return if (!mobileTopKey) return
if (teaserAudioEnabledForKey !== mobileTopKey) return if (teaserAudioEnabledForKey !== mobileTopKey) return
@ -983,7 +1457,7 @@ export default function FinishedDownloadsCardsView({
}) })
}, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked]) }, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked])
React.useEffect(() => { useEffect(() => {
if (mobileTopTeaserEnableTimerRef.current != null) { if (mobileTopTeaserEnableTimerRef.current != null) {
window.clearTimeout(mobileTopTeaserEnableTimerRef.current) window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
mobileTopTeaserEnableTimerRef.current = null mobileTopTeaserEnableTimerRef.current = null
@ -1049,18 +1523,59 @@ export default function FinishedDownloadsCardsView({
{!isSmall ? ( {!isSmall ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => { {rows.map((j) => {
const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' }) const k = keyFor(j)
return ( return (
<React.Fragment key={`${k}__desktop`}> <FinishedDownloadsDesktopCard
{cardInner} key={`${k}__desktop`}
</React.Fragment> 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>
) : ( ) : (
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}> <div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
{rows.length === 0 ? ( {mobileOrderedRows.length === 0 ? (
isLoading ? ( isLoading ? (
<MobileLoadingStack <MobileLoadingStack
stackPeekOffsetPx={stackPeekOffsetPx} stackPeekOffsetPx={stackPeekOffsetPx}
@ -1194,14 +1709,14 @@ export default function FinishedDownloadsCardsView({
onTap={() => { onTap={() => {
if (!mobileTopTapEnabled) return if (!mobileTopTapEnabled) return
if (footerTapRef.current[k]) { if (selectionStore.hasAnySelection()) {
delete footerTapRef.current[k] onToggleSelected(j)
openPlayer(j)
return return
} }
if (hasAnySelection) { if (footerTapRef.current[k]) {
onToggleSelected(j) delete footerTapRef.current[k]
openPlayer(j)
return return
} }
@ -1240,6 +1755,19 @@ export default function FinishedDownloadsCardsView({
</div> </div>
</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> </div>
)} )}
@ -1254,3 +1782,6 @@ export default function FinishedDownloadsCardsView({
</div> </div>
) )
} }
const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView)
export default MemoFinishedDownloadsCardsView

View File

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

View File

@ -16,9 +16,9 @@ import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
selectedKeys: Set<string> selectedKeys: Set<string>
selectionModeActive: boolean
onToggleSelected: (job: RecordJob) => void onToggleSelected: (job: RecordJob) => void
onToggleSelectAllPage: (checked: boolean) => void onSelectOnly: (job: RecordJob) => void
onColumnCountChange?: (count: number) => void
isLoading?: boolean isLoading?: boolean
blurPreviews?: boolean blurPreviews?: boolean
durations: Record<string, number> durations: Record<string, number>
@ -46,7 +46,6 @@ type Props = {
deletingKeys: Set<string> deletingKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
removingKeys: Set<string> removingKeys: Set<string>
deletedKeys: Set<string>
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
@ -66,6 +65,7 @@ type Props = {
onToggleHot: (job: RecordJob) => void | Promise<void> onToggleHot: (job: RecordJob) => void | Promise<void>
onSplit?: (job: RecordJob) => void | Promise<void> onSplit?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean> onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
onRecommendedPageSizeChange?: (size: number) => void
forcePreviewMuted?: boolean forcePreviewMuted?: boolean
} }
@ -81,7 +81,7 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value)) return Math.max(min, Math.min(max, value))
} }
export default function FinishedDownloadsGalleryView({ function FinishedDownloadsGalleryView({
rows, rows,
isLoading, isLoading,
blurPreviews, blurPreviews,
@ -91,9 +91,10 @@ export default function FinishedDownloadsGalleryView({
teaserState, teaserState,
renderPostworkBadge, renderPostworkBadge,
cardScale, cardScale,
onColumnCountChange,
selectedKeys, selectedKeys,
selectionModeActive,
onToggleSelected, onToggleSelected,
onSelectOnly,
handleDuration, handleDuration,
handleScrubberClickIndex, handleScrubberClickIndex,
jobForDetails, jobForDetails,
@ -121,6 +122,7 @@ export default function FinishedDownloadsGalleryView({
onToggleWatch, onToggleWatch,
onSplit, onSplit,
onAddToDownloads, onAddToDownloads,
onRecommendedPageSizeChange,
forcePreviewMuted, forcePreviewMuted,
}: Props) { }: Props) {
const observeTeasers = shouldObserveTeasers(teaserState.mode) const observeTeasers = shouldObserveTeasers(teaserState.mode)
@ -148,73 +150,105 @@ export default function FinishedDownloadsGalleryView({
[effectiveScale] [effectiveScale]
) )
const containerRef = React.useRef<HTMLDivElement | null>(null) const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
const [containerWidth, setContainerWidth] = React.useState(0) if (layoutScale >= 1.14) return 'xl'
if (layoutScale >= 0.99) return 'lg'
const gapPx = 12 if (layoutScale >= 0.88) return 'md'
return 'sm'
const preferredCardWidth = React.useMemo(() => {
const base = 240
return Math.round(base * layoutScale)
}, [layoutScale]) }, [layoutScale])
const columnCount = React.useMemo(() => { const minCardWidth = React.useMemo(() => {
if (!containerWidth || containerWidth <= 0) { return Math.round(270 * layoutScale)
return 1 }, [layoutScale])
}
const columnsThatFit = Math.floor( const rootRef = React.useRef<HTMLDivElement | null>(null)
(containerWidth + gapPx) / (preferredCardWidth + gapPx) const lastRecommendedPageSizeRef = React.useRef<number | null>(null)
)
return Math.max(1, columnsThatFit) const recommendPageSize = React.useCallback(() => {
}, [containerWidth, preferredCardWidth]) if (!onRecommendedPageSizeChange) return
React.useEffect(() => { const el = rootRef.current
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
if (!el) return if (!el) return
const update = () => { const containerWidth = el.clientWidth
setContainerWidth(el.clientWidth || 0) 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) ro.observe(el)
window.addEventListener('resize', recommendPageSize)
return () => { return () => {
ro.disconnect() 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 ( return (
<div className="relative w-full" ref={containerRef}> <div ref={rootRef} className="relative w-full">
<div <div
className="grid w-full gap-3" className="grid w-full justify-start gap-3"
style={{ style={{
...gridStyle, gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
gridAutoFlow: 'row',
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
}} }}
> >
{rows.map((j) => { {rows.map((j) => {
@ -222,11 +256,15 @@ export default function FinishedDownloadsGalleryView({
const durationForRow = durations[k] const durationForRow = durations[k]
return ( return (
<FinishedDownloadsGalleryCard <div
key={k} key={k}
className="min-w-0 w-full"
>
<FinishedDownloadsGalleryCard
job={j} job={j}
checked={selectedKeys.has(k)} selectedKeys={selectedKeys}
hasAnySelection={selectedKeys.size > 0} selectionModeActive={selectionModeActive}
rowKey={k}
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k} isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
forcePreviewMuted={forcePreviewMuted} forcePreviewMuted={forcePreviewMuted}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
@ -235,7 +273,6 @@ export default function FinishedDownloadsGalleryView({
renderPostworkBadge={renderPostworkBadge} renderPostworkBadge={renderPostworkBadge}
durationSeconds={durationForRow} durationSeconds={durationForRow}
teaserState={teaserState} teaserState={teaserState}
keyFor={keyFor}
baseName={baseName} baseName={baseName}
modelNameFromOutput={modelNameFromOutput} modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf} runtimeOf={runtimeOf}
@ -249,6 +286,7 @@ export default function FinishedDownloadsGalleryView({
removingKeys={removingKeys} removingKeys={removingKeys}
registerTeaserHost={registerTeaserHostIfNeeded} registerTeaserHost={registerTeaserHostIfNeeded}
onToggleSelected={onToggleSelected} onToggleSelected={onToggleSelected}
onSelectOnly={onSelectOnly}
onHoverPreviewKeyChange={onHoverPreviewKeyChange} onHoverPreviewKeyChange={onHoverPreviewKeyChange}
onToggleTagFilter={onToggleTagFilter} onToggleTagFilter={onToggleTagFilter}
onOpenPlayer={onOpenPlayer} onOpenPlayer={onOpenPlayer}
@ -268,6 +306,7 @@ export default function FinishedDownloadsGalleryView({
titleClass={titleClass} titleClass={titleClass}
sublineClass={sublineClass} sublineClass={sublineClass}
/> />
</div>
) )
})} })}
</div> </div>
@ -283,3 +322,6 @@ export default function FinishedDownloadsGalleryView({
</div> </div>
) )
} }
const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView)
export default MemoFinishedDownloadsGalleryView

View File

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

View File

@ -43,6 +43,7 @@ import {
scrubProgressRatioFromIndex, scrubProgressRatioFromIndex,
} from './previewSprite' } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon' import ModelGenderIcon from './ModelGenderIcon'
import CountryFlag, { normalizeCountryName } from './CountryFlag'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -183,110 +184,6 @@ function fmtBirthDate(v: string | Date | null | undefined) {
return raw 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) { function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name return name.startsWith('HOT ') ? name.slice(4) : name
} }
@ -493,7 +390,6 @@ type BioContext = {
interested_in?: string[] interested_in?: string[]
display_age?: number display_age?: number
sex?: string sex?: string
subgender?: string
room_status?: string // offline/online room_status?: string // offline/online
photo_sets?: BioPhotoSet[] photo_sets?: BioPhotoSet[]
social_medias?: BioSocial[] social_medias?: BioSocial[]
@ -550,7 +446,9 @@ type Props = {
| { ok?: boolean; oldFile?: string; newFile?: string } | { ok?: boolean; oldFile?: string; newFile?: string }
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }> | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
onDelete?: (job: RecordJob) => void | Promise<void> 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> onStopJob?: (id: string) => void | Promise<void>
} }
@ -1100,10 +998,6 @@ export default function ModelDetails({
const effectiveLocationText = effectiveLocationParts.locationText const effectiveLocationText = effectiveLocationParts.locationText
const effectiveCountryText = effectiveLocationParts.countryText const effectiveCountryText = effectiveLocationParts.countryText
const effectiveCountryCode = React.useMemo(() => {
return countryCodeFromInput(effectiveCountryText)
}, [effectiveCountryText])
const effectiveLocationSubText = React.useMemo(() => { const effectiveLocationSubText = React.useMemo(() => {
return effectiveCountryText || '' return effectiveCountryText || ''
}, [effectiveCountryText]) }, [effectiveCountryText])
@ -1407,14 +1301,18 @@ export default function ModelDetails({
> >
<span className="truncate">{titleUsername}</span> <span className="truncate">{titleUsername}</span>
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" />
<ModelGenderIcon <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" /> <CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</a> </a>
) : ( ) : (
<div className="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-gray-900 dark:text-white"> <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 <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
<CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</div> </div>
)} )}
</div> </div>
@ -1523,14 +1425,18 @@ export default function ModelDetails({
: ''} : ''}
</span> </span>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
<ModelGenderIcon <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" /> <CountryFlag
country={effectiveCountryText}
size="hero"
className="shrink-0"
/>
</a> </a>
) : ( ) : (
<div className="inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow"> <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 <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
</div> </div>
@ -1697,18 +1601,11 @@ export default function ModelDetails({
</div> </div>
</div> </div>
{effectiveCountryCode ? ( <CountryFlag
<span country={effectiveCountryText}
className={cn( size="lg"
'fi', className="self-center"
`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}
</div> </div>
</dd> </dd>
</div> </div>
@ -1910,14 +1807,12 @@ export default function ModelDetails({
: ''} : ''}
</span> </span>
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
<ModelGenderIcon <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
</a> </a>
) : ( ) : (
<div className="mt-0.5 inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow"> <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 <ModelGenderIcon
gender={effectiveRoom?.gender} gender={effectiveRoom?.gender}
sex={bio?.sex}
subgender={bio?.subgender}
variant="hero" variant="hero"
/> />
</div> </div>
@ -2089,19 +1982,11 @@ export default function ModelDetails({
)} )}
</span> </span>
{effectiveCountryCode ? ( <CountryFlag
<span country={effectiveCountryText}
className={cn( size="md"
'fi', className={showLocationTwoLines ? 'self-start mt-0.5' : 'self-center'}
`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}
</span> </span>
<span <span

View File

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

View File

@ -15,6 +15,25 @@ import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import TextInput from './TextInput' import TextInput from './TextInput'
import ModelGenderIcon from './ModelGenderIcon' 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 = { type ParsedModel = {
input: string input: string
@ -53,8 +72,7 @@ export type StoredModel = {
profileImageUpdatedAt?: string profileImageUpdatedAt?: string
gender?: string | null gender?: string | null
sex?: string | null country?: string | null
subgender?: string | null
watching: boolean watching: boolean
favorite: boolean favorite: boolean
@ -104,16 +122,7 @@ function normalizeHttpUrl(raw: string): string | null {
} }
function splitTags(raw?: string): string[] { function splitTags(raw?: string): string[] {
if (!raw) return [] return parseTagList(raw)
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
} }
function canonicalHost(raw?: string): string { function canonicalHost(raw?: string): string {
@ -265,25 +274,11 @@ function sortFromGallerySortMode(mode: GallerySortMode): { key: string; directio
} }
function GridIcon() { function GridIcon() {
return ( return <Squares2X2Icon className="size-4" />
<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>
)
} }
function TableIcon() { function TableIcon() {
return ( return <TableCellsIcon className="size-4" />
<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>
)
} }
type ModelsTabProps = { type ModelsTabProps = {
@ -305,6 +300,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
const [tagFilter, setTagFilter] = React.useState<string[]>([]) 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 [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false) 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 toggleTagFilter = React.useCallback((tag: string) => {
const k = tag.toLowerCase() const k = tag.toLowerCase()
@ -672,14 +708,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
patch(m.id, { watched: !watch }) patch(m.id, { watched: !watch })
}} }}
> >
<span {watch ? (
className={clsx( <EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-400" />
'text-base leading-none', ) : (
watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500' <EyeIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
)} )}
>
👁
</span>
</Button> </Button>
</span> </span>
@ -692,7 +725,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
if (fav) patch(m.id, { favorite: false }) if (fav) patch(m.id, { favorite: false })
else patch(m.id, { favorite: true, liked: 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 <IconToggle
@ -704,7 +743,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
if (liked) patch(m.id, { liked: false }) if (liked) patch(m.id, { liked: false })
else patch(m.id, { liked: true, favorite: 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> </div>
) )
@ -748,13 +793,6 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<div className="font-medium truncate">{m.modelKey}</div> <div className="font-medium truncate">{m.modelKey}</div>
<ModelGenderIcon
gender={m.gender}
sex={m.sex}
subgender={m.subgender}
className="shrink-0"
/>
</div>
{href ? ( {href ? (
<span <span
@ -765,9 +803,20 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
window.open(href, '_blank', 'noreferrer') window.open(href, '_blank', 'noreferrer')
}} }}
> >
<ArrowTopRightOnSquareIcon className="size-4" />
</span> </span>
) : null} ) : null}
<ModelGenderIcon
gender={m.gender}
className="shrink-0"
/>
<CountryFlag
country={m.country}
size="sm"
className="shrink-0"
/>
</div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate"> <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 filtered = React.useMemo(() => {
const needle = deferredQ.trim().toLowerCase() const needle = deferredQ.trim().toLowerCase()
const base = !needle let base = !needle
? models ? models
: modelsWithHay.filter((x) => x.hay.includes(needle)).map((x) => x.m) : 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 if (activeTagSet.size === 0) return base
return base.filter((m) => { return base.filter((m) => {
@ -905,7 +974,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
} }
return true return true
}) })
}, [models, modelsWithHay, deferredQ, activeTagSet]) }, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
const sortedAll = React.useMemo(() => { const sortedAll = React.useMemo(() => {
if (!sort) return filtered if (!sort) return filtered
@ -943,7 +1012,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
React.useEffect(() => { React.useEffect(() => {
setPage(1) setPage(1)
}, [q, tagFilter, viewMode, sort]) }, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
const totalItems = sortedAll.length const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) 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>
</div> </div>
{tagFilter.length > 0 ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400"> <span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Tag-Filter: Status-Filter:
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{tagFilter.map((t) => ( <ButtonGroup
<TagBadge key={t} tag={t} active onClick={toggleTagFilter} title={t} /> ariaLabel="Watched-Filter"
))} size="sm"
value={watchFilter}
onChange={(id) => {
if (id === 'all' || id === 'watched' || id === 'unwatched') {
setWatchFilter(id)
}
}}
items={[
{
id: 'all',
label: 'Alle',
},
{
id: 'watched',
label: 'Watched',
icon: <EyeIconSolid className="size-4" />,
},
{
id: 'unwatched',
label: 'Unwatched',
icon: <EyeSlashIcon className="size-4" />,
},
]}
/>
<Button <Button
size="sm" size="sm"
color="amber"
variant={activeStatusSet.has('favorite') ? 'soft' : 'secondary'}
onClick={() => toggleStatusFilter('favorite')}
title="Favoriten filtern"
leadingIcon={
activeStatusSet.has('favorite') ? (
<StarIconSolid className="size-4" />
) : (
<StarIconOutline className="size-4" />
)
}
>
Favorited
</Button>
<Button
size="sm"
color="red"
variant={activeStatusSet.has('liked') ? 'soft' : 'secondary'}
onClick={() => toggleStatusFilter('liked')}
title="Liked Models filtern"
leadingIcon={
activeStatusSet.has('liked') ? (
<HeartIconSolid className="size-4" />
) : (
<HeartIconOutline className="size-4" />
)
}
>
Liked
</Button>
{watchFilter !== 'all' || statusFilter.length > 0 ? (
<Button
size="md"
variant="soft" variant="soft"
onClick={clearStatusFilter}
className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300" className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300"
onClick={clearTagFilter}
> >
Zurücksetzen Zurücksetzen
</Button> </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>
</div> </div>
) : null} ) : null}
@ -1344,11 +1494,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<ModelGenderIcon <ModelGenderIcon
gender={m.gender} gender={m.gender}
sex={m.sex}
subgender={m.subgender}
className="shrink-0 text-white" className="shrink-0 text-white"
/> />
<CountryFlag
country={m.country}
size="sm"
className="shrink-0"
/>
</div> </div>
{m.host ? ( {m.host ? (
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"> <div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{m.host} {m.host}
@ -1363,7 +1518,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
title="Videos" title="Videos"
aria-hidden="true" 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)]"> <span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{videoCountsLoading ? '…' : videoCount} {videoCountsLoading ? '…' : videoCount}
</span> </span>
@ -1374,7 +1529,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
title="Tags" title="Tags"
aria-hidden="true" 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)]"> <span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{tags.length} {tags.length}
</span> </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"> <div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
{watch ? ( {watch ? (
<span <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" title="Beobachtet"
aria-hidden="true" aria-hidden="true"
> >
👁 <EyeIconSolid className="size-5" />
</span> </span>
) : null} ) : null}
{fav ? ( {fav ? (
<span <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" title="Favorit"
aria-hidden="true" aria-hidden="true"
> >
<StarIconSolid className="size-5" />
</span> </span>
) : null} ) : null}
{liked ? ( {liked ? (
<span <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" title="Gefällt mir"
aria-hidden="true" aria-hidden="true"
> >
<HeartIconSolid className="size-5" />
</span> </span>
) : null} ) : null}
</div> </div>
@ -1481,9 +1636,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <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')}> {watch ? (
👁 <EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-300" />
</span> ) : (
<EyeIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span> </span>
</Button> </Button>
@ -1504,9 +1661,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <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')}> {fav ? (
<StarIconSolid className="size-5 text-amber-600 dark:text-amber-300" />
</span> ) : (
<StarIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span> </span>
</Button> </Button>
@ -1527,9 +1686,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
}} }}
> >
<span className="inline-flex items-center justify-center gap-1"> <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')}> {liked ? (
<HeartIconSolid className="size-5 text-rose-600 dark:text-rose-300" />
</span> ) : (
<HeartIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
)}
</span> </span>
</Button> </Button>
</div> </div>

View File

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

View File

@ -32,7 +32,6 @@ type Props = {
resetLabel?: string resetLabel?: string
compact?: boolean compact?: boolean
softStep?: boolean
className?: string className?: string
inputClassName?: string inputClassName?: string
@ -42,20 +41,6 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value)) 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({ export default function Slider({
id, id,
value, value,
@ -77,7 +62,6 @@ export default function Slider({
resetValue, resetValue,
resetLabel = 'Reset', resetLabel = 'Reset',
compact = false, compact = false,
softStep = true,
className = '', className = '',
inputClassName = '', inputClassName = '',
}: Props) { }: Props) {
@ -88,13 +72,8 @@ export default function Slider({
const clamped = clamp(safeValue, min, max) const clamped = clamp(safeValue, min, max)
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0 const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
const emitCommit = () => {
onChangeEnd?.(clamped)
}
const resolvedValueLabel = const resolvedValueLabel =
valueLabel ?? valueLabel ?? (valueFormatter ? valueFormatter(clamped) : String(clamped))
(valueFormatter ? valueFormatter(clamped) : String(clamped))
const resolvedMinLabel = const resolvedMinLabel =
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min)) minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
@ -113,9 +92,18 @@ export default function Slider({
const thumbOffset = -((thumbSize - trackH) / 2) const thumbOffset = -((thumbSize - trackH) / 2)
const handleChange = (next: number) => { const handleChange = (next: number) => {
const raw = clamp(next, min, max) onChange(clamp(next, min, max))
const finalValue = softStep ? raw : roundToStep(raw, step, min) }
onChange(finalValue)
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 ( return (
@ -164,7 +152,7 @@ export default function Slider({
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={softStep ? 'any' : step} step={step}
value={clamped} value={clamped}
disabled={disabled} disabled={disabled}
aria-label={label ?? 'Slider'} aria-label={label ?? 'Slider'}
@ -213,9 +201,9 @@ export default function Slider({
} as React.CSSProperties } as React.CSSProperties
} }
onChange={(e) => handleChange(Number(e.target.value))} onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit} onPointerUp={handleCommit}
onMouseUp={emitCommit} onMouseUp={handleCommit}
onTouchEnd={emitCommit} onTouchEnd={handleCommit}
onKeyUp={(e) => { onKeyUp={(e) => {
if ( if (
e.key === 'ArrowLeft' || e.key === 'ArrowLeft' ||
@ -227,7 +215,7 @@ export default function Slider({
e.key === 'PageUp' || e.key === 'PageUp' ||
e.key === 'PageDown' 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 <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleChange(resetValue!)} onClick={handleReset}
className=" className="
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium 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 text-gray-700 ring-1 ring-gray-200 transition-colors
@ -313,7 +301,7 @@ export default function Slider({
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleChange(resetValue!)} onClick={handleReset}
className=" className="
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
text-gray-700 ring-1 ring-gray-200 transition-colors text-gray-700 ring-1 ring-gray-200 transition-colors
@ -355,7 +343,7 @@ export default function Slider({
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={softStep ? 'any' : step} step={step}
value={clamped} value={clamped}
disabled={disabled} disabled={disabled}
aria-label={label ?? 'Slider'} aria-label={label ?? 'Slider'}
@ -404,9 +392,9 @@ export default function Slider({
} as React.CSSProperties } as React.CSSProperties
} }
onChange={(e) => handleChange(Number(e.target.value))} onChange={(e) => handleChange(Number(e.target.value))}
onPointerUp={emitCommit} onPointerUp={handleCommit}
onMouseUp={emitCommit} onMouseUp={handleCommit}
onTouchEnd={emitCommit} onTouchEnd={handleCommit}
onKeyUp={(e) => { onKeyUp={(e) => {
if ( if (
e.key === 'ArrowLeft' || e.key === 'ArrowLeft' ||
@ -418,7 +406,7 @@ export default function Slider({
e.key === 'PageUp' || e.key === 'PageUp' ||
e.key === 'PageDown' 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 */ /** Wenn aktiv (z.B. in Filter), etwas "stärker" highlighten */
active?: boolean active?: boolean
/** Wenn gesetzt, wird ein <button> gerendert und der Tag ist klickbar */ /** Tag-basierter Click */
onClick?: (tag: string) => void onClick?: (tag: string) => void
/** Generischer Click, z.B. für Plus-Button */
onPress?: () => void
/** optional: default ist max-w-[11rem] wie in ModelsTab */ /** optional: default ist max-w-[11rem] wie in ModelsTab */
maxWidthClassName?: string maxWidthClassName?: string
className?: string className?: string
@ -32,6 +35,7 @@ export default function TagBadge({
title, title,
active, active,
onClick, onClick,
onPress,
maxWidthClassName = 'max-w-[11rem]', maxWidthClassName = 'max-w-[11rem]',
className, className,
stopPropagation = true, 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' : '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( ? clsx(
'cursor-pointer transition-colors', 'cursor-pointer transition-colors',
active active
@ -71,7 +77,7 @@ export default function TagBadge({
} }
: {} : {}
if (!onClick) { if (!isClickable) {
return ( return (
<span <span
className={cls} className={cls}
@ -96,7 +102,13 @@ export default function TagBadge({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
} }
if (!label) return
if (onPress) {
onPress()
return
}
if (!label || !onClick) return
onClick(label) 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 watching?: boolean | null
tags?: string tags?: string
gender?: string | null gender?: string | null
sex?: string | null
subgender?: string | null
} }
export type GalleryModelFlags = StoredModelFlags & { export type GalleryModelFlags = StoredModelFlags & {
@ -307,9 +305,9 @@ export type StoredModel = {
watching: boolean watching: boolean
favorite?: boolean favorite?: boolean
liked?: boolean | null liked?: boolean | null
tags?: string
isUrl?: boolean isUrl?: boolean
path?: string path?: string
roomStatus?: string roomStatus?: string
isOnline?: boolean isOnline?: boolean
chatRoomUrl?: string chatRoomUrl?: string