Chore: Port over SeriesDetail (WIP Panels)

This commit is contained in:
Youwes09
2026-05-28 23:05:02 -05:00
parent 584b917f98
commit 8c250021a0
53 changed files with 4570 additions and 885 deletions
+215 -62
View File
@@ -1,89 +1,242 @@
import type { Manga } from '$lib/types'
import type { MangaStatus } from '$lib/server-adapters/types'
import type { Manga } from "$lib/types";
import type { MangaStatus } from "$lib/server-adapters/types";
import type { Category } from "$lib/types";
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'
export type LibraryTab = 'saved' | 'downloaded'
export type LibrarySortOption =
| "alphabetical"
| "unread"
| "lastRead"
| "dateAdded"
| "totalChapters"
| "latestFetched"
| "latestUploaded";
export type LibrarySortDir = "asc" | "desc";
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked";
export type LibraryStatusFilter =
| "ALL"
| "ONGOING"
| "COMPLETED"
| "ON_HIATUS"
| "CANCELLED"
| "PUBLISHING_FINISHED";
class LibraryState {
items = $state<Manga[]>([])
loading = $state(false)
error = $state<string | null>(null)
refreshing = $state(false)
items = $state<Manga[]>([]);
categories = $state<Category[]>([]);
loading = $state(false);
error = $state<string | null>(null);
refreshing = $state(false);
tab = $state<LibraryTab>('saved')
sort = $state<LibrarySortOption>('alphabetical')
sortDesc = $state(false)
tab = $state<string>("library");
filter = $state({
status: 'all' as MangaStatus | 'all',
unread: false,
downloaded: false,
bookmarked: false,
query: '',
})
tabSort = $state<Record<string, { mode: LibrarySortOption; dir: LibrarySortDir }>>({});
tabStatus = $state<Record<string, LibraryStatusFilter>>({});
tabFilters = $state<Record<string, Partial<Record<LibraryContentFilter, boolean>>>>({});
selected = $state(new Set<number>())
selectMode = $state(false)
hiddenTabs = $state<Set<string>>(new Set());
pinnedTabOrder = $state<string[]>([]);
defaultCategoryId = $state<number | null>(null);
showAllInSaved = $state(true);
hideCompletedInSaved = $state(false);
categoryFrecency = $state<Record<number, number>>({});
filter = $state({ query: "" });
selected = $state(new Set<number>());
selectMode = $state(false);
refreshProgress = $state({ finished: 0, total: 0 });
refreshDone = $state(false);
refreshingMangaId = $state<number | null>(null);
refreshingCatId = $state<number | null>(null);
readonly COMPLETED_NAME = "Completed";
get completedCatId(): number | null {
return this.categories.find(c => c.name === this.COMPLETED_NAME && c.id !== 0)?.id ?? null;
}
get categoryMangaMap(): Map<number, Manga[]> {
const map = new Map<number, Manga[]>();
for (const cat of this.categories) {
map.set(cat.id, (cat as any).mangas?.nodes ?? []);
}
return map;
}
get allTabIds(): string[] {
const catIds = this.categories.filter(c => c.id !== 0).map(c => String(c.id));
const BUILTIN = ["library", "downloaded"];
const known = new Set([...BUILTIN, ...catIds]);
const ordered: string[] = [];
const inOrder = new Set<string>();
for (const id of this.pinnedTabOrder) {
if (known.has(id) && !inOrder.has(id)) { ordered.push(id); inOrder.add(id); }
}
for (const id of [...BUILTIN, ...catIds]) {
if (!inOrder.has(id)) { ordered.push(id); inOrder.add(id); }
}
return ordered;
}
get visibleTabIds(): string[] {
return this.allTabIds.filter(id => !this.hiddenTabs.has(id));
}
get visibleCategories(): Category[] {
const pinned = this.pinnedTabOrder;
const defId = this.defaultCategoryId;
const cats = this.categories.filter(c => c.id !== 0 && !this.hiddenTabs.has(String(c.id)));
const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; };
return [...cats].sort((a, b) => {
if (a.id === defId) return -1;
if (b.id === defId) return 1;
const pd = pinOrder(a.id) - pinOrder(b.id);
return pd !== 0 ? pd : (a as any).order - (b as any).order;
});
}
get counts(): Record<string, number> {
const m: Record<string, number> = {
library: this.showAllInSaved
? this.items.filter(x => x.inLibrary).length
: (this.categoryMangaMap.get(0) ?? []).length,
downloaded: this.items.filter(x => (x.downloadCount ?? 0) > 0).length,
};
for (const cat of this.visibleCategories) {
m[String(cat.id)] = (this.categoryMangaMap.get(cat.id) ?? []).length;
}
return m;
}
filteredItems = $derived.by(() => {
let result = this.tab === 'downloaded'
? this.items.filter(m => (m.downloadCount ?? 0) > 0)
: this.items.filter(m => m.inLibrary)
const tab = this.tab;
if (this.filter.unread) result = result.filter(m => (m.unreadCount ?? 0) > 0)
if (this.filter.downloaded) result = result.filter(m => (m.downloadCount ?? 0) > 0)
if (this.filter.bookmarked) result = result.filter(m => (m.bookmarkCount ?? 0) > 0)
let items: Manga[];
if (tab === "library") {
items = this.showAllInSaved
? this.items.filter(m => m.inLibrary)
: (this.categoryMangaMap.get(0) ?? []);
if (this.filter.status !== 'all') {
result = result.filter(
m => m.status?.toUpperCase().replace(/\s+/g, '_') === this.filter.status
)
}
if (this.filter.query) {
const q = this.filter.query.toLowerCase()
result = result.filter(m => m.title.toLowerCase().includes(q))
}
const sorted = [...result].sort((a, b) => {
switch (this.sort) {
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0)
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0)
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0)
default: return a.title.localeCompare(b.title)
if (this.showAllInSaved && this.hideCompletedInSaved) {
const completedCat = this.categories.find(c => c.name === this.COMPLETED_NAME);
if (completedCat) {
const completedIds = new Set((this.categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
items = items.filter(m => !completedIds.has(m.id));
}
}
})
} else if (tab === "downloaded") {
items = this.items.filter(m => (m.downloadCount ?? 0) > 0);
} else {
items = this.categoryMangaMap.get(Number(tab)) ?? [];
}
return this.sortDesc ? sorted.reverse() : sorted
})
const q = this.filter.query.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
get hasActiveFilters() {
return this.filter.status !== 'all'
|| this.filter.unread
|| this.filter.downloaded
|| this.filter.bookmarked
const status = this.tabStatus[tab] ?? "ALL";
if (status !== "ALL") {
items = items.filter(m => {
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
return s === status;
});
}
const f = this.tabFilters[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
const { mode, dir } = this.tabSort[tab] ?? { mode: "alphabetical" as LibrarySortOption, dir: "asc" as LibrarySortDir };
const sorted = [...items].sort((a, b) => {
switch (mode) {
case "unread": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
case "lastRead": return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
case "dateAdded": return (b.addedAt ?? 0) - (a.addedAt ?? 0);
case "totalChapters": return (b.chapters?.totalCount ?? 0) - (a.chapters?.totalCount ?? 0);
case "latestFetched": return Number(b.latestFetchedChapter?.uploadDate ?? 0) - Number(a.latestFetchedChapter?.uploadDate ?? 0);
case "latestUploaded": return Number(b.latestUploadedChapter?.uploadDate ?? 0) - Number(a.latestUploadedChapter?.uploadDate ?? 0);
default: return a.title.localeCompare(b.title);
}
});
return dir === "desc" ? sorted.reverse() : sorted;
});
get hasActiveFilters(): boolean {
const tab = this.tab;
const status = this.tabStatus[tab] ?? "ALL";
const filters = this.tabFilters[tab] ?? {};
return status !== "ALL" || Object.values(filters).some(Boolean);
}
setTabSort(tab: string, mode: LibrarySortOption, dir?: LibrarySortDir) {
const prev = this.tabSort[tab];
const newDir = dir ?? prev?.dir ?? "asc";
this.tabSort = { ...this.tabSort, [tab]: { mode, dir: newDir } };
}
toggleTabSortDir(tab: string) {
const prev = this.tabSort[tab];
const mode = prev?.mode ?? "alphabetical";
const dir = prev?.dir === "asc" ? "desc" : "asc";
this.setTabSort(tab, mode, dir);
}
setTabStatus(tab: string, status: LibraryStatusFilter) {
this.tabStatus = { ...this.tabStatus, [tab]: status };
}
toggleTabFilter(tab: string, filter: LibraryContentFilter) {
const current = this.tabFilters[tab] ?? {};
this.tabFilters = { ...this.tabFilters, [tab]: { ...current, [filter]: !current[filter] } };
}
clearTabFilters(tab: string) {
this.tabStatus = { ...this.tabStatus, [tab]: "ALL" };
this.tabFilters = { ...this.tabFilters, [tab]: {} };
}
setCategories(cats: Category[]) {
this.categories = cats;
}
bumpCategoryFrecency(catId: number) {
this.categoryFrecency = { ...this.categoryFrecency, [catId]: (this.categoryFrecency[catId] ?? 0) + 1 };
}
enterSelect(id?: number) {
this.selectMode = true
if (id !== undefined) this.selected = new Set([id])
this.selectMode = true;
if (id !== undefined) this.selected = new Set([id]);
}
exitSelect() {
this.selectMode = false
this.selected = new Set()
this.selectMode = false;
this.selected = new Set();
}
toggleSelect(id: number) {
const next = new Set(this.selected)
if (next.has(id)) next.delete(id); else next.add(id)
this.selected = next
if (next.size === 0) this.exitSelect()
const next = new Set(this.selected);
if (next.has(id)) next.delete(id); else next.add(id);
this.selected = next;
if (next.size === 0) this.exitSelect();
}
selectAll() {
this.selected = new Set(this.filteredItems.map(m => m.id))
selectAll(ids: number[]) {
this.selected = new Set(ids);
}
guardTab() {
if (this.tab === "library" || this.tab === "downloaded") return;
const id = Number(this.tab);
if (!this.categories.some(c => c.id === id)) this.tab = "library";
}
}
export const libraryState = new LibraryState()
export const libraryState = new LibraryState();