mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
chore: init svelte rewrite scaffold
This commit is contained in:
+151
-288
@@ -1,317 +1,180 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { writable, get } from "svelte/store";
|
||||
import type { Manga, Chapter, Source } from "../lib/types";
|
||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
export type FitMode = "width" | "height" | "screen" | "original";
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type Theme =
|
||||
| "dark" // default — near-black
|
||||
| "high-contrast" // darker + sharper text
|
||||
| "light" // warm off-white
|
||||
| "light-contrast" // light + max contrast
|
||||
| "midnight" // blue-black tint
|
||||
| "warm"; // amber/sepia tint
|
||||
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||
|
||||
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 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;
|
||||
}
|
||||
export interface Folder { id: string; name: string; mangaIds: number[]; showTab: 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[];
|
||||
/**
|
||||
* Mark a chapter as read when the user manually taps the "next chapter"
|
||||
* button/key while autoNextChapter is off. Has no effect when autoNextChapter
|
||||
* is on (the scroll-based mark-as-read logic handles that path).
|
||||
*/
|
||||
markReadOnNext: boolean;
|
||||
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||
readerDebounceMs: number;
|
||||
/** UI colour theme. Applied as data-theme on <html>. */
|
||||
theme: Theme;
|
||||
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;
|
||||
}
|
||||
|
||||
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: [],
|
||||
markReadOnNext: true,
|
||||
readerDebounceMs: 120,
|
||||
theme: "dark",
|
||||
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: [],
|
||||
markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
|
||||
};
|
||||
|
||||
interface Store {
|
||||
navPage: NavPage;
|
||||
setNavPage: (page: NavPage) => void;
|
||||
genreFilter: string;
|
||||
setGenreFilter: (genre: string) => void;
|
||||
searchPrefill: string;
|
||||
setSearchPrefill: (q: string) => void;
|
||||
activeManga: Manga | null;
|
||||
setActiveManga: (manga: Manga | null) => void;
|
||||
previewManga: Manga | null;
|
||||
setPreviewManga: (manga: Manga | null) => void;
|
||||
activeChapter: Chapter | null;
|
||||
activeChapterList: Chapter[];
|
||||
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
||||
closeReader: () => void;
|
||||
activeSource: Source | null;
|
||||
setActiveSource: (source: Source | null) => void;
|
||||
pageUrls: string[];
|
||||
setPageUrls: (urls: string[]) => void;
|
||||
pageNumber: number;
|
||||
setPageNumber: (n: number) => void;
|
||||
libraryFilter: LibraryFilter;
|
||||
setLibraryFilter: (filter: LibraryFilter) => void;
|
||||
libraryTagFilter: string[];
|
||||
setLibraryTagFilter: (tags: string[]) => void;
|
||||
settingsOpen: boolean;
|
||||
openSettings: () => void;
|
||||
closeSettings: () => void;
|
||||
activeDownloads: ActiveDownload[];
|
||||
setActiveDownloads: (items: ActiveDownload[]) => void;
|
||||
history: HistoryEntry[];
|
||||
addHistory: (entry: HistoryEntry) => void;
|
||||
clearHistory: () => void;
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
settings: Settings;
|
||||
updateSettings: (patch: Partial<Settings>) => void;
|
||||
resetKeybinds: () => void;
|
||||
// Folder helpers
|
||||
addFolder: (name: string) => string;
|
||||
removeFolder: (id: string) => void;
|
||||
renameFolder: (id: string, name: string) => void;
|
||||
toggleFolderTab: (id: string) => void;
|
||||
assignMangaToFolder: (folderId: string, mangaId: number) => void;
|
||||
removeMangaFromFolder: (folderId: string, mangaId: number) => void;
|
||||
getMangaFolders: (mangaId: number) => Folder[];
|
||||
function loadPersisted() {
|
||||
try {
|
||||
const raw = localStorage.getItem("moku-store");
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
function persist(key: string, value: unknown) {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||
}
|
||||
|
||||
export const useStore = create<Store>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
navPage: "library",
|
||||
setNavPage: (navPage) => set({ navPage }),
|
||||
genreFilter: "",
|
||||
setGenreFilter: (genreFilter) => set({ genreFilter }),
|
||||
searchPrefill: "",
|
||||
setSearchPrefill: (searchPrefill) => set({ searchPrefill }),
|
||||
activeManga: null,
|
||||
setActiveManga: (activeManga) => set({ activeManga }),
|
||||
previewManga: null,
|
||||
setPreviewManga: (previewManga) => set({ previewManga }),
|
||||
activeChapter: null,
|
||||
activeChapterList: [],
|
||||
openReader: (chapter, chapterList) =>
|
||||
set({ activeChapter: chapter, activeChapterList: chapterList, pageUrls: [], pageNumber: 1 }),
|
||||
closeReader: () =>
|
||||
set({ activeChapter: null, activeChapterList: [], pageUrls: [], pageNumber: 1 }),
|
||||
activeSource: null,
|
||||
setActiveSource: (activeSource) => set({ activeSource }),
|
||||
pageUrls: [],
|
||||
setPageUrls: (pageUrls) => set({ pageUrls }),
|
||||
pageNumber: 1,
|
||||
setPageNumber: (pageNumber) => set({ pageNumber }),
|
||||
libraryFilter: "library",
|
||||
setLibraryFilter: (libraryFilter) => set({ libraryFilter }),
|
||||
libraryTagFilter: [],
|
||||
setLibraryTagFilter: (libraryTagFilter) => set({ libraryTagFilter }),
|
||||
settingsOpen: false,
|
||||
openSettings: () => set({ settingsOpen: true }),
|
||||
closeSettings: () => set({ settingsOpen: false }),
|
||||
activeDownloads: [],
|
||||
setActiveDownloads: (activeDownloads) => set({ activeDownloads }),
|
||||
history: [],
|
||||
addHistory: (entry) =>
|
||||
set((s) => {
|
||||
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
||||
if (existing === 0) {
|
||||
const updated = [...s.history];
|
||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||
return { history: updated };
|
||||
}
|
||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||
return { history: [entry, ...deduped].slice(0, 300) };
|
||||
}),
|
||||
clearHistory: () => set({ history: [] }),
|
||||
toasts: [],
|
||||
addToast: (toast) =>
|
||||
set((s) => ({
|
||||
toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5),
|
||||
})),
|
||||
dismissToast: (id) =>
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
settings: DEFAULT_SETTINGS,
|
||||
updateSettings: (patch) =>
|
||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
||||
resetKeybinds: () =>
|
||||
set((s) => ({ settings: { ...s.settings, keybinds: DEFAULT_KEYBINDS } })),
|
||||
const saved = loadPersisted();
|
||||
|
||||
// ── Folder actions ──────────────────────────────────────────────────────
|
||||
addFolder: (name) => {
|
||||
const id = genId();
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: [...s.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }],
|
||||
},
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
removeFolder: (id) =>
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: s.settings.folders.filter((f) => f.id !== id),
|
||||
},
|
||||
})),
|
||||
renameFolder: (id, name) =>
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: s.settings.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f),
|
||||
},
|
||||
})),
|
||||
toggleFolderTab: (id) =>
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: s.settings.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
||||
},
|
||||
})),
|
||||
assignMangaToFolder: (folderId, mangaId) =>
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: s.settings.folders.map((f) =>
|
||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
||||
: f
|
||||
),
|
||||
},
|
||||
})),
|
||||
removeMangaFromFolder: (folderId, mangaId) =>
|
||||
set((s) => ({
|
||||
settings: {
|
||||
...s.settings,
|
||||
folders: s.settings.folders.map((f) =>
|
||||
f.id === folderId
|
||||
? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) }
|
||||
: f
|
||||
),
|
||||
},
|
||||
})),
|
||||
getMangaFolders: (mangaId) =>
|
||||
get().settings.folders.filter((f) => f.mangaIds.includes(mangaId)),
|
||||
}),
|
||||
{
|
||||
name: "moku-store",
|
||||
partialize: (s) => ({
|
||||
settings: s.settings,
|
||||
navPage: s.navPage,
|
||||
libraryFilter: s.libraryFilter,
|
||||
history: s.history,
|
||||
}),
|
||||
merge: (persisted: any, current) => ({
|
||||
...current,
|
||||
...(persisted as object),
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(persisted as any)?.settings,
|
||||
folders: (persisted as any)?.settings?.folders ?? [],
|
||||
keybinds: {
|
||||
...DEFAULT_KEYBINDS,
|
||||
...(persisted as any)?.settings?.keybinds,
|
||||
},
|
||||
},
|
||||
}),
|
||||
function mergeSettings(saved: any): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...saved?.settings,
|
||||
folders: saved?.settings?.folders ?? [],
|
||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||
};
|
||||
}
|
||||
|
||||
export const navPage = writable<NavPage>(saved?.navPage ?? "library");
|
||||
export const libraryFilter = writable<LibraryFilter>(saved?.libraryFilter ?? "library");
|
||||
export const history = writable<HistoryEntry[]>(saved?.history ?? []);
|
||||
export const settings = writable<Settings>(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 function openReader(chapter: Chapter, chapterList: Chapter[]) {
|
||||
activeChapter.set(chapter);
|
||||
activeChapterList.set(chapterList);
|
||||
pageUrls.set([]);
|
||||
pageNumber.set(1);
|
||||
}
|
||||
|
||||
export function closeReader() {
|
||||
activeChapter.set(null);
|
||||
activeChapterList.set([]);
|
||||
pageUrls.set([]);
|
||||
pageNumber.set(1);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export function addToast(toast: Omit<Toast, "id">) {
|
||||
toasts.update((t) => [...t, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5));
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
export function updateSettings(patch: Partial<Settings>) {
|
||||
settings.update((s) => ({ ...s, ...patch }));
|
||||
}
|
||||
|
||||
export function resetKeybinds() {
|
||||
settings.update((s) => ({ ...s, keybinds: DEFAULT_KEYBINDS }));
|
||||
}
|
||||
|
||||
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 }] }));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeFolder(id: string) {
|
||||
settings.update((s) => ({ ...s, folders: s.folders.filter((f) => f.id !== id) }));
|
||||
}
|
||||
|
||||
export function renameFolder(id: string, name: string) {
|
||||
settings.update((s) => ({ ...s, folders: s.folders.map((f) => f.id === id ? { ...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) }));
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeMangaFromFolder(folderId: string, mangaId: number) {
|
||||
settings.update((s) => ({
|
||||
...s, folders: s.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));
|
||||
}
|
||||
|
||||
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 }));
|
||||
settings.subscribe((v) => persist("moku-store", { ...loadPersisted(), settings: v }));
|
||||
|
||||
Reference in New Issue
Block a user