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:
parent
6eb7cdd320
commit
fa21a53384
2 changed files with 218 additions and 4 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue