goon/mobile/src/screens/StudioScenesScreen.tsx
jtrzupek 177a45eee7 style(mobile): SceneTile shared component, 2-col grid w 5 ekranach scen
Jan feedback po pierwszym overhaulu: layout 2-col tile pasuje, ale aktywnie
tylko na ScenesScreen - reszta ekranow scen (SiteScenes, PerformerScenes,
StudioScenes, TagScenes) dalej w full-width row layoucie.

Wyciagniety SceneTile do mobile/src/components/SceneTile.tsx ze wsparciem:
- secondLine: 'studio' | 'performers' | 'date' | 'none' - per-ekran dobor
  metadanej (Studio na SiteScenes/Performer, performers na Studio, etc)
- seenSince: ISO timestamp - pokazuje NEW badge gdy scene.created_at > seen
  (uzywane na Performer/Studio screens dla NEW od ostatniego markFavoriteSeen)
- onLongPress: opcjonalny custom handler (default = animated preview)

Refaktor 5 ekranow:
- ScenesScreen: usuwa lokalna kopie SceneTile, import shared
- SiteScenesScreen: SceneRow -> SceneTile (numColumns=2, secondLine='studio')
- PerformerScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2)
- StudioScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2, performers)
- TagScenesScreen: lokalna SceneRow -> SceneTile

FavoriteSceneRow component zostaje (legacy import w PerformerScenes - nie
ruszamy bo moze byc uzyty w innym kontekscie). gridRow style scaffold (gap+
marginBottom) dodany w kazdym StyleSheet osobno bo te ekrany maja rozne
paddingHorizontal w container.

OTA: 9eea7ac6-df72-460e-9660-22bf6c39c3ac live, runtime 1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:16:09 +02:00

174 lines
6.2 KiB
TypeScript

// Sceny dla wybranego studia (filter studio_slugs=<slug>). Mirror PerformerScenesScreen.
// Studios używają slug filter (nie id) — backend ScenesListParams ma `studio_slugs`,
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug — przekazujemy go
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile } from '../components/SceneTile';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
export function StudioScenesScreen() {
const client = useClient();
const queryClient = useQueryClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'StudioScenes'>>();
// `id` w nav params dla StudioScenes to studio.slug (nie UUID), żeby pasowało do
// backend ScenesListParams.studio_slugs. Trzymamy tę nazwę dla spójności z
// PerformerScenes — ale w handlerach favorite/blacklist używamy studioId (UUID)
// który dodajemy jako kolejny param.
const { id: slug, name, seenSince, 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'] }),
});
const blacklistMutation = useMutation({
mutationFn: () => client.addBlacklist('studio', studioId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scenes'] });
queryClient.invalidateQueries({ queryKey: ['studio-scenes', slug] });
navigation.goBack();
},
});
const onHide = () => {
Alert.alert(
'Hide studio',
`Hide all scenes from ${name}? You can undo this from Settings → Blacklist.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Hide', style: 'destructive', onPress: () => blacklistMutation.mutate() },
],
);
};
// Mark seen przy wejściu (jeśli już ulubiony) — zerujemy badge nowych scen
React.useEffect(() => {
if (isFavorite) {
client.markFavoriteStudioSeen(studioId).then(() => {
queryClient.invalidateQueries({ queryKey: ['favorites-studios'] });
}).catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFavorite, studioId]);
React.useLayoutEffect(() => {
navigation.setOptions({
title: name,
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
<Pressable onPress={onHide} hitSlop={10}>
<Text style={{ color: theme.mutedDim, fontSize: 18 }}>🚫</Text>
</Pressable>
<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>
</View>
),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['studio-scenes', slug],
queryFn: () =>
client.listScenes({
studio_slugs: [slug],
sort: 'release_date',
per_page: 200,
has_playback: true,
}),
});
const sortedItems = React.useMemo<SceneOut[]>(() => {
const items = data?.items ?? [];
if (!seenSince) return items;
const newOnes: SceneOut[] = [];
const rest: SceneOut[] = [];
for (const s of items) {
if (s.created_at && s.created_at > seenSince) {
newOnes.push(s);
} else {
rest.push(s);
}
}
return [...newOnes, ...rest];
}, [data?.items, seenSince]);
return (
<View style={styles.container}>
{isLoading && <ActivityIndicator color={theme.fg} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={sortedItems}
keyExtractor={(s) => s.id}
numColumns={2}
renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
)}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{data ? `${data.total} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '}
</Text>
</View>
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 4,
},
subtitle: { color: theme.muted },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
error: { color: theme.bad, padding: 12 },
});