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:
@@ -35,6 +35,10 @@ export const appState = $state({
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
libraryFilter: '',
|
||||
categories: [] as { id: number; name: string }[],
|
||||
history: [] as unknown[],
|
||||
toasts: [] as unknown[],
|
||||
})
|
||||
|
||||
export function setNavPage(next: NavPage) { app.setNavPage(next) }
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { DownloadItem } from '$lib/server-adapters/types'
|
||||
import type { DownloadItem } from "$lib/server-adapters/types";
|
||||
|
||||
export const downloadsState = $state({
|
||||
items: [] as DownloadItem[],
|
||||
error: null as string | null,
|
||||
})
|
||||
});
|
||||
|
||||
export function activeDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'downloading')
|
||||
return downloadsState.items.filter(d => d.state === "downloading");
|
||||
}
|
||||
|
||||
export function queuedDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'queued')
|
||||
return downloadsState.items.filter(d => d.state === "queued");
|
||||
}
|
||||
|
||||
export function downloadCount() {
|
||||
return downloadsState.items.length
|
||||
return downloadsState.items.length;
|
||||
}
|
||||
@@ -1,36 +1,37 @@
|
||||
import type { Extension, Source, Manga } from '$lib/types'
|
||||
import type { Extension, Source, Manga } from "$lib/types";
|
||||
|
||||
export const extensionsState = $state({
|
||||
items: [] as Extension[],
|
||||
sources: [] as Source[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
items: [] as Extension[],
|
||||
sources: [] as Source[],
|
||||
activeSource: null as Source | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
filter: {
|
||||
query: '',
|
||||
query: "",
|
||||
installed: false,
|
||||
language: 'all',
|
||||
language: "all",
|
||||
},
|
||||
|
||||
browseResults: [] as Manga[],
|
||||
browseLoading: false,
|
||||
browseError: null as string | null,
|
||||
browseError: null as string | null,
|
||||
browseHasMore: false,
|
||||
})
|
||||
});
|
||||
|
||||
export function filteredExtensions() {
|
||||
let result = extensionsState.items
|
||||
let result = extensionsState.items;
|
||||
|
||||
if (extensionsState.filter.installed) {
|
||||
result = result.filter(e => e.installed)
|
||||
result = result.filter(e => e.installed);
|
||||
}
|
||||
if (extensionsState.filter.language !== 'all') {
|
||||
result = result.filter(e => e.lang === extensionsState.filter.language)
|
||||
if (extensionsState.filter.language !== "all") {
|
||||
result = result.filter(e => e.lang === extensionsState.filter.language);
|
||||
}
|
||||
if (extensionsState.filter.query) {
|
||||
const q = extensionsState.filter.query.toLowerCase()
|
||||
result = result.filter(e => e.name.toLowerCase().includes(q))
|
||||
const q = extensionsState.filter.query.toLowerCase();
|
||||
result = result.filter(e => e.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
export interface HistoryEntry {
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
chapterId: number
|
||||
chapterName: string
|
||||
chapterNumber: number
|
||||
pageNumber: number
|
||||
readAt: number
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
chapterNumber: number;
|
||||
pageNumber: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
export interface ReadingStats {
|
||||
currentStreakDays: number
|
||||
totalChaptersRead: number
|
||||
totalMinutesRead: number
|
||||
totalMangaRead: number
|
||||
longestStreakDays: number
|
||||
currentStreakDays: number;
|
||||
totalChaptersRead: number;
|
||||
totalMinutesRead: number;
|
||||
totalMangaRead: number;
|
||||
longestStreakDays: number;
|
||||
}
|
||||
|
||||
export const homeState = $state({
|
||||
history: [] as HistoryEntry[],
|
||||
dailyReadCounts: {} as Record<string, number>,
|
||||
stats: {
|
||||
currentStreakDays: 0,
|
||||
totalChaptersRead: 0,
|
||||
totalMinutesRead: 0,
|
||||
totalMangaRead: 0,
|
||||
currentStreakDays: 0,
|
||||
totalChaptersRead: 0,
|
||||
totalMinutesRead: 0,
|
||||
totalMangaRead: 0,
|
||||
longestStreakDays: 0,
|
||||
} as ReadingStats,
|
||||
heroSlots: [null, null, null, null] as [number | null, number | null, number | null, number | null],
|
||||
})
|
||||
});
|
||||
|
||||
export function setHeroSlot(i: 1 | 2 | 3, mangaId: number | null) {
|
||||
homeState.heroSlots[i] = mangaId
|
||||
homeState.heroSlots[i] = mangaId;
|
||||
}
|
||||
|
||||
export function recordRead(entry: HistoryEntry) {
|
||||
homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)]
|
||||
const dateStr = new Date(entry.readAt).toISOString().slice(0, 10)
|
||||
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1
|
||||
homeState.stats.totalChaptersRead++
|
||||
homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)];
|
||||
const dateStr = new Date(entry.readAt).toISOString().slice(0, 10);
|
||||
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1;
|
||||
homeState.stats.totalChaptersRead++;
|
||||
}
|
||||
+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();
|
||||
@@ -1,38 +1,37 @@
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'download'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
duration?: number
|
||||
id: string;
|
||||
kind: "success" | "error" | "info" | "download";
|
||||
title: string;
|
||||
body?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ActiveDownload {
|
||||
chapterId: number
|
||||
mangaId: number
|
||||
progress: number
|
||||
chapterId: number;
|
||||
mangaId: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
class NotificationStore {
|
||||
toasts: Toast[] = $state([])
|
||||
activeDownloads: ActiveDownload[] = $state([])
|
||||
toasts: Toast[] = $state([]);
|
||||
activeDownloads: ActiveDownload[] = $state([]);
|
||||
|
||||
toast(toast: Omit<Toast, 'id'>) {
|
||||
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5)
|
||||
addToast(t: Omit<Toast, "id">) {
|
||||
this.toasts = [...this.toasts, { ...t, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||
}
|
||||
|
||||
dismissToast(id: string) {
|
||||
this.toasts = this.toasts.filter(x => x.id !== id)
|
||||
this.toasts = this.toasts.filter(x => x.id !== id);
|
||||
}
|
||||
|
||||
setActiveDownloads(next: ActiveDownload[]) {
|
||||
this.activeDownloads = next
|
||||
this.activeDownloads = next;
|
||||
}
|
||||
}
|
||||
|
||||
export const notifications = new NotificationStore()
|
||||
export const notifications = new NotificationStore();
|
||||
|
||||
export function toast(toast: Omit<Toast, 'id'>) { notifications.toast(toast) }
|
||||
export function dismissToast(id: string) { notifications.dismissToast(id) }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) }
|
||||
export function addToast(t: Omit<Toast, "id">) { notifications.addToast(t); }
|
||||
export function toast(t: Omit<Toast, "id">) { notifications.addToast(t); }
|
||||
export function dismissToast(id: string) { notifications.dismissToast(id); }
|
||||
export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next); }
|
||||
@@ -1,44 +1,44 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
import type { Page } from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter } from "$lib/types";
|
||||
import type { Page } from "$lib/server-adapters/types";
|
||||
|
||||
export type ReadMode = 'single' | 'strip'
|
||||
export type FitMode = 'width' | 'height' | 'original'
|
||||
export type ReadDirection = 'ltr' | 'rtl'
|
||||
export type ReadMode = "single" | "strip";
|
||||
export type FitMode = "width" | "height" | "original";
|
||||
export type ReadDirection = "ltr" | "rtl";
|
||||
|
||||
export const readerState = $state({
|
||||
manga: null as Manga | null,
|
||||
manga: null as Manga | null,
|
||||
chapter: null as Chapter | null,
|
||||
chapters: [] as Chapter[],
|
||||
|
||||
pages: [] as Page[],
|
||||
pages: [] as Page[],
|
||||
pagesLoading: false,
|
||||
pagesError: null as string | null,
|
||||
pagesError: null as string | null,
|
||||
|
||||
currentPage: 0,
|
||||
mode: 'single' as ReadMode,
|
||||
fit: 'width' as FitMode,
|
||||
direction: 'ltr' as ReadDirection,
|
||||
zoom: 1,
|
||||
currentPage: 0,
|
||||
mode: "single" as ReadMode,
|
||||
fit: "width" as FitMode,
|
||||
direction: "ltr" as ReadDirection,
|
||||
zoom: 1,
|
||||
|
||||
showControls: false,
|
||||
showSettings: false,
|
||||
fullscreen: false,
|
||||
})
|
||||
fullscreen: false,
|
||||
});
|
||||
|
||||
export function currentPageData() {
|
||||
return readerState.pages[readerState.currentPage] ?? null
|
||||
return readerState.pages[readerState.currentPage] ?? null;
|
||||
}
|
||||
|
||||
export function progress() {
|
||||
return readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function hasPrev() {
|
||||
return readerState.currentPage > 0
|
||||
return readerState.currentPage > 0;
|
||||
}
|
||||
|
||||
export function hasNext() {
|
||||
return readerState.currentPage < readerState.pages.length - 1
|
||||
return readerState.currentPage < readerState.pages.length - 1;
|
||||
}
|
||||
+126
-22
@@ -1,28 +1,132 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
import type { Manga, Chapter } from "$lib/types";
|
||||
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
import type { MangaPrefs } from "$lib/types/settings";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
class SeriesState {
|
||||
current = $state<Manga | null>(null)
|
||||
loading = $state(false)
|
||||
error = $state<string | null>(null)
|
||||
export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
export type { MangaPrefs } from "$lib/types/settings";
|
||||
|
||||
chapters = $state<Chapter[]>([])
|
||||
chaptersLoading = $state(false)
|
||||
chaptersError = $state<string | null>(null)
|
||||
class SeriesStore {
|
||||
current = $state<Manga | null>(null);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
chapterSortDesc = $state(true)
|
||||
chapterFilter = $state({ unread: false, downloaded: false, query: '' })
|
||||
chapters = $state<Chapter[]>([]);
|
||||
chaptersLoading = $state(false);
|
||||
chaptersError = $state<string | null>(null);
|
||||
|
||||
filteredChapters = $derived.by(() => {
|
||||
let result = this.chapters
|
||||
if (this.chapterFilter.unread) result = result.filter(c => !c.read)
|
||||
if (this.chapterFilter.downloaded) result = result.filter(c => c.downloaded)
|
||||
if (this.chapterFilter.query) {
|
||||
const q = this.chapterFilter.query.toLowerCase()
|
||||
result = result.filter(c => c.name.toLowerCase().includes(q))
|
||||
}
|
||||
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
|
||||
return this.chapterSortDesc ? sorted.reverse() : sorted
|
||||
})
|
||||
activeMangaId = $state<number | null>(null);
|
||||
activeManga = $state<Manga | null>(null);
|
||||
previewManga = $state<Manga | null>(null);
|
||||
activeChapter = $state<Chapter | null>(null);
|
||||
activeChapterList = $state<Chapter[]>([]);
|
||||
bookmarks = $state<BookmarkEntry[]>([]);
|
||||
markers = $state<MarkerEntry[]>([]);
|
||||
acknowledgedUpdates = $state<Set<number>>(new Set());
|
||||
|
||||
setActiveMangaId(next: number | null) { this.activeMangaId = next; }
|
||||
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
if (manga !== undefined) this.activeManga = manga;
|
||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
this.activeChapter = null;
|
||||
this.activeChapterList = [];
|
||||
}
|
||||
|
||||
acknowledgeUpdate(mangaId: number) {
|
||||
if (this.acknowledgedUpdates.has(mangaId)) return;
|
||||
this.acknowledgedUpdates = new Set([...this.acknowledgedUpdates, mangaId]);
|
||||
}
|
||||
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
|
||||
this.bookmarks = [
|
||||
{ ...entry, savedAt: Date.now(), label },
|
||||
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId); }
|
||||
clearBookmarks() { this.bookmarks = []; }
|
||||
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId); }
|
||||
|
||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||
return id;
|
||||
}
|
||||
|
||||
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m);
|
||||
}
|
||||
|
||||
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id); }
|
||||
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page); }
|
||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); }
|
||||
|
||||
getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
|
||||
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {};
|
||||
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
|
||||
}
|
||||
|
||||
setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
||||
updateSettings({
|
||||
mangaPrefs: {
|
||||
...settingsState.settings.mangaPrefs,
|
||||
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get settings() { return settingsState.settings; }
|
||||
}
|
||||
|
||||
export const seriesState = new SeriesState()
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
sortMode: "source",
|
||||
sortDir: "asc",
|
||||
preferredScanlator: "",
|
||||
scanlatorFilter: [],
|
||||
scanlatorBlacklist: [],
|
||||
scanlatorForce: false,
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
maxKeepChapters: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "global",
|
||||
coverUrl: "",
|
||||
};
|
||||
|
||||
export const seriesState = new SeriesStore();
|
||||
|
||||
export const seriesStore = seriesState;
|
||||
|
||||
export function setActiveMangaId(next: number | null) { seriesState.setActiveMangaId(next); }
|
||||
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next); }
|
||||
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next); }
|
||||
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { seriesState.openReader(ch, list, manga); }
|
||||
export function closeReader() { seriesState.closeReader(); }
|
||||
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId); }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { seriesState.addBookmark(entry, label); }
|
||||
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { seriesState.clearBookmarks(); }
|
||||
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId); }
|
||||
export function addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string { return seriesState.addMarker(entry); }
|
||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) { seriesState.updateMarker(id, patch); }
|
||||
export function removeMarker(id: string) { seriesState.removeMarker(id); }
|
||||
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page); }
|
||||
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId); }
|
||||
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId); }
|
||||
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId); }
|
||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key); }
|
||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) { seriesState.setPref(mangaId, key, value); }
|
||||
@@ -1,38 +1,38 @@
|
||||
import type { Settings } from '$lib/types/settings'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
import type { Settings } from "$lib/types/settings";
|
||||
import { DEFAULT_SETTINGS } from "$lib/types/settings";
|
||||
|
||||
const KEY = 'moku_settings'
|
||||
const KEY = "moku_settings";
|
||||
|
||||
function load(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
function save(s: Settings) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
|
||||
try { localStorage.setItem(KEY, JSON.stringify(s)); } catch {}
|
||||
}
|
||||
|
||||
export const settingsState = $state({ settings: load() })
|
||||
export const settingsState = $state({ settings: load() });
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0);
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<Settings>) {
|
||||
Object.assign(settingsState.settings, patch)
|
||||
save(settingsState.settings)
|
||||
Object.assign(settingsState.settings, patch);
|
||||
save(settingsState.settings);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (typeof document !== "undefined") {
|
||||
if (patch.uiZoom !== undefined) {
|
||||
document.documentElement.style.zoom = String(patch.uiZoom)
|
||||
document.documentElement.style.zoom = String(patch.uiZoom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
settingsState.settings = { ...DEFAULT_SETTINGS }
|
||||
save(settingsState.settings)
|
||||
settingsState.settings = { ...DEFAULT_SETTINGS };
|
||||
save(settingsState.settings);
|
||||
}
|
||||
@@ -1,29 +1,149 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
||||
import type { Tracker, TrackRecord } from '$lib/types'
|
||||
import type { Chapter } from '$lib/types/chapter'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
import type { Chapter } from '$lib/types'
|
||||
import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||
|
||||
export const trackingState = $state({
|
||||
trackers: [] as Tracker[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
syncing: false,
|
||||
type RecordMap = Map<number, TrackRecord[]>
|
||||
|
||||
records: [] as unknown[],
|
||||
recordsLoading: false,
|
||||
recordsError: null as string | null,
|
||||
class TrackingStore {
|
||||
private byManga: RecordMap = $state(new Map())
|
||||
|
||||
searchResults: [] as unknown[],
|
||||
searchLoading: false,
|
||||
searchError: null as string | null,
|
||||
})
|
||||
trackers: Tracker[] = $state([])
|
||||
loading: boolean = $state(false)
|
||||
error: string | null = $state(null)
|
||||
syncing: boolean = $state(false)
|
||||
|
||||
recordsLoading: boolean = $state(false)
|
||||
recordsError: string | null = $state(null)
|
||||
searchResults: unknown[] = $state([])
|
||||
searchLoading: boolean = $state(false)
|
||||
searchError: string | null = $state(null)
|
||||
|
||||
private loadingFor = new Set<number>()
|
||||
|
||||
recordsFor(mangaId: number): TrackRecord[] {
|
||||
return this.byManga.get(mangaId) ?? []
|
||||
}
|
||||
|
||||
private setFor(mangaId: number, records: TrackRecord[]) {
|
||||
const next = new Map(this.byManga)
|
||||
next.set(mangaId, records)
|
||||
this.byManga = next
|
||||
}
|
||||
|
||||
async loadForManga(mangaId: number) {
|
||||
if (this.loadingFor.has(mangaId)) return
|
||||
const existing = this.byManga.get(mangaId)
|
||||
if (existing && existing.length > 0) return
|
||||
|
||||
this.loadingFor.add(mangaId)
|
||||
try {
|
||||
const records = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, records)
|
||||
} catch (e) {
|
||||
// silently ignore — tracking is non-critical
|
||||
} finally {
|
||||
this.loadingFor.delete(mangaId)
|
||||
}
|
||||
}
|
||||
|
||||
async syncFromRemote(
|
||||
mangaId: number,
|
||||
record: TrackRecord,
|
||||
chapters: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
): Promise<{ markedIds: number[] }> {
|
||||
if (!settingsState.settings.trackerSyncBack) return { markedIds: [] }
|
||||
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
|
||||
const freshRecord = fresh.find(r => r.id === record.id)
|
||||
if (!freshRecord) return { markedIds: [] }
|
||||
|
||||
const markedIds = this._applyRemoteProgress(freshRecord, chapters, prefs)
|
||||
return { markedIds }
|
||||
} catch {
|
||||
return { markedIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private _applyRemoteProgress(
|
||||
record: TrackRecord,
|
||||
chapters: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
): number[] {
|
||||
const lastRead = record.lastChapterRead ?? 0
|
||||
if (lastRead <= 0) return []
|
||||
|
||||
const threshold = settingsState.settings.trackerSyncBackThreshold ?? null
|
||||
const respectScanlator = settingsState.settings.trackerRespectScanlatorFilter ?? true
|
||||
const activeScanlators: string[] | null =
|
||||
respectScanlator && (prefs as any).scanlatorFilter?.length
|
||||
? (prefs as any).scanlatorFilter
|
||||
: null
|
||||
|
||||
return chapters
|
||||
.filter(ch => {
|
||||
if (ch.read) return false
|
||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
||||
return threshold !== null
|
||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - threshold
|
||||
: ch.chapterNumber <= lastRead
|
||||
})
|
||||
.map(ch => ch.id)
|
||||
}
|
||||
|
||||
async updateFromRead(
|
||||
mangaId: number,
|
||||
chapter: Chapter,
|
||||
chapterList: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
) {
|
||||
const records = this.recordsFor(mangaId)
|
||||
if (!records.length) return
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async updateFromUnread(
|
||||
mangaId: number,
|
||||
chapterList: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
) {
|
||||
const records = this.recordsFor(mangaId)
|
||||
if (!records.length) return
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
clear(mangaId: number) {
|
||||
const next = new Map(this.byManga)
|
||||
next.delete(mangaId)
|
||||
this.byManga = next
|
||||
}
|
||||
}
|
||||
|
||||
export const trackingState = new TrackingStore()
|
||||
|
||||
// Standalone export for components that run their own sync loop (e.g. TrackingSettings)
|
||||
export async function syncBackFromTracker(
|
||||
records: TrackRecord[],
|
||||
records: TrackRecord[],
|
||||
chapters: Chapter[],
|
||||
opts: {
|
||||
threshold: number | null
|
||||
respectScanlatorFilter: boolean
|
||||
chapterPrefs: Partial<MangaPrefs>
|
||||
chapterPrefs: Partial<any>
|
||||
},
|
||||
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
|
||||
): Promise<Chapter[]> {
|
||||
@@ -46,8 +166,7 @@ export async function syncBackFromTracker(
|
||||
: ch.chapterNumber <= lastRead
|
||||
})
|
||||
|
||||
if (toMark.length === 0) continue
|
||||
|
||||
if (!toMark.length) continue
|
||||
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
|
||||
marked.push(...toMark)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user