goon/mobile/src/screens/PerformerScenesScreen.tsx
jtrzupek ffb80c7b60 feat(performer): replace dev Re-scrape button with top-tag chips
bug-report 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/kategorie by mogły".
Re-scrape was a dev-only bulk thumbnail/tag enrich — noise on the performer page
(per-scene enrich already happens on SceneDetail). Removed it; kept Search.

New GET /performers/{id}/tags aggregates scene_tags across the performer's
live-playback scenes (top N). PerformerScenes renders them as chips → tap navigates
to TagScenes. Search button widened to full row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:56:26 +02:00

459 lines
16 KiB
TypeScript

// Sceny + filmy dla wybranego performera. Tab "Scenes" / "Movies".
// Backend filtruje przez `performer_ids=<id>` w /scenes i /movies — oba endpointy
// to przyjmują (movies dodane 2026-05-16 bug-report "aktorzy powinni mieć też movies").
//
// has_playback ZAWSZE on dla obu — TPDB/StashDB stubs bez linków są niewidoczne.
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as Sentry from '@sentry/react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { MoviePosterCard } from '../components/MoviePosterCard';
import { SceneTile } from '../components/SceneTile';
import { ErrorBoundary } from '../ErrorBoundary';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
type Tab = 'scenes' | 'movies';
const MOVIE_COLS = 2;
export function PerformerScenesScreen() {
const client = useClient();
const queryClient = useQueryClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'PerformerScenes'>>();
const { id, name, seenSince } = route.params;
const [tab, setTab] = React.useState<Tab>('scenes');
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: () => client.listFavorites(),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id);
const addMutation = useMutation({
mutationFn: () => client.addFavorite(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }),
});
const removeMutation = useMutation({
mutationFn: () => client.removeFavorite(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }),
});
const blacklistMutation = useMutation({
mutationFn: () => client.addBlacklist('performer', id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scenes'] });
queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] });
queryClient.invalidateQueries({ queryKey: ['performer-movies', id] });
navigation.goBack();
},
});
const refreshMutation = useMutation({
mutationFn: () => client.refreshPerformer(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] });
Alert.alert(
'Refresh complete',
`${data.new_scenes} new scenes pulled across tubes.`,
);
},
onError: (e: any) => {
const msg = e?.message || 'unknown error';
Alert.alert('Refresh failed', msg);
},
});
// Top tagi/kategorie performera (chipsy w headerze, bug-report 1a4bf258 — zastąpiły
// dev-only przycisk Re-scrape). Backend agreguje scene_tags po scenach z żywym
// playbackiem. Tap → TagScenes. Rescrape (bulk enrich miniaturek/tagów) został
// przeniesiony do flow per-scene (SceneDetail) — na liście performera był devowy szum.
const tagsQuery = useQuery({
queryKey: ['performer-tags', id],
queryFn: () => client.performerTags(id, 18),
staleTime: 5 * 60_000,
});
const onHide = () => {
Alert.alert(
'Hide performer',
`Hide all scenes featuring ${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.markFavoriteSeen(id).then(() => {
queryClient.invalidateQueries({ queryKey: ['favorites'] });
}).catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFavorite, id]);
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 scenesQuery = useQuery({
queryKey: ['performer-scenes', id],
queryFn: () =>
client.listScenes({
performer_ids: [id],
sort: 'release_date',
per_page: 200,
// Sceny TPDB/StashDB bez tube-linków nie są usable — zawsze filtrujemy.
has_playback: true,
}),
});
const moviesQuery = useQuery({
queryKey: ['performer-movies', id],
queryFn: () =>
client.listMovies({
performer_ids: [id],
sort: 'release_date',
per_page: 100,
has_playback: true,
}),
// Lazy: fetcha dopiero przy switch na Movies tab. ~16k pandamovies refów
// → query nie powinna lecieć dla każdego performera od razu.
enabled: tab === 'movies',
});
// Sortowanie: NEW (created_at > seenSince) na górze; reszta po release_date desc
// jak zwracane z backendu. Bez `seenSince` (entry spoza Favorites) — kolejność nie zmieniana.
const sortedScenes = React.useMemo<SceneOut[]>(() => {
const items = scenesQuery.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];
}, [scenesQuery.data?.items, seenSince]);
const movies = moviesQuery.data?.items ?? [];
const scenesTotal = scenesQuery.data?.total ?? 0;
const scenesSuffix = scenesQuery.data?.total_capped ? '+' : '';
const moviesTotal = moviesQuery.data?.total ?? 0;
// Bug-report 2026-05-17 (562cf95c): "Przy przełączaniu na movies app crashed".
// Breadcrumb na każdy switch żeby przy crash dostać context co user kliknął
// ostatnie ~30s przed crashem (Sentry domyślnie trzyma ostatnie 100 breadcrumbs).
const handleTabSwitch = React.useCallback((next: Tab) => {
Sentry.addBreadcrumb({
category: 'ui.tab',
message: `PerformerScenes tab switch → ${next}`,
level: 'info',
data: { performerId: id, performerName: name, from: tab, to: next },
});
setTab(next);
}, [id, name, tab]);
const tabRow = (
<View style={styles.tabRow}>
<TabButton
label="Scenes"
count={scenesTotal}
active={tab === 'scenes'}
onPress={() => handleTabSwitch('scenes')}
/>
<TabButton
label="Movies"
count={moviesTotal}
active={tab === 'movies'}
onPress={() => handleTabSwitch('movies')}
/>
</View>
);
return (
<View style={styles.container}>
{/* Tab row ZAWSZE widoczny, nawet podczas loading. Wcześniej tabRow
renderował się tylko jako ListHeaderComponent — pierwsze przejście
na Movies tab pokazywało blank screen bez sposobu wrotu do Scenes. */}
{tabRow}
{tab === 'scenes' ? (
<>
{scenesQuery.isLoading && <ActivityIndicator color={theme.fg} />}
{scenesQuery.error instanceof Error && (
<Text style={styles.error}>{scenesQuery.error.message}</Text>
)}
<FlatList
key="scenes-list"
data={sortedScenes}
keyExtractor={(s) => s.id}
numColumns={2}
// Android default removeClippedSubviews=true odpina miniaturki poza
// viewportem i expo-image często nie re-renderuje ich po powrocie →
// "miniaturki znikają przy scrollu" (bug-report f181d382 2026-06-07).
removeClippedSubviews={false}
renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="studio" />
)}
columnWrapperStyle={styles.gridRow}
refreshing={scenesQuery.isRefetching}
onRefresh={scenesQuery.refetch}
ListHeaderComponent={
<View>
<View style={styles.header}>
<Text style={styles.subtitle}>
{scenesQuery.data
? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}`
: ' '}
</Text>
</View>
{/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe zajmują za
dużo ekranu" + 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/
kategorie by mogły". Re-scrape (dev-only bulk enrich) usunięty;
Search zostaje, pod nim chipsy top-tagów performera (tap → TagScenes). */}
<View style={styles.actionRow}>
<Pressable
style={[
styles.actionBtn,
refreshMutation.isPending && styles.refreshBtnLoading,
]}
onPress={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
>
{refreshMutation.isPending ? (
<ActivityIndicator color={theme.accent} size="small" />
) : (
<Text style={styles.actionBtnTextPrimary}> Search more scenes</Text>
)}
</Pressable>
</View>
{tab === 'scenes' && !!tagsQuery.data?.items.length && (
<View style={styles.tagRow}>
{tagsQuery.data.items.map((t) => (
<Pressable
key={t.id}
style={styles.tagChip}
onPress={() =>
navigation.push('TagScenes', { slug: t.slug, name: t.name })
}
>
<Text style={styles.tagChipText} numberOfLines={1}>
{t.name}
</Text>
</Pressable>
))}
</View>
)}
</View>
}
ListEmptyComponent={
!scenesQuery.isLoading ? <Text style={styles.muted}>no scenes</Text> : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</>
) : (
// Bug-report 2026-05-17 (562cf95c): app crashed przy switch na Movies.
// Lokalny boundary łapie React errors w drzewie Movies (np. native crash
// w expo-image jeśli poster_url malformed, lub VirtualizedList z dwu-kolumnowym
// numColumns). Crash idzie do Sentry z tagiem `boundary:perfscenes-movies`.
<ErrorBoundary scope="perfscenes-movies">
{moviesQuery.isLoading && <ActivityIndicator color={theme.fg} />}
{moviesQuery.error instanceof Error && (
<Text style={styles.error}>{moviesQuery.error.message}</Text>
)}
<FlatList
// Explicit key wymusza clean remount przy tab switch — bez tego React
// próbuje reuse'ować VirtualizedList z poprzedniej tab (numColumns=1
// dla scenes vs numColumns=2 dla movies), co na Androidzie czasem
// wywala native ViewGroup lifecycle.
key="movies-list"
data={movies}
numColumns={MOVIE_COLS}
removeClippedSubviews={false}
keyExtractor={(m) => m.id}
renderItem={({ item }) => {
// Defensive: jeśli backend kiedyś wyśle pojedynczy malformed movie,
// jeden bad item nie wywala całej listy (FlatList catchuje renderItem
// throws, ale wolimy fallback).
try {
return (
<MoviePosterCard
movie={item}
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
/>
);
} catch (e) {
Sentry.captureException(e, {
tags: { component: 'MoviePosterCard' },
contexts: { movie: { id: item?.id, title: item?.title } },
});
return null;
}
}}
refreshing={moviesQuery.isRefetching}
onRefresh={moviesQuery.refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{moviesQuery.data
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
: ' '}
</Text>
</View>
}
ListEmptyComponent={
!moviesQuery.isLoading ? (
<Text style={styles.muted}>no movies for this performer</Text>
) : null
}
// columnWrapperStyle musi być w obecności numColumns>1 — pusta `data=[]`
// z columnWrapperStyle ALE bez gap krzyczy w niektórych RN; trzymamy gap.
columnWrapperStyle={movies.length > 0 ? { gap: 8 } : undefined}
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }}
/>
</ErrorBoundary>
)}
</View>
);
}
function TabButton({
label,
count,
active,
onPress,
}: {
label: string;
count: number;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>
{label}
</Text>
{count > 0 ? (
<View style={styles.tabCount}>
<Text style={styles.tabCountText}>{count}</Text>
</View>
) : null}
</Pressable>
);
}
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 },
refreshBtnLoading: { opacity: 0.6 },
actionRow: {
flexDirection: 'row',
gap: 6,
marginHorizontal: 4,
marginBottom: 8,
},
actionBtn: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
paddingVertical: 6,
alignItems: 'center',
},
actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 },
actionBtnTextPrimary: { color: theme.accent, fontWeight: '700', fontSize: 12 },
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginHorizontal: 4,
marginBottom: 10,
},
tagChip: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 14,
paddingVertical: 4,
paddingHorizontal: 10,
maxWidth: 160,
},
tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tabBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabBtnActive: { borderColor: theme.accent, backgroundColor: theme.bgElevated },
tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabBtnTextActive: { color: theme.fg },
tabCount: {
backgroundColor: theme.border,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
tabCountText: { color: theme.muted, fontWeight: '700', fontSize: 11 },
});