diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 9825da5..a70048a 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -405,8 +405,9 @@ export class GoonClient { }); } - async listFavorites(): Promise { - return this.request('/favorites'); + /** `counts: false` = bez liczników +N (tylko stan gwiazdki, serwer odpowiada od razu). */ + async listFavorites(opts: { counts?: boolean } = {}): Promise { + return this.request(opts.counts === false ? '/favorites?counts=false' : '/favorites'); } async addFavorite(performerId: string): Promise { @@ -428,8 +429,8 @@ export class GoonClient { await this.request(`/favorites/${performerId}/seen`, { method: 'POST' }); } - async listFavoriteStudios(): Promise { - return this.request('/favorites/studios'); + async listFavoriteStudios(opts: { counts?: boolean } = {}): Promise { + return this.request(opts.counts === false ? '/favorites/studios?counts=false' : '/favorites/studios'); } async addFavoriteStudio(studioId: string): Promise { diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index ea8f9a3..bdab507 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -35,6 +35,23 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-09-15d', + date: 'September 2026', + items: [ + 'Edge: the bottom bar slides away when you swipe to the next clip and comes back when you swipe back, so it no longer covers the caption, tags and progress line.', + 'Edge: the bug report button is hidden there, it was sitting on top of the not-interested button. Reports still work from every other tab.', + ], + minRuntime: '1.4', + }, + { + id: '2026-09-15c', + date: 'September 2026', + items: [ + 'Favorites, performer and studio pages, Performers and Tags: tabs, sorting and buttons now share one row under the title instead of stacking three or four lines above the list.', + 'Favorites and similar scenes load much faster, and opening a performer or studio no longer waits for the new-scene counters.', + ], + }, { id: '2026-09-15b', date: 'September 2026', diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index 830a6a0..70c2db3 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -106,19 +106,27 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) { }, [unseen, client]); // Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen. const [currentRoute, setCurrentRoute] = useState(null); + const [currentTab, setCurrentTab] = useState(null); React.useEffect(() => { if (!navRef) return; const unsub = navRef.addListener('state', () => { try { const r = navRef.isReady() ? navRef.getCurrentRoute() : null; setCurrentRoute(r?.name ?? null); + const tab = (r?.params as { tab?: unknown } | undefined)?.tab; + setCurrentTab(typeof tab === 'string' ? tab : null); } catch { setCurrentRoute(null); + setCurrentTab(null); } }); return unsub; }, [navRef]); - const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute); + // Edge to wideo na caly ekran z kolumna przyciskow po prawej, a FAB lezal dokladnie na + // „Not interested" (2026-09-15). Zgloszenie da sie wyslac z kazdej innej zakladki. + const hidden = + currentRoute !== null && + (FAB_HIDDEN_SCREENS.has(currentRoute) || (currentRoute === 'Scenes' && currentTab === 'Edge')); // Na ekranie-gospodarzu (trasa 'Scenes') na dole wisi pasek zakladek, FAB idzie // nad niego; na pozostalych ekranach zostaje przy krawedzi. const insets = useSafeAreaInsets(); diff --git a/mobile/src/components/swipe/ClipCell.tsx b/mobile/src/components/swipe/ClipCell.tsx index e325698..35259e0 100644 --- a/mobile/src/components/swipe/ClipCell.tsx +++ b/mobile/src/components/swipe/ClipCell.tsx @@ -11,6 +11,11 @@ * double-tap → serce (ulubione) + wybuch serca + haptyka, * przytrzymanie → 2x do puszczenia, * przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera). + * + * Dolny pasek zakladek wisi nad feedem. Podpis, kolumna przyciskow i pasek postepu jada + * razem z nim (ta sama wartosc Animated `barHidden`): gdy pasek jest widoczny, stoja nad + * nim, gdy sie schowa, schodza na dol ekranu. Wczesniej pasek zaslanial podpis, tagi i + * postep (uwaga Jana 2026-09-15). */ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Image } from 'expo-image'; @@ -19,7 +24,9 @@ import { VideoView } from 'expo-video'; import React from 'react'; import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { BOTTOM_BAR_HEIGHT, barHidden } from '../../lib/bottomBar'; import { fonts, theme } from '../../theme'; import type { ClipFeedItem } from '../../types'; @@ -40,6 +47,15 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike const [progress, setProgress] = React.useState(0); const [ready, setReady] = React.useState(false); const heart = React.useRef(new Animated.Value(0)).current; + const insets = useSafeAreaInsets(); + const lift = React.useMemo( + () => + barHidden.interpolate({ + inputRange: [0, 1], + outputRange: [-(BOTTOM_BAR_HEIGHT + insets.bottom), -insets.bottom], + }), + [insets.bottom], + ); // Postep i gotowosc tylko dla aktywnej strony z playerem. React.useEffect(() => { @@ -158,61 +174,63 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike - {/* Kolumna przyciskow */} - - { - burst(); - onLike(); - }} - style={styles.railBtn} - accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'} - > - - - - - - - - - - - {/* Podpis: tworca, tytul, tagi */} - - - - {clip.creator ? `@${clip.creator}` : clip.source} - - {clip.creator_verified ? ( - - ) : null} - {item.is_fresh ? NEW : null} + + {/* Kolumna przyciskow */} + + { + burst(); + onLike(); + }} + style={styles.railBtn} + accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'} + > + + + + + + + + - {clip.title ? ( - - {clip.title} - - ) : null} - {tags.length ? ( - - {tags.map((t) => ( - onTagPress?.(t)} style={styles.tagChip}> - #{t} - - ))} - - ) : null} - - {/* Pasek postepu */} - - - + {/* Podpis: tworca, tytul, tagi */} + + + + {clip.creator ? `@${clip.creator}` : clip.source} + + {clip.creator_verified ? ( + + ) : null} + {item.is_fresh ? NEW : null} + + {clip.title ? ( + + {clip.title} + + ) : null} + {tags.length ? ( + + {tags.map((t) => ( + onTagPress?.(t)} style={styles.tagChip}> + #{t} + + ))} + + ) : null} + + + {/* Pasek postepu */} + + + + ); @@ -231,7 +249,8 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - rail: { position: 'absolute', right: 8, bottom: 96, alignItems: 'center' }, + // Nad dolnym paskiem pilnuje tego `lift`, wiec kolumna stoi na wysokosci podpisu. + rail: { position: 'absolute', right: 8, bottom: 20, alignItems: 'center' }, railBtn: { width: 48, height: 48, diff --git a/mobile/src/screens/FavoritesScreen.tsx b/mobile/src/screens/FavoritesScreen.tsx index 4d70d69..fc68faf 100644 --- a/mobile/src/screens/FavoritesScreen.tsx +++ b/mobile/src/screens/FavoritesScreen.tsx @@ -23,6 +23,7 @@ import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; +import { SegmentTabs, Toolbar } from '../ui'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; type Tab = 'scenes' | 'performers' | 'studios' | 'movies'; @@ -115,12 +116,6 @@ export function FavoritesScreen() { }; // performers/studios mają new_total badge; scenes/movies nie (single entity). - const activeData = - tab === 'performers' - ? performersQuery.data - : tab === 'studios' - ? studiosQuery.data - : null; const activeQuery = tab === 'performers' ? performersQuery @@ -129,7 +124,6 @@ export function FavoritesScreen() { : tab === 'movies' ? moviesQuery : scenesQuery; - const headerCount = activeData?.total ?? (activeQuery.data as { total?: number } | undefined)?.total ?? 0; const isLoading = activeQuery.isLoading; const isRefetching = activeQuery.isRefetching; const error = activeQuery.error; @@ -137,47 +131,21 @@ export function FavoritesScreen() { return ( - - Favorites - {activeData && activeData.new_total > 0 ? ( - - +{activeData.new_total} new - - ) : ( - {headerCount} - )} - - - - setTab('scenes')} + {/* Jeden wiersz zakładek (2026-09-15). Wcześniej pod natywnym nagłówkiem + "Favorites" stała druga etykieta FAVORITES z licznikiem, wiersz zakładek i + podpowiedź, czyli cztery linie przed listą. Podpowiedź zeszła pod listę. */} + + - setTab('performers')} - /> - setTab('studios')} - /> - setTab('movies')} - /> - - - - tap to open scenes (resets the new badge) · long-press to remove - + {isLoading && } {error instanceof Error && {error.message}} @@ -221,6 +189,7 @@ export function FavoritesScreen() { ) : null } + ListFooterComponent={scenesQuery.data?.items.length ? long-press to remove : null} contentContainerStyle={{ paddingBottom: 24 }} /> ) : tab === 'movies' ? ( @@ -260,6 +229,7 @@ export function FavoritesScreen() { ) : null } + ListFooterComponent={moviesQuery.data?.items.length ? long-press to remove : null} contentContainerStyle={{ paddingBottom: 24 }} /> ) : tab === 'performers' ? ( @@ -306,6 +276,7 @@ export function FavoritesScreen() { ) : null } + ListFooterComponent={performersQuery.data?.items.length ? tap to open (clears the new badge) · long-press to remove : null} contentContainerStyle={{ paddingBottom: 24 }} /> ) : ( @@ -351,6 +322,7 @@ export function FavoritesScreen() { ) : null } + ListFooterComponent={studiosQuery.data?.items.length ? tap to open (clears the new badge) · long-press to remove : null} contentContainerStyle={{ paddingBottom: 24 }} /> )} @@ -358,29 +330,6 @@ export function FavoritesScreen() { ); } -function TabButton({ - label, - active, - newCount, - onPress, -}: { - label: string; - active: boolean; - newCount: number; - onPress: () => void; -}) { - return ( - - {label} - {newCount > 0 ? ( - - +{newCount} - - ) : null} - - ); -} - function initials(name: string): string { const parts = name.trim().split(/\s+/); const first = parts[0]?.[0] ?? ''; @@ -520,63 +469,7 @@ function StudioRow({ 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' }, - totalBadge: { - backgroundColor: theme.accent, - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 10, - shadowColor: theme.accent, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.5, - shadowRadius: 6, - elevation: 3, - }, - totalBadgeText: { - color: theme.fg, - fontWeight: '800', - fontSize: 13, - letterSpacing: 0.3, - }, - - tabRow: { flexDirection: 'row', gap: 8, marginTop: 10, marginBottom: 4 }, - tabBtn: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingVertical: 8, - borderRadius: 8, - backgroundColor: theme.card, - borderColor: theme.border, - borderWidth: 1, - }, - tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated }, - tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 }, - tabBtnTextActive: { color: theme.fg }, - tabBadge: { - backgroundColor: theme.accent, - paddingHorizontal: 6, - paddingVertical: 1, - borderRadius: 4, - }, - tabBadgeText: { color: theme.bg, fontWeight: '800', fontSize: 11 }, - - hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 }, + hint: { color: theme.mutedDim, fontSize: 11, textAlign: 'center', marginTop: 4, marginBottom: 8 }, gridRow: { gap: 10, marginBottom: 14 }, diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index 964115c..ec8f975 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -12,8 +12,6 @@ import { ActivityIndicator, Alert, FlatList, - Pressable, - ScrollView, StyleSheet, Text, View, @@ -28,7 +26,7 @@ import { ErrorBoundary } from '../ErrorBoundary'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut } from '../types'; -import { HeaderIconButton, IconButton, Toolbar } from '../ui'; +import { HeaderIconButton, IconButton, SegmentTabs, Toolbar } from '../ui'; type Tab = 'scenes' | 'movies'; @@ -50,9 +48,12 @@ export function PerformerScenesScreen() { const [tab, setTab] = React.useState('scenes'); + // Sam stan gwiazdki, bez liczników +N: pełne /favorites liczy nowe sceny wszystkich + // ulubionych (sekundy przy setkach), a ten ekran potrzebuje tylko listy id. + // Klucz pod ['favorites'], więc każde invalidateQueries(['favorites']) odświeża też to. const favoritesQuery = useQuery({ - queryKey: ['favorites'], - queryFn: () => client.listFavorites(), + queryKey: ['favorites', 'ids'], + queryFn: () => client.listFavorites({ counts: false }), staleTime: 30_000, }); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id); @@ -240,21 +241,40 @@ export function PerformerScenesScreen() { setTab(next); }, [id, name, tab]); + // Jeden wiersz (2026-09-15): zakładki z licznikami po lewej, ikony sceny po prawej. + // Wcześniej zakładki, pod nimi wiersz "200+ scenes" z ikonami, czyli trzy linie + // razem z nagłówkiem, a licznik stał dwa razy. const tabRow = ( - - handleTabSwitch('scenes')} + + - handleTabSwitch('movies')} - /> - + {tab === 'scenes' ? ( + <> + {refreshMutation.isPending ? ( + + ) : ( + refreshMutation.mutate()} + /> + )} + 0} + badge={filterActiveCount} + onPress={() => setFilterOpen(true)} + /> + + ) : null} + ); return ( @@ -286,36 +306,6 @@ export function PerformerScenesScreen() { if (filter.sort === 'random') setSeed(Math.random().toString(36).slice(2) + Date.now().toString(36)); else scenesQuery.refetch(); }} - ListHeaderComponent={ - - {/* Jeden wiersz (2026-09-07): licznik scen po lewej, po prawej ikony - „szukaj wiecej" i filtr z licznikiem. Chipsy tagow pod spodem - zniknely, tagi sa w filtrze. */} - - - {scenesQuery.data - ? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}` - : ' '} - - {refreshMutation.isPending ? ( - - ) : ( - refreshMutation.mutate()} - /> - )} - 0} - badge={filterActiveCount} - onPress={() => setFilterOpen(true)} - /> - - - } ListEmptyComponent={ !scenesQuery.isLoading ? no scenes : null } @@ -363,15 +353,6 @@ export function PerformerScenesScreen() { }} refreshing={moviesQuery.isRefetching} onRefresh={moviesQuery.refetch} - ListHeaderComponent={ - - - {moviesQuery.data - ? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}` - : ' '} - - - } ListEmptyComponent={ !moviesQuery.isLoading ? ( no movies for this performer @@ -399,31 +380,6 @@ export function PerformerScenesScreen() { ); } -function TabButton({ - label, - count, - active, - onPress, -}: { - label: string; - count: number; - active: boolean; - onPress: () => void; -}) { - return ( - - - {label} - - {count > 0 ? ( - - {count} - - ) : null} - - ); -} - const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, gridRow: { gap: 10, marginBottom: 14 }, @@ -475,27 +431,4 @@ const styles = StyleSheet.create({ tagChipActive: { backgroundColor: theme.accent, borderColor: theme.accentText }, tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' }, tagChipTextActive: { color: theme.fg, fontWeight: '700' }, - tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, - tabBtn: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingVertical: 8, - borderRadius: 8, - backgroundColor: theme.card, - borderColor: theme.border, - borderWidth: 1, - }, - tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated }, - tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 }, - tabBtnTextActive: { color: theme.fg }, - tabCount: { - backgroundColor: theme.border, - paddingHorizontal: 6, - paddingVertical: 1, - borderRadius: 4, - }, - tabCountText: { color: theme.muted, fontWeight: '700', fontSize: 11 }, }); diff --git a/mobile/src/screens/PerformersScreen.tsx b/mobile/src/screens/PerformersScreen.tsx index 57cec9a..f2b86ae 100644 --- a/mobile/src/screens/PerformersScreen.tsx +++ b/mobile/src/screens/PerformersScreen.tsx @@ -12,12 +12,12 @@ import { Pressable, StyleSheet, Text, - TextInput, View, } from 'react-native'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; +import { SearchInput, SegmentTabs, Toolbar } from '../ui'; import type { PerformerCount } from '../types'; type Order = 'scene_count' | 'name'; @@ -29,7 +29,6 @@ export function PerformersScreen() { const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [order, setOrder] = useState('scene_count'); - const [searchFocused, setSearchFocused] = useState(false); const route = useRoute>(); const vertical = route.params?.vertical; @@ -44,41 +43,23 @@ export function PerformersScreen() { client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }), }); - const total = data?.items.length ?? 0; - return ( - - Performers - {total} - - tap a row → all their scenes - - - setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} - placeholder="search performer…" - placeholderTextColor={theme.mutedDim} - autoCapitalize="none" + {/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej + pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny + wiersz Top/A-Z, czyli cztery linie przed listą. */} + + + - - - - setOrder('scene_count')} - label="Top" - /> - setOrder('name')} - label="A-Z" - /> - + {isLoading && } {error instanceof Error && {error.message}} @@ -109,27 +90,6 @@ export function PerformersScreen() { ); } -function SegButton({ - active, - onPress, - label, -}: { - active: boolean; - onPress: () => void; - label: string; -}) { - return ( - - - {label} - - - ); -} - function initials(name: string): string { const parts = name.trim().split(/\s+/); if (parts.length === 0) return '?'; @@ -176,64 +136,7 @@ function PerformerRow({ 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.borderStrong, - 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 }, + order: { flex: 0, width: 124 }, row: { flexDirection: 'row', diff --git a/mobile/src/screens/StudioMoviesScreen.tsx b/mobile/src/screens/StudioMoviesScreen.tsx index 4dc18f5..dcf9861 100644 --- a/mobile/src/screens/StudioMoviesScreen.tsx +++ b/mobile/src/screens/StudioMoviesScreen.tsx @@ -27,8 +27,8 @@ export function StudioMoviesScreen() { const { slug, name, studioId } = route.params; const favoritesQuery = useQuery({ - queryKey: ['favorites-studios'], - queryFn: () => client.listFavoriteStudios(), + queryKey: ['favorites-studios', 'ids'], + queryFn: () => client.listFavoriteStudios({ counts: false }), staleTime: 30_000, }); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId); diff --git a/mobile/src/screens/StudioScenesScreen.tsx b/mobile/src/screens/StudioScenesScreen.tsx index 465cd0e..d3492be 100644 --- a/mobile/src/screens/StudioScenesScreen.tsx +++ b/mobile/src/screens/StudioScenesScreen.tsx @@ -17,7 +17,6 @@ import { ActivityIndicator, Alert, FlatList, - Pressable, StyleSheet, Text, View, @@ -30,7 +29,7 @@ import { useClient } from '../ClientContext'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; -import { HeaderIconButton } from '../ui'; +import { HeaderIconButton, SegmentTabs, Toolbar } from '../ui'; import type { SceneOut } from '../types'; type Tab = 'scenes' | 'movies'; @@ -51,9 +50,11 @@ export function StudioScenesScreen() { const { id: slug, name, seenSince, studioId, vertical } = route.params; const [tab, setTab] = React.useState('scenes'); + // Sam stan gwiazdki (bez liczników +N, patrz PerformerScenesScreen). Klucz pod + // ['favorites-studios'], więc invalidate tamtego klucza odświeża też ten. const favoritesQuery = useQuery({ - queryKey: ['favorites-studios'], - queryFn: () => client.listFavoriteStudios(), + queryKey: ['favorites-studios', 'ids'], + queryFn: () => client.listFavoriteStudios({ counts: false }), staleTime: 30_000, }); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId); @@ -158,21 +159,18 @@ export function StudioScenesScreen() { {/* Pasek zakładek ZAWSZE widoczny, też podczas ładowania: gdyby siedział w ListHeaderComponent, pierwsze wejście na Movies dałoby pusty ekran bez - drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). */} - - setTab('scenes')} + drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). Liczniki są + w zakładkach, osobny wiersz "N scenes" pod nimi zniknął (2026-09-15). */} + + - setTab('movies')} - /> - + {tab === 'scenes' ? ( <> @@ -190,13 +188,6 @@ export function StudioScenesScreen() { )} refreshing={isRefetching} onRefresh={refetch} - ListHeaderComponent={ - - - {data ? `${data.total}${data.total_capped ? '+' : ''} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '} - - - } ListEmptyComponent={!isLoading ? no scenes : null} contentContainerStyle={{ paddingBottom: 24 }} /> @@ -226,15 +217,6 @@ export function StudioScenesScreen() { )} refreshing={moviesQuery.isRefetching} onRefresh={moviesQuery.refetch} - ListHeaderComponent={ - - - {moviesQuery.data - ? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}` - : ' '} - - - } ListEmptyComponent={ !moviesQuery.isLoading ? ( no movies for this studio @@ -249,61 +231,9 @@ export function StudioScenesScreen() { ); } -function TabButton({ - label, - count, - active, - onPress, -}: { - label: string; - count: number; - active: boolean; - onPress: () => void; -}) { - return ( - - {label} - {count > 0 ? ( - - {count} - - ) : null} - - ); -} - const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, gridRow: { gap: 10, marginBottom: 14 }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - paddingHorizontal: 4, - }, - subtitle: { color: theme.muted }, - tabRow: { flexDirection: 'row', gap: 8, marginBottom: 10, paddingHorizontal: 4 }, - tabBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 6, - paddingHorizontal: 14, - borderRadius: 999, - borderWidth: 1, - borderColor: theme.border, - }, - tabBtnActive: { borderColor: theme.accentText }, - tabBtnText: { color: theme.muted, fontSize: 13 }, - tabBtnTextActive: { color: theme.accentText, fontWeight: '700' }, - tabCount: { - backgroundColor: theme.border, - borderRadius: 999, - paddingHorizontal: 6, - paddingVertical: 1, - }, - tabCountText: { color: theme.muted, fontSize: 11 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, error: { color: theme.bad, padding: 12 }, }); diff --git a/mobile/src/screens/SwipeScreen.tsx b/mobile/src/screens/SwipeScreen.tsx index 67a39ee..9427b8c 100644 --- a/mobile/src/screens/SwipeScreen.tsx +++ b/mobile/src/screens/SwipeScreen.tsx @@ -26,6 +26,7 @@ import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, import { useClient } from '../ClientContext'; import { ClipCell } from '../components/swipe/ClipCell'; import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion'; +import { setBarHidden } from '../lib/bottomBar'; import { usePersistedState } from '../lib/persistedState'; import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry'; import { fonts, theme } from '../theme'; @@ -99,6 +100,21 @@ export function SwipeScreen() { } }, [index, items.length, feed.hasNextPage, feed.isFetchingNextPage, feed]); + // ---- dolny pasek ---------------------------------------------------------------------- + // Jak na listach: przewijanie do przodu chowa pasek, cofniecie o klip go przywraca, na + // pierwszym klipie jest zawsze. Zeby wrocic do innych zakladek, wystarczy cofnac sie + // o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny. + React.useEffect(() => () => setBarHidden(false), []); + const onPageSelected = React.useCallback( + (e: { nativeEvent: { position: number } }) => { + const next = e.nativeEvent.position; + if (next === 0 || next < index) setBarHidden(false); + else if (next > index) setBarHidden(true); + setIndex(next); + }, + [index], + ); + // ---- telemetria ---------------------------------------------------------------------- const telemetry = React.useRef(null); if (!telemetry.current) telemetry.current = new SwipeTelemetry(client, newSessionId()); @@ -261,6 +277,7 @@ export function SwipeScreen() { telemetry.current = new SwipeTelemetry(client, newSessionId()); telemetry.current.start(); prevIndexRef.current = null; + setBarHidden(false); setIndex(0); setSeedKey((k) => k + 1); }, [client]); @@ -317,7 +334,7 @@ export function SwipeScreen() { initialPage={0} offscreenPageLimit={1} overdrag - onPageSelected={(e: { nativeEvent: { position: number } }) => setIndex(e.nativeEvent.position)} + onPageSelected={onPageSelected} > {items.map((it, i) => ( diff --git a/mobile/src/screens/TagsScreen.tsx b/mobile/src/screens/TagsScreen.tsx index db5372c..49256a1 100644 --- a/mobile/src/screens/TagsScreen.tsx +++ b/mobile/src/screens/TagsScreen.tsx @@ -12,12 +12,12 @@ import { Pressable, StyleSheet, Text, - TextInput, View, } from 'react-native'; import { useClient } from '../ClientContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; +import { SearchInput, SegmentTabs, Toolbar } from '../ui'; import type { TagCount } from '../types'; type Order = 'popular' | 'name'; @@ -31,7 +31,6 @@ export function TagsScreen() { const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [order, setOrder] = useState('popular'); - const [searchFocused, setSearchFocused] = useState(false); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 350); @@ -53,41 +52,23 @@ export function TagsScreen() { }), }); - const total = data?.items.length ?? 0; - return ( - - Tags - {total} - - tap a chip → filter scenes by tag - - - setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} - placeholder="search tag…" - placeholderTextColor={theme.mutedDim} - autoCapitalize="none" + {/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej + pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny + wiersz Top/A-Z, czyli cztery linie przed listą. */} + + + - - - - setOrder('popular')} - label="Top" - /> - setOrder('name')} - label="A-Z" - /> - + {isLoading && } {error instanceof Error && {error.message}} @@ -114,27 +95,6 @@ export function TagsScreen() { ); } -function SegButton({ - active, - onPress, - label, -}: { - active: boolean; - onPress: () => void; - label: string; -}) { - return ( - - - {label} - - - ); -} - function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) { return ( void }) { 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.borderStrong, - 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 }, + order: { flex: 0, width: 124 }, gridRow: { gap: 10, marginBottom: 10 }, chip: { diff --git a/mobile/src/ui.tsx b/mobile/src/ui.tsx index f942af0..c6eb488 100644 --- a/mobile/src/ui.tsx +++ b/mobile/src/ui.tsx @@ -186,6 +186,66 @@ export function Toolbar({ children }: { children: React.ReactNode }) { return {children}; } +export interface SegmentTab { + key: T; + label: string; + /** Liczba po etykiecie (np. 200+), pomijana gdy pusta albo 0. */ + count?: number | string; + /** Nowe pozycje: czerwony licznik w rogu, jak na IconButton. */ + badge?: number; +} + +/** + * Zakladki ekranu w jednym wierszu o wysokosci pola wyszukiwania (2026-09-15). Wczesniej + * ekrany podrzedne mialy pod naglowkiem osobny wiersz zakladek, pod nim wiersz z + * licznikiem i ikonami, a ulubione jeszcze etykiete i podpowiedz, czyli trzy albo cztery + * linie zanim zaczynala sie lista. Teraz zakladki siedza w Toolbarze obok ikon. + */ +export function SegmentTabs({ + tabs, + value, + onChange, + style, +}: { + tabs: SegmentTab[]; + value: T; + onChange: (key: T) => void; + style?: StyleProp; +}) { + return ( + + {tabs.map((t) => { + const on = t.key === value; + const showCount = t.count !== undefined && t.count !== 0 && t.count !== ''; + return ( + onChange(t.key)} + accessibilityRole="tab" + accessibilityState={{ selected: on }} + accessibilityLabel={showCount ? `${t.label}, ${t.count}` : t.label} + style={[styles.seg, on && styles.segActive]} + > + + {t.label} + + {showCount ? ( + + {t.count} + + ) : null} + {t.badge && t.badge > 0 ? ( + + +{t.badge} + + ) : null} + + ); + })} + + ); +} + const styles = StyleSheet.create({ toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }, headerIcon: { paddingHorizontal: 6, paddingVertical: 4 }, @@ -237,4 +297,30 @@ const styles = StyleSheet.create({ pillCompact: { height: 36, paddingHorizontal: 14 }, pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` }, pillText: { fontFamily: fonts.medium, fontSize: 14 }, + segWrap: { + flex: 1, + height: CONTROL_H, + flexDirection: 'row', + padding: 3, + gap: 3, + backgroundColor: theme.card, + borderColor: theme.borderStrong, + borderWidth: 1, + borderRadius: RADIUS, + }, + seg: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 5, + paddingHorizontal: 4, + borderRadius: RADIUS - 3, + borderWidth: 1, + borderColor: 'transparent', + }, + segActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` }, + segText: { fontFamily: fonts.medium, fontSize: 14, color: theme.muted, flexShrink: 1 }, + segTextActive: { color: theme.accentText }, + segCount: { fontFamily: fonts.body, fontSize: 12, color: theme.mutedDim }, });