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:
parent
f1b8b8d466
commit
9c988bc12a
7 changed files with 164 additions and 14 deletions
|
|
@ -8,6 +8,8 @@ import type {
|
||||||
FavoriteListOut,
|
FavoriteListOut,
|
||||||
FavoriteMovieAddOut,
|
FavoriteMovieAddOut,
|
||||||
FavoriteMovieListOut,
|
FavoriteMovieListOut,
|
||||||
|
FavoriteSiteAddOut,
|
||||||
|
FavoriteSiteListOut,
|
||||||
FavoriteStudioAddOut,
|
FavoriteStudioAddOut,
|
||||||
FavoriteStudioListOut,
|
FavoriteStudioListOut,
|
||||||
KeepSide,
|
KeepSide,
|
||||||
|
|
@ -443,6 +445,27 @@ export class GoonClient {
|
||||||
await this.request(`/favorites/studios/${studioId}/seen`, { method: 'POST' });
|
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> {
|
async listFavoriteMovies(): Promise<FavoriteMovieListOut> {
|
||||||
return this.request('/favorites/movies');
|
return this.request('/favorites/movies');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-09-02a',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
60
mobile/src/lib/persistedState.ts
Normal file
60
mobile/src/lib/persistedState.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
MovieFilters,
|
MovieFilters,
|
||||||
MovieFiltersSheet,
|
MovieFiltersSheet,
|
||||||
} from '../components/MovieFiltersSheet';
|
} from '../components/MovieFiltersSheet';
|
||||||
|
import { usePersistedState } from '../lib/persistedState';
|
||||||
import { MoviePosterCard } from '../components/MoviePosterCard';
|
import { MoviePosterCard } from '../components/MoviePosterCard';
|
||||||
import { getLastSeen, markSeen } from '../lib/seenStore';
|
import { getLastSeen, markSeen } from '../lib/seenStore';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
|
|
@ -36,7 +37,12 @@ export function MoviesScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Movies'>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Movies'>>();
|
||||||
const [q, setQ] = React.useState('');
|
const [q, setQ] = React.useState('');
|
||||||
const [debouncedQ, setDebouncedQ] = 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);
|
const [filtersOpen, setFiltersOpen] = React.useState(false);
|
||||||
// seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies
|
// seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies
|
||||||
// z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz
|
// z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz
|
||||||
|
|
@ -78,6 +84,7 @@ export function MoviesScreen() {
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
|
enabled: filtersHydrated,
|
||||||
queryKey: ['movies', debouncedQ, filters],
|
queryKey: ['movies', debouncedQ, filters],
|
||||||
queryFn: ({ pageParam = 1 }) =>
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
client.listMovies({
|
client.listMovies({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { theme } from '../theme';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import type { SceneOut } from '../types';
|
import type { SceneOut } from '../types';
|
||||||
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
|
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
|
||||||
|
import { usePersistedState } from '../lib/persistedState';
|
||||||
|
|
||||||
export function ScenesScreen({
|
export function ScenesScreen({
|
||||||
jav: javProp,
|
jav: javProp,
|
||||||
|
|
@ -52,7 +53,13 @@ export function ScenesScreen({
|
||||||
const hasCast = verticalKey !== 'animation';
|
const hasCast = verticalKey !== 'animation';
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [debouncedQ, setDebouncedQ] = 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);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -71,6 +78,7 @@ export function ScenesScreen({
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
|
enabled: filterHydrated,
|
||||||
queryKey: ['scenes', debouncedQ, filter, jav, vertical],
|
queryKey: ['scenes', debouncedQ, filter, jav, vertical],
|
||||||
queryFn: ({ pageParam = 1 }) =>
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
client.listScenes({
|
client.listScenes({
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// plus scene_count + relative-time "Xh temu" scraped, jeśli świeży.
|
// plus scene_count + relative-time "Xh temu" scraped, jeśli świeży.
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
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 React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
|
@ -48,6 +48,24 @@ export function SitesScreen() {
|
||||||
staleTime: 60_000,
|
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ć.
|
// Sort + filter client-side, lista ma <50 entries, nie warto roundtripować.
|
||||||
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
|
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
|
||||||
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
|
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
|
||||||
|
|
@ -60,16 +78,18 @@ export function SitesScreen() {
|
||||||
s.sitetag.toLowerCase().includes(debouncedQ.toLowerCase()),
|
s.sitetag.toLowerCase().includes(debouncedQ.toLowerCase()),
|
||||||
)
|
)
|
||||||
: all;
|
: all;
|
||||||
if (order === 'recent') {
|
const sorted =
|
||||||
return [...filtered].sort((a, b) => {
|
order === 'recent'
|
||||||
if (!a.last_scraped_at && !b.last_scraped_at) return 0;
|
? [...filtered].sort((a, b) => {
|
||||||
if (!a.last_scraped_at) return 1;
|
if (!a.last_scraped_at && !b.last_scraped_at) return 0;
|
||||||
if (!b.last_scraped_at) return -1;
|
if (!a.last_scraped_at) return 1;
|
||||||
return b.last_scraped_at.localeCompare(a.last_scraped_at);
|
if (!b.last_scraped_at) return -1;
|
||||||
});
|
return b.last_scraped_at.localeCompare(a.last_scraped_at);
|
||||||
}
|
})
|
||||||
return filtered;
|
: filtered;
|
||||||
}, [data?.items, debouncedQ, order]);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|
@ -81,6 +101,7 @@ export function SitesScreen() {
|
||||||
<PillButton label="Top" compact active={order === 'popular'} onPress={() => setOrder('popular')} />
|
<PillButton label="Top" compact active={order === 'popular'} onPress={() => setOrder('popular')} />
|
||||||
<PillButton label="Recent" compact active={order === 'recent'} onPress={() => setOrder('recent')} />
|
<PillButton label="Recent" compact active={order === 'recent'} onPress={() => setOrder('recent')} />
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={styles.hint}>long-press a site to pin it to the top</Text>
|
||||||
|
|
||||||
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
|
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
@ -93,12 +114,14 @@ export function SitesScreen() {
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<SiteChip
|
<SiteChip
|
||||||
source={item}
|
source={item}
|
||||||
|
pinned={pinned.has(item.origin)}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate('SiteScenes', {
|
navigation.navigate('SiteScenes', {
|
||||||
origin: item.origin,
|
origin: item.origin,
|
||||||
name: prettySiteName(item.display_name),
|
name: prettySiteName(item.display_name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onLongPress={() => togglePin.mutate(item.origin)}
|
||||||
onShowRating={() => setDetail(item)}
|
onShowRating={() => setDetail(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -150,23 +173,30 @@ function Stars({ value, size = 13 }: { value: number | null | undefined; size?:
|
||||||
|
|
||||||
function SiteChip({
|
function SiteChip({
|
||||||
source,
|
source,
|
||||||
|
pinned,
|
||||||
onPress,
|
onPress,
|
||||||
|
onLongPress,
|
||||||
onShowRating,
|
onShowRating,
|
||||||
}: {
|
}: {
|
||||||
source: SourceOut;
|
source: SourceOut;
|
||||||
|
pinned: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
onLongPress: () => void;
|
||||||
onShowRating: () => void;
|
onShowRating: () => void;
|
||||||
}) {
|
}) {
|
||||||
const rel = formatRelativeTime(source.last_scraped_at);
|
const rel = formatRelativeTime(source.last_scraped_at);
|
||||||
const stars = source.rating?.stars;
|
const stars = source.rating?.stars;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
style={({ pressed }) => [styles.chip, pinned && styles.chipPinned, pressed && styles.chipPressed]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
delayLongPress={350}
|
||||||
>
|
>
|
||||||
<View style={styles.chipTopRow}>
|
<View style={styles.chipTopRow}>
|
||||||
<View style={styles.chipMain}>
|
<View style={styles.chipMain}>
|
||||||
<Text style={styles.chipName} numberOfLines={1}>
|
<Text style={styles.chipName} numberOfLines={1}>
|
||||||
|
{pinned ? '★ ' : ''}
|
||||||
{prettySiteName(source.display_name)}
|
{prettySiteName(source.display_name)}
|
||||||
</Text>
|
</Text>
|
||||||
{rel ? <Text style={styles.chipRel}>{rel}</Text> : null}
|
{rel ? <Text style={styles.chipRel}>{rel}</Text> : null}
|
||||||
|
|
@ -287,6 +317,8 @@ const styles = StyleSheet.create({
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated },
|
chipPressed: { borderColor: theme.borderFocus, backgroundColor: theme.bgElevated },
|
||||||
|
chipPinned: { borderColor: theme.accent },
|
||||||
|
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 10 },
|
||||||
chipMain: { flex: 1, gap: 2 },
|
chipMain: { flex: 1, gap: 2 },
|
||||||
chipName: {
|
chipName: {
|
||||||
color: theme.fg,
|
color: theme.fg,
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,17 @@ export interface FavoriteStudioAddOut {
|
||||||
created: boolean;
|
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 {
|
export interface BlacklistEntry {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue