From 9c988bc12a63306d918a9c2fa6024f29cba7cbcc Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 2 Sep 2026 22:44:54 +0200 Subject: [PATCH] feat(mobile): przypinanie stron w zakladce Sites + filtry zapamietywane per sekcja Dwie prosby z apki 2026-09-02. Sites: dlugie nacisniecie przypina albo odpina strone (gwiazdka i akcent na ramce), przypiete ida na gore listy w tej samej kolejnosci co reszta, takze przy szukaniu i przy sortowaniu "Recent". Stan po stronie serwera (/favorites/sites), wiec przechodzi kodem synchronizacji jak inne ulubione. Filtry: `usePersistedState` w SecureStore, osobny klucz dla Scenes, JAV, Anime (`filters_`) i Movies. Zapis dopiero po wczytaniu, zeby domyslne z pierwszego renderu nie nadpisaly zapisanych; odczyt scalany z domyslnymi, zeby nowe pole w kolejnej wersji dostalo wartosc. Zapytanie o liste czeka na wczytanie (`enabled`), bez podwojnego strzalu. SecureStore ma ~2 KB na klucz; przy bardzo dlugiej liscie tagow zapis moze sie nie udac i wtedy filtr zyje tylko do zamkniecia apki, ale ekran sie nie wywraca. tsc: 11 bledow przed i po (baza). Wpis w changelogu 2026-09-02b. --- mobile/src/api.ts | 23 +++++++++++ mobile/src/changelog.ts | 9 +++++ mobile/src/lib/persistedState.ts | 60 +++++++++++++++++++++++++++++ mobile/src/screens/MoviesScreen.tsx | 9 ++++- mobile/src/screens/ScenesScreen.tsx | 10 ++++- mobile/src/screens/SitesScreen.tsx | 56 +++++++++++++++++++++------ mobile/src/types.ts | 11 ++++++ 7 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 mobile/src/lib/persistedState.ts diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 1340d17..d9f1169 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -8,6 +8,8 @@ import type { FavoriteListOut, FavoriteMovieAddOut, FavoriteMovieListOut, + FavoriteSiteAddOut, + FavoriteSiteListOut, FavoriteStudioAddOut, FavoriteStudioListOut, KeepSide, @@ -443,6 +445,27 @@ export class GoonClient { await this.request(`/favorites/studios/${studioId}/seen`, { method: 'POST' }); } + // Przypiete strony (zakladka Sites). Origin ma dwukropek (`tube:hqpornercom`), wiec + // idzie przez encodeURIComponent. + async listFavoriteSites(): Promise { + return this.request('/favorites/sites'); + } + + async addFavoriteSite(origin: string): Promise { + return this.request(`/favorites/sites/${encodeURIComponent(origin)}`, { method: 'POST' }); + } + + async removeFavoriteSite(origin: string): Promise { + const res = await fetch(`${this.baseUrl}/favorites/sites/${encodeURIComponent(origin)}`, { + method: 'DELETE', + headers: await this._authHeaders(), + }); + if (!res.ok && res.status !== 204) { + const text = await res.text().catch(() => res.statusText); + throw new ApiError(res.status, `${res.status}: ${text}`); + } + } + async listFavoriteMovies(): Promise { return this.request('/favorites/movies'); } diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 09186ab..8f0821f 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -29,6 +29,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-09-02b', + date: 'September 2026', + items: [ + 'Pin your favourite sites. Long-press a site in the Sites tab and it stays at the top of the list. Pins live with your other favourites, so they come along when you move to a new phone with your sync code.', + 'Filters are remembered. Scenes, JAV, Anime and Movies each keep their own filter and sort between sessions instead of resetting every time you open the app.', + 'Movie duplicates cleaned up. About 1,500 movies that existed twice under the same title were merged, and the matching that creates new movie entries is stricter about titles that only look similar.', + ], + }, { id: '2026-09-02a', date: 'September 2026', diff --git a/mobile/src/lib/persistedState.ts b/mobile/src/lib/persistedState.ts new file mode 100644 index 0000000..6f1ad6d --- /dev/null +++ b/mobile/src/lib/persistedState.ts @@ -0,0 +1,60 @@ +// Stan ekranu zapamietywany miedzy sesjami (prosba z apki 2026-09-02: filtry znikaly +// po zamknieciu apki). SecureStore, bo to jedyny magazyn w projekcie; wartosc to JSON. +// +// Dwie zasady: +// 1. Zapis dopiero PO wczytaniu. Inaczej pierwszy render z domyslna wartoscia nadpisalby +// to, co user zapisal wczesniej, zanim odczyt zdazyl wrocic. +// 2. Odczyt scalany z domyslnymi (`{...defaults, ...saved}`), zeby nowe pole dodane w +// kolejnej wersji dostalo swoja wartosc domyslna, a nie `undefined`. +// +// SecureStore na Androidzie ostrzega powyzej ~2 KB na klucz. Filtry to kilkaset bajtow, +// ale przy bardzo dlugiej liscie tagow zapis moze sie nie udac; wtedy zostaje stan w +// pamieci, a blad jest polykany, bo utrata zapamietanego filtra to nie powod, zeby +// wywracac ekran. +import * as SecureStore from 'expo-secure-store'; +import React from 'react'; + +const PREFIX = 'persisted_v1_'; + +function keyFor(name: string): string { + // SecureStore przyjmuje tylko [A-Za-z0-9._-]; dwukropki i spacje odpadaja. + return PREFIX + name.replace(/[^A-Za-z0-9._-]/g, '_'); +} + +export function usePersistedState( + name: string, + defaults: T, +): [T, React.Dispatch>, boolean] { + const [value, setValue] = React.useState(defaults); + const [hydrated, setHydrated] = React.useState(false); + const key = keyFor(name); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const raw = await SecureStore.getItemAsync(key); + if (!cancelled && raw) { + const saved = JSON.parse(raw); + if (saved && typeof saved === 'object') setValue({ ...defaults, ...saved }); + } + } catch { + // uszkodzony zapis albo brak SecureStore: zostaja domyslne + } finally { + if (!cancelled) setHydrated(true); + } + })(); + return () => { + cancelled = true; + }; + // `defaults` to stala modulu, `key` zmienia sie tylko z sekcja ekranu. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + React.useEffect(() => { + if (!hydrated) return; + SecureStore.setItemAsync(key, JSON.stringify(value)).catch(() => {}); + }, [key, value, hydrated]); + + return [value, setValue, hydrated]; +} diff --git a/mobile/src/screens/MoviesScreen.tsx b/mobile/src/screens/MoviesScreen.tsx index 349bd79..a7912f1 100644 --- a/mobile/src/screens/MoviesScreen.tsx +++ b/mobile/src/screens/MoviesScreen.tsx @@ -20,6 +20,7 @@ import { MovieFilters, MovieFiltersSheet, } from '../components/MovieFiltersSheet'; +import { usePersistedState } from '../lib/persistedState'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { getLastSeen, markSeen } from '../lib/seenStore'; import type { RootStackParamList } from '../navigation'; @@ -36,7 +37,12 @@ export function MoviesScreen() { const navigation = useNavigation>(); const [q, setQ] = React.useState(''); const [debouncedQ, setDebouncedQ] = React.useState(''); - const [filters, setFilters] = React.useState(DEFAULT_MOVIE_FILTERS); + // Filtry zapamietywane miedzy sesjami (prosba z apki 2026-09-02), osobny klucz od + // sekcji scen. + const [filters, setFilters, filtersHydrated] = usePersistedState( + 'filters_movies', + DEFAULT_MOVIE_FILTERS, + ); const [filtersOpen, setFiltersOpen] = React.useState(false); // seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies // z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz @@ -78,6 +84,7 @@ export function MoviesScreen() { hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ + enabled: filtersHydrated, queryKey: ['movies', debouncedQ, filters], queryFn: ({ pageParam = 1 }) => client.listMovies({ diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index a11004e..e352d19 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -25,6 +25,7 @@ import { theme } from '../theme'; import type { RootStackParamList } from '../navigation'; import type { SceneOut } from '../types'; import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal'; +import { usePersistedState } from '../lib/persistedState'; export function ScenesScreen({ jav: javProp, @@ -52,7 +53,13 @@ export function ScenesScreen({ const hasCast = verticalKey !== 'animation'; const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); - const [filter, setFilter] = useState(DEFAULT_FILTER); + // Filtry zapamietywane osobno dla kazdej sekcji (Scenes, JAV, Anime), prosba z apki + // 2026-09-02. Do wczytania zapytanie czeka, zeby nie strzelac raz z domyslnymi i raz + // z zapisanymi. + const [filter, setFilter, filterHydrated] = usePersistedState( + `filters_${verticalKey ?? 'scenes'}`, + DEFAULT_FILTER, + ); const [filterOpen, setFilterOpen] = useState(false); React.useEffect(() => { @@ -71,6 +78,7 @@ export function ScenesScreen({ hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ + enabled: filterHydrated, queryKey: ['scenes', debouncedQ, filter, jav, vertical], queryFn: ({ pageParam = 1 }) => client.listScenes({ diff --git a/mobile/src/screens/SitesScreen.tsx b/mobile/src/screens/SitesScreen.tsx index 71a93f4..cd22bf6 100644 --- a/mobile/src/screens/SitesScreen.tsx +++ b/mobile/src/screens/SitesScreen.tsx @@ -6,7 +6,7 @@ // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React, { useMemo, useState } from 'react'; import { ActivityIndicator, @@ -48,6 +48,24 @@ export function SitesScreen() { staleTime: 60_000, }); + // Przypiete strony (prosba z apki 2026-09-02): dlugie nacisniecie przypina albo + // odpina, przypiete ida na gore listy w tej samej kolejnosci co reszta. Po stronie + // serwera jak inne ulubione, wiec przechodza kodem synchronizacji. + const qc = useQueryClient(); + const favQuery = useQuery({ + queryKey: ['favorite-sites'], + queryFn: () => client.listFavoriteSites(), + staleTime: 60_000, + }); + const pinned = useMemo(() => new Set(favQuery.data?.items ?? []), [favQuery.data?.items]); + const togglePin = useMutation({ + mutationFn: async (origin: string): Promise => { + if (pinned.has(origin)) await client.removeFavoriteSite(origin); + else await client.addFavoriteSite(origin); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['favorite-sites'] }), + }); + // 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. @@ -60,16 +78,18 @@ export function SitesScreen() { 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]); + const sorted = + order === 'recent' + ? [...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); + }) + : filtered; + if (pinned.size === 0) return sorted; + return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))]; + }, [data?.items, debouncedQ, order, pinned]); return ( @@ -81,6 +101,7 @@ export function SitesScreen() { setOrder('popular')} /> setOrder('recent')} /> + long-press a site to pin it to the top {isLoading && } {error instanceof Error && {error.message}} @@ -93,12 +114,14 @@ export function SitesScreen() { renderItem={({ item }) => ( navigation.navigate('SiteScenes', { origin: item.origin, name: prettySiteName(item.display_name), }) } + onLongPress={() => togglePin.mutate(item.origin)} onShowRating={() => setDetail(item)} /> )} @@ -150,23 +173,30 @@ function Stars({ value, size = 13 }: { value: number | null | undefined; size?: function SiteChip({ source, + pinned, onPress, + onLongPress, onShowRating, }: { source: SourceOut; + pinned: boolean; onPress: () => void; + onLongPress: () => void; onShowRating: () => void; }) { const rel = formatRelativeTime(source.last_scraped_at); const stars = source.rating?.stars; return ( [styles.chip, pressed && styles.chipPressed]} + style={({ pressed }) => [styles.chip, pinned && styles.chipPinned, pressed && styles.chipPressed]} onPress={onPress} + onLongPress={onLongPress} + delayLongPress={350} > + {pinned ? '★ ' : ''} {prettySiteName(source.display_name)} {rel ? {rel} : null} @@ -287,6 +317,8 @@ const styles = StyleSheet.create({ gap: 8, }, chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated }, + chipPinned: { borderColor: theme.accent }, + hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 10 }, chipMain: { flex: 1, gap: 2 }, chipName: { color: theme.fg, diff --git a/mobile/src/types.ts b/mobile/src/types.ts index cf84d38..fe9a208 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -373,6 +373,17 @@ export interface FavoriteStudioAddOut { created: boolean; } +/** Przypiete strony w zakladce Sites: origins w kolejnosci dodania. */ +export interface FavoriteSiteListOut { + items: string[]; + total: number; +} + +export interface FavoriteSiteAddOut { + origin: string; + created: boolean; +} + export interface BlacklistEntry { id: string; name: string;