goon/mobile/src/screens/PerformersScreen.tsx
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

293 lines
7.9 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 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}>
{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 },
});