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 <noreply@anthropic.com>
This commit is contained in:
parent
4baf6ea896
commit
8ea8b94b4a
4 changed files with 167 additions and 1 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
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