import { useEffect, useRef, useState, useCallback } from "react"; import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react"; import { invoke } from "@tauri-apps/api/core"; import { gql } from "../../lib/client"; import { GET_DOWNLOADS_PATH } from "../../lib/queries"; import { useStore } from "../../store"; import type { Folder } from "../../store"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds"; import type { Settings, FitMode } from "../../store"; import s from "./Settings.module.css"; type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about"; const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "general", label: "General", icon: }, { id: "reader", label: "Reader", icon: }, { id: "library", label: "Library", icon: }, { id: "performance", label: "Performance", icon: }, { id: "keybinds", label: "Keybinds", icon: }, { id: "storage", label: "Storage", icon: }, { id: "folders", label: "Folders", icon: }, { id: "about", label: "About", icon: }, ]; // ── Primitives ──────────────────────────────────────────────────────────────── function Toggle({ checked, onChange, label, description }: { checked: boolean; onChange: (v: boolean) => void; label: string; description?: string; }) { return ( ); } function Stepper({ value, onChange, min, max, step = 1, label, description }: { value: number; onChange: (v: number) => void; min: number; max: number; step?: number; label: string; description?: string; }) { return (
{label} {description && {description}}
{value}
); } function SelectRow({ value, options, onChange, label, description }: { value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; label: string; description?: string; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const selected = options.find((o) => o.value === value); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", handler); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", handler); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{label} {description && {description}}
{open && (
{options.map((o) => ( ))}
)}
); } function TextRow({ value, onChange, label, description, placeholder }: { value: string; onChange: (v: string) => void; label: string; description?: string; placeholder?: string; }) { return (
{label} {description && {description}}
onChange(e.target.value)} placeholder={placeholder} spellCheck={false} />
); } // ── Tabs ────────────────────────────────────────────────────────────────────── function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { return (

Interface Scale

update({ uiScale: Number(e.target.value) })} className={s.scaleSlider} /> {settings.uiScale}%

{[70, 80, 90, 100, 110, 125, 150].map((v) => ( ))}

Server

update({ serverUrl: v })} placeholder="http://localhost:4567" /> update({ serverBinary: v })} placeholder="tachidesk-server" /> update({ autoStartServer: v })} />
); } function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { return (

Page Layout

update({ pageStyle: v as Settings["pageStyle"] })} /> update({ readingDirection: v as Settings["readingDirection"] })} /> update({ pageGap: v })} />

Fit & Zoom

update({ fitMode: v as FitMode })} />
Max page width Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live.
{settings.maxPageWidth ?? 900}px
update({ optimizeContrast: v })} />

Behaviour

update({ autoMarkRead: v })} /> update({ autoNextChapter: v })} /> update({ preloadPages: v })} />
); } function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { const clearHistory = useStore((s) => s.clearHistory); const historyLen = useStore((s) => s.history.length); return (

Display

update({ libraryCropCovers: v })} /> update({ showNsfw: v })} /> update({ libraryPageSize: v })} />

Chapters

update({ chapterSortDir: v as Settings["chapterSortDir"] })} /> update({ chapterPageSize: v })} />

Extensions

update({ preferredExtensionLang: v })} />

History

Reading history {historyLen} entries stored
); } function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { return (

Rendering

update({ gpuAcceleration: v })} />

Interface

update({ compactSidebar: v })} />
); } function KeybindsTab({ settings, update, reset }: { settings: Settings; update: (p: Partial) => void; reset: () => void; }) { const [listening, setListening] = useState(null); useEffect(() => { if (!listening) return; function onKey(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); const bind = eventToKeybind(e); if (!bind) return; update({ keybinds: { ...settings.keybinds, [listening!]: bind } }); setListening(null); } window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [listening, settings.keybinds]); return (

Keyboard shortcuts

Click a key to rebind, then press the new combination.

{(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => { const isListening = listening === key; const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key]; return (
{KEYBIND_LABELS[key]}
); })}
); } // ── Storage helpers ─────────────────────────────────────────────────────────── function fmtBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`; } interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; } function StorageBar({ used, limit, total }: { used: number; limit: number | null; total: number }) { const cap = limit ?? total; const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0; const critical = pctUsed > 90; const warning = pctUsed > 75; return (
{fmtBytes(used)} used {fmtBytes(Math.max(0, cap - used))} free
{limit !== null && total > 0 && (

Limit {fmtBytes(limit)} of {fmtBytes(total)} total

)}
); } function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial) => void }) { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [clearing, setClearing] = useState(false); const [cleared, setCleared] = useState(false); const limitGb = settings.storageLimitGb ?? null; const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null; async function fetchInfo() { setLoading(true); setError(null); try { const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH); const result = await invoke("get_storage_info", { downloadsPath: pathData.settings.downloadsPath, }); setInfo(result); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } } useEffect(() => { fetchInfo(); }, []); function handleClearCache() { setClearing(true); caches.keys() .then((names) => Promise.all(names.map((n) => caches.delete(n)))) .catch(() => {}) .finally(() => { setClearing(false); setCleared(true); setTimeout(() => setCleared(false), 2500); fetchInfo(); }); } const mangaBytes = info?.manga_bytes ?? 0; const totalBytes = info?.total_bytes ?? 0; const freeBytes = info?.free_bytes ?? 0; return (

Disk Usage

{loading &&

Reading filesystem…

} {error &&

{error}

} {!loading && !error && info && ( <>
Downloaded manga {fmtBytes(mangaBytes)}
Drive free {fmtBytes(freeBytes)}
Drive total {fmtBytes(totalBytes)}

{info.path}

)}

Storage Limit

Limit download storage {limitGb === null ? "No limit — uses full drive capacity" : `Warn when downloads exceed ${limitGb} GB`}
{limitGb === null ? ( ) : (
{limitGb} GB
)}
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (

Limit exceeds available free space ({fmtBytes(freeBytes)})

)}

Cache

Image cache Cached page images stored by the webview
); } // ── Folders tab ─────────────────────────────────────────────────────────────── function FoldersTab() { const folders = useStore((s) => s.settings.folders); const addFolder = useStore((s) => s.addFolder); const removeFolder = useStore((s) => s.removeFolder); const renameFolder = useStore((s) => s.renameFolder); const toggleFolderTab = useStore((s) => s.toggleFolderTab); const [newName, setNewName] = useState(""); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(""); function handleCreate() { const name = newName.trim(); if (!name) return; addFolder(name); setNewName(""); } function startEdit(folder: Folder) { setEditingId(folder.id); setEditingName(folder.name); } function commitEdit() { if (editingId && editingName.trim()) { renameFolder(editingId, editingName.trim()); } setEditingId(null); setEditingName(""); } return (

Manage Folders

Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library.

{/* Create new folder */}
setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} style={{ flex: 1, width: "auto" }} />
{/* Folder list */} {folders.length === 0 ? (

No folders yet. Create one above.

) : (
{folders.map((folder) => (
{editingId === folder.id ? ( <> setEditingName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { setEditingId(null); } }} onBlur={commitEdit} style={{ flex: 1, width: "auto" }} /> ) : ( <> {folder.name} {folder.mangaIds.length} manga {/* Show as tab toggle */} )}
))}
)}
); } function AboutTab() { return (

Moku

A manga reader frontend for Suwayomi / Tachidesk.

Built with Tauri + React. Connects to tachidesk-server.

); } // ── Modal ───────────────────────────────────────────────────────────────────── export default function SettingsModal() { const [tab, setTab] = useState("general"); const closeSettings = useStore((s) => s.closeSettings); const settings = useStore((s) => s.settings); const updateSettings = useStore((s) => s.updateSettings); const resetKeybinds = useStore((s) => s.resetKeybinds); const backdropRef = useRef(null); const contentBodyRef = useRef(null); useEffect(() => { contentBodyRef.current?.scrollTo({ top: 0 }); }, [tab]); const handleBackdrop = useCallback( (e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); }, [closeSettings] ); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSettings(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [closeSettings]); return (

Settings

{TABS.find((t) => t.id === tab)?.label}

{tab === "general" && } {tab === "reader" && } {tab === "library" && } {tab === "performance" && } {tab === "keybinds" && } {tab === "storage" && } {tab === "folders" && } {tab === "about" && }
); }