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);
|
||||
let routeName: string | null = null;
|
||||
let rawSceneId: string | undefined;
|
||||
let extraContext: string[] = [];
|
||||
try {
|
||||
const route = navRef.isReady() ? navRef.getCurrentRoute() : null;
|
||||
routeName = route?.name ?? null;
|
||||
|
|
@ -104,14 +105,29 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
|||
rawSceneId =
|
||||
(params['sceneId'] 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 {
|
||||
// navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable
|
||||
}
|
||||
const sceneId =
|
||||
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 {
|
||||
await client.submitBugReport({
|
||||
message: message.trim(),
|
||||
message: finalMessage,
|
||||
screen_name: routeName,
|
||||
app_version: appVersion,
|
||||
scene_id: sceneId,
|
||||
|
|
|
|||
|
|
@ -8,22 +8,25 @@
|
|||
// Infinite scroll bo niektóre tubey mają 100k+ scen (porntrex, xvideos).
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
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 * as Haptics from 'expo-haptics';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Thumb } from '../components/Thumb';
|
||||
import { useClient } from '../ClientContext';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { theme } from '../theme';
|
||||
import type { SceneOut } from '../types';
|
||||
import type { SceneOut, TagOut } from '../types';
|
||||
|
||||
export function SiteScenesScreen() {
|
||||
const client = useClient();
|
||||
|
|
@ -36,6 +39,11 @@ export function SiteScenesScreen() {
|
|||
navigation.setOptions({ title: 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 {
|
||||
data,
|
||||
|
|
@ -47,10 +55,11 @@ export function SiteScenesScreen() {
|
|||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['site-scenes', origin],
|
||||
queryKey: ['site-scenes', origin, selectedTags.sort().join(',')],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
client.listScenes({
|
||||
origin,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
sort: 'release_date',
|
||||
page: pageParam,
|
||||
per_page: PER_PAGE,
|
||||
|
|
@ -66,6 +75,32 @@ export function SiteScenesScreen() {
|
|||
|
||||
return (
|
||||
<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 }} />}
|
||||
{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 }) {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>();
|
||||
|
|
@ -211,4 +352,61 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 },
|
||||
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