Feat: Backtracing + NavPage Store

This commit is contained in:
Youwes09
2026-05-10 04:31:27 -05:00
parent f05f781b5b
commit 244447da9b
11 changed files with 88 additions and 29 deletions
+17 -8
View File
@@ -31,13 +31,13 @@ export function formatReadTime(m: number): string {
const STRICT_TAGS: string[] = [ const STRICT_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence", "18+", "smut", "explicit", "sexual violence",
"gore", "guro", "graphic violence", "torture", "body horror", "gore", "guro", "graphic violence", "torture", "body horror",
]; ];
const MODERATE_TAGS: string[] = [ const MODERATE_TAGS: string[] = [
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
"18+", "smut", "lemon", "explicit", "sexual violence", "18+", "smut", "explicit", "sexual violence",
]; ];
type ContentFilterSettings = Pick< type ContentFilterSettings = Pick<
@@ -53,7 +53,16 @@ function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean { function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
if (!blockedTags.length) return false; if (!blockedTags.length) return false;
return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag))); return genre.some(g => {
const norm = g.toLowerCase().trim();
return blockedTags.some(tag => {
const idx = norm.indexOf(tag);
if (idx === -1) return false;
const before = idx === 0 || /\W/.test(norm[idx - 1]);
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
return before && after;
});
});
} }
export function shouldHideNsfw( export function shouldHideNsfw(
@@ -69,10 +78,10 @@ export function shouldHideNsfw(
if (srcId && blocked.includes(srcId)) return true; if (srcId && blocked.includes(srcId)) return true;
const sourceAllowed = !!(srcId && allowed.includes(srcId)); const sourceAllowed = !!(srcId && allowed.includes(srcId));
const blockedTags = blockedTagsForSettings(settings);
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true; if (!sourceAllowed && manga.source?.isNsfw) return true;
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
} }
export function shouldHideSource( export function shouldHideSource(
@@ -83,10 +92,10 @@ export function shouldHideSource(
if (settings.sourceOverridesEnabled) { if (settings.sourceOverridesEnabled) {
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true; if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict"; if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
} }
return source.isNsfw && settings.contentLevel === "strict"; return source.isNsfw;
} }
export function dedupeSourcesByLang( export function dedupeSourcesByLang(
@@ -76,7 +76,7 @@
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang)); filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw) if (store.settings.contentLevel !== "unrestricted")
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings)); filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered; return filtered;
} }
@@ -8,7 +8,7 @@
import { deprioritizeQueue } from "@core/cache/imageCache"; import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter"; import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util"; import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte"; import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
import { import {
toCachedManga, toCachedManga,
type CachedManga, type CachedManga,
@@ -288,6 +288,8 @@
popularResults={popular_results} popularResults={popular_results}
popularLoading={popular_loading} popularLoading={popular_loading}
{sourceCache} {sourceCache}
query={store.searchQuery}
onQueryChange={setSearchQuery}
onPrefillConsumed={() => (pendingPrefill = "")} onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga} onPreview={setPreviewManga}
/> />
+13 -1
View File
@@ -18,6 +18,7 @@
store, setCategories, setLibraryUpdates, addToast, store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters, setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte"; } from "../store/libraryState.svelte";
import { saveScroll, getScroll } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types"; import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte"; import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
@@ -171,7 +172,18 @@
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); }); $effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }); $effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); }); let prevTab = tab;
$effect(() => {
const nextTab = tab;
if (scrollEl && nextTab !== prevTab) {
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
const saved = getScroll(`library:${nextTab}`);
untrack(() => { scrollEl.scrollTo({ top: saved }); });
prevTab = nextTab;
} else if (scrollEl && nextTab === prevTab) {
scrollEl.scrollTo({ top: 0 });
}
});
$effect(() => { $effect(() => {
const f = tab; const f = tab;
if (f === "library" || f === "downloaded") return; if (f === "library" || f === "downloaded") return;
@@ -14,6 +14,7 @@
enqueueing: Set<number>; enqueueing: Set<number>;
chapterPage: number; chapterPage: number;
totalPages: number; totalPages: number;
scrollEl?: HTMLDivElement | null;
onOpen: (ch: Chapter, inProgress: boolean) => void; onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void; onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void; onEnqueue: (ch: Chapter, e: MouseEvent) => void;
@@ -25,6 +26,7 @@
let { let {
pageChapters, sortedChapters, viewMode, loadingChapters, pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages, selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload, onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems, onPageChange, buildCtxItems,
}: Props = $props(); }: Props = $props();
@@ -48,7 +50,7 @@
} }
</script> </script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}> <div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
{#if loadingChapters && sortedChapters.length === 0} {#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"} {#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each} {#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
@@ -17,6 +17,7 @@
addBookmark, acknowledgeUpdate, addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted, checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga, clearMarkersForManga,
saveScroll, getScroll,
} from "@store/state.svelte"; } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte"; import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte"; import type { MangaPrefs } from "@store/state.svelte";
@@ -583,6 +584,20 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
let chapterListEl: HTMLDivElement | null = $state(null);
let prevMangaId: number | null = null;
$effect(() => {
const mangaId = store.activeManga?.id ?? null;
if (mangaId === prevMangaId) return;
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
prevMangaId = mangaId;
if (chapterListEl && mangaId !== null) {
const saved = getScroll(`series:${mangaId}`);
chapterListEl.scrollTo({ top: saved });
}
});
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
@@ -665,6 +680,7 @@
{enqueueing} {enqueueing}
{chapterPage} {chapterPage}
{totalPages} {totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead} onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onEnqueue={enqueue} onEnqueue={enqueue}
@@ -115,7 +115,7 @@
function openManga() { function openManga() {
if (!record.manga) return; if (!record.manga) return;
setActiveManga(record.manga as any); setActiveManga(record.manga as any);
setNavPage("library"); setNavPage(store.navPage);
onClose(); onClose();
} }
+2
View File
@@ -25,6 +25,7 @@
store.activeManga = null; store.activeManga = null;
store.activeSource = null; store.activeSource = null;
store.genreFilter = ""; store.genreFilter = "";
store.searchQuery = "";
} }
function goHome() { function goHome() {
@@ -33,6 +34,7 @@
store.activeManga = null; store.activeManga = null;
store.libraryFilter = "library"; store.libraryFilter = "library";
store.genreFilter = ""; store.genreFilter = "";
store.searchQuery = "";
} }
</script> </script>
+4 -1
View File
@@ -42,6 +42,8 @@
let loadingLinkList = $state(false); let loadingLinkList = $state(false);
let coverPickerOpen = $state(false); let coverPickerOpen = $state(false);
let originNavPage = store.navPage;
const linkedIds = $derived( const linkedIds = $derived(
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [], store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
); );
@@ -152,6 +154,7 @@
const shouldAutoLink = store.settings.autoLinkOnOpen; const shouldAutoLink = store.settings.autoLinkOnOpen;
const focal = store.previewManga; const focal = store.previewManga;
if (focal) { if (focal) {
originNavPage = store.navPage;
load(focal.id); load(focal.id);
loadCategories(focal.id); loadCategories(focal.id);
if (shouldAutoLink) { if (shouldAutoLink) {
@@ -256,7 +259,7 @@
function openSeriesDetail() { function openSeriesDetail() {
if (!displayManga) return; if (!displayManga) return;
setActiveManga(displayManga); setActiveManga(displayManga);
setNavPage("library"); setNavPage(originNavPage);
close(); close();
} }
+24 -12
View File
@@ -3,20 +3,32 @@ export type NavPage =
| "downloads" | "extensions" | "history" | "search" | "tracking"; | "downloads" | "extensions" | "history" | "search" | "tracking";
class AppStore { class AppStore {
navPage: NavPage = $state("home"); navPage: NavPage = $state("home");
settingsOpen: boolean = $state(false); settingsOpen: boolean = $state(false);
searchPrefill: string = $state(""); searchPrefill: string = $state("");
genreFilter: string = $state(""); searchQuery: string = $state("");
genreFilter: string = $state("");
scrollPositions: Map<string, number> = $state(new Map());
setNavPage(next: NavPage) { this.navPage = next; } setNavPage(next: NavPage) { this.navPage = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; } setSettingsOpen(next: boolean) { this.settingsOpen = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; } setSearchPrefill(next: string) { this.searchPrefill = next; }
setGenreFilter(next: string) { this.genreFilter = next; } setSearchQuery(next: string) { this.searchQuery = next; }
setGenreFilter(next: string) { this.genreFilter = next; }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions);
m.set(key, top);
this.scrollPositions = m;
}
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0; }
} }
export const app = new AppStore(); export const app = new AppStore();
export function setNavPage(next: NavPage) { app.setNavPage(next); } export function setNavPage(next: NavPage) { app.setNavPage(next); }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); } export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next); }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next); } export function setSearchPrefill(next: string) { app.setSearchPrefill(next); }
export function setGenreFilter(next: string) { app.setGenreFilter(next); } export function setSearchQuery(next: string) { app.setSearchQuery(next); }
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
export function getScroll(key: string): number { return app.getScroll(key); }
+3 -2
View File
@@ -43,7 +43,6 @@ function mergeSettings(saved: any): Settings {
mangaPrefs: saved?.settings?.mangaPrefs ?? {}, mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [], customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [], nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [], nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
@@ -94,6 +93,8 @@ class Store {
set settingsOpen(v) { app.setSettingsOpen(v); } set settingsOpen(v) { app.setSettingsOpen(v); }
get searchPrefill() { return app.searchPrefill; } get searchPrefill() { return app.searchPrefill; }
set searchPrefill(v) { app.setSearchPrefill(v); } set searchPrefill(v) { app.setSearchPrefill(v); }
get searchQuery() { return app.searchQuery; }
set searchQuery(v) { app.setSearchQuery(v); }
get genreFilter() { return app.genreFilter; } get genreFilter() { return app.genreFilter; }
set genreFilter(v) { app.setGenreFilter(v); } set genreFilter(v) { app.setGenreFilter(v); }
@@ -401,4 +402,4 @@ export async function checkAndMarkCompleted(
): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); } ): Promise<void> { return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus); }
export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte"; export { addToast, dismissToast, setActiveDownloads } from "./notifications.svelte";
export { setNavPage, setSettingsOpen, setSearchPrefill, setGenreFilter } from "./app.svelte"; export { setNavPage, setSettingsOpen, setSearchPrefill, setSearchQuery, setGenreFilter, saveScroll, getScroll } from "./app.svelte";