// Lista ulubionych performerów + studiów + badge "ile nowych scen". Dwie zakładki // na górze (Performers / Studios). Tap → odpowiedni screen scen + auto mark-seen // (zeruje licznik). Klucz: przekazujemy `seenSince` (last_seen_at SPRZED markSeen) // żeby PerformerScenesScreen / StudioScenesScreen mógł pokazać NEW badge na świeżych // scenach. import { useNavigation } from '@react-navigation/native'; import { formatCount } from '../lib/formatCount'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { ActivityIndicator, Alert, FlatList, Pressable, StyleSheet, Text, View, } from 'react-native'; import { Image } from 'expo-image'; import { useClient } from '../ClientContext'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; type Tab = 'scenes' | 'performers' | 'studios' | 'movies'; export function FavoritesScreen() { const client = useClient(); const { gridColumns } = usePreferences(); const queryClient = useQueryClient(); const nav = useNavigation>(); const [tab, setTab] = React.useState('scenes'); const performersQuery = useQuery({ queryKey: ['favorites'], queryFn: () => client.listFavorites(), }); const studiosQuery = useQuery({ queryKey: ['favorites-studios'], queryFn: () => client.listFavoriteStudios(), }); const moviesQuery = useQuery({ queryKey: ['favorites-movies'], queryFn: () => client.listFavoriteMovies(), }); const scenesQuery = useQuery({ queryKey: ['favorites-scenes'], queryFn: () => client.listSceneFavorites(), }); const removeScene = useMutation({ mutationFn: (id: string) => client.removeSceneFavorite(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-scenes'] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); const removeMovie = useMutation({ mutationFn: (id: string) => client.removeFavoriteMovie(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-movies'] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); const removePerformer = useMutation({ mutationFn: (id: string) => client.removeFavorite(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); const removeStudio = useMutation({ mutationFn: (id: string) => client.removeFavoriteStudio(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); const markPerformerSeen = (id: string) => { queryClient.setQueryData(['favorites'], (prev: typeof performersQuery.data) => prev ? { ...prev, items: prev.items.map((f) => f.performer_id === id ? { ...f, new_count: 0 } : f, ), new_total: Math.max( 0, prev.new_total - (prev.items.find((f) => f.performer_id === id)?.new_count ?? 0), ), } : prev, ); client.markFavoriteSeen(id).catch(() => {}); }; const markStudioSeen = (id: string) => { queryClient.setQueryData(['favorites-studios'], (prev: typeof studiosQuery.data) => prev ? { ...prev, items: prev.items.map((f) => f.studio_id === id ? { ...f, new_count: 0 } : f, ), new_total: Math.max( 0, prev.new_total - (prev.items.find((f) => f.studio_id === id)?.new_count ?? 0), ), } : prev, ); client.markFavoriteStudioSeen(id).catch(() => {}); }; // performers/studios mają new_total badge; scenes/movies nie (single entity). const activeData = tab === 'performers' ? performersQuery.data : tab === 'studios' ? studiosQuery.data : null; const activeQuery = tab === 'performers' ? performersQuery : tab === 'studios' ? studiosQuery : tab === 'movies' ? moviesQuery : scenesQuery; const headerCount = activeData?.total ?? (activeQuery.data as { total?: number } | undefined)?.total ?? 0; const isLoading = activeQuery.isLoading; const isRefetching = activeQuery.isRefetching; const error = activeQuery.error; const refetch = activeQuery.refetch; return ( Favorites {activeData && activeData.new_total > 0 ? ( +{activeData.new_total} new ) : ( {headerCount} )} setTab('scenes')} /> setTab('performers')} /> setTab('studios')} /> setTab('movies')} /> tap to open scenes (resets the new badge) · long-press to remove {isLoading && } {error instanceof Error && {error.message}} {tab === 'scenes' ? ( s.id} // patrz ScenesScreen: removeClippedSubviews blankuje miniaturki po scrollu. removeClippedSubviews={false} renderItem={({ item }) => ( Alert.alert( 'Remove favorite', `Remove "${item.title}" from favorites?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removeScene.mutate(item.id), }, ], ) } /> )} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ !isLoading ? ( No scene favorites yet. Tap the heart on a scene to save it here. ) : null } contentContainerStyle={{ paddingBottom: 24 }} /> ) : tab === 'movies' ? ( f.movie_id} renderItem={({ item }) => ( { client.markFavoriteMovieSeen(item.movie_id).catch(() => {}); nav.navigate('MovieDetail', { id: item.movie_id }); }} onLongPress={() => Alert.alert( 'Remove favorite', `Remove "${item.title}" from favorites?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removeMovie.mutate(item.movie_id), }, ], ) } /> )} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ !isLoading ? ( No movie favorites yet. Tap the star on a movie to follow. ) : null } contentContainerStyle={{ paddingBottom: 24 }} /> ) : tab === 'performers' ? ( f.performer_id} renderItem={({ item }) => ( { // Capture last_seen_at PRZED markSeen, żeby PerformerScenes mógł // porównać scene.created_at > seenSince i pokazać NEW badge. const seenSince = item.last_seen_at; markPerformerSeen(item.performer_id); nav.navigate('PerformerScenes', { id: item.performer_id, name: item.canonical_name, seenSince, }); }} onLongPress={() => Alert.alert( 'Remove favorite', `Stop following ${item.canonical_name}?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removePerformer.mutate(item.performer_id), }, ], ) } /> )} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ !isLoading ? ( No performer favorites yet. Tap the star on a performer to follow. ) : null } contentContainerStyle={{ paddingBottom: 24 }} /> ) : ( f.studio_id} renderItem={({ item }) => ( { const seenSince = item.last_seen_at; markStudioSeen(item.studio_id); nav.navigate('StudioScenes', { id: item.slug, studioId: item.studio_id, name: item.name, seenSince, }); }} onLongPress={() => Alert.alert( 'Remove favorite', `Stop following ${item.name}?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removeStudio.mutate(item.studio_id), }, ], ) } /> )} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ !isLoading ? ( No studio favorites yet. Tap the star on a studio to follow. ) : null } contentContainerStyle={{ paddingBottom: 24 }} /> )} ); } function TabButton({ label, active, newCount, onPress, }: { label: string; active: boolean; newCount: number; onPress: () => void; }) { return ( {label} {newCount > 0 ? ( +{newCount} ) : null} ); } function initials(name: string): string { const parts = name.trim().split(/\s+/); const first = parts[0]?.[0] ?? ''; const last = parts.length > 1 ? parts[parts.length - 1][0] ?? '' : ''; return (first + last).toUpperCase() || '?'; } function PerformerRow({ fav, onPress, onLongPress, }: { fav: FavoriteOut; onPress: () => void; onLongPress: () => void; }) { return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} onLongPress={onLongPress} delayLongPress={400} > {initials(fav.canonical_name)} {fav.canonical_name} {formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'} {fav.new_count > 0 ? ( +{fav.new_count} ) : ( › )} ); } function MovieRow({ fav, onPress, onLongPress, }: { fav: FavoriteMovieOut; onPress: () => void; onLongPress: () => void; }) { return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} onLongPress={onLongPress} delayLongPress={400} > {fav.poster_url ? ( ) : ( {initials(fav.title)} )} {fav.title} {fav.release_year ?? '—'} {fav.studio_name ? ` · ${fav.studio_name}` : ''} › ); } // Studio bywa WYŁĄCZNIE filmowe (Jonathan Jordan XXX 0 scen / 654 filmy, Kink Clips // 0/616), a wiersz pokazywał wtedy „0 scenes", czyli wyglądał na pusty, mimo że badge // „+N" liczył filmy od 2026-07-28. Pokazujemy oba, a gdy jednego nie ma, pomijamy go, // żeby nie robić z tego „0 scenes · 654 movies". function studioCounts(fav: FavoriteStudioOut): string { const parts: string[] = []; const movies = fav.movie_count ?? 0; if (fav.scene_count > 0 || movies === 0) { parts.push(`${formatCount(fav.scene_count)} ${fav.scene_count === 1 ? 'scene' : 'scenes'}`); } if (movies > 0) { parts.push(`${formatCount(movies)} ${movies === 1 ? 'movie' : 'movies'}`); } return parts.join(' · '); } function StudioRow({ fav, onPress, onLongPress, }: { fav: FavoriteStudioOut; onPress: () => void; onLongPress: () => void; }) { return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} onLongPress={onLongPress} delayLongPress={400} > {initials(fav.name)} {fav.name} {studioCounts(fav)} {fav.network ? ` · ${fav.network}` : ''} {fav.new_count > 0 ? ( +{fav.new_count} ) : ( › )} ); } 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' }, totalBadge: { backgroundColor: theme.accent, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.5, shadowRadius: 6, elevation: 3, }, totalBadgeText: { color: theme.fg, fontWeight: '800', fontSize: 13, letterSpacing: 0.3, }, tabRow: { flexDirection: 'row', gap: 8, marginTop: 10, marginBottom: 4 }, tabBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 8, borderRadius: 8, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, }, tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated }, tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 }, tabBtnTextActive: { color: theme.fg }, tabBadge: { backgroundColor: theme.accent, paddingHorizontal: 6, paddingVertical: 1, borderRadius: 4, }, tabBadgeText: { color: theme.bg, fontWeight: '800', fontSize: 11 }, hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 }, gridRow: { gap: 10, marginBottom: 14 }, 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, }, poster: { width: 40, height: 56, borderRadius: 6, backgroundColor: theme.bgElevated, borderColor: theme.border, borderWidth: 1, }, posterPlaceholder: { alignItems: 'center', justifyContent: 'center' }, rowContent: { flex: 1 }, rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 }, rowMuted: { color: theme.muted, fontSize: 13 }, newBadge: { backgroundColor: theme.accent, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.5, shadowRadius: 6, elevation: 3, }, newBadgeText: { color: theme.fg, fontWeight: '800', fontSize: 13 }, chevron: { color: theme.mutedDim, fontSize: 22, fontWeight: '300' }, emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 15, paddingHorizontal: 24, lineHeight: 22, }, error: { color: theme.bad, padding: 16 }, });