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' });
|
||||
}
|
||||
|
||||
async listSceneFavorites(): Promise<{ items: SceneOut[]; total: number }> {
|
||||
return this.request('/scene-favorites');
|
||||
}
|
||||
|
||||
async removeSceneFavorite(sceneId: string): Promise<void> {
|
||||
const res = await fetch(`${this.baseUrl}/scene-favorites/${sceneId}`, {
|
||||
method: 'DELETE',
|
||||
|
|
|
|||
|
|
@ -18,17 +18,18 @@ import {
|
|||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { useClient } from '../ClientContext';
|
||||
import { SceneTile } from '../components/SceneTile';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { theme } from '../theme';
|
||||
import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types';
|
||||
|
||||
type Tab = 'performers' | 'studios' | 'movies';
|
||||
type Tab = 'scenes' | 'performers' | 'studios' | 'movies';
|
||||
|
||||
export function FavoritesScreen() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>();
|
||||
const [tab, setTab] = React.useState<Tab>('performers');
|
||||
const [tab, setTab] = React.useState<Tab>('scenes');
|
||||
|
||||
const performersQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
|
|
@ -45,6 +46,17 @@ export function FavoritesScreen() {
|
|||
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'] }),
|
||||
|
|
@ -99,37 +111,26 @@ export function FavoritesScreen() {
|
|||
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; // movies — nie ma new_total bo single movie nie ma child scenes
|
||||
const moviesTotal = moviesQuery.data?.total ?? 0;
|
||||
const isLoading =
|
||||
: null;
|
||||
const activeQuery =
|
||||
tab === 'performers'
|
||||
? performersQuery.isLoading
|
||||
? performersQuery
|
||||
: tab === 'studios'
|
||||
? studiosQuery.isLoading
|
||||
: moviesQuery.isLoading;
|
||||
const isRefetching =
|
||||
tab === 'performers'
|
||||
? performersQuery.isRefetching
|
||||
: tab === 'studios'
|
||||
? studiosQuery.isRefetching
|
||||
: moviesQuery.isRefetching;
|
||||
const error =
|
||||
tab === 'performers'
|
||||
? performersQuery.error
|
||||
: tab === 'studios'
|
||||
? studiosQuery.error
|
||||
: moviesQuery.error;
|
||||
const refetch =
|
||||
tab === 'performers'
|
||||
? performersQuery.refetch
|
||||
: tab === 'studios'
|
||||
? studiosQuery.refetch
|
||||
: moviesQuery.refetch;
|
||||
? 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}>
|
||||
|
|
@ -140,13 +141,17 @@ export function FavoritesScreen() {
|
|||
<Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.headerCount}>
|
||||
{tab === 'movies' ? moviesTotal : activeData?.total ?? 0}
|
||||
</Text>
|
||||
<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'}
|
||||
|
|
@ -174,7 +179,46 @@ export function FavoritesScreen() {
|
|||
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
|
||||
{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
|
||||
data={moviesQuery.data?.items ?? []}
|
||||
keyExtractor={(f) => f.movie_id}
|
||||
|
|
@ -510,6 +554,8 @@ const styles = StyleSheet.create({
|
|||
|
||||
hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 },
|
||||
|
||||
gridRow: { gap: 10, marginBottom: 14 },
|
||||
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue