feat(mobile): Favorites "Scenes" tab to view saved scenes

Users could heart individual scenes from SceneDetail, but the Favorites screen only
had Performers/Studios/Movies tabs, so saved scenes were invisible (bug report: got a
bunch of scenes saved but no way to see them). Add a Scenes tab (now the default)
listing favorited scenes as tiles via GET /scene-favorites, long-press to remove.
Adds client.listSceneFavorites().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-12 10:06:12 +02:00
parent 1654d78d59
commit aebacc0389
2 changed files with 80 additions and 30 deletions

View file

@ -173,6 +173,10 @@ export class GoonClient {
await this.request(`/scene-favorites/${sceneId}`, { method: 'POST' }); await this.request(`/scene-favorites/${sceneId}`, { method: 'POST' });
} }
async listSceneFavorites(): Promise<{ items: SceneOut[]; total: number }> {
return this.request('/scene-favorites');
}
async removeSceneFavorite(sceneId: string): Promise<void> { async removeSceneFavorite(sceneId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/scene-favorites/${sceneId}`, { const res = await fetch(`${this.baseUrl}/scene-favorites/${sceneId}`, {
method: 'DELETE', method: 'DELETE',

View file

@ -18,17 +18,18 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { SceneTile } from '../components/SceneTile';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types';
type Tab = 'performers' | 'studios' | 'movies'; type Tab = 'scenes' | 'performers' | 'studios' | 'movies';
export function FavoritesScreen() { export function FavoritesScreen() {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>(); const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>();
const [tab, setTab] = React.useState<Tab>('performers'); const [tab, setTab] = React.useState<Tab>('scenes');
const performersQuery = useQuery({ const performersQuery = useQuery({
queryKey: ['favorites'], queryKey: ['favorites'],
@ -45,6 +46,17 @@ export function FavoritesScreen() {
queryFn: () => client.listFavoriteMovies(), 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({ const removeMovie = useMutation({
mutationFn: (id: string) => client.removeFavoriteMovie(id), mutationFn: (id: string) => client.removeFavoriteMovie(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-movies'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-movies'] }),
@ -99,37 +111,26 @@ export function FavoritesScreen() {
client.markFavoriteStudioSeen(id).catch(() => {}); client.markFavoriteStudioSeen(id).catch(() => {});
}; };
// performers/studios mają new_total badge; scenes/movies nie (single entity).
const activeData = const activeData =
tab === 'performers' tab === 'performers'
? performersQuery.data ? performersQuery.data
: tab === 'studios' : tab === 'studios'
? studiosQuery.data ? studiosQuery.data
: null; // movies — nie ma new_total bo single movie nie ma child scenes : null;
const moviesTotal = moviesQuery.data?.total ?? 0; const activeQuery =
const isLoading =
tab === 'performers' tab === 'performers'
? performersQuery.isLoading ? performersQuery
: tab === 'studios' : tab === 'studios'
? studiosQuery.isLoading ? studiosQuery
: moviesQuery.isLoading; : tab === 'movies'
const isRefetching = ? moviesQuery
tab === 'performers' : scenesQuery;
? performersQuery.isRefetching const headerCount = activeData?.total ?? (activeQuery.data as { total?: number } | undefined)?.total ?? 0;
: tab === 'studios' const isLoading = activeQuery.isLoading;
? studiosQuery.isRefetching const isRefetching = activeQuery.isRefetching;
: moviesQuery.isRefetching; const error = activeQuery.error;
const error = const refetch = activeQuery.refetch;
tab === 'performers'
? performersQuery.error
: tab === 'studios'
? studiosQuery.error
: moviesQuery.error;
const refetch =
tab === 'performers'
? performersQuery.refetch
: tab === 'studios'
? studiosQuery.refetch
: moviesQuery.refetch;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -140,13 +141,17 @@ export function FavoritesScreen() {
<Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text> <Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text>
</View> </View>
) : ( ) : (
<Text style={styles.headerCount}> <Text style={styles.headerCount}>{headerCount}</Text>
{tab === 'movies' ? moviesTotal : activeData?.total ?? 0}
</Text>
)} )}
</View> </View>
<View style={styles.tabRow}> <View style={styles.tabRow}>
<TabButton
label="Scenes"
active={tab === 'scenes'}
newCount={0}
onPress={() => setTab('scenes')}
/>
<TabButton <TabButton
label="Performers" label="Performers"
active={tab === 'performers'} active={tab === 'performers'}
@ -174,7 +179,46 @@ export function FavoritesScreen() {
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
{tab === 'movies' ? ( {tab === 'scenes' ? (
<FlatList
data={scenesQuery.data?.items ?? []}
keyExtractor={(s) => s.id}
numColumns={2}
// 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),
},
],
)
}
/>
)}
columnWrapperStyle={styles.gridRow}
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 <FlatList
data={moviesQuery.data?.items ?? []} data={moviesQuery.data?.items ?? []}
keyExtractor={(f) => f.movie_id} keyExtractor={(f) => f.movie_id}
@ -510,6 +554,8 @@ const styles = StyleSheet.create({
hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 }, hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 },
gridRow: { gap: 10, marginBottom: 14 },
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',