mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over SeriesDetail (WIP Panels)
This commit is contained in:
+215
-62
@@ -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();
|
||||
Reference in New Issue
Block a user