mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[BETA] Initial Commit (Nix Support Only)
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
/* ─── 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 ── */
|
||||
.select {
|
||||
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; cursor: pointer; flex-shrink: 0; transition: border-color var(--t-base);
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M0 0l5 6 5-6' fill='%23888'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 24px;
|
||||
}
|
||||
.select:focus { border-color: var(--border-focus); }
|
||||
.select option { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
|
||||
/* ─── 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; }
|
||||
|
||||
/* ─── 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; }
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear } from "@phosphor-icons/react";
|
||||
import { useStore } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
import s from "./Settings.module.css";
|
||||
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "about";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "general", label: "General", icon: <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: "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;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>{label}</span>
|
||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||
</div>
|
||||
<select className={s.select} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</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}
|
||||
options={[
|
||||
{ value: "single", label: "Single page" },
|
||||
{ value: "double", label: "Double 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="Offset double spreads"
|
||||
description="Shift double-page groups so spreads align correctly"
|
||||
checked={settings.offsetDoubleSpreads}
|
||||
onChange={(v) => update({ offsetDoubleSpreads: v })} />
|
||||
<Toggle label="Page gap"
|
||||
description="Add spacing between pages in double and longstrip modes"
|
||||
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 })} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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}>
|
||||
{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 === "about" && <AboutTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user