Files
Moku/src/components/settings/Settings.tsx
T
2026-02-22 16:44:25 -06:00

770 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: <Gear size={14} weight="light" /> },
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
];
// ── Primitives ────────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, label, description }: {
checked: boolean; onChange: (v: boolean) => void; label: string; description?: string;
}) {
return (
<label className={s.toggleRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<button role="switch" aria-checked={checked}
className={[s.toggle, checked ? s.toggleOn : ""].join(" ")}
onClick={() => onChange(!checked)}>
<span className={s.toggleThumb} />
</button>
</label>
);
}
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 (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<div className={s.stepControls}>
<button className={s.stepBtn} onClick={() => onChange(Math.max(min, value - step))} disabled={value <= min}></button>
<span className={s.stepVal}>{value}</span>
<button className={s.stepBtn} onClick={() => onChange(Math.min(max, value + step))} disabled={value >= max}>+</button>
</div>
</div>
);
}
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<HTMLDivElement>(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 (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<div className={s.selectWrap} ref={ref}>
<button className={s.selectBtn} onClick={() => setOpen((o) => !o)}>
<span>{selected?.label ?? value}</span>
<svg className={[s.selectCaret, open ? s.selectCaretOpen : ""].join(" ")} width="10" height="6" viewBox="0 0 10 6" fill="none">
<path d="M0 0l5 6 5-6" fill="currentColor" />
</svg>
</button>
{open && (
<div className={s.selectMenu}>
{options.map((o) => (
<button
key={o.value}
className={[s.selectOption, o.value === value ? s.selectOptionActive : ""].join(" ")}
onClick={() => { onChange(o.value); setOpen(false); }}
>
{o.label}
</button>
))}
</div>
)}
</div>
</div>
);
}
function TextRow({ value, onChange, label, description, placeholder }: {
value: string; onChange: (v: string) => void;
label: string; description?: string; placeholder?: string;
}) {
return (
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>{label}</span>
{description && <span className={s.toggleDesc}>{description}</span>}
</div>
<input className={s.textInput} value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} spellCheck={false} />
</div>
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Interface Scale</p>
<div className={s.scaleRow}>
<input type="range" min={70} max={150} step={5}
value={settings.uiScale}
onChange={(e) => update({ uiScale: Number(e.target.value) })}
className={s.scaleSlider} />
<span className={s.scaleVal}>{settings.uiScale}%</span>
<button className={s.stepBtn}
onClick={() => update({ uiScale: 100 })}
disabled={settings.uiScale === 100} title="Reset"></button>
</div>
<p className={s.scaleHint}>
{[70, 80, 90, 100, 110, 125, 150].map((v) => (
<button key={v}
className={[s.scalePreset, settings.uiScale === v ? s.scalePresetActive : ""].join(" ")}
onClick={() => update({ uiScale: v })}>{v}%</button>
))}
</p>
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Server</p>
<TextRow label="Server URL" description="Base URL of your Suwayomi instance"
value={settings.serverUrl ?? "http://localhost:4567"}
onChange={(v) => update({ serverUrl: v })}
placeholder="http://localhost:4567" />
<TextRow label="Server binary" description="Path or command to launch tachidesk-server"
value={settings.serverBinary}
onChange={(v) => update({ serverBinary: v })}
placeholder="tachidesk-server" />
<Toggle label="Auto-start server"
description="Launch tachidesk-server when Moku opens"
checked={settings.autoStartServer}
onChange={(v) => update({ autoStartServer: v })} />
</div>
</div>
);
}
function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Page Layout</p>
<SelectRow label="Default layout"
description="How chapters open by default"
value={settings.pageStyle === "double" ? "single" : settings.pageStyle}
options={[
{ value: "single", label: "Single page" },
{ value: "longstrip", label: "Long strip" },
]}
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
<SelectRow label="Reading direction"
description="Left-to-right for most manga, right-to-left for Japanese"
value={settings.readingDirection}
options={[
{ value: "ltr", label: "Left to right" },
{ value: "rtl", label: "Right to left" },
]}
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
<Toggle label="Page gap"
description="Add spacing between pages in longstrip mode"
checked={settings.pageGap}
onChange={(v) => update({ pageGap: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Fit &amp; Zoom</p>
<SelectRow label="Default fit mode"
description="How pages are sized to fit the screen"
value={settings.fitMode ?? "width"}
options={[
{ value: "width", label: "Fit width" },
{ value: "height", label: "Fit height" },
{ value: "screen", label: "Fit screen" },
{ value: "original", label: "Original (1:1)" },
]}
onChange={(v) => update({ fitMode: v as FitMode })} />
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Max page width</span>
<span className={s.toggleDesc}>Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live.</span>
</div>
<div className={s.stepControls}>
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.max(200, (settings.maxPageWidth ?? 900) - 100) })}></button>
<span className={s.stepVal}>{settings.maxPageWidth ?? 900}px</span>
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.min(2400, (settings.maxPageWidth ?? 900) + 100) })}>+</button>
</div>
</div>
<Toggle label="Optimize contrast"
description="Use webkit-optimize-contrast rendering (sharper on low-DPI)"
checked={settings.optimizeContrast}
onChange={(v) => update({ optimizeContrast: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Behaviour</p>
<Toggle label="Auto-mark chapters read"
description="Mark a chapter as read when you reach the last page"
checked={settings.autoMarkRead}
onChange={(v) => update({ autoMarkRead: v })} />
<Toggle label="Auto-advance chapters"
description="Automatically open the next chapter at the end of a long strip"
checked={settings.autoNextChapter ?? false}
onChange={(v) => update({ autoNextChapter: v })} />
<Stepper label="Pages to preload"
description="Images loaded ahead of the current page"
value={settings.preloadPages} min={0} max={10}
onChange={(v) => update({ preloadPages: v })} />
</div>
</div>
);
}
function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
const clearHistory = useStore((s) => s.clearHistory);
const historyLen = useStore((s) => s.history.length);
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Display</p>
<Toggle label="Crop cover images"
description="Fill grid cells — may crop cover edges"
checked={settings.libraryCropCovers}
onChange={(v) => update({ libraryCropCovers: v })} />
<Toggle label="Show NSFW sources"
description="Display adult content sources in the sources list"
checked={settings.showNsfw}
onChange={(v) => update({ showNsfw: v })} />
<Stepper label="Initial cards to display"
description="Cards shown before 'Show more' appears"
value={settings.libraryPageSize} min={12} max={200} step={12}
onChange={(v) => update({ libraryPageSize: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Chapters</p>
<SelectRow label="Default sort direction"
value={settings.chapterSortDir}
options={[
{ value: "desc", label: "Newest first" },
{ value: "asc", label: "Oldest first" },
]}
onChange={(v) => update({ chapterSortDir: v as Settings["chapterSortDir"] })} />
<Stepper label="Chapters per page"
description="Chapter list pagination size"
value={settings.chapterPageSize} min={10} max={100} step={5}
onChange={(v) => update({ chapterPageSize: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Extensions</p>
<SelectRow label="Preferred language"
description="Language variant shown first when an extension has multiple"
value={settings.preferredExtensionLang ?? "en"}
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "pt-br", label: "Portuguese (BR)" },
{ value: "it", label: "Italian" },
{ value: "ru", label: "Russian" },
{ value: "ar", label: "Arabic" },
{ value: "tr", label: "Turkish" },
{ value: "zh", label: "Chinese (Simplified)" },
{ value: "zh-hant", label: "Chinese (Traditional)" },
{ value: "ko", label: "Korean" },
{ value: "ja", label: "Japanese" },
{ value: "id", label: "Indonesian" },
{ value: "vi", label: "Vietnamese" },
{ value: "th", label: "Thai" },
{ value: "pl", label: "Polish" },
{ value: "nl", label: "Dutch" },
]}
onChange={(v) => update({ preferredExtensionLang: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>History</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Reading history</span>
<span className={s.toggleDesc}>{historyLen} entries stored</span>
</div>
<button className={s.dangerBtn} onClick={clearHistory} disabled={historyLen === 0}>
Clear history
</button>
</div>
</div>
</div>
);
}
function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Rendering</p>
<Toggle label="GPU acceleration"
description="Promote reader and library to compositor layers (recommended)"
checked={settings.gpuAcceleration}
onChange={(v) => update({ gpuAcceleration: v })} />
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Interface</p>
<Toggle label="Compact sidebar"
description="Reduce sidebar icon spacing"
checked={settings.compactSidebar}
onChange={(v) => update({ compactSidebar: v })} />
</div>
</div>
);
}
function KeybindsTab({ settings, update, reset }: {
settings: Settings; update: (p: Partial<Settings>) => void; reset: () => void;
}) {
const [listening, setListening] = useState<keyof Keybinds | null>(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 (
<div className={s.panel}>
<div className={s.section}>
<div className={s.kbHeader}>
<p className={s.sectionTitle}>Keyboard shortcuts</p>
<button className={s.resetAllBtn} onClick={reset}>Reset all</button>
</div>
<p className={s.kbHint}>Click a key to rebind, then press the new combination.</p>
<div className={s.kbList}>
{(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => {
const isListening = listening === key;
const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key];
return (
<div key={key} className={s.kbRow}>
<span className={s.kbLabel}>{KEYBIND_LABELS[key]}</span>
<div className={s.kbRight}>
<button
className={[s.kbBind, isListening ? s.kbBindListening : ""].join(" ")}
onClick={() => setListening(isListening ? null : key)}>
{isListening ? "Press key…" : settings.keybinds[key]}
</button>
<button className={s.kbReset}
onClick={() => update({ keybinds: { ...settings.keybinds, [key]: DEFAULT_KEYBINDS[key] } })}
disabled={isDefault} title="Reset"></button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── 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 (
<div className={s.storageBarWrap}>
<div className={s.storageBar}>
<div
className={[s.storageBarFill, critical ? s.storageBarCritical : warning ? s.storageBarWarn : ""].join(" ")}
style={{ width: `${pctUsed}%` }}
/>
</div>
<div className={s.storageBarLabels}>
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
<span className={s.storageBarFree}>{fmtBytes(Math.max(0, cap - used))} free</span>
</div>
{limit !== null && total > 0 && (
<p className={s.storageBarNote}>Limit {fmtBytes(limit)} of {fmtBytes(total)} total</p>
)}
</div>
);
}
function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
const [info, setInfo] = useState<StorageInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<StorageInfo>("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 (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Disk Usage</p>
{loading && <p className={s.storageLoading}>Reading filesystem</p>}
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
{!loading && !error && info && (
<>
<StorageBar used={mangaBytes} limit={limitBytes} total={totalBytes} />
<div className={s.storageLegend}>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
<span className={s.storageLegendLabel}>Downloaded manga</span>
<span className={s.storageLegendVal}>{fmtBytes(mangaBytes)}</span>
</div>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotFree].join(" ")} />
<span className={s.storageLegendLabel}>Drive free</span>
<span className={s.storageLegendVal}>{fmtBytes(freeBytes)}</span>
</div>
<div className={s.storageLegendRow}>
<span className={[s.storageDot, s.storageDotApp].join(" ")} />
<span className={s.storageLegendLabel}>Drive total</span>
<span className={s.storageLegendVal}>{fmtBytes(totalBytes)}</span>
</div>
</div>
<p className={s.storagePathNote}>{info.path}</p>
</>
)}
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Storage Limit</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Limit download storage</span>
<span className={s.toggleDesc}>
{limitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${limitGb} GB`}
</span>
</div>
{limitGb === null ? (
<button className={s.setLimitBtn} onClick={() => update({ storageLimitGb: 10 })}>
Set limit
</button>
) : (
<div className={s.stepControls}>
<button className={s.stepBtn}
onClick={() => update({ storageLimitGb: Math.max(1, limitGb - 1) })}
disabled={limitGb <= 1}>&#8722;</button>
<span className={s.stepVal}>{limitGb} GB</span>
<button className={s.stepBtn}
onClick={() => update({ storageLimitGb: limitGb + 1 })}>+</button>
<button className={s.kbReset} onClick={() => update({ storageLimitGb: null })} title="Remove limit"></button>
</div>
)}
</div>
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (
<p className={s.storageLimitHint}>Limit exceeds available free space ({fmtBytes(freeBytes)})</p>
)}
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Cache</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Image cache</span>
<span className={s.toggleDesc}>Cached page images stored by the webview</span>
</div>
<button className={s.dangerBtn} onClick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
</button>
</div>
</div>
</div>
);
}
// ── 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<string | null>(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 (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Manage Folders</p>
<p className={s.toggleDesc} style={{ padding: "0 var(--sp-3) var(--sp-3)", display: "block" }}>
Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library.
</p>
{/* Create new folder */}
<div className={s.folderCreateRow}>
<input
className={s.textInput}
placeholder="New folder name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
style={{ flex: 1, width: "auto" }}
/>
<button
className={s.folderCreateBtn}
onClick={handleCreate}
disabled={!newName.trim()}
>
<Plus size={13} weight="bold" />
Create
</button>
</div>
{/* Folder list */}
{folders.length === 0 ? (
<p className={s.storageLoading}>No folders yet. Create one above.</p>
) : (
<div className={s.folderList}>
{folders.map((folder) => (
<div key={folder.id} className={s.folderRow}>
{editingId === folder.id ? (
<>
<input
autoFocus
className={s.textInput}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") { setEditingId(null); }
}}
onBlur={commitEdit}
style={{ flex: 1, width: "auto" }}
/>
<button className={s.kbReset} onClick={commitEdit} title="Save"></button>
</>
) : (
<>
<FolderSimple size={14} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
<span className={s.folderRowName}>{folder.name}</span>
<span className={s.folderRowCount}>{folder.mangaIds.length} manga</span>
{/* Show as tab toggle */}
<button
className={[s.folderTabToggle, folder.showTab ? s.folderTabToggleOn : ""].join(" ")}
onClick={() => toggleFolderTab(folder.id)}
title={folder.showTab ? "Shown as library tab — click to hide" : "Click to show as library tab"}
>
{folder.showTab ? "Tab on" : "Tab off"}
</button>
<button
className={s.kbReset}
onClick={() => startEdit(folder)}
title="Rename"
>
<Pencil size={12} weight="light" />
</button>
<button
className={[s.kbReset, s.folderDeleteBtn].join(" ")}
onClick={() => removeFolder(folder.id)}
title="Delete folder"
>
<Trash size={12} weight="light" />
</button>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
function AboutTab() {
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Moku</p>
<div className={s.aboutBlock}>
<p className={s.aboutLine}>A manga reader frontend for Suwayomi / Tachidesk.</p>
<p className={s.aboutLine} style={{ color: "var(--text-faint)", marginTop: "var(--sp-2)" }}>
Built with Tauri + React. Connects to tachidesk-server.
</p>
</div>
</div>
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export default function SettingsModal() {
const [tab, setTab] = useState<Tab>("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<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(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 (
<div className={s.backdrop} ref={backdropRef} onClick={handleBackdrop}>
<div className={s.modal} role="dialog" aria-label="Settings">
<div className={s.sidebar}>
<p className={s.modalTitle}>Settings</p>
<nav className={s.nav}>
{TABS.map((t) => (
<button key={t.id}
className={[s.navItem, tab === t.id ? s.navActive : ""].join(" ")}
onClick={() => setTab(t.id)}>
{t.icon}
<span>{t.label}</span>
</button>
))}
</nav>
</div>
<div className={s.content}>
<div className={s.contentHeader}>
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
</div>
<div className={s.contentBody} ref={contentBodyRef}>
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
{tab === "folders" && <FoldersTab />}
{tab === "about" && <AboutTab />}
</div>
</div>
</div>
</div>
);
}