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