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:
parent
1654d78d59
commit
aebacc0389
2 changed files with 80 additions and 30 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue