feat(movies): tap studio in MovieDetail to see that studio's full filmography
Some checks failed
Backend tests / test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-07-05 12:14:13 +02:00
parent d7c334be8d
commit cedabae2d8
4 changed files with 167 additions and 1 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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', id: '2026-07-05',
date: 'July 2026', date: 'July 2026',

View file

@ -23,6 +23,7 @@ import { ScenesScreen } from './screens/ScenesScreen';
import { SceneDetailScreen } from './screens/SceneDetailScreen'; import { SceneDetailScreen } from './screens/SceneDetailScreen';
import { SiteScenesScreen } from './screens/SiteScenesScreen'; import { SiteScenesScreen } from './screens/SiteScenesScreen';
import { SitesScreen } from './screens/SitesScreen'; import { SitesScreen } from './screens/SitesScreen';
import { StudioMoviesScreen } from './screens/StudioMoviesScreen';
import { StudioScenesScreen } from './screens/StudioScenesScreen'; import { StudioScenesScreen } from './screens/StudioScenesScreen';
import { TagScenesScreen } from './screens/TagScenesScreen'; import { TagScenesScreen } from './screens/TagScenesScreen';
import { TagsScreen } from './screens/TagsScreen'; import { TagsScreen } from './screens/TagsScreen';
@ -48,6 +49,8 @@ export type RootStackParamList = {
// `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug, // `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug,
// ale favorites + blacklist używają UUID. // ale favorites + blacklist używają UUID.
StudioScenes: { id: string; name: string; studioId: string; seenSince?: string }; 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; Favorites: undefined;
Tags: undefined; Tags: undefined;
TagScenes: { slug: string; name: string }; TagScenes: { slug: string; name: string };
@ -257,6 +260,11 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
component={StudioScenesScreen} component={StudioScenesScreen}
options={{ title: 'Studio scenes' }} options={{ title: 'Studio scenes' }}
/> />
<Stack.Screen
name="StudioMovies"
component={StudioMoviesScreen}
options={{ title: 'Studio movies' }}
/>
<Stack.Screen name="Tags" component={TagsScreen} options={{ title: 'Tags' }} /> <Stack.Screen name="Tags" component={TagsScreen} options={{ title: 'Tags' }} />
<Stack.Screen <Stack.Screen
name="TagScenes" name="TagScenes"

View file

@ -92,7 +92,23 @@ export function MovieDetailScreen() {
<Text style={styles.title}>{data.title}</Text> <Text style={styles.title}>{data.title}</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
{data.release_year ?? '—'} {data.release_year ?? '—'}
{data.studio?.name ? ` · ${data.studio.name}` : ''} {data.studio ? (
<Text>
{' · '}
<Text
style={styles.studioLink}
onPress={() =>
navigation.navigate('StudioMovies', {
slug: data.studio!.slug,
name: data.studio!.name,
studioId: data.studio!.id,
})
}
>
{data.studio.name}
</Text>
</Text>
) : null}
{dur ? ` · ${dur}` : ''} {dur ? ` · ${dur}` : ''}
</Text> </Text>
{data.director ? <Text style={styles.subtitle}>dir. {data.director}</Text> : null} {data.director ? <Text style={styles.subtitle}>dir. {data.director}</Text> : null}
@ -375,6 +391,7 @@ const styles = StyleSheet.create({
heroMeta: { flex: 1, gap: 4 }, heroMeta: { flex: 1, gap: 4 },
title: { color: theme.fg, fontSize: 18, fontWeight: '700' }, title: { color: theme.fg, fontSize: 18, fontWeight: '700' },
subtitle: { color: theme.muted, fontSize: 13 }, subtitle: { color: theme.muted, fontSize: 13 },
studioLink: { color: theme.accent, fontWeight: '600' },
rating: { color: theme.accent, fontSize: 14, fontWeight: '700', marginTop: 4 }, rating: { color: theme.accent, fontSize: 14, fontWeight: '700', marginTop: 4 },
section: { paddingHorizontal: 14, paddingVertical: 10 }, section: { paddingHorizontal: 14, paddingVertical: 10 },

View file

@ -0,0 +1,134 @@
// Filmy wybranego studia (filter studio_slugs=<slug>). 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<NativeStackNavigationProp<RootStackParamList, 'StudioMovies'>>();
const route = useRoute<RouteProp<RootStackParamList, 'StudioMovies'>>();
// `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: () => (
<Pressable
onPress={() => (isFavorite ? removeMutation.mutate() : addMutation.mutate())}
hitSlop={12}
disabled={addMutation.isPending || removeMutation.isPending}
>
<Text style={{ color: isFavorite ? theme.accent : theme.muted, fontSize: 22 }}>
{isFavorite ? '★' : '☆'}
</Text>
</Pressable>
),
});
// 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 (
<View style={styles.container}>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={items}
numColumns={NUM_COLS}
keyExtractor={(m) => m.id}
removeClippedSubviews={false}
renderItem={({ item }) => (
<MoviePosterCard
movie={item}
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
total > 0 ? (
<Text style={styles.subtitle}>
{total} {total === 1 ? 'film' : 'films'}
</Text>
) : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }}
columnWrapperStyle={{ gap: 8 }}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.3}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator color={theme.fg} style={{ margin: 16 }} /> : null
}
/>
</View>
);
}
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 },
});