mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
770 lines
32 KiB
TypeScript
770 lines
32 KiB
TypeScript
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 & 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}>−</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>
|
||
);
|
||
} |