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_<sekcja>`) 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.
This commit is contained in:
goon-foss 2026-09-02 22:44:54 +02:00
parent f1b8b8d466
commit 9c988bc12a
7 changed files with 164 additions and 14 deletions

View file

@ -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<FavoriteSiteListOut> {
return this.request('/favorites/sites');
}
async addFavoriteSite(origin: string): Promise<FavoriteSiteAddOut> {
return this.request(`/favorites/sites/${encodeURIComponent(origin)}`, { method: 'POST' });
}
async removeFavoriteSite(origin: string): Promise<void> {
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<FavoriteMovieListOut> {
return this.request('/favorites/movies');
}

View file

@ -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',

View file

@ -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<T extends object>(
name: string,
defaults: T,
): [T, React.Dispatch<React.SetStateAction<T>>, boolean] {
const [value, setValue] = React.useState<T>(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];
}

View file

@ -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<NativeStackNavigationProp<RootStackParamList, 'Movies'>>();
const [q, setQ] = React.useState('');
const [debouncedQ, setDebouncedQ] = React.useState('');
const [filters, setFilters] = React.useState<MovieFilters>(DEFAULT_MOVIE_FILTERS);
// Filtry zapamietywane miedzy sesjami (prosba z apki 2026-09-02), osobny klucz od
// sekcji scen.
const [filters, setFilters, filtersHydrated] = usePersistedState<MovieFilters>(
'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({

View file

@ -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<FilterState>(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<FilterState>(
`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({

View file

@ -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<void> => {
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) => {
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);
});
}
return filtered;
}, [data?.items, debouncedQ, order]);
})
: 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 (
<View style={styles.container}>
@ -81,6 +101,7 @@ export function SitesScreen() {
<PillButton label="Top" compact active={order === 'popular'} onPress={() => setOrder('popular')} />
<PillButton label="Recent" compact active={order === 'recent'} onPress={() => setOrder('recent')} />
</View>
<Text style={styles.hint}>long-press a site to pin it to the top</Text>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -93,12 +114,14 @@ export function SitesScreen() {
renderItem={({ item }) => (
<SiteChip
source={item}
pinned={pinned.has(item.origin)}
onPress={() =>
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 (
<Pressable
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
style={({ pressed }) => [styles.chip, pinned && styles.chipPinned, pressed && styles.chipPressed]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
>
<View style={styles.chipTopRow}>
<View style={styles.chipMain}>
<Text style={styles.chipName} numberOfLines={1}>
{pinned ? '★ ' : ''}
{prettySiteName(source.display_name)}
</Text>
{rel ? <Text style={styles.chipRel}>{rel}</Text> : 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,

View file

@ -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;