goon/mobile/src/screens/PerformersScreen.tsx
goon-foss 4417ae0ff0
Some checks failed
Backend tests / test (push) Has been cancelled
feat(tags): scal duplikaty liczby mnogiej + prewencja + skrocone liczniki
1) Duplikaty tagow wrocily, ale INNYM wzorcem niz w lipcu: alnum-klucz z natury nie
lapie liczby mnogiej ('blowjob' != 'blowjobs'). Audyt: 1944 pary, m.in.
Blowjob/Blowjobs (390728 vs 791), Teen/Teens (53096 vs 41294), Cumshot/Cumshots.
Scalone oba kierunki (kanoniczny = wiekszy scene_count): 1938 + 2063 tagow,
przeniesione 251251 + 26142 przypisan scen i 118522 + 392 filmow. Zostaje 581 par
(lancuchy i remisy) - celowo pominiete, wymagaja recznej decyzji.

2) Prewencja w _resolve_by_altkey: gdy alnum-klucz nie trafi, probujemy obu form
(+s / -s). Tylko proste 's' - form nieregularnych (panty/panties) NIE ruszamy, bo
zbyt latwo o falszywy zlew (glass/glasses, bra/bras). Zweryfikowane na prodzie:
Blowjobs->Blowjob, Teens->Teen, Cumshots->Cumshot, Lesbians->Lesbian.

3) UI: formatCount() skraca duze liczniki (181764 -> 181k, 1200 -> 1,2k) w tagach,
performerach, ulubionych i filtrach filmow - user-request, chipy przestaja puchnac.
2026-08-03 09:19:50 +02:00

294 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Lista performerów + tap → PerformerScenes.
// Domyślny order: scene_count desc (najpierw popularni); search po name_normalized.
import { useNavigation } from '@react-navigation/native';
import { formatCount } from '../lib/formatCount';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQuery } from '@tanstack/react-query';
import React, { useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { PerformerCount } from '../types';
type Order = 'scene_count' | 'name';
export function PerformersScreen() {
const client = useClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Performers'>>();
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('scene_count');
const [searchFocused, setSearchFocused] = useState(false);
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 350);
return () => clearTimeout(t);
}, [q]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['performers', debouncedQ, order],
queryFn: () => client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200 }),
});
const total = data?.items.length ?? 0;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Performers</Text>
<Text style={styles.headerCount}>{total}</Text>
</View>
<Text style={styles.hint}>tap a row all their scenes</Text>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, searchFocused && styles.searchFocused]}
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search performer…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/>
</View>
<View style={styles.segment}>
<SegButton
active={order === 'scene_count'}
onPress={() => setOrder('scene_count')}
label="Top"
/>
<SegButton
active={order === 'name'}
onPress={() => setOrder('name')}
label="AZ"
/>
</View>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={data?.items ?? []}
keyExtractor={(p) => p.id}
renderItem={({ item }) => (
<PerformerRow
performer={item}
onPress={() =>
navigation.navigate('PerformerScenes', {
id: item.id,
name: item.canonical_name,
})
}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
ListEmptyComponent={
!isLoading ? <Text style={styles.emptyText}>no performers</Text> : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return '?';
const first = parts[0][0] ?? '';
const last = parts.length > 1 ? parts[parts.length - 1][0] ?? '' : '';
return (first + last).toUpperCase() || '?';
}
function PerformerRow({
performer,
onPress,
}: {
performer: PerformerCount;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initials(performer.canonical_name)}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{performer.canonical_name}
</Text>
<View style={styles.rowMetaRow}>
<Text style={styles.rowCount}>
{formatCount(performer.scene_count)} {performer.scene_count === 1 ? 'scene' : 'scenes'}
</Text>
{performer.gender ? (
<View style={styles.genderPill}>
<Text style={styles.genderPillText}>{performer.gender}</Text>
</View>
) : null}
</View>
</View>
<Text style={styles.chevron}></Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
},
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: {
color: theme.muted,
fontWeight: '700',
fontSize: 13,
},
segButtonTextActive: { color: theme.fg },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
rowPressed: { backgroundColor: theme.bgElevated, borderColor: theme.borderFocus },
avatar: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: theme.accentGlow,
fontWeight: '800',
fontSize: 13,
letterSpacing: 0.5,
},
rowContent: { flex: 1 },
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
rowMetaRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
rowCount: { color: theme.muted, fontSize: 13 },
genderPill: {
backgroundColor: `${theme.accentSecondary}1F`,
borderColor: `${theme.accentSecondary}55`,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 2,
},
genderPillText: {
color: theme.accentSecondary,
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.6,
},
chevron: {
color: theme.mutedDim,
fontSize: 22,
fontWeight: '300',
},
emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 },
error: { color: theme.bad, padding: 16 },
});