feat(movies): tap studio in MovieDetail to see that studio's full filmography
Some checks failed
Backend tests / test (push) Has been cancelled
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:
parent
d7c334be8d
commit
cedabae2d8
4 changed files with 167 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StudioMovies"
|
||||
component={StudioMoviesScreen}
|
||||
options={{ title: 'Studio movies' }}
|
||||
/>
|
||||
<Stack.Screen name="Tags" component={TagsScreen} options={{ title: 'Tags' }} />
|
||||
<Stack.Screen
|
||||
name="TagScenes"
|
||||
|
|
|
|||
|
|
@ -92,7 +92,23 @@ export function MovieDetailScreen() {
|
|||
<Text style={styles.title}>{data.title}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{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}` : ''}
|
||||
</Text>
|
||||
{data.director ? <Text style={styles.subtitle}>dir. {data.director}</Text> : 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 },
|
||||
|
|
|
|||
134
mobile/src/screens/StudioMoviesScreen.tsx
Normal file
134
mobile/src/screens/StudioMoviesScreen.tsx
Normal 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 },
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue