diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index 6ff4c1e..2f50846 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -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, diff --git a/mobile/src/screens/SiteScenesScreen.tsx b/mobile/src/screens/SiteScenesScreen.tsx index 72c10ce..9ab0935 100644 --- a/mobile/src/screens/SiteScenesScreen.tsx +++ b/mobile/src/screens/SiteScenesScreen.tsx @@ -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([]); + 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 ( + + 0 && styles.filterBtnActive]} + onPress={() => setFilterOpen(true)} + > + + Tagi{selectedTags.length > 0 ? ` ${selectedTags.length}` : ''} + + + {selectedTags.length > 0 ? ( + setSelectedTags([])}> + Wyczyść + + ) : null} + + + { + setSelectedTags(next); + setFilterOpen(false); + }} + onClose={() => setFilterOpen(false)} + /> + {isLoading && } {error instanceof Error && {error.message}} @@ -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(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 ( + + + + + Filtruj po tagach + + + + + + {isLoading ? ( + + ) : ( + + {tags.map((t) => { + const on = selected.includes(t.slug); + return ( + toggle(t.slug)} + style={[modalStyles.chip, on && modalStyles.chipOn]} + > + + {t.name} + + + ); + })} + {tags.length === 0 ? ( + Brak wyników + ) : null} + + )} + + setSelected([])} + disabled={selected.length === 0} + > + + Wyczyść + + + onApply(selected)} + > + + Zastosuj{selected.length > 0 ? ` (${selected.length})` : ''} + + + + + + + ); +} + function SceneRow({ scene }: { scene: SceneOut }) { const navigation = useNavigation>(); @@ -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' }, });