goon/mobile/src/screens/SitesScreen.tsx
goon-foss 6d1d9dbd46 feat(mobile): strip .com/.org clutter from site names
User-report 18105d14: drop the TLD suffix from Sites list + SiteScenes header (hqporner.com -> hqporner, fpo.xxx -> fpo). Logos skipped (needs a per-site logo source) — TLD strip is the quick win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:38:45 +02:00

296 lines
8.7 KiB
TypeScript

// 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,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SourceOut } from '../types';
type Order = 'popular' | 'recent';
export function SitesScreen() {
const client = useClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Sites'>>();
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('popular');
const [searchFocused, setSearchFocused] = useState(false);
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<SourceOut[]>(() => {
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 (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Sites</Text>
<Text style={styles.headerCount}>{items.length}</Text>
</View>
<Text style={styles.hint}>tap a tube newest scenes from that site</Text>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, searchFocused && styles.searchFocused]}
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search site…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
/>
</View>
<View style={styles.segment}>
<SegButton
active={order === 'popular'}
onPress={() => setOrder('popular')}
label="Top"
/>
<SegButton
active={order === 'recent'}
onPress={() => setOrder('recent')}
label="Recent"
/>
</View>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={items}
keyExtractor={(s) => s.origin}
numColumns={2}
columnWrapperStyle={styles.gridRow}
renderItem={({ item }) => (
<SiteChip
source={item}
onPress={() =>
navigation.navigate('SiteScenes', {
origin: item.origin,
name: prettySiteName(item.display_name),
})
}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
ListEmptyComponent={
!isLoading ? <Text style={styles.emptyText}>no sites</Text> : null
}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
// 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;
}
function SiteChip({ source, onPress }: { source: SourceOut; onPress: () => void }) {
const rel = formatRelativeTime(source.last_scraped_at);
return (
<Pressable
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
onPress={onPress}
>
<View style={styles.chipMain}>
<Text style={styles.chipName} numberOfLines={1}>
{prettySiteName(source.display_name)}
</Text>
{rel ? <Text style={styles.chipRel}>{rel}</Text> : null}
</View>
<View style={styles.chipCountWrap}>
<Text style={styles.chipCount}>{source.scene_count}</Text>
</View>
</Pressable>
);
}
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,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
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,
},
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 },
});