// Lista tube źródeł — top-level tab obok Scenes/Movies. Tap → SiteScenes. // Bug-report 2026-05-24 (ea6f05f9): user chce wybierać "pages" obok Scenes // i Movies, widzieć najnowsze sceny z konkretnego scrapowanego site'u. // // Layout: chip-grid analogiczny do TagsScreen — krótkie nazwy (domena.tld) // plus scene_count + relative-time "Xh temu" scraped, jeśli świeży. import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useQuery } from '@tanstack/react-query'; import React, { useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SourceOut, SourceRating } from '../types'; type Order = 'popular' | 'recent'; export function SitesScreen() { const client = useClient(); const navigation = useNavigation>(); const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [order, setOrder] = useState('popular'); const [searchFocused, setSearchFocused] = useState(false); // Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki). const [detail, setDetail] = useState(null); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 250); return () => clearTimeout(t); }, [q]); const { data, isLoading, error, refetch, isRefetching } = useQuery({ queryKey: ['sources'], queryFn: () => client.listSources(), staleTime: 60_000, }); // Sort + filter client-side — lista ma <50 entries, nie warto roundtripować. // Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność // zachowana. Dla 'recent' sortujemy po last_scraped_at DESC. const items = useMemo(() => { const all = data?.items ?? []; const filtered = debouncedQ ? all.filter( (s) => s.display_name.toLowerCase().includes(debouncedQ.toLowerCase()) || s.sitetag.toLowerCase().includes(debouncedQ.toLowerCase()), ) : all; if (order === 'recent') { return [...filtered].sort((a, b) => { if (!a.last_scraped_at && !b.last_scraped_at) return 0; if (!a.last_scraped_at) return 1; if (!b.last_scraped_at) return -1; return b.last_scraped_at.localeCompare(a.last_scraped_at); }); } return filtered; }, [data?.items, debouncedQ, order]); return ( Sites {items.length} tap a tube → newest scenes · tap the ★ → rating breakdown setSearchFocused(true)} onBlur={() => setSearchFocused(false)} placeholder="search site…" placeholderTextColor={theme.mutedDim} autoCapitalize="none" /> setOrder('popular')} label="Top" /> setOrder('recent')} label="Recent" /> {isLoading && } {error instanceof Error && {error.message}} s.origin} numColumns={2} columnWrapperStyle={styles.gridRow} renderItem={({ item }) => ( navigation.navigate('SiteScenes', { origin: item.origin, name: prettySiteName(item.display_name), }) } onShowRating={() => setDetail(item)} /> )} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ !isLoading ? no sites : null } contentContainerStyle={{ paddingBottom: 24 }} /> setDetail(null)} /> ); } function SegButton({ active, onPress, label, }: { active: boolean; onPress: () => void; label: string; }) { return ( {label} ); } // Lista Sites pokazuje display_name = domena (hqporner.com). User-report 18105d14: // pozbyć się suffixów com/org/itp. Strip końcowego TLD → czysta nazwa. function prettySiteName(name: string): string { return name.replace(/\.[a-z]{2,5}$/i, '').trim() || name; } function formatRelativeTime(iso: string | null): string | null { if (!iso) return null; const ts = Date.parse(iso); if (Number.isNaN(ts)) return null; const diffSec = (Date.now() - ts) / 1000; if (diffSec < 60) return 'just now'; if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; const days = Math.floor(diffSec / 86400); if (days < 30) return `${days}d ago`; return null; } // Rząd gwiazdek 0-5. 0 = offline (czerwony label zamiast gwiazdek). Brak oceny → null. function Stars({ value, size = 13 }: { value: number | null | undefined; size?: number }) { if (value == null) return — not rated; if (value <= 0) return ● OFFLINE; const full = '★'.repeat(value); const empty = '☆'.repeat(5 - value); return ( {full} {empty} ); } function SiteChip({ source, onPress, onShowRating, }: { source: SourceOut; onPress: () => void; onShowRating: () => void; }) { const rel = formatRelativeTime(source.last_scraped_at); const stars = source.rating?.stars; return ( [styles.chip, pressed && styles.chipPressed]} onPress={onPress} > {prettySiteName(source.display_name)} {rel ? {rel} : null} {source.scene_count} [styles.starsTap, pressed && { opacity: 0.6 }]} > ); } function AxisBar({ label, value }: { label: string; value: number | null }) { const v = value ?? 0; const pct = Math.max(0, Math.min(100, (v / 5) * 100)); return ( {label} {value == null ? '—' : `${value}/5`} ); } const _PCT_LABELS: Record = { thumb: 'Thumbnails', tag: 'Tags', perf: 'Performers', desc: 'Descriptions', studio: 'Studio', dur: 'Duration', }; function RatingModal({ source, onClose }: { source: SourceOut | null; onClose: () => void }) { const r: SourceRating | null | undefined = source?.rating; const pct = r?.components?.pct ?? {}; const basis = r?.health_basis ?? r?.components?.health_basis; const attempts = r?.components?.pb_attempts_7d ?? 0; const successRate = r?.components?.pb_success_rate; return ( {}}> {source ? prettySiteName(source.display_name) : ''} {!r ? ( Not rated yet — check back soon. ) : ( <> Metadata coverage {Object.keys(_PCT_LABELS).map((k) => ( {_PCT_LABELS[k]} {pct[k] != null ? `${pct[k]}%` : '—'} ))} Playback {basis === 'telemetry' ? `Measured from real playback: ${successRate ?? 0}% success over ${attempts} plays (7d).` : 'Estimated from how this source streams (no playback data yet — collecting).'} )} Close ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 }, headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 4, }, headerLabel: { color: theme.muted, fontSize: 12, textTransform: 'uppercase', letterSpacing: 1.2, fontWeight: '700', }, headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' }, hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 }, toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 }, search: { flex: 1, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1.5, borderRadius: 12, color: theme.fg, padding: 12, fontSize: 16, }, searchFocused: { borderColor: theme.borderFocus }, segment: { flexDirection: 'row', backgroundColor: theme.bgElevated, borderColor: theme.border, borderWidth: 1, borderRadius: 12, padding: 4, marginBottom: 14, alignSelf: 'flex-start', }, segButton: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8 }, segButtonActive: { backgroundColor: theme.accent, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 6, elevation: 2, }, segButtonText: { color: theme.muted, fontWeight: '700', fontSize: 13 }, segButtonTextActive: { color: theme.fg }, gridRow: { gap: 10, marginBottom: 10 }, chip: { flex: 1, gap: 8, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.18, shadowRadius: 3, elevation: 2, }, chipTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, }, chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated }, chipMain: { flex: 1, gap: 2 }, chipName: { color: theme.fg, fontWeight: '600', fontSize: 14, }, chipRel: { color: theme.mutedDim, fontSize: 10, }, chipCountWrap: { backgroundColor: `${theme.accentSecondary}1F`, borderColor: `${theme.accentSecondary}55`, borderWidth: 1, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 2, minWidth: 36, alignItems: 'center', }, chipCount: { color: theme.accentSecondary, fontSize: 12, fontWeight: '700', }, emptyText: { color: theme.muted, textAlign: 'center', marginTop: 48, fontSize: 16 }, error: { color: theme.bad, padding: 16 }, starsTap: { alignSelf: 'flex-start', paddingVertical: 2, paddingRight: 8 }, starsRow: { letterSpacing: 1 }, starsFull: { color: '#f5c518' }, // złoto starsEmpty: { color: theme.mutedDim }, starsDim: { color: theme.mutedDim, fontStyle: 'italic' }, starsOffline: { color: theme.bad, fontWeight: '700', fontSize: 11 }, modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24, }, modalCard: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 16, padding: 18, maxHeight: '80%', }, modalTitle: { color: theme.fg, fontSize: 20, fontWeight: '800', marginBottom: 8 }, modalStarsBig: { marginBottom: 14 }, modalSection: { color: theme.muted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 1, fontWeight: '700', marginTop: 16, marginBottom: 8, }, modalMuted: { color: theme.mutedDim, fontSize: 13, lineHeight: 18 }, axisRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }, axisLabel: { color: theme.fg, fontSize: 13, width: 80 }, axisTrack: { flex: 1, height: 8, backgroundColor: theme.bgElevated, borderRadius: 4, overflow: 'hidden', }, axisFill: { height: 8, backgroundColor: theme.accent, borderRadius: 4 }, axisVal: { color: theme.muted, fontSize: 12, width: 30, textAlign: 'right' }, pctRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3, }, pctLabel: { color: theme.muted, fontSize: 13 }, pctVal: { color: theme.fg, fontSize: 13, fontWeight: '600' }, modalClose: { marginTop: 20, backgroundColor: theme.accent, borderRadius: 10, paddingVertical: 10, alignItems: 'center', }, modalCloseText: { color: theme.fg, fontWeight: '700', fontSize: 14 }, });