From cedabae2d8115d943ca33c654ea37e7fb49b1b16 Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Sun, 5 Jul 2026 12:14:13 +0200 Subject: [PATCH] feat(movies): tap studio in MovieDetail to see that studio's full filmography The studio line in MovieDetail is now tappable and opens a new StudioMovies screen listing every movie from that studio (studio_slugs filter, which the backend already supports). Mirrors StudioScenes for scenes. Works for any studio, including long-tail ones that are not in the top-40 chips of the Movies filter sheet (where studio filtering already existed but was easy to miss). Co-Authored-By: Claude Opus 4.8 --- mobile/src/changelog.ts | 7 ++ mobile/src/navigation.tsx | 8 ++ mobile/src/screens/MovieDetailScreen.tsx | 19 ++- mobile/src/screens/StudioMoviesScreen.tsx | 134 ++++++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 mobile/src/screens/StudioMoviesScreen.tsx diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 11ef9c5..5c63ea9 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-05b', + date: 'July 2026', + items: [ + 'Tap a movie\'s studio to see every other film from that studio. You can also filter the Movies list by studio (Movies → Filter → Studios).', + ], + }, { id: '2026-07-05', date: 'July 2026', diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 17c07fc..3bcfc2a 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -23,6 +23,7 @@ import { ScenesScreen } from './screens/ScenesScreen'; import { SceneDetailScreen } from './screens/SceneDetailScreen'; import { SiteScenesScreen } from './screens/SiteScenesScreen'; import { SitesScreen } from './screens/SitesScreen'; +import { StudioMoviesScreen } from './screens/StudioMoviesScreen'; import { StudioScenesScreen } from './screens/StudioScenesScreen'; import { TagScenesScreen } from './screens/TagScenesScreen'; import { TagsScreen } from './screens/TagsScreen'; @@ -48,6 +49,8 @@ export type RootStackParamList = { // `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug, // ale favorites + blacklist używają UUID. StudioScenes: { id: string; name: string; studioId: string; seenSince?: string }; + // StudioMovies: `slug` do filter movies `studio_slugs`, `studioId` (UUID) do favorite. + StudioMovies: { slug: string; name: string; studioId: string }; Favorites: undefined; Tags: undefined; TagScenes: { slug: string; name: string }; @@ -257,6 +260,11 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps component={StudioScenesScreen} options={{ title: 'Studio scenes' }} /> + {data.title} {data.release_year ?? '—'} - {data.studio?.name ? ` · ${data.studio.name}` : ''} + {data.studio ? ( + + {' · '} + + navigation.navigate('StudioMovies', { + slug: data.studio!.slug, + name: data.studio!.name, + studioId: data.studio!.id, + }) + } + > + {data.studio.name} + + + ) : null} {dur ? ` · ${dur}` : ''} {data.director ? dir. {data.director} : null} @@ -375,6 +391,7 @@ const styles = StyleSheet.create({ heroMeta: { flex: 1, gap: 4 }, title: { color: theme.fg, fontSize: 18, fontWeight: '700' }, subtitle: { color: theme.muted, fontSize: 13 }, + studioLink: { color: theme.accent, fontWeight: '600' }, rating: { color: theme.accent, fontSize: 14, fontWeight: '700', marginTop: 4 }, section: { paddingHorizontal: 14, paddingVertical: 10 }, diff --git a/mobile/src/screens/StudioMoviesScreen.tsx b/mobile/src/screens/StudioMoviesScreen.tsx new file mode 100644 index 0000000..af67a02 --- /dev/null +++ b/mobile/src/screens/StudioMoviesScreen.tsx @@ -0,0 +1,134 @@ +// Filmy wybranego studia (filter studio_slugs=). Mirror StudioScenesScreen, ale +// grid plakatów filmów (MoviePosterCard) zamiast SceneTile. Wchodzi się tu klikając +// studio w MovieDetail, więc dociera do KAŻDEGO studia, nie tylko top-40 z MovieFiltersSheet. +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useClient } from '../ClientContext'; +import { MoviePosterCard } from '../components/MoviePosterCard'; +import type { RootStackParamList } from '../navigation'; +import { theme } from '../theme'; + +const PER_PAGE = 30; +// 3 kolumny — parytet z MoviesScreen (plakaty 2:3, 50% więcej filmów na ekran). +const NUM_COLS = 3; + +export function StudioMoviesScreen() { + const client = useClient(); + const queryClient = useQueryClient(); + const navigation = + useNavigation>(); + const route = useRoute>(); + // `slug` to studio.slug (backend MoviesListParams.studio_slugs). `studioId` (UUID) + // tylko do favorite/star (favorites-studios są po UUID). + const { slug, name, studioId } = route.params; + + const favoritesQuery = useQuery({ + queryKey: ['favorites-studios'], + queryFn: () => client.listFavoriteStudios(), + staleTime: 30_000, + }); + const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId); + const addMutation = useMutation({ + mutationFn: () => client.addFavoriteStudio(studioId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }), + }); + const removeMutation = useMutation({ + mutationFn: () => client.removeFavoriteStudio(studioId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }), + }); + + React.useLayoutEffect(() => { + navigation.setOptions({ + title: name, + headerRight: () => ( + (isFavorite ? removeMutation.mutate() : addMutation.mutate())} + hitSlop={12} + disabled={addMutation.isPending || removeMutation.isPending} + > + + {isFavorite ? '★' : '☆'} + + + ), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]); + + const { + data, + isLoading, + error, + refetch, + isRefetching, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['studio-movies', slug], + queryFn: ({ pageParam = 1 }) => + client.listMovies({ + studio_slugs: [slug], + sort: 'release_year', + page: pageParam, + per_page: PER_PAGE, + }), + initialPageParam: 1, + getNextPageParam: (last) => { + const loaded = last.page * last.per_page; + return loaded < last.total ? last.page + 1 : undefined; + }, + }); + + const items = data?.pages.flatMap((p) => p.items) ?? []; + const total = data?.pages[0]?.total ?? 0; + + return ( + + {isLoading && } + {error instanceof Error && {error.message}} + + m.id} + removeClippedSubviews={false} + renderItem={({ item }) => ( + navigation.navigate('MovieDetail', { id: item.id })} + /> + )} + refreshing={isRefetching} + onRefresh={refetch} + ListHeaderComponent={ + total > 0 ? ( + + {total} {total === 1 ? 'film' : 'films'} + + ) : null + } + ListEmptyComponent={!isLoading ? no movies : null} + contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }} + columnWrapperStyle={{ gap: 8 }} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isFetchingNextPage ? : null + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.bg, paddingTop: 8 }, + subtitle: { color: theme.muted, fontSize: 12, paddingHorizontal: 10, paddingTop: 6, paddingBottom: 8 }, + muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, + error: { color: theme.bad, padding: 12 }, +});