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
+4
View File
@@ -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) }
+5 -5
View File
@@ -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;
}
+17 -16
View File
@@ -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;
}
+23 -23
View File
@@ -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
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();
+19 -20
View File
@@ -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); }
+19 -19
View File
@@ -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
View File
@@ -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); }
+16 -16
View File
@@ -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);
}
+137 -18
View File
@@ -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)
}