Feat: Filtering in Library

This commit is contained in:
Youwes09
2026-04-01 22:26:29 -05:00
parent d91ed2e6d1
commit a62512bf42
2 changed files with 99 additions and 12 deletions
+87 -11
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown } from "phosphor-svelte"; import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries"; import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter } from "../../store/state.svelte"; import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
import type { Manga, Category, Chapter } from "../../lib/types"; import type { Manga, Category, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -73,6 +73,12 @@
const tabStatus = $derived( const tabStatus = $derived(
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
); );
const tabFilters = $derived(
store.settings.libraryTabFilters?.[store.libraryFilter] ?? {} as Partial<Record<LibraryContentFilter, boolean>>
);
const hasActiveFilters = $derived(
tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)
);
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) { function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[store.libraryFilter]; const prev = store.settings.libraryTabSort[store.libraryFilter];
@@ -96,7 +102,32 @@
[store.libraryFilter]: status, [store.libraryFilter]: status,
}, },
}); });
filterPanelOpen = false; }
function toggleTabFilter(filter: LibraryContentFilter) {
const current = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
updateSettings({
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[store.libraryFilter]: {
...current,
[filter]: !current[filter],
},
},
});
}
function clearAllFilters() {
updateSettings({
libraryTabStatus: {
...store.settings.libraryTabStatus,
[store.libraryFilter]: "ALL",
},
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[store.libraryFilter]: {},
},
});
} }
let allManga: Manga[] = $state([]); let allManga: Manga[] = $state([]);
@@ -333,6 +364,13 @@
}); });
} }
// 4b. Content filters (additive — each active filter further narrows the list)
const filters = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (filters.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
if (filters.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (filters.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
// 5. Sort // 5. Sort
const recentlyReadMap = new Map<number, number>(); const recentlyReadMap = new Map<number, number>();
if (mode === "recentlyRead") { if (mode === "recentlyRead") {
@@ -712,7 +750,11 @@
</button> </button>
{#if sortPanelOpen} {#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu"> <div class="dropdown-panel sort-panel" role="menu">
<p class="panel-label">Sort by</p> <div class="panel-header">
<span class="panel-heading">Sort</span>
</div>
<div class="panel-divider"></div>
<p class="panel-label">Order by</p>
{#each ALL_SORT_MODES as m} {#each ALL_SORT_MODES as m}
<button <button
class="panel-item" class="panel-item"
@@ -750,22 +792,47 @@
<div class="filter-panel-wrap"> <div class="filter-panel-wrap">
<button <button
class="icon-btn" class="icon-btn"
class:icon-btn-active={tabStatus !== "ALL"} class:icon-btn-active={hasActiveFilters}
title="Filter by status" title="Filter"
onclick={openFilterPanel} onclick={openFilterPanel}
> >
<Funnel size={15} weight={tabStatus !== "ALL" ? "fill" : "bold"} /> <Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
</button> </button>
{#if filterPanelOpen} {#if filterPanelOpen}
<div class="dropdown-panel filter-panel" role="menu"> <div class="dropdown-panel filter-panel" role="menu">
<p class="panel-label">Filter by status</p> <div class="panel-header">
{#each ALL_STATUS_FILTERS as s} <span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={clearAllFilters}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each ([["unread","Unread"],["started","Started"],["downloaded","Downloaded"],["bookmarked","Bookmarked"]] as const) as [f, label]}
<button <button
class="panel-item" class="panel-item panel-item-check"
class:panel-item-active={tabFilters[f]}
role="menuitem"
onclick={() => toggleTabFilter(f)}
>
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
<div class="panel-divider"></div>
<p class="panel-label">Status</p>
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
<button
class="panel-item panel-item-check"
class:panel-item-active={tabStatus === s} class:panel-item-active={tabStatus === s}
role="menuitem" role="menuitem"
onclick={() => setTabStatus(s)} onclick={() => setTabStatus(tabStatus === s ? "ALL" : s)}
> >
<span class="panel-check" class:panel-check-on={tabStatus === s}>
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
</span>
{STATUS_LABELS[s]} {STATUS_LABELS[s]}
</button> </button>
{/each} {/each}
@@ -934,6 +1001,15 @@
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); } .panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); } .panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; } .panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
/* Panel header row — shared by sort + filter panels */
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.panel-clear-btn:hover { color: var(--color-error); }
/* Check items */
.panel-item-check { justify-content: flex-start; gap: var(--sp-2); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; } .dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
.sort-caret { flex-shrink: 0; } .sort-caret { flex-shrink: 0; }
+11
View File
@@ -27,6 +27,13 @@ export type LibraryStatusFilter =
| "CANCELLED" | "CANCELLED"
| "HIATUS" | "HIATUS"
| "UNKNOWN"; | "UNKNOWN";
/** Checkbox-style content filters — multiple can be active at once per tab. */
export type LibraryContentFilter =
| "unread"
| "started"
| "downloaded"
| "bookmarked";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm"; export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123" export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
@@ -252,6 +259,8 @@ export interface Settings {
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */ /** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>; libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>; libraryTabStatus: Record<string, LibraryStatusFilter>;
/** Per-tab active content filters — keys are LibraryContentFilter, value is true when active. */
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
// Legacy fields kept for migration reads only — never written after v3. // Legacy fields kept for migration reads only — never written after v3.
/** @deprecated use readerZoom */ /** @deprecated use readerZoom */
maxPageWidth?: number; maxPageWidth?: number;
@@ -328,6 +337,7 @@ export const DEFAULT_SETTINGS: Settings = {
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
libraryTabSort: {}, libraryTabSort: {},
libraryTabStatus: {}, libraryTabStatus: {},
libraryTabFilters: {},
extraScanDirs: [], extraScanDirs: [],
serverDownloadsPath: "", serverDownloadsPath: "",
serverLocalSourcePath: "", serverLocalSourcePath: "",
@@ -393,6 +403,7 @@ function mergeSettings(saved: any): Settings {
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
libraryTabFilters: saved?.settings?.libraryTabFilters ?? {},
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"], nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [], nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [], nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],