goon/mobile/src/screens/FavoritesScreen.tsx
goon-foss 7deda639bf fix(theme): rozdziel role akcentu i dodaj ramke, ktora cokolwiek oddziela
Pomiar WCAG na realnych parach z apki (2026-08-13):

    accent #B23A48 jako tekst:  3,21 na bg | 2,96 na bgElevated | 2,75 na card
    border #3A3128 jako granica: 1,48 na bg | 1,27 na card

Czyli kolor MARKI byl ponizej nawet progu 3:1 dla elementow interfejsu,
a nie tylko ponizej 4,5 dla tekstu. Dotyczylo to aktywnych zakladek,
gwiazdek ulubionych, linkow i chipow. Ramki przy 1,27 nie byly granica,
tylko cieniem.

NIE zmieniam koloru marki. Podniesienie samego `accent` do progu tekstu
wymagaloby zejscia w roz (#CE6975), co likwiduje tozsamosc, ktorej broni
naglowek theme.ts. Zamiast tego rozdzielam ROLE tego samego koloru:

  accent      zostaje do WYPELNIEN — jasny tekst na nim ma 5,03, przechodzi
  accentText  #C75260 dla tekstu i obwodek — 4,30 / 3,96 / 3,68

62 podmiany w 24 plikach (34 color, 26 borderColor, 2 thumbColor).
38 wypelnien nietknietych. Sprawdzone skryptem, ze zaden zmieniony tekst
nie stoi na tle akcentu.

borderStrong #7B7065 dla granic NIOSACYCH ZNACZENIE (pola tekstowe, 12
miejsc); dekoracyjne wloski zostaja na `border`. Wartosc dobrana pod obie
powierzchnie, bo pola leza i na bg, i na card: #6B6157 dawal na karcie
2,66, a #73695E dokladnie 2,999, czyli wciaz ponizej progu.
2026-08-14 09:50:08 +02:00

654 lines
20 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 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<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>();
const [tab, setTab] = React.useState<Tab>('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 (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Favorites</Text>
{activeData && activeData.new_total > 0 ? (
<View style={styles.totalBadge}>
<Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text>
</View>
) : (
<Text style={styles.headerCount}>{headerCount}</Text>
)}
</View>
<View style={styles.tabRow}>
<TabButton
label="Scenes"
active={tab === 'scenes'}
newCount={0}
onPress={() => setTab('scenes')}
/>
<TabButton
label="Performers"
active={tab === 'performers'}
newCount={performersQuery.data?.new_total ?? 0}
onPress={() => setTab('performers')}
/>
<TabButton
label="Studios"
active={tab === 'studios'}
newCount={studiosQuery.data?.new_total ?? 0}
onPress={() => setTab('studios')}
/>
<TabButton
label="Movies"
active={tab === 'movies'}
newCount={0}
onPress={() => setTab('movies')}
/>
</View>
<Text style={styles.hint}>
tap to open scenes (resets the new badge) · long-press to remove
</Text>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
{tab === 'scenes' ? (
<FlatList
{...sceneGridProps(gridColumns)}
// key per tab + per liczba kolumn: bez remountu RN rzuca "Changing numColumns
// on the fly is not supported" (GOON-11). Zmiana kolumn też wymaga remountu.
key={`fav-scenes-${gridColumns}`}
data={scenesQuery.data?.items ?? []}
keyExtractor={(s) => s.id}
// patrz ScenesScreen: removeClippedSubviews blankuje miniaturki po scrollu.
removeClippedSubviews={false}
renderItem={({ item }) => (
<SceneTile
scene={item}
secondLine="studio"
onLongPress={() =>
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 ? (
<Text style={styles.emptyText}>
No scene favorites yet. Tap the heart on a scene to save it here.
</Text>
) : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : tab === 'movies' ? (
<FlatList
key="fav-list"
data={moviesQuery.data?.items ?? []}
keyExtractor={(f) => f.movie_id}
renderItem={({ item }) => (
<MovieRow
fav={item}
onPress={() => {
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 ? (
<Text style={styles.emptyText}>
No movie favorites yet. Tap the star on a movie to follow.
</Text>
) : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : tab === 'performers' ? (
<FlatList
key="fav-list"
data={performersQuery.data?.items ?? []}
keyExtractor={(f) => f.performer_id}
renderItem={({ item }) => (
<PerformerRow
fav={item}
onPress={() => {
// 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 ? (
<Text style={styles.emptyText}>
No performer favorites yet. Tap the star on a performer to follow.
</Text>
) : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : (
<FlatList
key="fav-list"
data={studiosQuery.data?.items ?? []}
keyExtractor={(f) => f.studio_id}
renderItem={({ item }) => (
<StudioRow
fav={item}
onPress={() => {
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 ? (
<Text style={styles.emptyText}>
No studio favorites yet. Tap the star on a studio to follow.
</Text>
) : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
)}
</View>
);
}
function TabButton({
label,
active,
newCount,
onPress,
}: {
label: string;
active: boolean;
newCount: number;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
{newCount > 0 ? (
<View style={styles.tabBadge}>
<Text style={styles.tabBadgeText}>+{newCount}</Text>
</View>
) : null}
</Pressable>
);
}
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 (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={400}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initials(fav.canonical_name)}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{fav.canonical_name}
</Text>
<Text style={styles.rowMuted}>
{formatCount(fav.scene_count)} {fav.scene_count === 1 ? 'scene' : 'scenes'}
</Text>
</View>
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</View>
) : (
<Text style={styles.chevron}>›</Text>
)}
</Pressable>
);
}
function MovieRow({
fav,
onPress,
onLongPress,
}: {
fav: FavoriteMovieOut;
onPress: () => void;
onLongPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={400}
>
{fav.poster_url ? (
<Image source={{ uri: fav.poster_url }} style={styles.poster} contentFit="cover" />
) : (
<View style={[styles.poster, styles.posterPlaceholder]}>
<Text style={styles.avatarText}>{initials(fav.title)}</Text>
</View>
)}
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{fav.title}
</Text>
<Text style={styles.rowMuted}>
{fav.release_year ?? '—'}
{fav.studio_name ? ` · ${fav.studio_name}` : ''}
</Text>
</View>
<Text style={styles.chevron}>›</Text>
</Pressable>
);
}
// 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 (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={400}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initials(fav.name)}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{fav.name}
</Text>
<Text style={styles.rowMuted}>
{studioCounts(fav)}
{fav.network ? ` · ${fav.network}` : ''}
</Text>
</View>
{fav.new_count > 0 ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>+{fav.new_count}</Text>
</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' },
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 },
});