Some checks failed
Backend tests / test (push) Has been cancelled
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.
294 lines
8 KiB
TypeScript
294 lines
8 KiB
TypeScript
// 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="A–Z"
|
||
/>
|
||
</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 },
|
||
});
|