feat(mobile): bug-report context capture + SiteScenes tag filter

BugReportFAB (bug-report #4 / bda4383a 2026-05-26 "sceny z tej strony nie
dzialaja"): zbiera siteId/studioId/performerId/movieId/tagId z route params
i appenduje [auto-context: ...] do message body. Bez tego ekrany takie jak
SiteScenes/PerformerScenes/StudioScenes raportowaly bez kontekstu - admin
widzial tylko screen_name. Bez DB schema migration.

SiteScenesScreen (bug-report #13 / 43f81a46 2026-05-26 "przydalyby sie
kategorie na stronach Sites"): toolbar z Filter button (counter aktywnych
tagow) + Clear button. TagPickerModal: search + multi-select chipy z
popular tags (only_with_content=true). Selected slugs -> listScenes
({tags: [...]}) - backend juz wspiera AND. React Query keyed na (origin,
selected.sort().join(',')).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-05-28 23:24:20 +02:00
parent 6eb7cdd320
commit fa21a53384
2 changed files with 218 additions and 4 deletions

View file

@ -97,6 +97,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
setSubmitting(true); setSubmitting(true);
let routeName: string | null = null; let routeName: string | null = null;
let rawSceneId: string | undefined; let rawSceneId: string | undefined;
let extraContext: string[] = [];
try { try {
const route = navRef.isReady() ? navRef.getCurrentRoute() : null; const route = navRef.isReady() ? navRef.getCurrentRoute() : null;
routeName = route?.name ?? null; routeName = route?.name ?? null;
@ -104,14 +105,29 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
rawSceneId = rawSceneId =
(params['sceneId'] as string | undefined) ?? (params['sceneId'] as string | undefined) ??
(params['id'] as string | undefined); (params['id'] as string | undefined);
// Zbieramy non-scene entity IDs (siteId/studioId/performerId/movieId) jako
// hint dla admina — backend schema ma tylko `scene_id`, ale ekrany takie
// jak SiteScenes/PerformerScenes/StudioScenes raportują bez konkretnej
// sceny ("sceny z tej strony nie działają", bug-report bda4383a 2026-05-26).
// Appendujemy do message zamiast schema-migracji.
const UUID_RE = /^[0-9a-f-]{36}$/;
for (const key of ['siteId', 'studioId', 'performerId', 'movieId', 'tagId']) {
const val = params[key];
if (typeof val === 'string' && UUID_RE.test(val)) {
extraContext.push(`${key}=${val}`);
}
}
} catch { } catch {
// navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable // navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable
} }
const sceneId = const sceneId =
rawSceneId && /^[0-9a-f-]{36}$/.test(rawSceneId) ? rawSceneId : null; rawSceneId && /^[0-9a-f-]{36}$/.test(rawSceneId) ? rawSceneId : null;
const finalMessage = extraContext.length > 0
? `${message.trim()}\n\n[auto-context: ${extraContext.join(', ')}]`
: message.trim();
try { try {
await client.submitBugReport({ await client.submitBugReport({
message: message.trim(), message: finalMessage,
screen_name: routeName, screen_name: routeName,
app_version: appVersion, app_version: appVersion,
scene_id: sceneId, scene_id: sceneId,

View file

@ -8,22 +8,25 @@
// Infinite scroll bo niektóre tubey mają 100k+ scen (porntrex, xvideos). // Infinite scroll bo niektóre tubey mają 100k+ scen (porntrex, xvideos).
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import React, { useState } from 'react'; import React, { useState } from 'react';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
Modal,
Pressable, Pressable,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { SceneOut } from '../types'; import type { SceneOut, TagOut } from '../types';
export function SiteScenesScreen() { export function SiteScenesScreen() {
const client = useClient(); const client = useClient();
@ -36,6 +39,11 @@ export function SiteScenesScreen() {
navigation.setOptions({ title: name }); navigation.setOptions({ title: name });
}, [navigation, name]); }, [navigation, name]);
// Tag filter — user-report 2026-05-26 (43f81a46) "Przydałyby się kategorie na
// stronach Sites". Multi-select AND (passed as comma-CSV do `tags` query).
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filterOpen, setFilterOpen] = useState(false);
const PER_PAGE = 50; const PER_PAGE = 50;
const { const {
data, data,
@ -47,10 +55,11 @@ export function SiteScenesScreen() {
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ['site-scenes', origin], queryKey: ['site-scenes', origin, selectedTags.sort().join(',')],
queryFn: ({ pageParam = 1 }) => queryFn: ({ pageParam = 1 }) =>
client.listScenes({ client.listScenes({
origin, origin,
tags: selectedTags.length > 0 ? selectedTags : undefined,
sort: 'release_date', sort: 'release_date',
page: pageParam, page: pageParam,
per_page: PER_PAGE, per_page: PER_PAGE,
@ -66,6 +75,32 @@ export function SiteScenesScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.toolbar}>
<Pressable
style={[styles.filterBtn, selectedTags.length > 0 && styles.filterBtnActive]}
onPress={() => setFilterOpen(true)}
>
<Text style={styles.filterBtnText}>
Tagi{selectedTags.length > 0 ? ` ${selectedTags.length}` : ''}
</Text>
</Pressable>
{selectedTags.length > 0 ? (
<Pressable style={styles.clearBtn} onPress={() => setSelectedTags([])}>
<Text style={styles.clearBtnText}>Wyczyść</Text>
</Pressable>
) : null}
</View>
<TagPickerModal
visible={filterOpen}
initialSelected={selectedTags}
onApply={(next) => {
setSelectedTags(next);
setFilterOpen(false);
}}
onClose={() => setFilterOpen(false)}
/>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -100,6 +135,112 @@ export function SiteScenesScreen() {
); );
} }
function TagPickerModal({
visible,
initialSelected,
onApply,
onClose,
}: {
visible: boolean;
initialSelected: string[];
onApply: (selected: string[]) => void;
onClose: () => void;
}) {
const client = useClient();
const [selected, setSelected] = useState<string[]>(initialSelected);
const [q, setQ] = useState('');
React.useEffect(() => {
if (visible) setSelected(initialSelected);
}, [visible, initialSelected]);
const { data, isLoading } = useQuery({
queryKey: ['tags-popular', q],
queryFn: () =>
client.listTags({
q: q.trim() || undefined,
order: 'popular',
per_page: 200,
only_with_content: true,
}),
enabled: visible,
});
const tags: TagOut[] = data?.items ?? [];
const toggle = (slug: string) => {
setSelected((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} transparent>
<View style={modalStyles.backdrop}>
<View style={modalStyles.sheet}>
<View style={modalStyles.header}>
<Text style={modalStyles.title}>Filtruj po tagach</Text>
<Pressable onPress={onClose}>
<Text style={modalStyles.close}></Text>
</Pressable>
</View>
<TextInput
style={modalStyles.search}
placeholder="Szukaj tagu…"
placeholderTextColor={theme.muted}
value={q}
onChangeText={setQ}
autoCapitalize="none"
/>
{isLoading ? (
<ActivityIndicator color={theme.fg} style={{ marginTop: 16 }} />
) : (
<ScrollView style={modalStyles.chipScroll} contentContainerStyle={modalStyles.chipRow}>
{tags.map((t) => {
const on = selected.includes(t.slug);
return (
<Pressable
key={t.id}
onPress={() => toggle(t.slug)}
style={[modalStyles.chip, on && modalStyles.chipOn]}
>
<Text style={[modalStyles.chipText, on && modalStyles.chipTextOn]}>
{t.name}
</Text>
</Pressable>
);
})}
{tags.length === 0 ? (
<Text style={modalStyles.muted}>Brak wyników</Text>
) : null}
</ScrollView>
)}
<View style={modalStyles.footer}>
<Pressable
style={modalStyles.footerBtn}
onPress={() => setSelected([])}
disabled={selected.length === 0}
>
<Text
style={[modalStyles.footerBtnText, selected.length === 0 && { opacity: 0.4 }]}
>
Wyczyść
</Text>
</Pressable>
<Pressable
style={[modalStyles.footerBtn, modalStyles.footerBtnPrimary]}
onPress={() => onApply(selected)}
>
<Text style={modalStyles.footerBtnTextPrimary}>
Zastosuj{selected.length > 0 ? ` (${selected.length})` : ''}
</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
function SceneRow({ scene }: { scene: SceneOut }) { function SceneRow({ scene }: { scene: SceneOut }) {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>();
@ -211,4 +352,61 @@ const styles = StyleSheet.create({
}, },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 },
error: { color: theme.bad, padding: 12 }, error: { color: theme.bad, padding: 12 },
toolbar: { flexDirection: 'row', gap: 8, marginBottom: 8, alignItems: 'center' },
filterBtn: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
filterBtnActive: { backgroundColor: theme.accentDeep, borderColor: theme.accent },
filterBtnText: { color: theme.fg, fontSize: 13, fontWeight: '600' },
clearBtn: { paddingHorizontal: 10, paddingVertical: 8 },
clearBtnText: { color: theme.muted, fontSize: 13 },
});
const modalStyles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
sheet: {
backgroundColor: theme.bg,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
padding: 16,
},
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
title: { color: theme.fg, fontSize: 17, fontWeight: '700' },
close: { color: theme.muted, fontSize: 20, padding: 4 },
search: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 10,
color: theme.fg,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
chipScroll: { maxHeight: 380 },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, paddingBottom: 8 },
chip: {
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 999,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
chipOn: { backgroundColor: theme.accentDeep, borderColor: theme.accent },
chipText: { color: theme.fg, fontSize: 13 },
chipTextOn: { color: theme.fg, fontWeight: '700' },
muted: { color: theme.muted, fontSize: 13, paddingVertical: 8 },
footer: { flexDirection: 'row', gap: 8, marginTop: 12, justifyContent: 'flex-end' },
footerBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10 },
footerBtnText: { color: theme.muted, fontSize: 14, fontWeight: '600' },
footerBtnPrimary: { backgroundColor: theme.accent },
footerBtnTextPrimary: { color: theme.bg, fontSize: 14, fontWeight: '700' },
}); });