import { useEffect, useRef, useState, useCallback } from "react";
import { X, Book, Image, Sliders, Info, Keyboard, Gear } from "@phosphor-icons/react";
import { useStore } 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" | "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: "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 (
);
}
// ── 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]}
);
})}
);
}
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);
// Scroll to top on every tab switch
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 === "about" &&
}
);
}