chore: init svelte rewrite scaffold

This commit is contained in:
Youwes09
2026-03-18 22:29:03 -05:00
parent bf38e00cf3
commit 0e87c51801
69 changed files with 1249 additions and 17097 deletions
-561
View File
@@ -1,561 +0,0 @@
/* ─── Backdrop ── */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: var(--z-settings);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.12s ease both;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* ─── Modal shell ── */
.modal {
width: min(720px, calc(100vw - 48px));
height: min(520px, calc(100vh - 80px));
display: flex;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: scaleIn 0.16s ease both;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4);
}
/* ─── Sidebar ── */
.sidebar {
width: 152px;
flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
padding: var(--sp-5) var(--sp-3);
gap: var(--sp-1);
}
.modalTitle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: 0 var(--sp-2) var(--sp-3);
}
.nav { display: flex; flex-direction: column; gap: 1px; }
.navItem {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: background var(--t-fast), color var(--t-fast);
}
.navItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.navActive { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.navActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
/* ─── Content ── */
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.contentHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.contentTitle {
font-size: var(--text-md);
font-weight: var(--weight-medium);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
.closeBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.contentBody { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); }
/* ─── Panel / Section ── */
.panel { display: flex; flex-direction: column; gap: var(--sp-6); }
.section { display: flex; flex-direction: column; gap: 1px; }
.sectionTitle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
margin-bottom: var(--sp-2);
}
/* ─── Toggle ── */
.toggleRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
cursor: pointer; transition: background var(--t-fast);
}
.toggleRow:hover { background: var(--bg-raised); }
.toggleInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.toggleLabel { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-tight); }
.toggleDesc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-snug); }
.toggle {
position: relative; width: 34px; height: 18px; border-radius: var(--radius-full);
background: var(--bg-subtle); border: 1px solid var(--border-strong); flex-shrink: 0;
cursor: pointer; transition: background var(--t-base), border-color var(--t-base);
}
.toggleOn { background: var(--accent-dim); border-color: var(--accent); }
.toggleThumb {
position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
border-radius: 50%; background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.toggleOn .toggleThumb { transform: translateX(16px); background: var(--accent-fg); }
/* ─── Stepper ── */
.stepRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.stepRow:hover { background: var(--bg-raised); }
.stepControls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.stepBtn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); font-size: var(--text-base);
color: var(--text-muted); transition: background var(--t-base), color var(--t-base);
line-height: 1;
}
.stepBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.stepBtn:disabled { opacity: 0.25; cursor: default; }
.stepVal {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
}
/* ─── Select (custom) ── */
.selectWrap { position: relative; flex-shrink: 0; min-width: 130px; }
.selectBtn {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
width: 100%; padding: 5px 10px;
background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
text-align: left;
}
.selectBtn:hover { border-color: var(--border-focus); }
.selectCaret {
color: var(--text-faint); flex-shrink: 0;
transition: transform var(--t-base);
}
.selectCaretOpen { transform: rotate(180deg); }
.selectMenu {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-1);
display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 200; animation: scaleIn 0.1s ease both; transform-origin: top center;
}
.selectOption {
padding: 6px 10px; border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-secondary); background: none; border: none;
cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.selectOption:hover { background: var(--bg-overlay); color: var(--text-primary); }
.selectOptionActive { color: var(--accent-fg); background: var(--accent-muted); }
.selectOptionActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
/* ─── Scale ── */
.scaleRow {
display: flex; align-items: center; gap: var(--sp-3);
padding: 10px var(--sp-3); border-radius: var(--radius-md);
}
.scaleSlider { flex: 1; }
.scaleVal {
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
min-width: 36px; text-align: right; letter-spacing: var(--tracking-wide);
}
.scaleHint {
display: flex; flex-wrap: wrap; gap: var(--sp-1);
padding: 0 var(--sp-3) var(--sp-2);
}
.scalePreset {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.scalePreset:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scalePresetActive {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
/* ─── Text input ── */
.textInput {
background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
outline: none; flex-shrink: 0; width: 180px;
transition: border-color var(--t-base);
}
.textInput:focus { border-color: var(--border-focus); }
/* ─── Keybinds ── */
.kbHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.kbHint { font-size: var(--text-xs); color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-3); }
.resetAllBtn {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.resetAllBtn:hover { color: var(--color-error); border-color: var(--color-error); }
.kbList { display: flex; flex-direction: column; gap: 1px; }
.kbRow {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-4); padding: 8px var(--sp-3); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.kbRow:hover { background: var(--bg-raised); }
.kbLabel { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
.kbRight { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.kbBind {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay);
color: var(--text-secondary); cursor: pointer; min-width: 100px; text-align: center;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.kbBind:hover { border-color: var(--accent); color: var(--accent-fg); }
.kbBindListening {
border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg);
animation: pulse 1s ease infinite;
}
.kbReset {
font-size: var(--text-base); color: var(--text-faint); width: 22px; height: 22px;
border-radius: var(--radius-sm); border: 1px solid transparent; background: none;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: color var(--t-base), border-color var(--t-base);
}
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
.kbReset:disabled { opacity: 0.2; cursor: default; }
/* ─── Storage ── */
.storageLoading {
font-size: var(--text-sm); color: var(--text-faint);
padding: var(--sp-3) var(--sp-3);
}
.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); }
.storageBar {
width: 100%; height: 7px;
background: var(--bg-overlay); border-radius: var(--radius-full);
overflow: hidden;
}
.storageBarFill {
height: 100%; border-radius: var(--radius-full);
background: var(--accent);
transition: width 0.4s ease;
}
.storageBarWarn { background: #d97706; }
.storageBarCritical { background: var(--color-error); }
.storageBarLabels {
display: flex; justify-content: space-between;
margin-top: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
}
.storageBarUsed { color: var(--text-secondary); }
.storageBarFree { color: var(--text-faint); }
.storageBarNote {
font-size: var(--text-xs); color: var(--text-faint);
margin-top: var(--sp-1);
}
.storageLegend {
display: flex; flex-direction: column; gap: 1px;
padding: var(--sp-2) var(--sp-3);
}
.storageLegendRow {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px 0;
font-size: var(--text-sm);
}
.storageDot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.storageDotManga { background: var(--accent); }
.storageDotApp { background: var(--border-strong); }
.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
.storageLegendLabel { flex: 1; color: var(--text-muted); }
.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.storageLimitHint {
font-size: var(--text-xs); color: #d97706;
padding: 0 var(--sp-3) var(--sp-2);
}
.setLimitBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: none; border: 1px solid var(--border-strong);
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); }
.storagePathNote {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
padding: var(--sp-1) var(--sp-3) var(--sp-2);
word-break: break-all;
}
/* ─── About ── */
.aboutBlock {
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
}
.aboutLine { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-base); }
.dangerBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md);
background: none; border: 1px solid var(--color-error);
color: var(--color-error); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base);
}
.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.dangerBtn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder management (Settings FoldersTab) ────────────────────────── */
.folderCreateRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-3) var(--sp-3);
}
.folderCreateBtn {
display: flex;
align-items: center;
gap: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 5px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.folderCreateBtn:hover:not(:disabled) {
color: var(--accent-fg);
border-color: var(--accent);
}
.folderCreateBtn:disabled { opacity: 0.3; cursor: default; }
.folderList {
display: flex;
flex-direction: column;
gap: 1px;
}
.folderRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 9px var(--sp-3);
border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.folderRow:hover { background: var(--bg-raised); }
.folderRowName {
flex: 1;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.folderRowCount {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-right: var(--sp-1);
}
.folderTabToggle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.folderTabToggle:hover {
color: var(--text-muted);
border-color: var(--border-strong);
}
.folderTabToggleOn {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.folderTabToggleOn:hover {
background: var(--accent-muted);
color: var(--accent-fg);
}
/* ─── Theme picker ── */
.themeGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
}
.themeCard {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
cursor: pointer;
text-align: left;
transition: border-color var(--t-base), background var(--t-base);
}
.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
.themeCardActive {
border-color: var(--accent);
background: var(--accent-muted);
}
.themeCardActive:hover { border-color: var(--accent); }
.themePreview {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid rgba(0,0,0,0.15);
flex-shrink: 0;
}
.themePreviewBg {
width: 100%; height: 100%;
display: flex;
}
.themePreviewSidebar {
width: 22%;
height: 100%;
flex-shrink: 0;
opacity: 0.9;
}
.themePreviewContent {
flex: 1;
padding: 10% 12%;
display: flex;
flex-direction: column;
gap: 8%;
justify-content: center;
}
.themePreviewAccent {
height: 14%;
border-radius: 2px;
width: 55%;
}
.themePreviewText {
height: 9%;
border-radius: 2px;
width: 100%;
}
.themeCardInfo {
display: flex;
flex-direction: column;
gap: 1px;
}
.themeCardLabel {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-secondary);
line-height: var(--leading-tight);
}
.themeCardDesc {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.themeCardCheck {
position: absolute;
top: var(--sp-1);
right: var(--sp-2);
font-size: var(--text-xs);
color: var(--accent-fg);
font-family: var(--font-ui);
}
+3
View File
@@ -0,0 +1,3 @@
<script lang="ts">
</script>
<div>Settings stub</div>
-954
View File
@@ -1,954 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } 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, Theme } from "../../store";
import s from "./Settings.module.css";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
{ id: "appearance", label: "Appearance", icon: <PaintBrush 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" /> },
{ id: "devtools", label: "Dev Tools", icon: <Wrench 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 className={s.section}>
<p className={s.sectionTitle}>Inactivity</p>
<SelectRow
label="Idle screen timeout"
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
value={String(settings.idleTimeoutMin ?? 5)}
options={[
{ value: "0", label: "Never" },
{ value: "1", label: "1 minute" },
{ value: "2", label: "2 minutes" },
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]}
onChange={(v) => update({ idleTimeoutMin: Number(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 })} />
{!(settings.autoNextChapter ?? false) && (
<Toggle label="Mark read when skipping to next chapter"
description="When auto-advance is off, mark the current chapter as read if you tap the next chapter button before finishing it"
checked={settings.markReadOnNext ?? true}
onChange={(v) => update({ markReadOnNext: 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}>Idle / Splash Screen</p>
<Toggle label="Animated card background"
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
checked={settings.splashCards ?? true}
onChange={(v) => update({ splashCards: 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 className={s.section}>
<p className={s.sectionTitle}>Reader</p>
<Stepper
label="Input debounce"
description="Delay (ms) before page-turn input is processed. Increase if the reader feels laggy or skips pages. Set to 0 to disable."
value={settings.readerDebounceMs ?? 120}
min={0}
max={500}
step={20}
onChange={(v) => update({ readerDebounceMs: 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, free, limit, total }: { used: number; free: number; limit: number | null; total: number }) {
// "Available space" = what's actually usable: already-used manga bytes + free bytes on disk.
// We intentionally do NOT use total_bytes (full drive size) as the cap — other apps / OS
// overhead eat into that, and it makes our bar look almost empty even when downloads are large.
const available = used + free; // usable space relevant to downloads
const cap = limit !== null ? Math.min(limit, available) : available;
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const critical = pctUsed > 90;
const warning = pctUsed > 75;
const freeInCap = Math.max(0, cap - used);
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(freeInCap)} free</span>
</div>
{limit !== null && (
<p className={s.storageBarNote}>
Limit {fmtBytes(limit)} · {fmtBytes(free)} free on disk 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} free={freeBytes} 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 + mangaBytes) && (
<p className={s.storageLimitHint}>Limit exceeds available space ({fmtBytes(freeBytes)} free on disk)</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>
);
}
// ── Appearance tab ────────────────────────────────────────────────────────────
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
{
id: "dark",
label: "Dark",
description: "Default near-black",
swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"],
},
{
id: "high-contrast",
label: "High Contrast",
description: "Darker base, sharper text",
swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"],
},
{
id: "light",
label: "Light",
description: "Warm off-white",
swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"],
},
{
id: "light-contrast",
label: "Light Contrast",
description: "Light with maximum text contrast",
swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"],
},
{
id: "midnight",
label: "Midnight",
description: "Deep blue-black tint",
swatches: ["#0c1020", "#101428", "#a8b4e8", "#eeeef8"],
},
{
id: "warm",
label: "Warm",
description: "Amber and sepia tones",
swatches: ["#16130c", "#1c1810", "#e0b860", "#f5f0e0"],
},
];
function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
const current = settings.theme ?? "dark";
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Theme</p>
<div className={s.themeGrid}>
{THEMES.map((theme) => {
const active = current === theme.id;
return (
<button
key={theme.id}
className={[s.themeCard, active ? s.themeCardActive : ""].join(" ")}
onClick={() => update({ theme: theme.id })}
title={theme.description}
>
<div className={s.themePreview}>
{/* Mini UI preview using the theme swatches */}
<div className={s.themePreviewBg} style={{ background: theme.swatches[0] }}>
<div className={s.themePreviewSidebar} style={{ background: theme.swatches[1] }} />
<div className={s.themePreviewContent}>
<div className={s.themePreviewAccent} style={{ background: theme.swatches[2] }} />
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "55" }} />
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "33", width: "60%" }} />
</div>
</div>
</div>
<div className={s.themeCardInfo}>
<span className={s.themeCardLabel}>{theme.label}</span>
<span className={s.themeCardDesc}>{theme.description}</span>
</div>
{active && <span className={s.themeCardCheck}></span>}
</button>
);
})}
</div>
</div>
</div>
);
}
function DevToolsTab() {
const [splashTriggered, setSplashTriggered] = useState(false);
function triggerSplash() {
setSplashTriggered(true);
setTimeout(() => setSplashTriggered(false), 200);
(window as any).__mokuShowSplash?.();
}
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Splash Screen</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Preview idle screen</span>
<span className={s.toggleDesc}>Show the idle splash dismiss with any click or key</span>
</div>
<button
className={s.dangerBtn}
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
color: splashTriggered ? "var(--bg-base)" : undefined,
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
transition: "all 0.15s ease" }}
onClick={triggerSplash}
>
Show idle
</button>
</div>
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Build Info</p>
<div className={s.aboutBlock}>
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
Mode: {import.meta.env.MODE}
</p>
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
Dev: {String(import.meta.env.DEV)}
</p>
</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 === "appearance" && <AppearanceTab 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 />}
{tab === "devtools" && <DevToolsTab />}
</div>
</div>
</div>
</div>
);
}