ui fixes
This commit is contained in:
parent
3a6e1d0f48
commit
691c24e899
@ -166,18 +166,25 @@ func nsfwLabelPriority(label string) int {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
|
||||
switch label {
|
||||
case
|
||||
"anus_exposed",
|
||||
"female_genitalia_exposed",
|
||||
"male_genitalia_exposed",
|
||||
"female_breast_exposed",
|
||||
"buttocks_exposed":
|
||||
return 300
|
||||
case "female_genitalia_exposed":
|
||||
return 1000
|
||||
|
||||
case "female_breast_exposed":
|
||||
return 900
|
||||
|
||||
case "anus_exposed":
|
||||
return 800
|
||||
|
||||
case "male_genitalia_exposed":
|
||||
return 700
|
||||
|
||||
case "buttocks_exposed":
|
||||
return 600
|
||||
|
||||
case
|
||||
"female_genitalia_covered",
|
||||
"male_genitalia_covered",
|
||||
"female_breast_covered",
|
||||
"male_genitalia_covered",
|
||||
"buttocks_covered",
|
||||
"male_breast_exposed",
|
||||
"male_breast_covered":
|
||||
|
||||
@ -1,114 +1,114 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"modelKey": "jam_cream",
|
||||
"url": "https://www.myfreecams.com/#Jam_cream",
|
||||
"modelKey": "sw33t_g1rl",
|
||||
"url": "https://www.myfreecams.com/#Sw33t_g1rl",
|
||||
"mode": "probe_retry",
|
||||
"nextProbeAtMs": 1775899166952,
|
||||
"nextProbeAtMs": 1776172330458,
|
||||
"currentShow": "offline",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "milk_bunny_",
|
||||
"url": "https://chaturbate.com/milk_bunny_/",
|
||||
"modelKey": "angel_from_sky",
|
||||
"url": "https://chaturbate.com/angel_from_sky/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "vanicha_007",
|
||||
"url": "https://chaturbate.com/vanicha_007/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "kali_hiill",
|
||||
"url": "https://chaturbate.com/kali_hiill/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "passiondesire_",
|
||||
"url": "https://chaturbate.com/passiondesire_/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "away",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "tsukuyami_mi",
|
||||
"url": "https://chaturbate.com/tsukuyami_mi/",
|
||||
"modelKey": "tomokosturgeon",
|
||||
"url": "https://chaturbate.com/tomokosturgeon/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "swtty_meivi",
|
||||
"url": "https://chaturbate.com/swtty_meivi/",
|
||||
"modelKey": "tammieprotano",
|
||||
"url": "https://chaturbate.com/tammieprotano/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "amandatalk",
|
||||
"url": "https://chaturbate.com/amandatalk/",
|
||||
"modelKey": "troybailly",
|
||||
"url": "https://chaturbate.com/troybailly/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "vonen_ray",
|
||||
"url": "https://chaturbate.com/vonen_ray/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "oliver_and_megan",
|
||||
"url": "https://chaturbate.com/oliver_and_megan/",
|
||||
"modelKey": "sharitaklemme",
|
||||
"url": "https://chaturbate.com/sharitaklemme/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "aurora_asti_",
|
||||
"url": "https://chaturbate.com/aurora_asti_/",
|
||||
"modelKey": "sosaguillypleasure",
|
||||
"url": "https://chaturbate.com/sosaguillypleasure/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "wasianbby",
|
||||
"url": "https://chaturbate.com/wasianbby/",
|
||||
"modelKey": "kittymeow_xxx",
|
||||
"url": "https://chaturbate.com/kittymeow_xxx/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/wasianbby.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "gimbobar",
|
||||
"url": "https://chaturbate.com/gimbobar/",
|
||||
"modelKey": "superstar090",
|
||||
"url": "https://chaturbate.com/superstar090/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "lizbethbiers",
|
||||
"url": "https://chaturbate.com/lizbethbiers/",
|
||||
"modelKey": "victoryglow",
|
||||
"url": "https://chaturbate.com/victoryglow/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "bonnettbonett",
|
||||
"url": "https://chaturbate.com/bonnettbonett/",
|
||||
"modelKey": "palmiratownzen",
|
||||
"url": "https://chaturbate.com/palmiratownzen/",
|
||||
"mode": "wait_public",
|
||||
"currentShow": "private",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg",
|
||||
"currentShow": "hidden",
|
||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg",
|
||||
"source": "watched"
|
||||
},
|
||||
{
|
||||
"modelKey": "pazrapacki",
|
||||
"url": "https://chaturbate.com/pazrapacki/",
|
||||
"modelKey": "rafaelaloghry",
|
||||
"url": "https://chaturbate.com/rafaelaloghry/",
|
||||
"mode": "probe_retry",
|
||||
"nextProbeAtMs": 1775899181131,
|
||||
"nextProbeAtMs": 1776172331673,
|
||||
"currentShow": "unknown",
|
||||
"source": "watched"
|
||||
}
|
||||
|
||||
@ -145,10 +145,10 @@ type modelsOverviewModel struct {
|
||||
Input string `json:"input"`
|
||||
Host string `json:"host,omitempty"`
|
||||
ModelKey string `json:"modelKey"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Sex string `json:"sex,omitempty"`
|
||||
Subgender string `json:"subgender,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
|
||||
Watching bool `json:"watching"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
|
||||
@ -24,9 +24,8 @@ type StoredModel struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
ModelKey string `json:"modelKey"` // Display/Key
|
||||
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Sex string `json:"sex,omitempty"`
|
||||
Subgender string `json:"subgender,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
|
||||
Tags string `json:"tags,omitempty"`
|
||||
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
|
||||
@ -1053,27 +1052,25 @@ ORDER BY updated_at DESC;
|
||||
continue
|
||||
}
|
||||
|
||||
var gender, sex, subgender string
|
||||
var gender, country string
|
||||
|
||||
if strings.TrimSpace(cbOnlineJSON) != "" {
|
||||
var snap ChaturbateOnlineSnapshot
|
||||
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
|
||||
gender = strings.TrimSpace(snap.Gender)
|
||||
country = strings.TrimSpace(snap.Country)
|
||||
}
|
||||
}
|
||||
|
||||
m := StoredModel{
|
||||
ID: id,
|
||||
Input: input,
|
||||
IsURL: isURL,
|
||||
Host: host,
|
||||
Path: path,
|
||||
ModelKey: modelKey,
|
||||
|
||||
Gender: gender,
|
||||
Sex: sex,
|
||||
Subgender: subgender,
|
||||
|
||||
ID: id,
|
||||
Input: input,
|
||||
IsURL: isURL,
|
||||
Host: host,
|
||||
Path: path,
|
||||
ModelKey: modelKey,
|
||||
Gender: gender,
|
||||
Country: country,
|
||||
Tags: tags,
|
||||
LastStream: fmtNullTime(lastStream),
|
||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||
@ -1889,12 +1886,13 @@ WHERE id=$1;
|
||||
}
|
||||
}
|
||||
|
||||
var gender, sex, subgender string
|
||||
var gender, country string
|
||||
|
||||
if strings.TrimSpace(cbOnlineJSON) != "" {
|
||||
var snap ChaturbateOnlineSnapshot
|
||||
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
|
||||
gender = strings.TrimSpace(snap.Gender)
|
||||
country = strings.TrimSpace(snap.Country)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1907,8 +1905,7 @@ WHERE id=$1;
|
||||
ModelKey: modelKey,
|
||||
|
||||
Gender: gender,
|
||||
Sex: sex,
|
||||
Subgender: subgender,
|
||||
Country: country,
|
||||
Tags: tags,
|
||||
LastStream: fmtNullTime(lastStream),
|
||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||
|
||||
1345
frontend/src/App.tsx
1345
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
246
frontend/src/components/ui/CountryFlag.tsx
Normal file
246
frontend/src/components/ui/CountryFlag.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
]
|
||||
}
|
||||
@ -20,6 +20,7 @@ import DownloadsCardRow, {
|
||||
import SwipeActionRow from './SwipeActionRow'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import LazyMountWhenVisible from './LazyMountWhenVisible'
|
||||
import CountryFlag from './CountryFlag'
|
||||
|
||||
type AutostartState = {
|
||||
paused?: boolean
|
||||
@ -41,8 +42,7 @@ type Props = {
|
||||
roomStatus?: string
|
||||
imageUrl?: string
|
||||
gender?: string | null
|
||||
sex?: string | null
|
||||
subgender?: string | null
|
||||
country?: string | null
|
||||
}
|
||||
>
|
||||
roomStatusByModelKey?: Record<string, string>
|
||||
@ -61,6 +61,39 @@ type Props = {
|
||||
maxConcurrentDownloads?: number
|
||||
}
|
||||
|
||||
const countryOfJob = (
|
||||
job: RecordJob,
|
||||
modelsByKey?: Record<string, { country?: string | null }>
|
||||
): string => {
|
||||
const anyJ = job as any
|
||||
const modelKey = modelKeyFromJob(job)
|
||||
|
||||
return String(
|
||||
modelsByKey?.[modelKey]?.country ??
|
||||
anyJ?.country ??
|
||||
anyJ?.model?.country ??
|
||||
anyJ?.room?.country ??
|
||||
anyJ?.meta?.country ??
|
||||
anyJ?.meta?.model?.country ??
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
const countryOfPending = (
|
||||
p: PendingWatchedRoom,
|
||||
modelsByKey?: Record<string, { country?: string | null }>
|
||||
): string => {
|
||||
const anyP = p as any
|
||||
const pendingKey = String(anyP?.modelKey ?? pendingModelName(p)).trim().toLowerCase()
|
||||
|
||||
return String(
|
||||
modelsByKey?.[pendingKey]?.country ??
|
||||
anyP?.country ??
|
||||
anyP?.room?.country ??
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
const isChaturbateJob = (
|
||||
job: RecordJob,
|
||||
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||
@ -1044,8 +1077,12 @@ export default function Downloads({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={modelMeta?.gender}
|
||||
sex={modelMeta?.sex}
|
||||
subgender={modelMeta?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={countryOfJob(j, modelsByKey)}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
@ -1098,8 +1135,12 @@ export default function Downloads({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={pendingFlags?.gender}
|
||||
sex={pendingFlags?.sex}
|
||||
subgender={pendingFlags?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={countryOfPending(p, modelsByKey)}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,7 @@ import ProgressBar from './ProgressBar'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import type { RecordJob } from '../../types'
|
||||
import LazyMountWhenVisible from './LazyMountWhenVisible'
|
||||
import CountryFlag from './CountryFlag'
|
||||
|
||||
export type PendingWatchedRoom = WaitingModelRow & {
|
||||
currentShow: string
|
||||
@ -34,8 +35,7 @@ type ModelFlags = {
|
||||
roomStatus?: string
|
||||
imageUrl?: string
|
||||
gender?: string | null
|
||||
sex?: string | null
|
||||
subgender?: string | null
|
||||
country?: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -649,8 +649,12 @@ export default function DownloadsCardRow({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={pendingFlags?.gender}
|
||||
sex={pendingFlags?.sex}
|
||||
subgender={pendingFlags?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={pendingFlags?.country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
@ -868,8 +872,12 @@ export default function DownloadsCardRow({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={flags?.gender}
|
||||
sex={flags?.sex}
|
||||
subgender={flags?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={flags?.country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,6 @@ import { useMemo, useEffect, useCallback } from 'react'
|
||||
import Card from './Card'
|
||||
import type {
|
||||
ViewMode,
|
||||
SelectedItem,
|
||||
UndoAction,
|
||||
PersistedUndoState,
|
||||
RunFileMutationOptions,
|
||||
@ -43,7 +42,6 @@ import FinishedDownloadsTableView from './FinishedDownloadsTableView'
|
||||
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
|
||||
import Pagination from './Pagination'
|
||||
import { applyInlineVideoPolicy } from './videoPolicy'
|
||||
import TagBadge from './TagBadge'
|
||||
import Button from './Button'
|
||||
import { useNotify } from './notify'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
@ -54,6 +52,13 @@ import HoverPopover from './HoverPopover'
|
||||
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'
|
||||
import Slider from './Slider'
|
||||
import TextInput from './TextInput'
|
||||
import TagFilterBar from './TagFilterBar'
|
||||
import { parseTagList, lowerTag } from './tagUtils'
|
||||
import {
|
||||
createFinishedSelectionStore,
|
||||
useSelectionVersion,
|
||||
type FinishedSelectionStore,
|
||||
} from './finishedSelectionStore'
|
||||
|
||||
type Props = {
|
||||
jobs: RecordJob[]
|
||||
@ -73,7 +78,9 @@ type Props = {
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onKeepJob?: (job: RecordJob) => void | Promise<void>
|
||||
onKeepJob?: (
|
||||
job: RecordJob
|
||||
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
|
||||
onSplitJob?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
doneTotal: number
|
||||
@ -113,6 +120,36 @@ const isTrashOutput = (output?: string) => {
|
||||
return p.includes('/.trash/') || p.endsWith('/.trash')
|
||||
}
|
||||
|
||||
const GALLERY_CARD_SCALE_PRESETS = [
|
||||
{ label: 'Klein', scale: 0.8 },
|
||||
{ label: 'Normal', scale: 1.0 },
|
||||
{ label: 'XL', scale: 1.2 },
|
||||
] as const
|
||||
|
||||
const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2
|
||||
|
||||
function clampPresetIndex(value: number) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(GALLERY_CARD_SCALE_PRESETS.length - 1, Math.round(value))
|
||||
)
|
||||
}
|
||||
|
||||
function nearestPresetIndex(value: number) {
|
||||
let bestIndex = 0
|
||||
let bestDist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[0].scale)
|
||||
|
||||
for (let i = 1; i < GALLERY_CARD_SCALE_PRESETS.length; i++) {
|
||||
const dist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[i].scale)
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
@ -193,44 +230,28 @@ function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredM
|
||||
}
|
||||
}
|
||||
|
||||
const reModelFromFilename = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
|
||||
|
||||
const modelNameFromOutput = (output?: string) => {
|
||||
const fileRaw = baseName(output || '')
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
if (!file) return '—'
|
||||
|
||||
const stem = file.replace(/\.[^.]+$/, '')
|
||||
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
|
||||
if (m?.[1]) return m[1]
|
||||
const m = stem.match(reModelFromFilename)
|
||||
|
||||
if (m?.[1]?.trim()) {
|
||||
return m[1].trim()
|
||||
}
|
||||
|
||||
const i = stem.lastIndexOf('_')
|
||||
return i > 0 ? stem.slice(0, i) : stem
|
||||
}
|
||||
|
||||
const lower = (s: string) => (s || '').trim().toLowerCase()
|
||||
|
||||
// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (!s) return []
|
||||
const parts = s
|
||||
.split(/[\n,;|]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
const parseTags = parseTagList
|
||||
|
||||
// stable dedupe (case-insensitive), aber original casing behalten
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
const k = p.toLowerCase()
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(p)
|
||||
}
|
||||
|
||||
// wie in ModelsTab: alphabetisch (case-insensitive)
|
||||
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
return out
|
||||
}
|
||||
const lower = lowerTag
|
||||
|
||||
// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
|
||||
const sizeBytesOf = (job: RecordJob): number | null => {
|
||||
@ -507,7 +528,7 @@ function metaDurationSeconds(meta: any): number | undefined {
|
||||
function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
|
||||
const state = String(summary?.state ?? '').toLowerCase()
|
||||
const label = String(summary?.label ?? '').toLowerCase()
|
||||
const isPartialDone = state === 'done' && label.startsWith('teilweise fertig')
|
||||
const isPartialDone = state === 'done' && label.startsWith('fertig (')
|
||||
|
||||
if (isPartialDone) {
|
||||
return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
|
||||
@ -860,7 +881,7 @@ function buildCompletedPostworkSummaryFromMeta(
|
||||
label: allDone
|
||||
? 'Fertig'
|
||||
: doneCount > 0
|
||||
? `Teilweise fertig (${doneCount}/${steps.length})`
|
||||
? `Fertig (${doneCount}/${steps.length})`
|
||||
: 'Kein Status',
|
||||
steps,
|
||||
ts: Number(meta?.updatedAtUnix ?? 0),
|
||||
@ -1043,7 +1064,7 @@ function summarizePostworkSteps(
|
||||
: allDone
|
||||
? 'Fertig'
|
||||
: partiallyDone
|
||||
? `Teilweise fertig (${doneCount}/${steps.length})`
|
||||
? `Fertig (${doneCount}/${steps.length})`
|
||||
: 'Kein Status'
|
||||
|
||||
const ts = Math.max(0, ...steps.map((s) => s.ts || 0))
|
||||
@ -1156,7 +1177,7 @@ function mergePostworkSummaries(
|
||||
: allDone
|
||||
? 'Fertig'
|
||||
: partiallyDone
|
||||
? `Teilweise fertig (${doneCount}/${orderedSteps.length})`
|
||||
? `Fertig (${doneCount}/${orderedSteps.length})`
|
||||
: 'Kein Status'
|
||||
|
||||
const ts = Math.max(
|
||||
@ -1428,18 +1449,30 @@ export default function FinishedDownloads({
|
||||
const [postworkByFile, setPostworkByFile] =
|
||||
React.useState<Record<string, FinishedPostworkBadge[]>>({})
|
||||
|
||||
const [selectedItems, setSelectedItems] =
|
||||
React.useState<Record<string, SelectedItem>>({})
|
||||
|
||||
const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})
|
||||
|
||||
const [view, setView] = React.useState<ViewMode>('table')
|
||||
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
|
||||
const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)
|
||||
|
||||
const [galleryCardScale, setGalleryCardScale] = React.useState<number>(1)
|
||||
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
|
||||
const [galleryColumnCount, setGalleryColumnCount] = React.useState<number>(1)
|
||||
const [galleryCardScaleIndex, setGalleryCardScaleIndex] = React.useState<number>(
|
||||
DEFAULT_GALLERY_CARD_SCALE_INDEX
|
||||
)
|
||||
|
||||
const selectionStoreRef = React.useRef<FinishedSelectionStore | null>(null)
|
||||
|
||||
const selectionStore =
|
||||
selectionStoreRef.current ??
|
||||
(selectionStoreRef.current = createFinishedSelectionStore())
|
||||
const selectionVersion = useSelectionVersion(selectionStore)
|
||||
|
||||
const galleryCardScale =
|
||||
GALLERY_CARD_SCALE_PRESETS[galleryCardScaleIndex]?.scale ??
|
||||
GALLERY_CARD_SCALE_PRESETS[DEFAULT_GALLERY_CARD_SCALE_INDEX].scale
|
||||
|
||||
const handleGalleryScalePresetChange = useCallback((nextIndex: number) => {
|
||||
setGalleryCardScaleIndex(clampPresetIndex(nextIndex))
|
||||
}, [])
|
||||
|
||||
const [allDoneJobs, setAllDoneJobs] = React.useState<RecordJob[] | null>(null)
|
||||
const [allDoneJobsLoading, setAllDoneJobsLoading] = React.useState(false)
|
||||
@ -1492,9 +1525,6 @@ export default function FinishedDownloads({
|
||||
|
||||
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
|
||||
|
||||
const scaleLiveRafRef = React.useRef<number | null>(null)
|
||||
const scaleLivePendingRef = React.useRef<number>(1)
|
||||
|
||||
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
|
||||
const fileToKeyRef = React.useRef<Map<string, string>>(new Map())
|
||||
|
||||
@ -1518,9 +1548,24 @@ export default function FinishedDownloads({
|
||||
|
||||
const deferredSearchQuery = React.useDeferredValue(searchQuery)
|
||||
|
||||
const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])
|
||||
const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
|
||||
|
||||
const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])
|
||||
const allKnownTags = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
|
||||
for (const flags of Object.values(modelsByKey ?? {})) {
|
||||
for (const tag of parseTags(flags?.tags)) {
|
||||
const k = lowerTag(tag)
|
||||
if (!k || seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
return out
|
||||
}, [modelsByKey])
|
||||
|
||||
const teaserState = useMemo<FinishedDownloadsTeaserState>(
|
||||
() => ({
|
||||
@ -1537,17 +1582,17 @@ export default function FinishedDownloads({
|
||||
const tagSetByModelKey: Record<string, Set<string>> = {}
|
||||
|
||||
for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
|
||||
const key = lower(k)
|
||||
const key = lowerTag(k)
|
||||
const arr = parseTags(flags?.tags)
|
||||
tagsByModelKey[key] = arr
|
||||
tagSetByModelKey[key] = new Set(arr.map(lower))
|
||||
tagSetByModelKey[key] = new Set(arr.map(lowerTag))
|
||||
}
|
||||
|
||||
return { tagsByModelKey, tagSetByModelKey }
|
||||
}, [modelsByKey])
|
||||
|
||||
const searchTokens = useMemo(
|
||||
() => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||||
() => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
|
||||
[deferredSearchQuery]
|
||||
)
|
||||
|
||||
@ -1557,7 +1602,10 @@ export default function FinishedDownloads({
|
||||
const globalFilterActive = searchActiveForGlobalFetch
|
||||
const effectiveAllMode = globalFilterActive || loadMode === 'all'
|
||||
|
||||
const galleryPageSize = Math.max(2, galleryColumnCount * 2)
|
||||
const DEFAULT_GALLERY_PAGE_SIZE = 8
|
||||
const [galleryPageSize, setGalleryPageSize] = React.useState<number>(
|
||||
DEFAULT_GALLERY_PAGE_SIZE
|
||||
)
|
||||
|
||||
const effectivePageSize =
|
||||
view === 'gallery'
|
||||
@ -1724,10 +1772,10 @@ export default function FinishedDownloads({
|
||||
? base.filter((j) => {
|
||||
const file = baseName(j.output || '')
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const modelKey = lower(model)
|
||||
const modelKey = lowerTag(model)
|
||||
const tags = modelTags.tagsByModelKey[modelKey] ?? []
|
||||
|
||||
const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
|
||||
const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))
|
||||
|
||||
for (const t of searchTokens) {
|
||||
if (!hay.includes(t)) return false
|
||||
@ -1739,7 +1787,7 @@ export default function FinishedDownloads({
|
||||
if (activeTagSet.size === 0) return searched
|
||||
|
||||
return searched.filter((j) => {
|
||||
const modelKey = lower(modelNameFromOutput(j.output))
|
||||
const modelKey = lowerTag(modelNameFromOutput(j.output))
|
||||
const have = modelTags.tagSetByModelKey[modelKey]
|
||||
if (!have || have.size === 0) return false
|
||||
|
||||
@ -1750,12 +1798,6 @@ export default function FinishedDownloads({
|
||||
})
|
||||
}, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])
|
||||
|
||||
const selectedFiles = useMemo(() => {
|
||||
return Object.values(selectedItems)
|
||||
.map((x) => String(x.file || '').trim())
|
||||
.filter(Boolean)
|
||||
}, [selectedItems])
|
||||
|
||||
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
|
||||
|
||||
const pageRows = useMemo(() => {
|
||||
@ -1775,84 +1817,69 @@ export default function FinishedDownloads({
|
||||
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
|
||||
const emptyByFilter = globalFilterActive && visibleRows.length === 0
|
||||
|
||||
const selectedCount = selectedFiles.length
|
||||
const allPageSelected =
|
||||
pageRows.length > 0 &&
|
||||
pageRows.every((j) => selectedKeys.has(keyFor(j)))
|
||||
const selectedCount = React.useMemo(() => {
|
||||
return selectionStore.getCount()
|
||||
}, [selectionStore, selectionVersion])
|
||||
|
||||
const selectedFiles = React.useMemo(() => {
|
||||
return selectionStore.getSelectedFiles()
|
||||
}, [selectionStore, selectionVersion])
|
||||
|
||||
const allPageSelected = React.useMemo(() => {
|
||||
return (
|
||||
pageRows.length > 0 &&
|
||||
pageRows.every((j) => selectionStore.isSelected(keyFor(j)))
|
||||
)
|
||||
}, [pageRows, keyFor, selectionStore, selectionVersion])
|
||||
|
||||
const hasSearchQuery = (searchQuery || '').trim() !== ''
|
||||
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
|
||||
const selectionModeActive = selectedCount > 0
|
||||
|
||||
const selectedKeys = React.useMemo(() => {
|
||||
const next = new Set<string>()
|
||||
|
||||
for (const j of pageRows) {
|
||||
const k = keyFor(j)
|
||||
if (selectionStore.isSelected(k)) {
|
||||
next.add(k)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}, [pageRows, keyFor, selectionStore, selectionVersion])
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// callbacks
|
||||
// -----------------------------------------------------------------------------
|
||||
const handleGalleryScaleLiveChange = useCallback((next: number) => {
|
||||
const clamped = Math.max(0.8, Math.min(1.35, next))
|
||||
scaleLivePendingRef.current = clamped
|
||||
|
||||
if (scaleLiveRafRef.current != null) return
|
||||
|
||||
scaleLiveRafRef.current = requestAnimationFrame(() => {
|
||||
scaleLiveRafRef.current = null
|
||||
setGalleryCardScaleLive(scaleLivePendingRef.current)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleGalleryScaleCommit = useCallback((next: number) => {
|
||||
const clamped = Math.max(0.8, Math.min(1.35, next))
|
||||
|
||||
if (scaleLiveRafRef.current != null) {
|
||||
cancelAnimationFrame(scaleLiveRafRef.current)
|
||||
scaleLiveRafRef.current = null
|
||||
}
|
||||
|
||||
scaleLivePendingRef.current = clamped
|
||||
setGalleryCardScaleLive(clamped)
|
||||
setGalleryCardScale(clamped)
|
||||
}, [])
|
||||
|
||||
const toggleSelected = useCallback((job: RecordJob) => {
|
||||
const k = keyFor(job)
|
||||
const file = baseName(job.output || '')
|
||||
if (!file) return
|
||||
|
||||
setSelectedItems((prev) => {
|
||||
const next = { ...prev }
|
||||
if (next[k]) {
|
||||
delete next[k]
|
||||
} else {
|
||||
next[k] = { key: k, file }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
selectionStore.toggle({ key: k, file })
|
||||
}, [selectionStore])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItems({})
|
||||
}, [])
|
||||
selectionStore.clear()
|
||||
}, [selectionStore])
|
||||
|
||||
const selectPageRows = useCallback((rows: RecordJob[]) => {
|
||||
setSelectedItems((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const j of rows) {
|
||||
const k = keyFor(j)
|
||||
const items = rows
|
||||
.map((j) => {
|
||||
const key = keyFor(j)
|
||||
const file = baseName(j.output || '')
|
||||
if (!file) continue
|
||||
next[k] = { key: k, file }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
if (!file) return null
|
||||
return { key, file }
|
||||
})
|
||||
.filter((x): x is { key: string; file: string } => Boolean(x))
|
||||
|
||||
selectionStore.selectMany(items)
|
||||
}, [selectionStore])
|
||||
|
||||
const deselectPageRows = useCallback((rows: RecordJob[]) => {
|
||||
setSelectedItems((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const j of rows) {
|
||||
delete next[keyFor(j)]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
selectionStore.deselectMany(rows.map((j) => keyFor(j)))
|
||||
}, [selectionStore, keyFor])
|
||||
|
||||
const refreshHoverPreviewFromPointer = useCallback(() => {
|
||||
if (!canHover) return
|
||||
@ -1901,14 +1928,40 @@ export default function FinishedDownloads({
|
||||
const clearSearch = useCallback(() => setSearchQuery(''), [])
|
||||
|
||||
const toggleTagFilter = useCallback((tag: string) => {
|
||||
const k = lower(tag)
|
||||
const k = lowerTag(tag)
|
||||
setTagFilter((prev) => {
|
||||
const has = prev.some((t) => lower(t) === k)
|
||||
return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
|
||||
const has = prev.some((t) => lowerTag(t) === k)
|
||||
return has ? prev.filter((t) => lowerTag(t) !== k) : [...prev, tag]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
||||
const clearTagFilter = useCallback(() => {
|
||||
setTagFilter([])
|
||||
}, [])
|
||||
|
||||
const addTagFilter = useCallback((tag: string) => {
|
||||
const clean = String(tag ?? '').trim()
|
||||
if (!clean) return
|
||||
|
||||
setTagFilter((prev) =>
|
||||
prev.some((t) => lowerTag(t) === lowerTag(clean)) ? prev : [...prev, clean]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const removeTagFilter = useCallback((tag: string) => {
|
||||
setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag)))
|
||||
}, [])
|
||||
|
||||
const renderTagFilterControls = (opts?: { mobile?: boolean }): React.ReactNode => (
|
||||
<TagFilterBar
|
||||
mobile={Boolean(opts?.mobile)}
|
||||
activeTags={tagFilter}
|
||||
allTags={allKnownTags}
|
||||
onAddTag={addTagFilter}
|
||||
onRemoveTag={removeTagFilter}
|
||||
onClear={clearTagFilter}
|
||||
/>
|
||||
)
|
||||
|
||||
const loadAllDoneJobs = useCallback(async (): Promise<RecordJob[]> => {
|
||||
const res = await fetch(
|
||||
@ -2371,6 +2424,8 @@ export default function FinishedDownloads({
|
||||
return (await r.json().catch(() => null)) as any
|
||||
},
|
||||
onSuccess: async (result: any) => {
|
||||
selectionStore.deselect(key)
|
||||
|
||||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||
|
||||
if (undoToken) {
|
||||
@ -2392,6 +2447,7 @@ export default function FinishedDownloads({
|
||||
withFileReleaseRetry,
|
||||
runFileMutation,
|
||||
emitCountHint,
|
||||
selectionStore,
|
||||
]
|
||||
)
|
||||
|
||||
@ -2436,6 +2492,8 @@ export default function FinishedDownloads({
|
||||
return (await r.json().catch(() => null)) as any
|
||||
},
|
||||
onSuccess: async (data: any) => {
|
||||
selectionStore.deselect(key)
|
||||
|
||||
const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file
|
||||
|
||||
setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })
|
||||
@ -2454,9 +2512,12 @@ export default function FinishedDownloads({
|
||||
runFileMutation,
|
||||
emitCountHint,
|
||||
includeKeep,
|
||||
selectionStore,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
const applyRename = useCallback((oldFile: string, newFile: string) => {
|
||||
if (!oldFile || !newFile || oldFile === newFile) return
|
||||
|
||||
@ -2469,22 +2530,8 @@ export default function FinishedDownloads({
|
||||
return next
|
||||
})
|
||||
|
||||
setSelectedItems((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, SelectedItem> = {}
|
||||
|
||||
for (const [k, item] of Object.entries(prev)) {
|
||||
if (item.file === oldFile) {
|
||||
next[k] = { ...item, file: newFile }
|
||||
changed = true
|
||||
} else {
|
||||
next[k] = item
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
selectionStore.replaceFile(oldFile, newFile)
|
||||
}, [selectionStore])
|
||||
|
||||
const clearRenamePair = useCallback((a: string, b: string) => {
|
||||
if (!a && !b) return
|
||||
@ -2497,6 +2544,15 @@ export default function FinishedDownloads({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectOnly = useCallback((job: RecordJob) => {
|
||||
const key = keyFor(job)
|
||||
const file = baseName(job.output || '')
|
||||
if (!file) return
|
||||
|
||||
selectionStore.clear()
|
||||
selectionStore.selectMany([{ key, file }])
|
||||
}, [selectionStore])
|
||||
|
||||
const undoLastAction = useCallback(async () => {
|
||||
if (!lastAction || undoing) return
|
||||
setUndoing(true)
|
||||
@ -2676,6 +2732,7 @@ export default function FinishedDownloads({
|
||||
},
|
||||
onError: async () => {
|
||||
clearRenamePair(oldFile, optimisticNew)
|
||||
selectionStore.replaceFile(optimisticNew, oldFile)
|
||||
setLastAction(null)
|
||||
},
|
||||
})
|
||||
@ -2688,6 +2745,7 @@ export default function FinishedDownloads({
|
||||
clearRenamePair,
|
||||
onToggleHot,
|
||||
withFileReleaseRetry,
|
||||
selectionStore,
|
||||
]
|
||||
)
|
||||
|
||||
@ -2858,7 +2916,6 @@ export default function FinishedDownloads({
|
||||
if (!file) continue
|
||||
|
||||
const key =
|
||||
Object.values(selectedItems).find((x) => x.file === file)?.key ||
|
||||
fileToKeyRef.current.get(file) ||
|
||||
file
|
||||
|
||||
@ -2881,7 +2938,6 @@ export default function FinishedDownloads({
|
||||
}, [
|
||||
bulkBusy,
|
||||
selectedFiles,
|
||||
selectedItems,
|
||||
animateRemove,
|
||||
clearSelection,
|
||||
emitCountHint,
|
||||
@ -2918,7 +2974,6 @@ export default function FinishedDownloads({
|
||||
if (!file) continue
|
||||
|
||||
const key =
|
||||
Object.values(selectedItems).find((x) => x.file === file)?.key ||
|
||||
fileToKeyRef.current.get(file) ||
|
||||
file
|
||||
|
||||
@ -2941,7 +2996,6 @@ export default function FinishedDownloads({
|
||||
}, [
|
||||
bulkBusy,
|
||||
selectedFiles,
|
||||
selectedItems,
|
||||
animateRemove,
|
||||
clearSelection,
|
||||
emitCountHint,
|
||||
@ -2995,36 +3049,45 @@ export default function FinishedDownloads({
|
||||
useEffect(() => {
|
||||
if (view !== 'gallery') return
|
||||
|
||||
const wanted = Math.max(2, galleryColumnCount * 2)
|
||||
const wanted = galleryPageSize
|
||||
|
||||
if (pageSize !== wanted) {
|
||||
onPageSizeChange?.(wanted)
|
||||
}
|
||||
}, [view, galleryColumnCount, pageSize, onPageSizeChange])
|
||||
}, [view, pageSize, galleryPageSize, onPageSizeChange])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY)
|
||||
const parsed = Number(raw)
|
||||
const initial = Number.isFinite(parsed)
|
||||
? Math.max(0.8, Math.min(1.35, parsed))
|
||||
: 1
|
||||
|
||||
setGalleryCardScale(initial)
|
||||
setGalleryCardScaleLive(initial)
|
||||
scaleLivePendingRef.current = initial
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
|
||||
return
|
||||
}
|
||||
|
||||
// neuer Fall: gespeicherter Index
|
||||
if (
|
||||
Number.isInteger(parsed) &&
|
||||
parsed >= 0 &&
|
||||
parsed <= GALLERY_CARD_SCALE_PRESETS.length - 1
|
||||
) {
|
||||
setGalleryCardScaleIndex(clampPresetIndex(parsed))
|
||||
return
|
||||
}
|
||||
|
||||
// alter Fall: gespeicherter Scale-Wert wie 0.8 / 0.9 / 1 / 1.2
|
||||
setGalleryCardScaleIndex(nearestPresetIndex(parsed))
|
||||
} catch {
|
||||
setGalleryCardScale(1)
|
||||
setGalleryCardScaleLive(1)
|
||||
scaleLivePendingRef.current = 1
|
||||
setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScale))
|
||||
localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScaleIndex))
|
||||
} catch {}
|
||||
}, [galleryCardScale])
|
||||
}, [galleryCardScaleIndex])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@ -3079,15 +3142,6 @@ export default function FinishedDownloads({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scaleLiveRafRef.current != null) {
|
||||
cancelAnimationFrame(scaleLiveRafRef.current)
|
||||
scaleLiveRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (teaserKeyRafRef.current != null) {
|
||||
@ -3169,6 +3223,8 @@ export default function FinishedDownloads({
|
||||
const file = String(item?.file ?? '').trim()
|
||||
if (!file) continue
|
||||
|
||||
syncedFiles.add(file)
|
||||
|
||||
let nextBadges: FinishedPostworkBadge[] | undefined
|
||||
|
||||
const assetId =
|
||||
@ -3564,24 +3620,29 @@ export default function FinishedDownloads({
|
||||
}, [selectedCount, selectionActionsOpen])
|
||||
|
||||
const desktopToolbarControlsClass = [
|
||||
'flex items-center gap-3',
|
||||
'flex-nowrap',
|
||||
'md:flex-wrap',
|
||||
'xl:flex-nowrap',
|
||||
'flex flex-wrap items-start gap-3',
|
||||
'min-[1800px]:flex-nowrap min-[1800px]:items-center',
|
||||
].join(' ')
|
||||
|
||||
const desktopToolbarLeftClass = [
|
||||
'flex min-w-0 shrink-0 items-center gap-2',
|
||||
'flex-nowrap',
|
||||
'flex min-w-0 flex-wrap items-center gap-2',
|
||||
'min-[1800px]:min-w-max min-[1800px]:shrink-0 min-[1800px]:flex-nowrap',
|
||||
].join(' ')
|
||||
|
||||
const desktopToolbarRightClass = [
|
||||
'flex min-w-0 items-center gap-2',
|
||||
'flex-nowrap',
|
||||
'md:ml-0 md:basis-full md:flex-wrap',
|
||||
'xl:ml-auto xl:basis-auto xl:flex-nowrap xl:justify-end',
|
||||
'flex w-full min-w-0 flex-wrap items-center gap-2',
|
||||
'min-[1800px]:ml-auto min-[1800px]:w-auto min-[1800px]:min-w-max min-[1800px]:flex-nowrap min-[1800px]:justify-end',
|
||||
].join(' ')
|
||||
|
||||
const handleToggleSelectAllPage = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
selectPageRows(pageRows)
|
||||
return
|
||||
}
|
||||
|
||||
deselectPageRows(pageRows)
|
||||
}, [pageRows, selectPageRows, deselectPageRows])
|
||||
|
||||
const renderClickablePostworkBadge = useCallback(
|
||||
(badge?: FinishedPostworkSummary) =>
|
||||
renderFinishedPostworkBadge(badge, {
|
||||
@ -3602,7 +3663,7 @@ export default function FinishedDownloads({
|
||||
return (
|
||||
<>
|
||||
{/* Toolbar (sticky) */}
|
||||
<div className="sticky top-[56px] z-20">
|
||||
<div className="sticky top-[56px] z-[80]">
|
||||
<div
|
||||
className="
|
||||
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
|
||||
@ -3627,7 +3688,10 @@ export default function FinishedDownloads({
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<LoadingSpinner
|
||||
size="lg"
|
||||
className="text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none"
|
||||
className={[
|
||||
'text-indigo-500 transition-opacity duration-150 pointer-events-none',
|
||||
allDoneJobsLoading ? 'opacity-100' : 'opacity-0',
|
||||
].join(' ')}
|
||||
srLabel="Lade Downloads…"
|
||||
/>
|
||||
</div>
|
||||
@ -3699,23 +3763,28 @@ export default function FinishedDownloads({
|
||||
{/* rechts */}
|
||||
<div className={desktopToolbarRightClass}>
|
||||
{view === 'gallery' ? (
|
||||
<div className="w-[160px] shrink-0 md:w-[180px]">
|
||||
<div className="w-[200px] shrink-0 md:w-[220px]">
|
||||
<Slider
|
||||
id="finished-gallery-card-scale"
|
||||
label="Größe"
|
||||
min={0.8}
|
||||
max={1.35}
|
||||
value={galleryCardScaleLive}
|
||||
onChange={handleGalleryScaleLiveChange}
|
||||
onChangeEnd={handleGalleryScaleCommit}
|
||||
min={0}
|
||||
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
|
||||
step={1}
|
||||
value={galleryCardScaleIndex}
|
||||
onChange={handleGalleryScalePresetChange}
|
||||
onChangeEnd={handleGalleryScalePresetChange}
|
||||
valueFormatter={(v) => {
|
||||
const idx = clampPresetIndex(v)
|
||||
return GALLERY_CARD_SCALE_PRESETS[idx].label
|
||||
}}
|
||||
showValueLabel
|
||||
compact
|
||||
softStep
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view !== 'table' ? (
|
||||
<div className="w-[190px] shrink-0 md:w-[220px]">
|
||||
<div className="w-[150px] shrink-0 md:w-[165px] xl:w-[175px]">
|
||||
<label className="sr-only" htmlFor="finished-sort">
|
||||
Sortierung
|
||||
</label>
|
||||
@ -4044,28 +4113,16 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tagFilter.length > 0 ? (
|
||||
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
Tag-Filter
|
||||
</span>
|
||||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||||
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="pt-1 text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
Tag-Filter
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tagFilter.map((t) => (
|
||||
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
|
||||
))}
|
||||
|
||||
<Button
|
||||
className="
|
||||
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
||||
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
|
||||
"
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={clearTagFilter}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderTagFilterControls()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -4125,13 +4182,17 @@ export default function FinishedDownloads({
|
||||
<Slider
|
||||
id="finished-gallery-card-scale-mobile"
|
||||
label="Kartengröße"
|
||||
min={0.8}
|
||||
max={1.35}
|
||||
value={galleryCardScaleLive}
|
||||
onChange={handleGalleryScaleLiveChange}
|
||||
onChangeEnd={handleGalleryScaleCommit}
|
||||
softStep
|
||||
showValueLabel={false}
|
||||
min={0}
|
||||
max={GALLERY_CARD_SCALE_PRESETS.length - 1}
|
||||
step={1}
|
||||
value={galleryCardScaleIndex}
|
||||
onChange={handleGalleryScalePresetChange}
|
||||
onChangeEnd={handleGalleryScalePresetChange}
|
||||
valueFormatter={(v) => {
|
||||
const idx = clampPresetIndex(v)
|
||||
return GALLERY_CARD_SCALE_PRESETS[idx].label
|
||||
}}
|
||||
showValueLabel
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -4168,31 +4229,13 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tagFilter.length > 0 ? (
|
||||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||||
<div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
Tag-Filter
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tagFilter.map((t) => (
|
||||
<TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
|
||||
))}
|
||||
|
||||
<Button
|
||||
className="
|
||||
ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
|
||||
text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
|
||||
"
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={clearTagFilter}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">
|
||||
Tag-Filter
|
||||
</div>
|
||||
|
||||
{renderTagFilterControls({ mobile: true })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -4251,12 +4294,10 @@ export default function FinishedDownloads({
|
||||
<div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
|
||||
<FinishedDownloadsCardsView
|
||||
rows={pageRows}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionStore={selectionStore}
|
||||
onToggleSelected={toggleSelected}
|
||||
onToggleSelectAllPage={(checked) => {
|
||||
if (checked) selectPageRows(pageRows)
|
||||
else deselectPageRows(pageRows)
|
||||
}}
|
||||
onToggleSelectAllPage={handleToggleSelectAllPage}
|
||||
onSelectOnly={selectOnly}
|
||||
isSmall={isSmall}
|
||||
jobForDetails={jobForDetails}
|
||||
isLoading={allDoneJobsLoading}
|
||||
@ -4274,7 +4315,7 @@ export default function FinishedDownloads({
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
lower={lowerTag}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openPlayer={openPlayer}
|
||||
onOpenPlayerAt={openPlayerAt}
|
||||
@ -4313,6 +4354,7 @@ export default function FinishedDownloads({
|
||||
rows={pageRows}
|
||||
isLoading={allDoneJobsLoading}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionModeActive={selectionModeActive}
|
||||
onToggleSelected={toggleSelected}
|
||||
onToggleSelectAllPage={(checked) => {
|
||||
if (checked) selectPageRows(pageRows)
|
||||
@ -4321,7 +4363,7 @@ export default function FinishedDownloads({
|
||||
jobForDetails={jobForDetails}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
lower={lower}
|
||||
lower={lowerTag}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
@ -4365,13 +4407,10 @@ export default function FinishedDownloads({
|
||||
{view === 'gallery' && (
|
||||
<FinishedDownloadsGalleryView
|
||||
rows={pageRows}
|
||||
onColumnCountChange={setGalleryColumnCount}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionModeActive={selectionModeActive}
|
||||
onToggleSelected={toggleSelected}
|
||||
onToggleSelectAllPage={(checked) => {
|
||||
if (checked) selectPageRows(pageRows)
|
||||
else deselectPageRows(pageRows)
|
||||
}}
|
||||
onSelectOnly={selectOnly}
|
||||
isLoading={allDoneJobsLoading}
|
||||
jobForDetails={jobForDetails}
|
||||
blurPreviews={blurPreviews}
|
||||
@ -4387,14 +4426,13 @@ export default function FinishedDownloads({
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
deletedKeys={deletedKeys}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
onToggleHot={toggleHotVideo}
|
||||
lower={lower}
|
||||
lower={lowerTag}
|
||||
modelsByKey={modelsByKey}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
@ -4407,8 +4445,9 @@ export default function FinishedDownloads({
|
||||
postworkByFile={effectivePostworkSummaryByFile}
|
||||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
cardScale={galleryCardScaleLive}
|
||||
cardScale={galleryCardScale}
|
||||
renderPostworkBadge={renderClickablePostworkBadge}
|
||||
onRecommendedPageSizeChange={setGalleryPageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
// frontend\src\components\ui\FinishedDownloadsCardsView.tsx
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import Card from './Card'
|
||||
import Button from './Button'
|
||||
import type {
|
||||
RecordJob,
|
||||
FinishedPostworkSummary,
|
||||
@ -30,14 +39,22 @@ import {
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
import React from 'react'
|
||||
import {
|
||||
type FinishedSelectionStore,
|
||||
useSelectionChecked,
|
||||
} from './finishedSelectionStore'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
isSmall: boolean
|
||||
selectedKeys: Set<string>
|
||||
selectionStore: FinishedSelectionStore
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
@ -48,7 +65,7 @@ type Props = {
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
|
||||
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
|
||||
swipeRefs: MutableRefObject<Map<string, SwipeCardHandle>>
|
||||
|
||||
assetNonce?: number
|
||||
|
||||
@ -97,32 +114,13 @@ type Props = {
|
||||
summaries: Record<string, FinishedPostworkSummary>
|
||||
) => FinishedPostworkSummary | undefined
|
||||
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||
|
||||
forcePreviewMuted?: boolean
|
||||
mobileTeaserAudioUnlocked?: boolean
|
||||
onUnlockMobileTeaserAudio?: () => void
|
||||
}
|
||||
|
||||
const parseTags = (raw?: string): string[] => {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (!s) return []
|
||||
const parts = s
|
||||
.split(/[\n,;|]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
const k = p.toLowerCase()
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function CardBlurWrapper({
|
||||
blurred,
|
||||
animateUnblurOnMount,
|
||||
@ -130,11 +128,11 @@ function CardBlurWrapper({
|
||||
}: {
|
||||
blurred?: boolean
|
||||
animateUnblurOnMount?: boolean
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [entered, setEntered] = React.useState(!animateUnblurOnMount)
|
||||
const [entered, setEntered] = useState(!animateUnblurOnMount)
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!animateUnblurOnMount) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
setEntered(true)
|
||||
@ -161,11 +159,11 @@ function PromoteToFrontWrapper({
|
||||
children,
|
||||
}: {
|
||||
animateOnMount?: boolean
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [entered, setEntered] = React.useState(!animateOnMount)
|
||||
const [entered, setEntered] = useState(!animateOnMount)
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!animateOnMount) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
setEntered(true)
|
||||
@ -288,14 +286,14 @@ function MobileLoadingStack({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
|
||||
<LoadingCardSkeleton />
|
||||
<div className="absolute inset-0 grid place-items-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade…</div>
|
||||
</div>
|
||||
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
|
||||
<LoadingCardSkeleton />
|
||||
<div className="absolute inset-0 grid place-items-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -303,11 +301,434 @@ function MobileLoadingStack({
|
||||
)
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsCardsView({
|
||||
type FinishedDownloadsDesktopCardProps = {
|
||||
job: RecordJob
|
||||
selectionStore: FinishedSelectionStore
|
||||
isDeleting: boolean
|
||||
isKeeping: boolean
|
||||
isRemoving: boolean
|
||||
postworkBadge?: FinishedPostworkSummary
|
||||
|
||||
blurPreviews?: boolean
|
||||
durationSeconds?: number
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
inlinePlay: InlinePlayState
|
||||
assetNonce?: number
|
||||
forcePreviewMuted?: boolean
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
modelNameFromOutput: (output?: string) => string
|
||||
runtimeOf: (job: RecordJob) => string
|
||||
sizeBytesOf: (job: RecordJob) => number | null
|
||||
formatBytes: (bytes?: number | null) => string
|
||||
lower: (s: string) => string
|
||||
|
||||
jobForDetails: (job: RecordJob) => RecordJob
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
openPlayer: (job: RecordJob) => void
|
||||
startInline: (key: string) => void
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
|
||||
modelsByKey: Record<string, GalleryModelFlags>
|
||||
activeTagSet: Set<string>
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
deleteVideo: (job: RecordJob) => Promise<boolean>
|
||||
keepVideo: (job: RecordJob) => Promise<boolean>
|
||||
|
||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode
|
||||
}
|
||||
|
||||
function samePostworkSummaryLite(
|
||||
a?: FinishedPostworkSummary,
|
||||
b?: FinishedPostworkSummary
|
||||
) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return !a && !b
|
||||
|
||||
return (
|
||||
a.file === b.file &&
|
||||
a.state === b.state &&
|
||||
a.label === b.label &&
|
||||
a.ts === b.ts
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewActiveForKey(
|
||||
state: FinishedDownloadsTeaserState,
|
||||
key: string
|
||||
) {
|
||||
return state.activeKey === key || state.hoverKey === key
|
||||
}
|
||||
|
||||
function inlineStateForKey(
|
||||
inlinePlay: InlinePlayState,
|
||||
key: string
|
||||
) {
|
||||
const active = inlinePlay?.key === key
|
||||
return {
|
||||
active,
|
||||
nonce: active ? inlinePlay?.nonce ?? 0 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function SelectionCheckboxOverlay({
|
||||
selectionStore,
|
||||
rowKey,
|
||||
label,
|
||||
onToggle,
|
||||
}: {
|
||||
selectionStore: FinishedSelectionStore
|
||||
rowKey: string
|
||||
label: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const checked = useSelectionChecked(selectionStore, rowKey)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={label}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FinishedDownloadsDesktopCard = React.memo(
|
||||
function FinishedDownloadsDesktopCard({
|
||||
job: j,
|
||||
selectionStore,
|
||||
isDeleting,
|
||||
isKeeping,
|
||||
isRemoving,
|
||||
postworkBadge,
|
||||
blurPreviews,
|
||||
durationSeconds,
|
||||
teaserState,
|
||||
inlinePlay,
|
||||
assetNonce,
|
||||
forcePreviewMuted,
|
||||
keyFor,
|
||||
baseName,
|
||||
modelNameFromOutput,
|
||||
runtimeOf,
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
lower,
|
||||
jobForDetails,
|
||||
handleDuration,
|
||||
onToggleSelected,
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
openPlayer,
|
||||
startInline,
|
||||
registerTeaserHost,
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
onToggleHot,
|
||||
onToggleFavorite,
|
||||
onToggleLike,
|
||||
onToggleWatch,
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
deleteVideo,
|
||||
keepVideo,
|
||||
renderPostworkBadge,
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
const allowSound = shouldEnableTeaserAudio({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
inlineActive,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const handleSelectOrOpen = React.useCallback(() => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [selectionStore, onToggleSelected, j])
|
||||
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
|
||||
const meta = parseJobMeta((j as any)?.meta)
|
||||
const resObj =
|
||||
typeof meta?.file?.video?.width === 'number' &&
|
||||
typeof meta?.file?.video?.height === 'number' &&
|
||||
meta.file.video.width > 0 &&
|
||||
meta.file.video.height > 0
|
||||
? { w: meta.file.video.width, h: meta.file.video.height }
|
||||
: null
|
||||
|
||||
const resLabel = formatResolution(resObj)
|
||||
|
||||
const inlineDomId = `inline-prev-${encodeURIComponent(k)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-finished-hover-key={k}
|
||||
className={[
|
||||
'group relative rounded-lg overflow-visible outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||
'bg-white dark:bg-gray-900/40',
|
||||
'transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
isRemoving && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
onOpenPlayer(j)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
<div
|
||||
id={inlineDomId}
|
||||
ref={registerTeaserHost(k)}
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
startInline(k)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
{!inlineActive ? (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white"
|
||||
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size].filter(Boolean).join(' • ')}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{resLabel ? <span aria-hidden="true">•</span> : null}
|
||||
{resLabel ? <span>{resLabel}</span> : null}
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{size}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FinishedVideoPreview
|
||||
job={j}
|
||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||
className="h-full w-full"
|
||||
variant="fill"
|
||||
durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds}
|
||||
onDuration={handleDuration}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={shouldAnimateTeaser({
|
||||
state: teaserState,
|
||||
itemKey: k,
|
||||
disabled: false,
|
||||
})}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
inlineVideo={inlineActive ? 'always' : false}
|
||||
inlineNonce={inlineNonce}
|
||||
inlineControls={inlineActive}
|
||||
inlineLoop={false}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
assetNonce={assetNonce ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative min-h-[112px] overflow-visible px-4 py-3 border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{model}
|
||||
</div>
|
||||
|
||||
<ModelGenderIcon gender={flags?.gender} className="shrink-0" />
|
||||
<CountryFlag country={modelCountry} size="sm" className="shrink-0" />
|
||||
</div>
|
||||
|
||||
{renderPostworkBadge && postworkBadge ? (
|
||||
<div className="relative z-20 shrink-0">
|
||||
{renderPostworkBadge(postworkBadge)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-start gap-2 min-w-0">
|
||||
{isHot ? (
|
||||
<span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
|
||||
HOT
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="min-w-0 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{stripHotPrefix(fileRaw) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||
<RecordJobActions
|
||||
job={jobForDetails(j)}
|
||||
variant="table"
|
||||
busy={busy}
|
||||
collapseToMenu
|
||||
compact={false}
|
||||
isHot={isHot}
|
||||
isFavorite={Boolean(flags?.favorite)}
|
||||
isLiked={flags?.liked === true}
|
||||
isWatching={Boolean(flags?.watching)}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleHot={onToggleHot}
|
||||
onKeep={keepVideo}
|
||||
onDelete={deleteVideo}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
|
||||
className="w-full gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<TagOverflowRow
|
||||
rowKey={k}
|
||||
tags={tags}
|
||||
activeTagSet={activeTagSet}
|
||||
lower={lower}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
const prevKey = prev.keyFor(prev.job)
|
||||
const nextKey = next.keyFor(next.job)
|
||||
|
||||
const prevPreviewActive = isPreviewActiveForKey(prev.teaserState, prevKey)
|
||||
const nextPreviewActive = isPreviewActiveForKey(next.teaserState, nextKey)
|
||||
|
||||
const prevInline = inlineStateForKey(prev.inlinePlay, prevKey)
|
||||
const nextInline = inlineStateForKey(next.inlinePlay, nextKey)
|
||||
|
||||
return (
|
||||
prev.job === next.job &&
|
||||
prev.isDeleting === next.isDeleting &&
|
||||
prev.isKeeping === next.isKeeping &&
|
||||
prev.isRemoving === next.isRemoving &&
|
||||
samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) &&
|
||||
prev.durationSeconds === next.durationSeconds &&
|
||||
prev.forcePreviewMuted === next.forcePreviewMuted &&
|
||||
prev.blurPreviews === next.blurPreviews &&
|
||||
prev.teaserState.mode === next.teaserState.mode &&
|
||||
prev.teaserState.audioEnabled === next.teaserState.audioEnabled &&
|
||||
prevPreviewActive === nextPreviewActive &&
|
||||
prevInline.active === nextInline.active &&
|
||||
prevInline.nonce === nextInline.nonce &&
|
||||
prev.modelsByKey === next.modelsByKey &&
|
||||
prev.activeTagSet === next.activeTagSet &&
|
||||
prev.renderPostworkBadge === next.renderPostworkBadge
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function FinishedDownloadsCardsView({
|
||||
rows,
|
||||
isSmall,
|
||||
selectedKeys,
|
||||
selectionStore,
|
||||
onToggleSelected,
|
||||
onSelectOnly,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
durations,
|
||||
@ -360,14 +781,13 @@ export default function FinishedDownloadsCardsView({
|
||||
onUnlockMobileTeaserAudio,
|
||||
}: Props) {
|
||||
|
||||
const hasAnySelection = selectedKeys.size > 0
|
||||
const footerTapRef = React.useRef<Record<string, boolean>>({})
|
||||
const footerTapRef = useRef<Record<string, boolean>>({})
|
||||
|
||||
const teaserPlayFnsRef = React.useRef<Record<string, (() => void) | null>>({})
|
||||
const teaserPlayFnsRef = useRef<Record<string, (() => void) | null>>({})
|
||||
|
||||
const [speedHoldKey, setSpeedHoldKey] = React.useState<string | null>(null)
|
||||
const [speedHoldKey, setSpeedHoldKey] = useState<string | null>(null)
|
||||
|
||||
const registerTeaserPlayForKey = React.useCallback(
|
||||
const registerTeaserPlayForKey = useCallback(
|
||||
(key: string) => (fn: (() => void) | null) => {
|
||||
if (fn) teaserPlayFnsRef.current[key] = fn
|
||||
else delete teaserPlayFnsRef.current[key]
|
||||
@ -375,11 +795,11 @@ export default function FinishedDownloadsCardsView({
|
||||
[]
|
||||
)
|
||||
|
||||
const parseMeta = React.useCallback((j: RecordJob): any | null => {
|
||||
const parseMeta = useCallback((j: RecordJob): any | null => {
|
||||
return parseJobMeta((j as any)?.meta)
|
||||
}, [])
|
||||
|
||||
const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const resolutionObjOf = useCallback((j: RecordJob): { w: number; h: number } | null => {
|
||||
const meta = parseMeta(j)
|
||||
|
||||
const w =
|
||||
@ -396,12 +816,12 @@ export default function FinishedDownloadsCardsView({
|
||||
return null
|
||||
}, [parseMeta])
|
||||
|
||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
||||
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({})
|
||||
const [scrubActiveByKey, setScrubActiveByKey] = useState<Record<string, number | undefined>>({})
|
||||
const [scrubHoveringByKey, setScrubHoveringByKey] = useState<Record<string, boolean | undefined>>({})
|
||||
|
||||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
||||
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
||||
|
||||
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
|
||||
const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => {
|
||||
setScrubActiveByKey((prev) => {
|
||||
if (index === undefined) {
|
||||
if (!(key in prev)) return prev
|
||||
@ -415,11 +835,11 @@ export default function FinishedDownloadsCardsView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearScrubActiveIndex = React.useCallback((key: string) => {
|
||||
const clearScrubActiveIndex = useCallback((key: string) => {
|
||||
setScrubActiveIndex(key, undefined)
|
||||
}, [setScrubActiveIndex])
|
||||
|
||||
const setScrubHovering = React.useCallback((key: string, hovering: boolean | undefined) => {
|
||||
const setScrubHovering = useCallback((key: string, hovering: boolean | undefined) => {
|
||||
setScrubHoveringByKey((prev) => {
|
||||
if (hovering === undefined) {
|
||||
if (!(key in prev)) return prev
|
||||
@ -450,6 +870,14 @@ export default function FinishedDownloadsCardsView({
|
||||
) => {
|
||||
const k = keyFor(j)
|
||||
|
||||
const handleSelectOrOpen = () => {
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const realInlineActive = inlinePlay?.key === k
|
||||
const inlineActive = opts?.disableInline ? false : realInlineActive
|
||||
|
||||
@ -488,13 +916,18 @@ export default function FinishedDownloadsCardsView({
|
||||
})
|
||||
|
||||
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
|
||||
const checked = selectedKeys.has(k)
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const livePostwork = getPostworkSummaryForOutput(j.output, postworkByFile)
|
||||
const modelKey = lower(model)
|
||||
const flags = modelsByKey[modelKey]
|
||||
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const scrubActiveIndex = scrubActiveByKey[k]
|
||||
const scrubHovering = scrubHoveringByKey[k] === true
|
||||
|
||||
@ -525,7 +958,9 @@ export default function FinishedDownloadsCardsView({
|
||||
const isLiked = flags?.liked === true
|
||||
const isWatching = Boolean(flags?.watching)
|
||||
|
||||
const tags = parseTags(flags?.tags)
|
||||
const tags = parseFinishedTags(flags?.tags)
|
||||
|
||||
const modelCountry = resolveFinishedCountry(flags, j)
|
||||
|
||||
const dur = runtimeOf(j)
|
||||
const size = formatBytes(sizeBytesOf(j))
|
||||
@ -567,12 +1002,8 @@ export default function FinishedDownloadsCardsView({
|
||||
data-finished-hover-key={k}
|
||||
className={shellCls}
|
||||
onClick={(e) => {
|
||||
if (hasAnySelection) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!isSmall) {
|
||||
openPlayer(j)
|
||||
@ -598,6 +1029,8 @@ export default function FinishedDownloadsCardsView({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
onOpenPlayer(j)
|
||||
}
|
||||
}}
|
||||
@ -607,24 +1040,12 @@ export default function FinishedDownloadsCardsView({
|
||||
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
||||
>
|
||||
{!isSmall ? (
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
||||
onChange={() => onToggleSelected(j)}
|
||||
/>
|
||||
</div>
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Card shell keeps backgrounds consistent */}
|
||||
@ -654,11 +1075,6 @@ export default function FinishedDownloadsCardsView({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (hasAnySelection) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
if (isSmall || opts?.disableInline) return
|
||||
startInline(k)
|
||||
}}
|
||||
@ -846,6 +1262,12 @@ export default function FinishedDownloadsCardsView({
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
openPlayer(j)
|
||||
}}
|
||||
>
|
||||
@ -859,8 +1281,12 @@ export default function FinishedDownloadsCardsView({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={flags?.gender}
|
||||
sex={flags?.sex}
|
||||
subgender={flags?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={modelCountry}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
@ -947,30 +1373,78 @@ export default function FinishedDownloadsCardsView({
|
||||
allowTeaserAnimation,
|
||||
}
|
||||
}
|
||||
|
||||
const [skippedMobileKeys, setSkippedMobileKeys] = useState<string[]>([])
|
||||
|
||||
const mobileOrderedRows = useMemo(() => {
|
||||
if (!isSmall || skippedMobileKeys.length === 0) return rows
|
||||
|
||||
const skippedSet = new Set(skippedMobileKeys)
|
||||
|
||||
const front = rows.filter((row) => !skippedSet.has(keyFor(row)))
|
||||
|
||||
const byKey = new Map(rows.map((row) => [keyFor(row), row] as const))
|
||||
|
||||
const back = skippedMobileKeys
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((row): row is RecordJob => Boolean(row))
|
||||
|
||||
return [...front, ...back]
|
||||
}, [isSmall, rows, skippedMobileKeys, keyFor])
|
||||
|
||||
const skipTopMobileCard = useCallback(() => {
|
||||
if (!isSmall || mobileOrderedRows.length <= 1) return
|
||||
|
||||
const topRow = mobileOrderedRows[0]
|
||||
if (!topRow) return
|
||||
|
||||
const topKey = keyFor(topRow)
|
||||
|
||||
setSpeedHoldKey((prev) => (prev === topKey ? null : prev))
|
||||
|
||||
setSkippedMobileKeys((prev) => [
|
||||
...prev.filter((key) => key !== topKey),
|
||||
topKey,
|
||||
])
|
||||
}, [isSmall, mobileOrderedRows, keyFor])
|
||||
|
||||
const mobileStackDepth = 3
|
||||
const mobilePreloadDepth = mobileStackDepth + 3
|
||||
|
||||
// ✅ 4 Karten mounten, aber nur 3 sichtbar stacken
|
||||
const mobileLoadedStackRows = isSmall ? rows.slice(0, mobilePreloadDepth) : []
|
||||
const mobileLoadedStackRows = isSmall ? mobileOrderedRows.slice(0, mobilePreloadDepth) : []
|
||||
const mobileVisibleStackRows = mobileLoadedStackRows.slice(0, mobileStackDepth)
|
||||
const mobilePreloadedRow = mobileLoadedStackRows[mobileStackDepth] ?? null
|
||||
const mobileTopRow = mobileVisibleStackRows[0] ?? null
|
||||
|
||||
const mobileTopKey = isSmall && mobileVisibleStackRows[0]
|
||||
? keyFor(mobileVisibleStackRows[0])
|
||||
: ''
|
||||
|
||||
const [mobileTopActivationNonce, setMobileTopActivationNonce] = React.useState(0)
|
||||
const [mobileTopActivationNonce, setMobileTopActivationNonce] = useState(0)
|
||||
|
||||
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = React.useState(false)
|
||||
const mobileTopTeaserEnableTimerRef = React.useRef<number | null>(null)
|
||||
const [mobileTopTeaserEnabled, setMobileTopTeaserEnabled] = useState(false)
|
||||
const mobileTopTeaserEnableTimerRef = useRef<number | null>(null)
|
||||
|
||||
const [mobileTopTapEnabled, setMobileTopTapEnabled] = React.useState(true)
|
||||
const mobileTopTapEnableTimerRef = React.useRef<number | null>(null)
|
||||
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = React.useState<string | null>(null)
|
||||
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = React.useState<string | null>(null)
|
||||
const [mobileTopTapEnabled, setMobileTopTapEnabled] = useState(true)
|
||||
const mobileTopTapEnableTimerRef = useRef<number | null>(null)
|
||||
const [audioButtonDismissedForKey, setAudioButtonDismissedForKey] = useState<string | null>(null)
|
||||
const [teaserAudioEnabledForKey, setTeaserAudioEnabledForKey] = useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!isSmall) {
|
||||
setSkippedMobileKeys([])
|
||||
return
|
||||
}
|
||||
|
||||
const validKeys = new Set(rows.map((row) => keyFor(row)))
|
||||
|
||||
setSkippedMobileKeys((prev) => {
|
||||
const next = prev.filter((key) => validKeys.has(key))
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, [isSmall, rows, keyFor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSmall) return
|
||||
if (!mobileTopKey) return
|
||||
if (teaserAudioEnabledForKey !== mobileTopKey) return
|
||||
@ -983,7 +1457,7 @@ export default function FinishedDownloadsCardsView({
|
||||
})
|
||||
}, [isSmall, mobileTopKey, teaserAudioEnabledForKey, mobileTeaserAudioUnlocked])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (mobileTopTeaserEnableTimerRef.current != null) {
|
||||
window.clearTimeout(mobileTopTeaserEnableTimerRef.current)
|
||||
mobileTopTeaserEnableTimerRef.current = null
|
||||
@ -1049,18 +1523,59 @@ export default function FinishedDownloadsCardsView({
|
||||
{!isSmall ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rows.map((j) => {
|
||||
const { k, cardInner } = renderCardItem(j, { renderRole: 'desktop' })
|
||||
const k = keyFor(j)
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${k}__desktop`}>
|
||||
{cardInner}
|
||||
</React.Fragment>
|
||||
<FinishedDownloadsDesktopCard
|
||||
key={`${k}__desktop`}
|
||||
job={j}
|
||||
selectionStore={selectionStore}
|
||||
isDeleting={deletingKeys.has(k)}
|
||||
isKeeping={keepingKeys.has(k)}
|
||||
isRemoving={removingKeys.has(k)}
|
||||
postworkBadge={getPostworkSummaryForOutput(j.output, postworkByFile)}
|
||||
blurPreviews={blurPreviews}
|
||||
durationSeconds={durations[k]}
|
||||
teaserState={teaserState}
|
||||
inlinePlay={inlinePlay}
|
||||
assetNonce={assetNonce}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
jobForDetails={jobForDetails}
|
||||
handleDuration={handleDuration}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onSelectOnly={onSelectOnly}
|
||||
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openPlayer={openPlayer}
|
||||
startInline={startInline}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onToggleHot={onToggleHot}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
renderPostworkBadge={renderPostworkBadge}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
|
||||
{rows.length === 0 ? (
|
||||
{mobileOrderedRows.length === 0 ? (
|
||||
isLoading ? (
|
||||
<MobileLoadingStack
|
||||
stackPeekOffsetPx={stackPeekOffsetPx}
|
||||
@ -1194,14 +1709,14 @@ export default function FinishedDownloadsCardsView({
|
||||
onTap={() => {
|
||||
if (!mobileTopTapEnabled) return
|
||||
|
||||
if (footerTapRef.current[k]) {
|
||||
delete footerTapRef.current[k]
|
||||
openPlayer(j)
|
||||
if (selectionStore.hasAnySelection()) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasAnySelection) {
|
||||
onToggleSelected(j)
|
||||
if (footerTapRef.current[k]) {
|
||||
delete footerTapRef.current[k]
|
||||
openPlayer(j)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1240,6 +1755,19 @@ export default function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTopRow && mobileOrderedRows.length > 1 ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="soft"
|
||||
onClick={skipTopMobileCard}
|
||||
className="w-full"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1254,3 +1782,6 @@ export default function FinishedDownloadsCardsView({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView)
|
||||
export default MemoFinishedDownloadsCardsView
|
||||
@ -23,11 +23,12 @@ import Checkbox from './Checkbox'
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
parseJobMeta,
|
||||
firstNonEmptyString,
|
||||
readPreviewSpriteInfo,
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
|
||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||
@ -36,8 +37,9 @@ function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||
|
||||
type Props = {
|
||||
job: RecordJob
|
||||
checked: boolean
|
||||
hasAnySelection: boolean
|
||||
selectedKeys: Set<string>
|
||||
selectionModeActive: boolean
|
||||
rowKey: string
|
||||
isPreviewActive: boolean
|
||||
forcePreviewMuted?: boolean
|
||||
blurPreviews?: boolean
|
||||
@ -52,7 +54,6 @@ type Props = {
|
||||
durationSeconds?: number
|
||||
teaserState: FinishedDownloadsTeaserState
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
modelNameFromOutput: (output?: string) => string
|
||||
runtimeOf: (job: RecordJob) => string
|
||||
@ -70,6 +71,7 @@ type Props = {
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
@ -93,8 +95,9 @@ type Props = {
|
||||
|
||||
function FinishedDownloadsGalleryCardInner({
|
||||
job: j,
|
||||
checked,
|
||||
hasAnySelection,
|
||||
selectedKeys,
|
||||
selectionModeActive,
|
||||
rowKey,
|
||||
isPreviewActive,
|
||||
forcePreviewMuted,
|
||||
blurPreviews,
|
||||
@ -103,7 +106,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
renderPostworkBadge,
|
||||
durationSeconds,
|
||||
teaserState,
|
||||
keyFor,
|
||||
baseName,
|
||||
modelNameFromOutput,
|
||||
runtimeOf,
|
||||
@ -117,6 +119,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
removingKeys,
|
||||
registerTeaserHost,
|
||||
onToggleSelected,
|
||||
onSelectOnly,
|
||||
onHoverPreviewKeyChange,
|
||||
onToggleTagFilter,
|
||||
onOpenPlayer,
|
||||
@ -136,12 +139,27 @@ function FinishedDownloadsGalleryCardInner({
|
||||
titleClass,
|
||||
sublineClass,
|
||||
}: Props) {
|
||||
const k = keyFor(j)
|
||||
const k = rowKey
|
||||
const checked = selectedKeys.has(rowKey)
|
||||
|
||||
const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)
|
||||
const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})
|
||||
const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)
|
||||
|
||||
const handlePrimaryClick = React.useCallback(
|
||||
(e?: React.SyntheticEvent) => {
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
|
||||
onOpenPlayer(j)
|
||||
},
|
||||
[selectionModeActive, onSelectOnly, onOpenPlayer, j]
|
||||
)
|
||||
|
||||
const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
|
||||
setScrubIndexByKey((prev) => {
|
||||
if (index === undefined) {
|
||||
@ -166,8 +184,13 @@ function FinishedDownloadsGalleryCardInner({
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const modelKey = lower(model)
|
||||
const flags = modelsByKey[modelKey]
|
||||
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = React.useMemo(() => {
|
||||
const s = String(flags?.tags ?? '').trim()
|
||||
@ -188,6 +211,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
const isLiked = flags?.liked === true
|
||||
const isWatching = Boolean(flags?.watching)
|
||||
|
||||
const country = resolveFinishedCountry(flags, j)
|
||||
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
@ -219,13 +244,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
const isKeeping = keepingKeys.has(k)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
|
||||
const modelImageSrc = firstNonEmptyString(
|
||||
flags?.image,
|
||||
flags?.imageUrl,
|
||||
(j as any)?.meta?.modelImage,
|
||||
(j as any)?.meta?.modelImageUrl
|
||||
)
|
||||
|
||||
const sprite = React.useMemo(
|
||||
() =>
|
||||
readPreviewSpriteInfo((j as any)?.meta, {
|
||||
@ -257,9 +275,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
[spriteUrl, sprite.cols, sprite.rows, sprite.count, activeScrubIndex]
|
||||
)
|
||||
|
||||
const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
|
||||
const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)
|
||||
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
||||
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
||||
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
|
||||
|
||||
const previewDurationSeconds =
|
||||
durationSeconds ??
|
||||
@ -274,23 +291,17 @@ function FinishedDownloadsGalleryCardInner({
|
||||
data-finished-hover-key={k}
|
||||
className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
|
||||
onClick={(e) => {
|
||||
if (hasAnySelection) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onToggleSelected(j)
|
||||
return
|
||||
}
|
||||
onOpenPlayer(j)
|
||||
handlePrimaryClick(e)
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => {
|
||||
onHoverPreviewKeyChange?.(null)
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
clearScrubIndex(k)
|
||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
handlePrimaryClick(e)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -311,11 +322,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
@ -331,7 +339,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
onMouseLeave={() => {
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
clearScrubIndex(k)
|
||||
setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||
@ -377,22 +384,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showModelPreviewInThumb && modelImageSrc ? (
|
||||
<div className="absolute inset-0 z-[6]">
|
||||
<img
|
||||
src={modelImageSrc}
|
||||
alt={model ? `${model} preview` : 'Model preview'}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
|
||||
<div className="text-[10px] font-semibold tracking-wide text-white/95">
|
||||
MODEL PREVIEW
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasScrubber && hoveredThumbKey === k ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||
@ -435,9 +426,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
contentPaddingClass,
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onOpenPlayer(j)
|
||||
handlePrimaryClick(e)
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@ -449,8 +438,12 @@ function FinishedDownloadsGalleryCardInner({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={flags?.gender}
|
||||
sex={flags?.sex}
|
||||
subgender={flags?.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -16,9 +16,9 @@ import FinishedDownloadsGalleryCard from './FinishedDownloadsGalleryCard'
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
selectedKeys: Set<string>
|
||||
selectionModeActive: boolean
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
onColumnCountChange?: (count: number) => void
|
||||
onSelectOnly: (job: RecordJob) => void
|
||||
isLoading?: boolean
|
||||
blurPreviews?: boolean
|
||||
durations: Record<string, number>
|
||||
@ -46,7 +46,6 @@ type Props = {
|
||||
deletingKeys: Set<string>
|
||||
keepingKeys: Set<string>
|
||||
removingKeys: Set<string>
|
||||
deletedKeys: Set<string>
|
||||
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
|
||||
@ -66,6 +65,7 @@ type Props = {
|
||||
onToggleHot: (job: RecordJob) => void | Promise<void>
|
||||
onSplit?: (job: RecordJob) => void | Promise<void>
|
||||
onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
|
||||
onRecommendedPageSizeChange?: (size: number) => void
|
||||
|
||||
forcePreviewMuted?: boolean
|
||||
}
|
||||
@ -81,7 +81,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
export default function FinishedDownloadsGalleryView({
|
||||
function FinishedDownloadsGalleryView({
|
||||
rows,
|
||||
isLoading,
|
||||
blurPreviews,
|
||||
@ -91,9 +91,10 @@ export default function FinishedDownloadsGalleryView({
|
||||
teaserState,
|
||||
renderPostworkBadge,
|
||||
cardScale,
|
||||
onColumnCountChange,
|
||||
selectedKeys,
|
||||
selectionModeActive,
|
||||
onToggleSelected,
|
||||
onSelectOnly,
|
||||
handleDuration,
|
||||
handleScrubberClickIndex,
|
||||
jobForDetails,
|
||||
@ -121,6 +122,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
onToggleWatch,
|
||||
onSplit,
|
||||
onAddToDownloads,
|
||||
onRecommendedPageSizeChange,
|
||||
forcePreviewMuted,
|
||||
}: Props) {
|
||||
const observeTeasers = shouldObserveTeasers(teaserState.mode)
|
||||
@ -148,73 +150,105 @@ export default function FinishedDownloadsGalleryView({
|
||||
[effectiveScale]
|
||||
)
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [containerWidth, setContainerWidth] = React.useState(0)
|
||||
|
||||
const gapPx = 12
|
||||
|
||||
const preferredCardWidth = React.useMemo(() => {
|
||||
const base = 240
|
||||
return Math.round(base * layoutScale)
|
||||
const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => {
|
||||
if (layoutScale >= 1.14) return 'xl'
|
||||
if (layoutScale >= 0.99) return 'lg'
|
||||
if (layoutScale >= 0.88) return 'md'
|
||||
return 'sm'
|
||||
}, [layoutScale])
|
||||
|
||||
const columnCount = React.useMemo(() => {
|
||||
if (!containerWidth || containerWidth <= 0) {
|
||||
return 1
|
||||
}
|
||||
const minCardWidth = React.useMemo(() => {
|
||||
return Math.round(270 * layoutScale)
|
||||
}, [layoutScale])
|
||||
|
||||
const columnsThatFit = Math.floor(
|
||||
(containerWidth + gapPx) / (preferredCardWidth + gapPx)
|
||||
)
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const lastRecommendedPageSizeRef = React.useRef<number | null>(null)
|
||||
|
||||
return Math.max(1, columnsThatFit)
|
||||
}, [containerWidth, preferredCardWidth])
|
||||
const recommendPageSize = React.useCallback(() => {
|
||||
if (!onRecommendedPageSizeChange) return
|
||||
|
||||
React.useEffect(() => {
|
||||
onColumnCountChange?.(columnCount)
|
||||
}, [columnCount, onColumnCountChange])
|
||||
|
||||
const cardMinWidth = React.useMemo(() => {
|
||||
return preferredCardWidth
|
||||
}, [preferredCardWidth])
|
||||
|
||||
const gridStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
['--gallery-card-min' as any]: `${cardMinWidth}px`,
|
||||
}
|
||||
}, [cardMinWidth])
|
||||
|
||||
const contentPaddingClass = 'px-3.5 py-3'
|
||||
const footerMinHeightClass = 'min-h-[118px]'
|
||||
const titleClass = 'text-sm'
|
||||
const sublineClass = 'text-xs'
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const el = containerRef.current
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const update = () => {
|
||||
setContainerWidth(el.clientWidth || 0)
|
||||
}
|
||||
const containerWidth = el.clientWidth
|
||||
if (!Number.isFinite(containerWidth) || containerWidth <= 0) return
|
||||
|
||||
update()
|
||||
const gapPx = 12 // entspricht Tailwind gap-3
|
||||
const columns = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth + gapPx) / (minCardWidth + gapPx))
|
||||
)
|
||||
|
||||
// mobil etwas mehr vertikale Füllung, desktop wie bisher ca. 2 Reihen
|
||||
const rowsPerPage = containerWidth < 640 ? 3 : 2
|
||||
|
||||
const nextPageSize = clamp(columns * rowsPerPage, rowsPerPage, 24)
|
||||
|
||||
if (lastRecommendedPageSizeRef.current === nextPageSize) return
|
||||
lastRecommendedPageSizeRef.current = nextPageSize
|
||||
|
||||
onRecommendedPageSizeChange(nextPageSize)
|
||||
}, [minCardWidth, onRecommendedPageSizeChange])
|
||||
|
||||
React.useEffect(() => {
|
||||
recommendPageSize()
|
||||
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
recommendPageSize()
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => update())
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', recommendPageSize)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', recommendPageSize)
|
||||
}
|
||||
}, [])
|
||||
}, [recommendPageSize])
|
||||
|
||||
const contentPaddingClass =
|
||||
sizePreset === 'xl'
|
||||
? 'px-4 py-3.5'
|
||||
: sizePreset === 'lg'
|
||||
? 'px-3.5 py-3'
|
||||
: sizePreset === 'md'
|
||||
? 'px-3 py-2.5'
|
||||
: 'px-2.5 py-2'
|
||||
|
||||
const footerMinHeightClass =
|
||||
sizePreset === 'xl'
|
||||
? 'min-h-[132px]'
|
||||
: sizePreset === 'lg'
|
||||
? 'min-h-[124px]'
|
||||
: sizePreset === 'md'
|
||||
? 'min-h-[118px]'
|
||||
: 'min-h-[110px]'
|
||||
|
||||
const titleClass =
|
||||
sizePreset === 'xl'
|
||||
? 'text-[15px]'
|
||||
: sizePreset === 'lg'
|
||||
? 'text-sm'
|
||||
: sizePreset === 'md'
|
||||
? 'text-[13px]'
|
||||
: 'text-xs'
|
||||
|
||||
const sublineClass =
|
||||
sizePreset === 'xl'
|
||||
? 'text-[13px]'
|
||||
: sizePreset === 'lg'
|
||||
? 'text-xs'
|
||||
: 'text-[11px]'
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<div ref={rootRef} className="relative w-full">
|
||||
<div
|
||||
className="grid w-full gap-3"
|
||||
className="grid w-full justify-start gap-3"
|
||||
style={{
|
||||
...gridStyle,
|
||||
gridAutoFlow: 'row',
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
|
||||
}}
|
||||
>
|
||||
{rows.map((j) => {
|
||||
@ -222,52 +256,57 @@ export default function FinishedDownloadsGalleryView({
|
||||
const durationForRow = durations[k]
|
||||
|
||||
return (
|
||||
<FinishedDownloadsGalleryCard
|
||||
<div
|
||||
key={k}
|
||||
job={j}
|
||||
checked={selectedKeys.has(k)}
|
||||
hasAnySelection={selectedKeys.size > 0}
|
||||
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
blurPreviews={blurPreviews}
|
||||
postworkByFile={postworkByFile}
|
||||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||
renderPostworkBadge={renderPostworkBadge}
|
||||
durationSeconds={durationForRow}
|
||||
teaserState={teaserState}
|
||||
keyFor={keyFor}
|
||||
baseName={baseName}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleDuration={handleDuration}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
onToggleHot={onToggleHot}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
jobForDetails={jobForDetails}
|
||||
contentPaddingClass={contentPaddingClass}
|
||||
footerMinHeightClass={footerMinHeightClass}
|
||||
titleClass={titleClass}
|
||||
sublineClass={sublineClass}
|
||||
/>
|
||||
className="min-w-0 w-full"
|
||||
>
|
||||
<FinishedDownloadsGalleryCard
|
||||
job={j}
|
||||
selectedKeys={selectedKeys}
|
||||
selectionModeActive={selectionModeActive}
|
||||
rowKey={k}
|
||||
isPreviewActive={teaserState.activeKey === k || teaserState.hoverKey === k}
|
||||
forcePreviewMuted={forcePreviewMuted}
|
||||
blurPreviews={blurPreviews}
|
||||
postworkByFile={postworkByFile}
|
||||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||
renderPostworkBadge={renderPostworkBadge}
|
||||
durationSeconds={durationForRow}
|
||||
teaserState={teaserState}
|
||||
baseName={baseName}
|
||||
modelNameFromOutput={modelNameFromOutput}
|
||||
runtimeOf={runtimeOf}
|
||||
sizeBytesOf={sizeBytesOf}
|
||||
formatBytes={formatBytes}
|
||||
lower={lower}
|
||||
modelsByKey={modelsByKey}
|
||||
activeTagSet={activeTagSet}
|
||||
deletingKeys={deletingKeys}
|
||||
keepingKeys={keepingKeys}
|
||||
removingKeys={removingKeys}
|
||||
registerTeaserHost={registerTeaserHostIfNeeded}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onSelectOnly={onSelectOnly}
|
||||
onHoverPreviewKeyChange={onHoverPreviewKeyChange}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleDuration={handleDuration}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
onToggleHot={onToggleHot}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleLike={onToggleLike}
|
||||
onToggleWatch={onToggleWatch}
|
||||
onSplit={onSplit}
|
||||
onAddToDownloads={onAddToDownloads}
|
||||
jobForDetails={jobForDetails}
|
||||
contentPaddingClass={contentPaddingClass}
|
||||
footerMinHeightClass={footerMinHeightClass}
|
||||
titleClass={titleClass}
|
||||
sublineClass={sublineClass}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -282,4 +321,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView)
|
||||
export default MemoFinishedDownloadsGalleryView
|
||||
@ -23,11 +23,14 @@ import {
|
||||
import Checkbox from './Checkbox'
|
||||
import { parseJobMeta } from './previewSprite'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import { resolveFinishedModelFlags } from './finishedModelFlags'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
|
||||
type Props = {
|
||||
rows: RecordJob[]
|
||||
isLoading?: boolean
|
||||
selectedKeys: Set<string>
|
||||
selectionModeActive: boolean
|
||||
onToggleSelected: (job: RecordJob) => void
|
||||
onToggleSelectAllPage: (checked: boolean) => void
|
||||
|
||||
@ -91,6 +94,7 @@ export default function FinishedDownloadsTableView({
|
||||
rows,
|
||||
isLoading,
|
||||
selectedKeys,
|
||||
selectionModeActive,
|
||||
onToggleSelected,
|
||||
onToggleSelectAllPage,
|
||||
keyFor,
|
||||
@ -223,6 +227,18 @@ export default function FinishedDownloadsTableView({
|
||||
const someSelectedOnPage =
|
||||
rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage
|
||||
|
||||
const handlePrimaryAction = React.useCallback(
|
||||
(job: RecordJob) => {
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(job)
|
||||
return
|
||||
}
|
||||
|
||||
onOpenPlayer(job)
|
||||
},
|
||||
[selectionModeActive, onToggleSelected, onOpenPlayer]
|
||||
)
|
||||
|
||||
const columns = React.useMemo<Column<RecordJob>[]>(() => {
|
||||
return [
|
||||
{
|
||||
@ -298,7 +314,11 @@ export default function FinishedDownloadsTableView({
|
||||
<div
|
||||
ref={registerTeaserHost(k)}
|
||||
className="group relative py-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlePrimaryAction(j)
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseLeave={() => {
|
||||
setScrubActiveIndex(k, undefined)
|
||||
@ -357,12 +377,20 @@ export default function FinishedDownloadsTableView({
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
const model = modelNameFromOutput(j.output)
|
||||
|
||||
const modelKey = lower(modelNameFromOutput(j.output))
|
||||
const tags = parseTags(modelsByKey[modelKey]?.tags)
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const tags = parseTags(flags?.tags)
|
||||
|
||||
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
|
||||
const showPostworkBadge = !!postworkBadge
|
||||
|
||||
const country = resolveFinishedCountry(flags, j)
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@ -372,9 +400,13 @@ export default function FinishedDownloadsTableView({
|
||||
</div>
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={modelsByKey[modelKey]?.gender}
|
||||
sex={modelsByKey[modelKey]?.sex}
|
||||
subgender={modelsByKey[modelKey]?.subgender}
|
||||
gender={flags?.gender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
@ -493,8 +525,13 @@ export default function FinishedDownloadsTableView({
|
||||
const fileRaw = baseName(j.output || '')
|
||||
const isHot = isHotName(fileRaw)
|
||||
|
||||
const modelKey = lower(modelNameFromOutput(j.output))
|
||||
const flags = modelsByKey[modelKey]
|
||||
const { flags } = resolveFinishedModelFlags(
|
||||
j,
|
||||
modelsByKey,
|
||||
modelNameFromOutput,
|
||||
lower
|
||||
)
|
||||
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
const isWatching = Boolean(flags?.watching)
|
||||
@ -541,6 +578,7 @@ export default function FinishedDownloadsTableView({
|
||||
modelsByKey,
|
||||
activeTagSet,
|
||||
onToggleTagFilter,
|
||||
handlePrimaryAction,
|
||||
resolutions,
|
||||
deletingKeys,
|
||||
keepingKeys,
|
||||
@ -557,7 +595,6 @@ export default function FinishedDownloadsTableView({
|
||||
sizeBytesOf,
|
||||
formatBytes,
|
||||
runtimeOf,
|
||||
parseTags,
|
||||
runtimeSecondsForSort,
|
||||
forcePreviewMuted,
|
||||
renderPostworkBadge,
|
||||
@ -623,7 +660,7 @@ export default function FinishedDownloadsTableView({
|
||||
card
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
onRowClick={onOpenPlayer}
|
||||
onRowClick={handlePrimaryAction}
|
||||
rowAttrs={(j) => ({
|
||||
'data-finished-hover-key': keyFor(j),
|
||||
})}
|
||||
|
||||
@ -43,6 +43,7 @@ import {
|
||||
scrubProgressRatioFromIndex,
|
||||
} from './previewSprite'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import CountryFlag, { normalizeCountryName } from './CountryFlag'
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
@ -183,110 +184,6 @@ function fmtBirthDate(v: string | Date | null | undefined) {
|
||||
return raw
|
||||
}
|
||||
|
||||
function countryCodeFromInput(input?: string | null) {
|
||||
const raw = String(input ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
usa: 'US',
|
||||
us: 'US',
|
||||
'united states': 'US',
|
||||
'united states of america': 'US',
|
||||
uk: 'GB',
|
||||
gb: 'GB',
|
||||
'united kingdom': 'GB',
|
||||
'great britain': 'GB',
|
||||
england: 'GB',
|
||||
germany: 'DE',
|
||||
deutschland: 'DE',
|
||||
austria: 'AT',
|
||||
österreich: 'AT',
|
||||
switzerland: 'CH',
|
||||
schweiz: 'CH',
|
||||
netherlands: 'NL',
|
||||
holland: 'NL',
|
||||
france: 'FR',
|
||||
italy: 'IT',
|
||||
spain: 'ES',
|
||||
poland: 'PL',
|
||||
romania: 'RO',
|
||||
hungary: 'HU',
|
||||
czechia: 'CZ',
|
||||
'czech republic': 'CZ',
|
||||
slovakia: 'SK',
|
||||
ukraine: 'UA',
|
||||
russia: 'RU',
|
||||
ru: 'RU',
|
||||
turkey: 'TR',
|
||||
brazil: 'BR',
|
||||
colombia: 'CO',
|
||||
mexico: 'MX',
|
||||
canada: 'CA',
|
||||
japan: 'JP',
|
||||
china: 'CN',
|
||||
'south korea': 'KR',
|
||||
'north korea': 'KP',
|
||||
}
|
||||
|
||||
let code = ''
|
||||
|
||||
if (/^[a-z]{2}$/i.test(raw)) {
|
||||
code = raw.toUpperCase()
|
||||
} else {
|
||||
code = aliases[raw.toLowerCase()] ?? ''
|
||||
}
|
||||
|
||||
return /^[A-Z]{2}$/.test(code) ? code : ''
|
||||
}
|
||||
|
||||
function normalizeCountryName(input?: string | null) {
|
||||
const raw = String(input ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
usa: 'United States',
|
||||
us: 'United States',
|
||||
'united states': 'United States',
|
||||
'united states of america': 'United States',
|
||||
uk: 'United Kingdom',
|
||||
gb: 'United Kingdom',
|
||||
'united kingdom': 'United Kingdom',
|
||||
'great britain': 'United Kingdom',
|
||||
england: 'United Kingdom',
|
||||
germany: 'Germany',
|
||||
deutschland: 'Germany',
|
||||
austria: 'Austria',
|
||||
österreich: 'Austria',
|
||||
switzerland: 'Switzerland',
|
||||
schweiz: 'Switzerland',
|
||||
netherlands: 'Netherlands',
|
||||
holland: 'Netherlands',
|
||||
france: 'France',
|
||||
italy: 'Italy',
|
||||
spain: 'Spain',
|
||||
poland: 'Poland',
|
||||
romania: 'Romania',
|
||||
hungary: 'Hungary',
|
||||
czechia: 'Czechia',
|
||||
'czech republic': 'Czechia',
|
||||
slovakia: 'Slovakia',
|
||||
ukraine: 'Ukraine',
|
||||
russia: 'Russia',
|
||||
ru: 'Russia',
|
||||
turkey: 'Turkey',
|
||||
brazil: 'Brazil',
|
||||
colombia: 'Colombia',
|
||||
mexico: 'Mexico',
|
||||
canada: 'Canada',
|
||||
japan: 'Japan',
|
||||
china: 'China',
|
||||
'south korea': 'South Korea',
|
||||
'north korea': 'North Korea',
|
||||
}
|
||||
|
||||
return aliases[raw.toLowerCase()] ?? raw
|
||||
}
|
||||
|
||||
function stripHotPrefix(name: string) {
|
||||
return name.startsWith('HOT ') ? name.slice(4) : name
|
||||
}
|
||||
@ -493,7 +390,6 @@ type BioContext = {
|
||||
interested_in?: string[]
|
||||
display_age?: number
|
||||
sex?: string
|
||||
subgender?: string
|
||||
room_status?: string // offline/online
|
||||
photo_sets?: BioPhotoSet[]
|
||||
social_medias?: BioSocial[]
|
||||
@ -550,7 +446,9 @@ type Props = {
|
||||
| { ok?: boolean; oldFile?: string; newFile?: string }
|
||||
| Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
|
||||
onDelete?: (job: RecordJob) => void | Promise<void>
|
||||
onKeep?: (job: RecordJob) => void | Promise<void>
|
||||
onKeep?: (
|
||||
job: RecordJob
|
||||
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
|
||||
onStopJob?: (id: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
@ -1100,10 +998,6 @@ export default function ModelDetails({
|
||||
const effectiveLocationText = effectiveLocationParts.locationText
|
||||
const effectiveCountryText = effectiveLocationParts.countryText
|
||||
|
||||
const effectiveCountryCode = React.useMemo(() => {
|
||||
return countryCodeFromInput(effectiveCountryText)
|
||||
}, [effectiveCountryText])
|
||||
|
||||
const effectiveLocationSubText = React.useMemo(() => {
|
||||
return effectiveCountryText || ''
|
||||
}, [effectiveCountryText])
|
||||
@ -1407,14 +1301,18 @@ export default function ModelDetails({
|
||||
>
|
||||
<span className="truncate">{titleUsername}</span>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" />
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3.5 shrink-0" />
|
||||
<CountryFlag
|
||||
country={effectiveCountryText}
|
||||
size="hero"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@ -1422,10 +1320,14 @@ export default function ModelDetails({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={effectiveCountryText}
|
||||
size="hero"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -1523,14 +1425,18 @@ export default function ModelDetails({
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
|
||||
<CountryFlag
|
||||
country={effectiveCountryText}
|
||||
size="hero"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow">
|
||||
@ -1544,8 +1450,6 @@ export default function ModelDetails({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
</div>
|
||||
@ -1697,18 +1601,11 @@ export default function ModelDetails({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{effectiveCountryCode ? (
|
||||
<span
|
||||
className={cn(
|
||||
'fi',
|
||||
`fi-${effectiveCountryCode.toLowerCase()}`,
|
||||
'inline-block shrink-0 self-center rounded-[2px] ring-1 ring-black/5 dark:ring-white/10'
|
||||
)}
|
||||
aria-label={effectiveCountryText ? `${effectiveCountryText} Flagge` : 'Flagge'}
|
||||
title={effectiveCountryText || 'Flagge'}
|
||||
style={{ width: 24, height: 18 }}
|
||||
/>
|
||||
) : null}
|
||||
<CountryFlag
|
||||
country={effectiveCountryText}
|
||||
size="lg"
|
||||
className="self-center"
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
@ -1910,14 +1807,12 @@ export default function ModelDetails({
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
|
||||
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="mt-0.5 inline-flex max-w-full items-center gap-1 text-xs text-white/85 drop-shadow">
|
||||
@ -1931,8 +1826,6 @@ export default function ModelDetails({
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={effectiveRoom?.gender}
|
||||
sex={bio?.sex}
|
||||
subgender={bio?.subgender}
|
||||
variant="hero"
|
||||
/>
|
||||
</div>
|
||||
@ -2089,19 +1982,11 @@ export default function ModelDetails({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{effectiveCountryCode ? (
|
||||
<span
|
||||
className={cn(
|
||||
'fi',
|
||||
`fi-${effectiveCountryCode.toLowerCase()}`,
|
||||
'inline-block shrink-0 rounded-[2px] ring-1 ring-white/15',
|
||||
showLocationTwoLines ? 'self-start mt-0.5' : 'self-center'
|
||||
)}
|
||||
aria-label={effectiveCountryText ? `${effectiveCountryText} Flagge` : 'Flagge'}
|
||||
title={effectiveCountryText || 'Flagge'}
|
||||
style={{ width: 20, height: 15 }}
|
||||
/>
|
||||
) : null}
|
||||
<CountryFlag
|
||||
country={effectiveCountryText}
|
||||
size="md"
|
||||
className={showLocationTwoLines ? 'self-start mt-0.5' : 'self-center'}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
||||
@ -12,10 +12,8 @@ type GenderKind = 'female' | 'male' | 'trans' | 'couple' | null
|
||||
|
||||
function normalizeGender(
|
||||
gender?: string | null,
|
||||
sex?: string | null,
|
||||
subgender?: string | null
|
||||
): GenderKind {
|
||||
const raw = `${gender || ''} ${sex || ''} ${subgender || ''}`.trim().toLowerCase()
|
||||
const raw = `${gender || ''}`.trim().toLowerCase()
|
||||
|
||||
if (!raw) return null
|
||||
|
||||
@ -127,8 +125,6 @@ function CoupleIcon({ className }: IconProps) {
|
||||
|
||||
type Props = {
|
||||
gender?: string | null
|
||||
sex?: string | null
|
||||
subgender?: string | null
|
||||
variant?: 'hero' | 'default'
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
@ -136,13 +132,11 @@ type Props = {
|
||||
|
||||
export default function ModelGenderIcon({
|
||||
gender,
|
||||
sex,
|
||||
subgender,
|
||||
variant = 'default',
|
||||
className,
|
||||
iconClassName,
|
||||
}: Props) {
|
||||
const kind = normalizeGender(gender, sex, subgender)
|
||||
const kind = normalizeGender(gender)
|
||||
|
||||
if (!kind) return null
|
||||
|
||||
|
||||
@ -15,6 +15,25 @@ import RecordJobActions from './RecordJobActions'
|
||||
import type { RecordJob } from '../../types'
|
||||
import TextInput from './TextInput'
|
||||
import ModelGenderIcon from './ModelGenderIcon'
|
||||
import CountryFlag from './CountryFlag'
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
EyeIcon as EyeIconOutline,
|
||||
EyeSlashIcon,
|
||||
FilmIcon,
|
||||
HeartIcon as HeartIconOutline,
|
||||
Squares2X2Icon,
|
||||
StarIcon as StarIconOutline,
|
||||
TableCellsIcon,
|
||||
TagIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import {
|
||||
EyeIcon as EyeIconSolid,
|
||||
HeartIcon as HeartIconSolid,
|
||||
StarIcon as StarIconSolid,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import TagFilterBar from './TagFilterBar'
|
||||
import { parseTagList, lowerTag } from './tagUtils'
|
||||
|
||||
type ParsedModel = {
|
||||
input: string
|
||||
@ -53,8 +72,7 @@ export type StoredModel = {
|
||||
profileImageUpdatedAt?: string
|
||||
|
||||
gender?: string | null
|
||||
sex?: string | null
|
||||
subgender?: string | null
|
||||
country?: string | null
|
||||
|
||||
watching: boolean
|
||||
favorite: boolean
|
||||
@ -104,16 +122,7 @@ function normalizeHttpUrl(raw: string): string | null {
|
||||
}
|
||||
|
||||
function splitTags(raw?: string): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
const tags = raw
|
||||
.split(/[\n,;|]+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const uniq = Array.from(new Set(tags))
|
||||
uniq.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
return uniq
|
||||
return parseTagList(raw)
|
||||
}
|
||||
|
||||
function canonicalHost(raw?: string): string {
|
||||
@ -265,25 +274,11 @@ function sortFromGallerySortMode(mode: GallerySortMode): { key: string; directio
|
||||
}
|
||||
|
||||
function GridIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
)
|
||||
return <Squares2X2Icon className="size-4" />
|
||||
}
|
||||
|
||||
function TableIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M9 10v10" />
|
||||
<path d="M15 10v10" />
|
||||
</svg>
|
||||
)
|
||||
return <TableCellsIcon className="size-4" />
|
||||
}
|
||||
|
||||
type ModelsTabProps = {
|
||||
@ -305,6 +300,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
|
||||
const [tagFilter, setTagFilter] = React.useState<string[]>([])
|
||||
|
||||
type WatchFilterMode = 'all' | 'watched' | 'unwatched'
|
||||
type StatusFilterKey = 'favorite' | 'liked'
|
||||
|
||||
const [watchFilter, setWatchFilter] = React.useState<WatchFilterMode>('all')
|
||||
const [statusFilter, setStatusFilter] = React.useState<StatusFilterKey[]>([])
|
||||
|
||||
const activeStatusSet = React.useMemo(
|
||||
() => new Set(statusFilter),
|
||||
[statusFilter]
|
||||
)
|
||||
|
||||
const toggleStatusFilter = React.useCallback((key: StatusFilterKey) => {
|
||||
setStatusFilter((prev) =>
|
||||
prev.includes(key)
|
||||
? prev.filter((x) => x !== key)
|
||||
: [...prev, key]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const clearStatusFilter = React.useCallback(() => {
|
||||
setWatchFilter('all')
|
||||
setStatusFilter([])
|
||||
}, [])
|
||||
|
||||
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
|
||||
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
||||
|
||||
@ -342,7 +361,24 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeTagSet = React.useMemo(() => new Set(tagFilter.map((t) => t.toLowerCase())), [tagFilter])
|
||||
const activeTagSet = React.useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter])
|
||||
|
||||
const allKnownTags = React.useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
|
||||
for (const m of models) {
|
||||
for (const tag of splitTags(m.tags)) {
|
||||
const k = lowerTag(tag)
|
||||
if (!k || seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
return out
|
||||
}, [models])
|
||||
|
||||
const toggleTagFilter = React.useCallback((tag: string) => {
|
||||
const k = tag.toLowerCase()
|
||||
@ -672,14 +708,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
patch(m.id, { watched: !watch })
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base leading-none',
|
||||
watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'
|
||||
)}
|
||||
>
|
||||
👁
|
||||
</span>
|
||||
{watch ? (
|
||||
<EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-400" />
|
||||
) : (
|
||||
<EyeIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
@ -692,7 +725,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
if (fav) patch(m.id, { favorite: false })
|
||||
else patch(m.id, { favorite: true, liked: false })
|
||||
}}
|
||||
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}>★</span>}
|
||||
icon={
|
||||
fav ? (
|
||||
<StarIconSolid className="size-5 text-amber-500" />
|
||||
) : (
|
||||
<StarIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<IconToggle
|
||||
@ -704,7 +743,13 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
if (liked) patch(m.id, { liked: false })
|
||||
else patch(m.id, { liked: true, favorite: false })
|
||||
}}
|
||||
icon={<span className={liked ? 'text-rose-500' : 'text-gray-400 dark:text-gray-500'}>♥</span>}
|
||||
icon={
|
||||
liked ? (
|
||||
<HeartIconSolid className="size-5 text-rose-500" />
|
||||
) : (
|
||||
<HeartIconOutline className="size-5 text-gray-400 dark:text-gray-500" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@ -748,26 +793,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="font-medium truncate">{m.modelKey}</div>
|
||||
|
||||
{href ? (
|
||||
<span
|
||||
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(href, '_blank', 'noreferrer')
|
||||
}}
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={m.gender}
|
||||
sex={m.sex}
|
||||
subgender={m.subgender}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<CountryFlag
|
||||
country={m.country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{href ? (
|
||||
<span
|
||||
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||
title={href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(href, '_blank', 'noreferrer')
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
@ -890,10 +939,30 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
const filtered = React.useMemo(() => {
|
||||
const needle = deferredQ.trim().toLowerCase()
|
||||
|
||||
const base = !needle
|
||||
let base = !needle
|
||||
? models
|
||||
: modelsWithHay.filter((x) => x.hay.includes(needle)).map((x) => x.m)
|
||||
|
||||
// Watch-Filter: Alle / Watched / Unwatched
|
||||
if (watchFilter === 'watched') {
|
||||
base = base.filter((m) => m.watching === true)
|
||||
} else if (watchFilter === 'unwatched') {
|
||||
base = base.filter((m) => m.watching !== true)
|
||||
}
|
||||
|
||||
// Favorite / Liked: OR-Logik
|
||||
if (activeStatusSet.size > 0) {
|
||||
base = base.filter((m) => {
|
||||
const fav = m.favorite === true
|
||||
const liked = m.liked === true
|
||||
|
||||
return (
|
||||
(activeStatusSet.has('favorite') && fav) ||
|
||||
(activeStatusSet.has('liked') && liked)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (activeTagSet.size === 0) return base
|
||||
|
||||
return base.filter((m) => {
|
||||
@ -905,7 +974,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [models, modelsWithHay, deferredQ, activeTagSet])
|
||||
}, [models, modelsWithHay, deferredQ, watchFilter, activeStatusSet, activeTagSet])
|
||||
|
||||
const sortedAll = React.useMemo(() => {
|
||||
if (!sort) return filtered
|
||||
@ -943,7 +1012,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1)
|
||||
}, [q, tagFilter, viewMode, sort])
|
||||
}, [q, tagFilter, watchFilter, statusFilter, viewMode, sort])
|
||||
|
||||
const totalItems = sortedAll.length
|
||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||
@ -1207,25 +1276,106 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tagFilter.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Status-Filter:
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Tag-Filter:
|
||||
</span>
|
||||
<ButtonGroup
|
||||
ariaLabel="Watched-Filter"
|
||||
size="sm"
|
||||
value={watchFilter}
|
||||
onChange={(id) => {
|
||||
if (id === 'all' || id === 'watched' || id === 'unwatched') {
|
||||
setWatchFilter(id)
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
id: 'all',
|
||||
label: 'Alle',
|
||||
},
|
||||
{
|
||||
id: 'watched',
|
||||
label: 'Watched',
|
||||
icon: <EyeIconSolid className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'unwatched',
|
||||
label: 'Unwatched',
|
||||
icon: <EyeSlashIcon className="size-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tagFilter.map((t) => (
|
||||
<TagBadge key={t} tag={t} active onClick={toggleTagFilter} title={t} />
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
color="amber"
|
||||
variant={activeStatusSet.has('favorite') ? 'soft' : 'secondary'}
|
||||
onClick={() => toggleStatusFilter('favorite')}
|
||||
title="Favoriten filtern"
|
||||
leadingIcon={
|
||||
activeStatusSet.has('favorite') ? (
|
||||
<StarIconSolid className="size-4" />
|
||||
) : (
|
||||
<StarIconOutline className="size-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
Favorited
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
variant={activeStatusSet.has('liked') ? 'soft' : 'secondary'}
|
||||
onClick={() => toggleStatusFilter('liked')}
|
||||
title="Liked Models filtern"
|
||||
leadingIcon={
|
||||
activeStatusSet.has('liked') ? (
|
||||
<HeartIconSolid className="size-4" />
|
||||
) : (
|
||||
<HeartIconOutline className="size-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
Liked
|
||||
</Button>
|
||||
|
||||
{watchFilter !== 'all' || statusFilter.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
size="md"
|
||||
variant="soft"
|
||||
onClick={clearStatusFilter}
|
||||
className="text-xs font-medium text-gray-600 hover:underline dark:text-gray-300"
|
||||
onClick={clearTagFilter}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<span className="pt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Tag-Filter:
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TagFilterBar
|
||||
activeTags={tagFilter}
|
||||
allTags={allKnownTags}
|
||||
onAddTag={(tag) => {
|
||||
setTagFilter((prev) =>
|
||||
prev.some((t) => lowerTag(t) === lowerTag(tag)) ? prev : [...prev, tag]
|
||||
)
|
||||
}}
|
||||
onRemoveTag={(tag) => {
|
||||
setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag)))
|
||||
}}
|
||||
onClear={clearTagFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -1344,11 +1494,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
|
||||
<ModelGenderIcon
|
||||
gender={m.gender}
|
||||
sex={m.sex}
|
||||
subgender={m.subgender}
|
||||
className="shrink-0 text-white"
|
||||
/>
|
||||
|
||||
<CountryFlag
|
||||
country={m.country}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{m.host ? (
|
||||
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
{m.host}
|
||||
@ -1363,7 +1518,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
title="Videos"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🎬</span>
|
||||
<FilmIcon className="size-3.5 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]" />
|
||||
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
{videoCountsLoading ? '…' : videoCount}
|
||||
</span>
|
||||
@ -1374,7 +1529,7 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
title="Tags"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-[11px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">🏷️</span>
|
||||
<TagIcon className="size-3.5 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]" />
|
||||
<span className="drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
{tags.length}
|
||||
</span>
|
||||
@ -1409,31 +1564,31 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1.5 pointer-events-none select-none">
|
||||
{watch ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center text-xl leading-none text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
className="inline-flex items-center justify-center text-indigo-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
title="Beobachtet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
👁
|
||||
<EyeIconSolid className="size-5" />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{fav ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center text-xl leading-none text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
className="inline-flex items-center justify-center text-amber-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
title="Favorit"
|
||||
aria-hidden="true"
|
||||
>
|
||||
★
|
||||
<StarIconSolid className="size-5" />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{liked ? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center text-xl leading-none text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
className="inline-flex items-center justify-center text-rose-300 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||
title="Gefällt mir"
|
||||
aria-hidden="true"
|
||||
>
|
||||
♥
|
||||
<HeartIconSolid className="size-5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@ -1481,9 +1636,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-1">
|
||||
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||
👁
|
||||
</span>
|
||||
{watch ? (
|
||||
<EyeIconSolid className="size-5 text-indigo-600 dark:text-indigo-300" />
|
||||
) : (
|
||||
<EyeIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@ -1504,9 +1661,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-1">
|
||||
<span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||
★
|
||||
</span>
|
||||
{fav ? (
|
||||
<StarIconSolid className="size-5 text-amber-600 dark:text-amber-300" />
|
||||
) : (
|
||||
<StarIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@ -1527,9 +1686,11 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-1">
|
||||
<span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||
♥
|
||||
</span>
|
||||
{liked ? (
|
||||
<HeartIconSolid className="size-5 text-rose-600 dark:text-rose-300" />
|
||||
) : (
|
||||
<HeartIconOutline className="size-5 text-gray-500 dark:text-slate-300" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -256,7 +256,9 @@ export type PlayerProps = {
|
||||
isWatching?: boolean
|
||||
|
||||
// actions
|
||||
onKeep?: (job: RecordJob) => void | Promise<void>
|
||||
onKeep?: (
|
||||
job: RecordJob
|
||||
) => void | { newFile?: string } | Promise<void | { newFile?: string }>
|
||||
onDelete?: (job: RecordJob) => void | Promise<void>
|
||||
onToggleHot?: (
|
||||
job: RecordJob
|
||||
|
||||
@ -32,7 +32,6 @@ type Props = {
|
||||
resetLabel?: string
|
||||
|
||||
compact?: boolean
|
||||
softStep?: boolean
|
||||
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
@ -42,20 +41,6 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function countDecimals(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
const s = String(value)
|
||||
const i = s.indexOf('.')
|
||||
return i >= 0 ? s.length - i - 1 : 0
|
||||
}
|
||||
|
||||
function roundToStep(value: number, step: number, min: number) {
|
||||
if (!Number.isFinite(step) || step <= 0) return value
|
||||
const precision = Math.max(countDecimals(step), countDecimals(min))
|
||||
const rounded = Math.round((value - min) / step) * step + min
|
||||
return Number(rounded.toFixed(precision))
|
||||
}
|
||||
|
||||
export default function Slider({
|
||||
id,
|
||||
value,
|
||||
@ -77,7 +62,6 @@ export default function Slider({
|
||||
resetValue,
|
||||
resetLabel = 'Reset',
|
||||
compact = false,
|
||||
softStep = true,
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
}: Props) {
|
||||
@ -88,13 +72,8 @@ export default function Slider({
|
||||
const clamped = clamp(safeValue, min, max)
|
||||
const percent = max > min ? ((clamped - min) / (max - min)) * 100 : 0
|
||||
|
||||
const emitCommit = () => {
|
||||
onChangeEnd?.(clamped)
|
||||
}
|
||||
|
||||
const resolvedValueLabel =
|
||||
valueLabel ??
|
||||
(valueFormatter ? valueFormatter(clamped) : String(clamped))
|
||||
valueLabel ?? (valueFormatter ? valueFormatter(clamped) : String(clamped))
|
||||
|
||||
const resolvedMinLabel =
|
||||
minLabel ?? (valueFormatter ? valueFormatter(min) : String(min))
|
||||
@ -113,9 +92,18 @@ export default function Slider({
|
||||
const thumbOffset = -((thumbSize - trackH) / 2)
|
||||
|
||||
const handleChange = (next: number) => {
|
||||
const raw = clamp(next, min, max)
|
||||
const finalValue = softStep ? raw : roundToStep(raw, step, min)
|
||||
onChange(finalValue)
|
||||
onChange(clamp(next, min, max))
|
||||
}
|
||||
|
||||
const handleCommit = (e: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!canReset) return
|
||||
const next = clamp(resetValue!, min, max)
|
||||
onChange(next)
|
||||
onChangeEnd?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -164,7 +152,7 @@ export default function Slider({
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={softStep ? 'any' : step}
|
||||
step={step}
|
||||
value={clamped}
|
||||
disabled={disabled}
|
||||
aria-label={label ?? 'Slider'}
|
||||
@ -213,9 +201,9 @@ export default function Slider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
onPointerUp={emitCommit}
|
||||
onMouseUp={emitCommit}
|
||||
onTouchEnd={emitCommit}
|
||||
onPointerUp={handleCommit}
|
||||
onMouseUp={handleCommit}
|
||||
onTouchEnd={handleCommit}
|
||||
onKeyUp={(e) => {
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
@ -227,7 +215,7 @@ export default function Slider({
|
||||
e.key === 'PageUp' ||
|
||||
e.key === 'PageDown'
|
||||
) {
|
||||
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
|
||||
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -266,7 +254,7 @@ export default function Slider({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleChange(resetValue!)}
|
||||
onClick={handleReset}
|
||||
className="
|
||||
inline-flex h-7 shrink-0 items-center rounded-md px-2 text-xs font-medium
|
||||
text-gray-700 ring-1 ring-gray-200 transition-colors
|
||||
@ -313,7 +301,7 @@ export default function Slider({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleChange(resetValue!)}
|
||||
onClick={handleReset}
|
||||
className="
|
||||
inline-flex h-7 items-center rounded-md px-2 text-xs font-medium
|
||||
text-gray-700 ring-1 ring-gray-200 transition-colors
|
||||
@ -355,7 +343,7 @@ export default function Slider({
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={softStep ? 'any' : step}
|
||||
step={step}
|
||||
value={clamped}
|
||||
disabled={disabled}
|
||||
aria-label={label ?? 'Slider'}
|
||||
@ -404,9 +392,9 @@ export default function Slider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
onPointerUp={emitCommit}
|
||||
onMouseUp={emitCommit}
|
||||
onTouchEnd={emitCommit}
|
||||
onPointerUp={handleCommit}
|
||||
onMouseUp={handleCommit}
|
||||
onTouchEnd={handleCommit}
|
||||
onKeyUp={(e) => {
|
||||
if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
@ -418,7 +406,7 @@ export default function Slider({
|
||||
e.key === 'PageUp' ||
|
||||
e.key === 'PageDown'
|
||||
) {
|
||||
onChangeEnd?.(Number((e.currentTarget as HTMLInputElement).value))
|
||||
onChangeEnd?.(clamp(Number(e.currentTarget.value), min, max))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -15,9 +15,12 @@ type Props = {
|
||||
/** Wenn aktiv (z.B. in Filter), etwas "stärker" highlighten */
|
||||
active?: boolean
|
||||
|
||||
/** Wenn gesetzt, wird ein <button> gerendert und der Tag ist klickbar */
|
||||
/** Tag-basierter Click */
|
||||
onClick?: (tag: string) => void
|
||||
|
||||
/** Generischer Click, z.B. für Plus-Button */
|
||||
onPress?: () => void
|
||||
|
||||
/** optional: default ist max-w-[11rem] wie in ModelsTab */
|
||||
maxWidthClassName?: string
|
||||
className?: string
|
||||
@ -32,6 +35,7 @@ export default function TagBadge({
|
||||
title,
|
||||
active,
|
||||
onClick,
|
||||
onPress,
|
||||
maxWidthClassName = 'max-w-[11rem]',
|
||||
className,
|
||||
stopPropagation = true,
|
||||
@ -50,7 +54,9 @@ export default function TagBadge({
|
||||
: 'bg-white text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-200 dark:ring-sky-400/20 dark:shadow-none'
|
||||
)
|
||||
|
||||
const clickableCls = onClick
|
||||
const isClickable = Boolean(onClick || onPress)
|
||||
|
||||
const clickableCls = isClickable
|
||||
? clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
active
|
||||
@ -71,7 +77,7 @@ export default function TagBadge({
|
||||
}
|
||||
: {}
|
||||
|
||||
if (!onClick) {
|
||||
if (!isClickable) {
|
||||
return (
|
||||
<span
|
||||
className={cls}
|
||||
@ -96,7 +102,13 @@ export default function TagBadge({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
if (!label) return
|
||||
|
||||
if (onPress) {
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
|
||||
if (!label || !onClick) return
|
||||
onClick(label)
|
||||
}}
|
||||
>
|
||||
|
||||
265
frontend/src/components/ui/TagFilterBar.tsx
Normal file
265
frontend/src/components/ui/TagFilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
frontend/src/components/ui/finishedModelFlags.ts
Normal file
143
frontend/src/components/ui/finishedModelFlags.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
24
frontend/src/components/ui/tagUtils.ts
Normal file
24
frontend/src/components/ui/tagUtils.ts
Normal 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
|
||||
}
|
||||
@ -229,8 +229,6 @@ export type StoredModelFlags = {
|
||||
watching?: boolean | null
|
||||
tags?: string
|
||||
gender?: string | null
|
||||
sex?: string | null
|
||||
subgender?: string | null
|
||||
}
|
||||
|
||||
export type GalleryModelFlags = StoredModelFlags & {
|
||||
@ -307,9 +305,9 @@ export type StoredModel = {
|
||||
watching: boolean
|
||||
favorite?: boolean
|
||||
liked?: boolean | null
|
||||
tags?: string
|
||||
isUrl?: boolean
|
||||
path?: string
|
||||
|
||||
roomStatus?: string
|
||||
isOnline?: boolean
|
||||
chatRoomUrl?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user