mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Revamped Lib Files for Svelte 5 Rewrite
This commit is contained in:
+271
-203
@@ -1,4 +1,3 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import type { Manga, Chapter, Source } from "../lib/types";
|
||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||
|
||||
@@ -13,109 +12,175 @@ export type Theme = "dark" | "high-contrast" | "light" | "light-contr
|
||||
export const COMPLETED_FOLDER_ID = "completed";
|
||||
|
||||
export interface HistoryEntry {
|
||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||
chapterId: number; chapterName: string; pageNumber: number; readAt: number;
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
pageNumber: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
export interface ReadingStats {
|
||||
totalChaptersRead: number;
|
||||
totalMangaRead: number;
|
||||
totalMinutesRead: number;
|
||||
firstReadAt: number;
|
||||
lastReadAt: number;
|
||||
currentStreakDays: number;
|
||||
longestStreakDays: number;
|
||||
lastStreakDate: string;
|
||||
totalChaptersRead: number;
|
||||
totalMangaRead: number;
|
||||
totalMinutesRead: number;
|
||||
firstReadAt: number;
|
||||
lastReadAt: number;
|
||||
currentStreakDays: number;
|
||||
longestStreakDays: number;
|
||||
lastStreakDate: string;
|
||||
}
|
||||
|
||||
const AVG_MIN_PER_CHAPTER = 5;
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
totalChaptersRead: 0, totalMangaRead: 0, totalMinutesRead: 0,
|
||||
firstReadAt: 0, lastReadAt: 0,
|
||||
currentStreakDays: 0, longestStreakDays: 0, lastStreakDate: "",
|
||||
totalChaptersRead: 0,
|
||||
totalMangaRead: 0,
|
||||
totalMinutesRead: 0,
|
||||
firstReadAt: 0,
|
||||
lastReadAt: 0,
|
||||
currentStreakDays: 0,
|
||||
longestStreakDays: 0,
|
||||
lastStreakDate: "",
|
||||
};
|
||||
|
||||
export interface Toast {
|
||||
id: string; kind: "success" | "error" | "info" | "download";
|
||||
title: string; body?: 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 }
|
||||
export interface ActiveDownload {
|
||||
chapterId: number;
|
||||
mangaId: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string; name: string; mangaIds: number[]; showTab: boolean;
|
||||
system?: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
mangaIds: number[];
|
||||
showTab: boolean;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
|
||||
maxPageWidth: number; pageGap: boolean; optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean; preloadPages: number; autoMarkRead: boolean;
|
||||
autoNextChapter: boolean; libraryCropCovers: boolean; libraryPageSize: number;
|
||||
showNsfw: boolean; chapterSortDir: ChapterSortDir; chapterPageSize: number;
|
||||
uiScale: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
||||
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
||||
preferredExtensionLang: string; keybinds: Keybinds; idleTimeoutMin?: number;
|
||||
splashCards?: boolean; storageLimitGb: number | null; folders: Folder[];
|
||||
markReadOnNext: boolean; readerDebounceMs: number; theme: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
/**
|
||||
* User-defined manga links — manually registered "same series" pairs.
|
||||
* Key: mangaId, Value: array of mangaIds this entry is linked to.
|
||||
* Links are bidirectional at lookup time; only stored in one direction.
|
||||
*/
|
||||
mangaLinks: Record<number, number[]>;
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
maxPageWidth: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
preloadPages: number;
|
||||
autoMarkRead: boolean;
|
||||
autoNextChapter: boolean;
|
||||
libraryCropCovers: boolean;
|
||||
libraryPageSize: number;
|
||||
showNsfw: boolean;
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterPageSize: number;
|
||||
uiScale: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
serverUrl: string;
|
||||
serverBinary: string;
|
||||
autoStartServer: boolean;
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
idleTimeoutMin?: number;
|
||||
splashCards?: boolean;
|
||||
storageLimitGb: number | null;
|
||||
folders: Folder[];
|
||||
markReadOnNext: boolean;
|
||||
readerDebounceMs: number;
|
||||
theme: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
mangaLinks: Record<number, number[]>;
|
||||
}
|
||||
|
||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
||||
id: COMPLETED_FOLDER_ID, name: "Completed", mangaIds: [], showTab: true, system: true,
|
||||
id: COMPLETED_FOLDER_ID,
|
||||
name: "Completed",
|
||||
mangaIds: [],
|
||||
showTab: true,
|
||||
system: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
||||
maxPageWidth: 900, pageGap: true, optimizeContrast: false,
|
||||
offsetDoubleSpreads: false, preloadPages: 3, autoMarkRead: true,
|
||||
autoNextChapter: true, libraryCropCovers: true, libraryPageSize: 48,
|
||||
showNsfw: false, chapterSortDir: "desc", chapterPageSize: 25,
|
||||
uiScale: 100, compactSidebar: false, gpuAcceleration: true,
|
||||
serverUrl: "http://localhost:4567", serverBinary: "tachidesk-server",
|
||||
autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
||||
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
|
||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
||||
markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
|
||||
libraryBranches: true, renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
mangaLinks: {},
|
||||
pageStyle: "longstrip",
|
||||
readingDirection: "ltr",
|
||||
fitMode: "width",
|
||||
maxPageWidth: 900,
|
||||
pageGap: true,
|
||||
optimizeContrast: false,
|
||||
offsetDoubleSpreads: false,
|
||||
preloadPages: 3,
|
||||
autoMarkRead: true,
|
||||
autoNextChapter: true,
|
||||
libraryCropCovers: true,
|
||||
libraryPageSize: 48,
|
||||
showNsfw: false,
|
||||
chapterSortDir: "desc",
|
||||
chapterPageSize: 25,
|
||||
uiScale: 100,
|
||||
compactSidebar: false,
|
||||
gpuAcceleration: true,
|
||||
serverUrl: "http://localhost:4567",
|
||||
serverBinary: "tachidesk-server",
|
||||
autoStartServer: true,
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
idleTimeoutMin: 5,
|
||||
splashCards: true,
|
||||
storageLimitGb: null,
|
||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
||||
markReadOnNext: true,
|
||||
readerDebounceMs: 120,
|
||||
theme: "dark",
|
||||
libraryBranches: true,
|
||||
renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
mangaLinks: {},
|
||||
};
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
|
||||
function loadPersisted() {
|
||||
try { const raw = localStorage.getItem("moku-store"); return raw ? JSON.parse(raw) : null; }
|
||||
catch { return null; }
|
||||
function loadPersisted(): any {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(key: string, value: unknown) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||
function persist(patch: Record<string, unknown>) {
|
||||
try {
|
||||
const current = loadPersisted() ?? {};
|
||||
localStorage.setItem("moku-store", JSON.stringify({ ...current, ...patch }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saved = loadPersisted();
|
||||
|
||||
function mergeSettings(saved: any): Settings {
|
||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||
const completedFolder: Folder = existingCompleted
|
||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||
const completedFolder: Folder = existingCompleted
|
||||
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
||||
: COMPLETED_FOLDER_DEFAULT;
|
||||
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...saved?.settings,
|
||||
folders: [completedFolder, ...otherFolders],
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
folders: [completedFolder, ...otherFolders],
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||
};
|
||||
@@ -125,38 +190,52 @@ function mergeStats(saved: any): ReadingStats {
|
||||
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
|
||||
}
|
||||
|
||||
// ── Stores ────────────────────────────────────────────────────────────────────
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const navPage = writable<NavPage>(saved?.navPage ?? "home");
|
||||
export const libraryFilter = writable<LibraryFilter>(saved?.libraryFilter ?? "library");
|
||||
export const history = writable<HistoryEntry[]>(saved?.history ?? []);
|
||||
export const readingStats = writable<ReadingStats>(mergeStats(saved));
|
||||
export const settings = writable<Settings>(mergeSettings(saved));
|
||||
export let navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||
export let libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
||||
export let history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||
export let readingStats: ReadingStats = $state(mergeStats(saved));
|
||||
export let settings: Settings = $state(mergeSettings(saved));
|
||||
|
||||
export const genreFilter = writable<string>("");
|
||||
export const searchPrefill = writable<string>("");
|
||||
export const activeManga = writable<Manga | null>(null);
|
||||
export const previewManga = writable<Manga | null>(null);
|
||||
export const activeSource = writable<Source | null>(null);
|
||||
export const pageUrls = writable<string[]>([]);
|
||||
export const pageNumber = writable<number>(1);
|
||||
export const libraryTagFilter = writable<string[]>([]);
|
||||
export const settingsOpen = writable<boolean>(false);
|
||||
export const activeDownloads = writable<ActiveDownload[]>([]);
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
export const activeChapter = writable<Chapter | null>(null);
|
||||
export const activeChapterList = writable<Chapter[]>([]);
|
||||
export let genreFilter: string = $state("");
|
||||
export let searchPrefill: string = $state("");
|
||||
export let activeManga: Manga | null = $state(null);
|
||||
export let previewManga: Manga | null = $state(null);
|
||||
export let activeSource: Source | null = $state(null);
|
||||
export let pageUrls: string[] = $state([]);
|
||||
export let pageNumber: number = $state(1);
|
||||
export let libraryTagFilter: string[] = $state([]);
|
||||
export let settingsOpen: boolean = $state(false);
|
||||
export let activeDownloads: ActiveDownload[] = $state([]);
|
||||
export let toasts: Toast[] = $state([]);
|
||||
export let activeChapter: Chapter | null = $state(null);
|
||||
export let activeChapterList: Chapter[] = $state([]);
|
||||
|
||||
// ── Persistence effects ───────────────────────────────────────────────────────
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => { persist({ navPage }); });
|
||||
$effect(() => { persist({ libraryFilter }); });
|
||||
$effect(() => { persist({ history }); });
|
||||
$effect(() => { persist({ readingStats }); });
|
||||
$effect(() => { persist({ settings }); });
|
||||
});
|
||||
|
||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) {
|
||||
activeChapter.set(chapter); activeChapterList.set(chapterList);
|
||||
pageUrls.set([]); pageNumber.set(1);
|
||||
activeChapter = chapter;
|
||||
activeChapterList = chapterList;
|
||||
pageUrls = [];
|
||||
pageNumber = 1;
|
||||
}
|
||||
|
||||
export function closeReader() {
|
||||
activeChapter.set(null); activeChapterList.set([]);
|
||||
pageUrls.set([]); pageNumber.set(1);
|
||||
activeChapter = null;
|
||||
activeChapterList = [];
|
||||
pageUrls = [];
|
||||
pageNumber = 1;
|
||||
}
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────────────────
|
||||
@@ -167,157 +246,138 @@ function todayStr(): string {
|
||||
}
|
||||
|
||||
export function addHistory(entry: HistoryEntry) {
|
||||
history.update((h) => {
|
||||
if (h[0]?.chapterId === entry.chapterId) {
|
||||
const updated = [...h];
|
||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||
return updated;
|
||||
}
|
||||
return [entry, ...h.filter((x) => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||
});
|
||||
const isNewChapter = !history.some(x => x.chapterId === entry.chapterId);
|
||||
|
||||
readingStats.update((s) => {
|
||||
const currentH = get(history);
|
||||
const uniqueChapters = new Set(currentH.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(currentH.map(e => e.mangaId));
|
||||
const isNewChapter = !uniqueChapters.has(entry.chapterId) || currentH[0]?.chapterId !== entry.chapterId;
|
||||
if (history[0]?.chapterId === entry.chapterId) {
|
||||
history[0] = { ...history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||
} else {
|
||||
history = [entry, ...history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||
}
|
||||
|
||||
const today = todayStr();
|
||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = s;
|
||||
if (lastStreakDate !== today) {
|
||||
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
||||
lastStreakDate = today;
|
||||
}
|
||||
const uniqueChapters = new Set(history.map(e => e.chapterId));
|
||||
const uniqueManga = new Set(history.map(e => e.mangaId));
|
||||
|
||||
return {
|
||||
totalChaptersRead: Math.max(s.totalChaptersRead, uniqueChapters.size),
|
||||
totalMangaRead: Math.max(s.totalMangaRead, uniqueManga.size),
|
||||
totalMinutesRead: s.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
||||
firstReadAt: s.firstReadAt === 0 ? entry.readAt : s.firstReadAt,
|
||||
lastReadAt: entry.readAt,
|
||||
currentStreakDays, longestStreakDays, lastStreakDate,
|
||||
};
|
||||
});
|
||||
const today = todayStr();
|
||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = readingStats;
|
||||
if (lastStreakDate !== today) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
||||
lastStreakDate = today;
|
||||
}
|
||||
|
||||
readingStats = {
|
||||
totalChaptersRead: Math.max(readingStats.totalChaptersRead, uniqueChapters.size),
|
||||
totalMangaRead: Math.max(readingStats.totalMangaRead, uniqueManga.size),
|
||||
totalMinutesRead: readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
||||
firstReadAt: readingStats.firstReadAt === 0 ? entry.readAt : readingStats.firstReadAt,
|
||||
lastReadAt: entry.readAt,
|
||||
currentStreakDays,
|
||||
longestStreakDays,
|
||||
lastStreakDate,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearHistory() { history.set([]); }
|
||||
export function clearHistory() {
|
||||
history = [];
|
||||
}
|
||||
|
||||
export function clearHistoryForManga(mangaId: number) {
|
||||
history = history.filter(x => x.mangaId !== mangaId);
|
||||
}
|
||||
|
||||
export function wipeAllData() {
|
||||
history = [];
|
||||
readingStats = { ...DEFAULT_READING_STATS };
|
||||
settings = { ...settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||
}
|
||||
|
||||
// ── Completed manga ───────────────────────────────────────────────────────────
|
||||
|
||||
export function markMangaCompleted(mangaId: number) {
|
||||
settings.update((s) => {
|
||||
let folders = [...s.folders];
|
||||
const idx = folders.findIndex(f => f.id === COMPLETED_FOLDER_ID);
|
||||
if (idx >= 0) {
|
||||
if (folders[idx].mangaIds.includes(mangaId)) return s;
|
||||
folders[idx] = { ...folders[idx], mangaIds: [...folders[idx].mangaIds, mangaId] };
|
||||
} else {
|
||||
folders = [{ ...COMPLETED_FOLDER_DEFAULT, mangaIds: [mangaId] }, ...folders];
|
||||
}
|
||||
return { ...s, folders };
|
||||
let folders = settings.folders.map(f => {
|
||||
if (f.id !== COMPLETED_FOLDER_ID) return f;
|
||||
if (f.mangaIds.includes(mangaId)) return f;
|
||||
return { ...f, mangaIds: [...f.mangaIds, mangaId] };
|
||||
});
|
||||
if (!settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)) {
|
||||
folders = [{ ...COMPLETED_FOLDER_DEFAULT, mangaIds: [mangaId] }, ...folders];
|
||||
}
|
||||
settings = { ...settings, folders };
|
||||
}
|
||||
|
||||
export function unmarkMangaCompleted(mangaId: number) {
|
||||
settings.update((s) => ({
|
||||
...s,
|
||||
folders: s.folders.map(f =>
|
||||
settings = {
|
||||
...settings,
|
||||
folders: settings.folders.map(f =>
|
||||
f.id === COMPLETED_FOLDER_ID
|
||||
? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) }
|
||||
: f
|
||||
),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function isCompleted(mangaId: number): boolean {
|
||||
return get(settings).folders
|
||||
.find(f => f.id === COMPLETED_FOLDER_ID)
|
||||
?.mangaIds.includes(mangaId) ?? false;
|
||||
return settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from SeriesDetail after marking chapters read.
|
||||
* If ALL chapters are read, auto-adds to the Completed folder.
|
||||
* If NOT all chapters are read, removes from Completed (handles un-read).
|
||||
* Pure function — no UI side effects.
|
||||
*/
|
||||
export function checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
||||
if (chapters.length === 0) return;
|
||||
const allRead = chapters.every(c => c.isRead);
|
||||
if (allRead) markMangaCompleted(mangaId);
|
||||
if (!chapters.length) return;
|
||||
if (chapters.every(c => c.isRead)) markMangaCompleted(mangaId);
|
||||
else unmarkMangaCompleted(mangaId);
|
||||
}
|
||||
|
||||
// ── Manga links ("same series" user overrides) ────────────────────────────────
|
||||
// ── Manga links ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Link two manga as "same series". Bidirectional — looking up either id
|
||||
* will return the other. Idempotent.
|
||||
*/
|
||||
export function linkManga(idA: number, idB: number) {
|
||||
if (idA === idB) return;
|
||||
settings.update(s => {
|
||||
const links = { ...s.mangaLinks };
|
||||
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
||||
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
||||
return { ...s, mangaLinks: links };
|
||||
});
|
||||
const links = { ...settings.mangaLinks };
|
||||
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
||||
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
||||
settings = { ...settings, mangaLinks: links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link between two manga.
|
||||
*/
|
||||
export function unlinkManga(idA: number, idB: number) {
|
||||
settings.update(s => {
|
||||
const links = { ...s.mangaLinks };
|
||||
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
||||
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
||||
if (!links[idA].length) delete links[idA];
|
||||
if (!links[idB].length) delete links[idB];
|
||||
return { ...s, mangaLinks: links };
|
||||
});
|
||||
const links = { ...settings.mangaLinks };
|
||||
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
||||
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
||||
if (!links[idA].length) delete links[idA];
|
||||
if (!links[idB].length) delete links[idB];
|
||||
settings = { ...settings, mangaLinks: links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all mangaIds linked to a given mangaId (direct links only, not transitive).
|
||||
*/
|
||||
export function getLinkedMangaIds(mangaId: number): number[] {
|
||||
return get(settings).mangaLinks[mangaId] ?? [];
|
||||
return settings.mangaLinks[mangaId] ?? [];
|
||||
}
|
||||
|
||||
// ── Hero slots ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pin a manga to a hero slot (indices 1-3). Index 0 is always auto.
|
||||
* Pass null to unpin and revert to auto (recent history).
|
||||
*/
|
||||
export function setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
||||
settings.update(s => {
|
||||
const slots = [...(s.heroSlots ?? [null, null, null, null])];
|
||||
slots[index] = mangaId;
|
||||
return { ...s, heroSlots: slots };
|
||||
});
|
||||
const slots = [...(settings.heroSlots ?? [null, null, null, null])];
|
||||
slots[index] = mangaId;
|
||||
settings = { ...settings, heroSlots: slots };
|
||||
}
|
||||
|
||||
// ── Toasts ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function addToast(toast: Omit<Toast, "id">) {
|
||||
toasts.update((t) => [...t, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5));
|
||||
toasts = [...toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||
toasts = toasts.filter(x => x.id !== id);
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function updateSettings(patch: Partial<Settings>) {
|
||||
settings.update((s) => ({ ...s, ...patch }));
|
||||
settings = { ...settings, ...patch };
|
||||
}
|
||||
|
||||
export function resetKeybinds() {
|
||||
settings.update((s) => ({ ...s, keybinds: DEFAULT_KEYBINDS }));
|
||||
settings = { ...settings, keybinds: DEFAULT_KEYBINDS };
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
@@ -326,40 +386,48 @@ const genId = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
export function addFolder(name: string): string {
|
||||
const id = genId();
|
||||
settings.update((s) => ({ ...s, folders: [...s.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] }));
|
||||
settings = { ...settings, folders: [...settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeFolder(id: string) {
|
||||
settings.update((s) => ({ ...s, folders: s.folders.filter(f => f.id !== id || f.system) }));
|
||||
settings = { ...settings, folders: settings.folders.filter(f => f.id !== id || f.system) };
|
||||
}
|
||||
|
||||
export function renameFolder(id: string, name: string) {
|
||||
settings.update((s) => ({ ...s, folders: s.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f) }));
|
||||
settings = {
|
||||
...settings,
|
||||
folders: settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleFolderTab(id: string) {
|
||||
settings.update((s) => ({ ...s, folders: s.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f) }));
|
||||
settings = {
|
||||
...settings,
|
||||
folders: settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
||||
};
|
||||
}
|
||||
|
||||
export function assignMangaToFolder(folderId: string, mangaId: number) {
|
||||
settings.update((s) => ({
|
||||
...s, folders: s.folders.map(f =>
|
||||
f.id === folderId && !f.mangaIds.includes(mangaId) ? { ...f, mangaIds: [...f.mangaIds, mangaId] } : f
|
||||
settings = {
|
||||
...settings,
|
||||
folders: settings.folders.map(f =>
|
||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
||||
: f
|
||||
),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMangaFromFolder(folderId: string, mangaId: number) {
|
||||
settings.update((s) => ({
|
||||
...s, folders: s.folders.map(f =>
|
||||
settings = {
|
||||
...settings,
|
||||
folders: settings.folders.map(f =>
|
||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
||||
),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function getMangaFolders(mangaId: number): Folder[] {
|
||||
return get(settings).folders.filter(f => f.mangaIds.includes(mangaId));
|
||||
return settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
||||
}
|
||||
|
||||
// ── Persistence subscriptions ─────────────────────────────────────────────────
|
||||
|
||||
navPage.subscribe(v => persist("moku-store", { ...loadPersisted(), navPage: v }));
|
||||
libraryFilter.subscribe(v => persist("moku-store", { ...loadPersisted(), libraryFilter: v }));
|
||||
history.subscribe(v => persist("moku-store", { ...loadPersisted(), history: v }));
|
||||
readingStats.subscribe(v => persist("moku-store", { ...loadPersisted(), readingStats: v }));
|
||||
settings.subscribe(v => persist("moku-store", { ...loadPersisted(), settings: v }));
|
||||
|
||||
Reference in New Issue
Block a user