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>
174 lines
6.2 KiB
TypeScript
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 },
|
|
});
|