Files
Moku/src/components/settings/Settings.svelte
T
2026-03-29 15:38:39 -05:00

2172 lines
124 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock, Eye, EyeSlash, Star } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client";
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import type { Category } from "../../lib/types";
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
import type { Keybinds } from "../../lib/keybinds";
import type { Tracker } from "../../lib/types";
interface Props {
onOpenThemeEditor?: (id?: string | null) => void;
}
let { onOpenThemeEditor }: Props = $props();
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "security" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear },
{ id: "appearance", label: "Appearance", icon: PaintBrush },
{ id: "reader", label: "Reader", icon: Book },
{ id: "library", label: "Library", icon: Image },
{ id: "performance",label: "Performance", icon: Sliders },
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
{ id: "storage", label: "Storage", icon: HardDrives },
{ id: "folders", label: "Folders", icon: FolderSimple },
{ id: "tracking", label: "Tracking", icon: ListChecks },
{ id: "security", label: "Security", icon: Lock },
{ id: "about", label: "About", icon: Info },
{ id: "devtools", label: "Dev Tools", icon: Wrench },
];
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 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"] },
];
let tab: Tab = $state("general");
let contentBodyEl: HTMLDivElement;
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
function close() { setSettingsOpen(false); }
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
$effect(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
let listeningKey: keyof Keybinds | null = $state(null);
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key;
}
function onKeyCapture(e: KeyboardEvent) {
if (!listeningKey) return;
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
listeningKey = null;
}
$effect(() => {
if (listeningKey) {
window.addEventListener("keydown", onKeyCapture, true);
return () => window.removeEventListener("keydown", onKeyCapture, true);
}
});
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
let storageInfo: StorageInfo | null = $state(null);
let storageLoading = $state(false);
let storageError: string | null = $state(null);
let clearing = $state(false);
let cleared = $state(false);
async function fetchStorage() {
storageLoading = true; storageError = null;
try {
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath });
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
finally { storageLoading = false; }
}
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
function handleClearCache() {
clearing = true;
caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))).catch(() => {})
.finally(() => { clearing = false; cleared = true; setTimeout(() => cleared = false, 2500); fetchStorage(); });
}
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]}`;
}
// ── Performance metrics ───────────────────────────────────────────────────────
interface PerfSnapshot {
cacheEntries: number;
cacheKeys: string[];
oldestEntryMs: number | null;
newestEntryMs: number | null;
}
let perfSnapshot: PerfSnapshot | null = $state(null);
function refreshPerfMetrics() {
const knownPrefixes = ["library", "sources", "popular", "genre:", "manga:", "chapters:", "page:", "pages:"];
let entries = 0;
let oldest: number | null = null;
let newest: number | null = null;
const foundKeys: string[] = [];
const checkKey = (k: string) => {
const age = cache.ageOf(k);
if (age !== undefined) {
entries++;
foundKeys.push(k);
const ts = Date.now() - age;
if (oldest === null || ts < oldest) oldest = ts;
if (newest === null || ts > newest) newest = ts;
}
};
["library", "sources", "popular"].forEach(checkKey);
["Action","Romance","Fantasy","Comedy","Drama","Horror","Sci-Fi","Adventure","Thriller",
"Isekai","Supernatural","Historical","Psychological","Sports","Mystery","Mecha",
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
}
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
function fmtAge(ts: number | null): string {
if (ts === null) return "—";
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
// Storage limit input state
let storageLimitInput = $state(String(store.settings.storageLimitGb ?? ""));
function applyStorageLimit() {
const v = storageLimitInput.trim();
if (v === "" || v === "0") { updateSettings({ storageLimitGb: null }); return; }
const n = parseFloat(v);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}
// catsLoading / catsError are local UI state only.
// The category list itself lives in store.categories (shared with Library).
let catsLoading: boolean = $state(false);
let catsError: string|null = $state(null);
let newFolderName = $state("");
let editingId: number | null = $state(null);
let editingName = $state("");
async function loadCategories() {
catsLoading = true; catsError = null;
try {
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
// Merge server data onto existing store.categories to preserve mangas.nodes
// that Library loaded — Settings' GET_CATEGORIES query may not include them.
// Also preserve any id=0 (Default) entry that Library manages.
const zeroCat = store.categories.filter(c => c.id === 0);
const fresh = res.categories.nodes.filter(c => c.id !== 0);
const merged = fresh.map(f => {
const existing = store.categories.find(c => c.id === f.id);
return existing ? { ...existing, ...f } : f;
});
setCategories([...zeroCat, ...merged]);
} catch (e: any) {
catsError = e?.message ?? "Failed to load folders";
} finally { catsLoading = false; }
}
async function createFolder() {
const name = newFolderName.trim();
if (!name) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
setCategories([...store.categories, res.createCategory.category]);
newFolderName = "";
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
}
function startEdit(id: number, name: string) { editingId = id; editingName = name; }
async function commitEdit() {
if (editingId !== null && editingName.trim()) {
try {
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
setCategories(store.categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c));
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
}
editingId = null; editingName = "";
}
async function deleteFolder(id: number) {
try {
await gql(DELETE_CATEGORY, { id });
setCategories(store.categories.filter(c => c.id !== id));
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
}
async function moveCategory(id: number, direction: -1 | 1) {
// Work only on the non-default (id !== 0) categories, sorted by order,
// which is what the Settings list renders and what the server expects.
const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => a.order - b.order);
const idx = sortable.findIndex(c => c.id === id);
if (idx < 0) return;
const newPos = idx + 1 + direction; // 1-based server position
if (newPos < 1 || newPos > sortable.length) return;
// Optimistic reorder — splice within sortable slice, keep mangas.nodes intact
const reordered = [...sortable];
const [moved] = reordered.splice(idx, 1);
reordered.splice(idx + direction, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
try {
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
// Server returns bare order data — merge to preserve mangas.nodes, keep id=0 entry
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([
...zeroCat,
...updated
.sort((a, b) => a.order - b.order)
.map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
} catch (e: any) {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
}
}
// Load categories when the folders tab is first opened and the shared store
// is empty (e.g. user opened Settings before Library was mounted).
$effect(() => { if (tab === "folders" && !store.categories.length && !catsLoading) loadCategories(); });
let selectOpen: string | null = $state(null);
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
function onSelectOutside(e: MouseEvent) {
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
}
$effect(() => {
document.addEventListener("mousedown", onSelectOutside);
return () => document.removeEventListener("mousedown", onSelectOutside);
});
let splashTriggered = $state(false);
let showAuthPass = $state(false);
let showSocksPass = $state(false);
let pinInput = $state(store.settings.appLockPin ?? "");
let pinError = $state("");
let secLoading = $state(false);
let secError = $state<string | null>(null);
let secSaved = $state<string | null>(null);
let authUsername = $state(store.settings.serverAuthUser ?? "");
let authPassword = $state(store.settings.serverAuthPass ?? "");
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
let socksHost = $state(store.settings.socksProxyHost ?? "");
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
let socksVersion = $state(store.settings.socksProxyVersion ?? 5);
let socksUsername = $state(store.settings.socksProxyUsername ?? "");
let socksPassword = $state(store.settings.socksProxyPassword ?? "");
let flareEnabled = $state(store.settings.flareSolverrEnabled ?? false);
let flareUrl = $state(store.settings.flareSolverrUrl ?? "http://localhost:8191");
let flareTimeout = $state(store.settings.flareSolverrTimeout ?? 60);
let flareSession = $state(store.settings.flareSolverrSessionName ?? "moku");
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrFallback ?? false);
function showSaved(key: string) {
secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
}
async function loadServerSecurity() {
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
const authOn = s.authMode === "BASIC_AUTH";
updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername });
authUsername = s.authUsername;
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
} catch {}
}
$effect(() => { if (tab === "security") loadServerSecurity(); });
async function enableAuth() {
if (!authUsername.trim() || !authPassword.trim()) {
secError = "Username and password are required"; return;
}
secLoading = true; secError = null;
updateSettings({ serverAuthEnabled: true, serverAuthUser: authUsername, serverAuthPass: authPassword });
try {
await gql(SET_SERVER_AUTH, { authMode: "BASIC_AUTH", authUsername: authUsername.trim(), authPassword: authPassword.trim() });
showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthEnabled: false });
secError = e?.message ?? "Failed to enable authentication";
} finally { secLoading = false; }
}
async function disableAuth() {
secLoading = true; secError = null;
try {
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" });
authUsername = ""; authPassword = "";
showSaved("auth");
} catch (e: any) {
secError = e?.message ?? "Failed to disable authentication";
} finally { secLoading = false; }
}
async function saveSocksProxy() {
secLoading = true; secError = null;
try {
await gql(SET_SOCKS_PROXY, {
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
});
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
});
showSaved("socks");
} catch (e: any) {
secError = e?.message ?? "Failed to save SOCKS proxy";
} finally { secLoading = false; }
}
async function saveFlareSolverr() {
secLoading = true; secError = null;
try {
await gql(SET_FLARESOLVERR, {
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
showSaved("flare");
} catch (e: any) {
secError = e?.message ?? "Failed to save FlareSolverr";
} finally { secLoading = false; }
}
function commitPin() {
const cleaned = pinInput.replace(/\D/g, "").slice(0, 8);
pinInput = cleaned;
if (cleaned.length >= 4) {
updateSettings({ appLockPin: cleaned }); pinError = "";
} else if (cleaned.length > 0) {
pinError = "PIN must be at least 4 digits";
} else {
updateSettings({ appLockPin: "" }); pinError = "";
}
}
// ── Tracker state ─────────────────────────────────────────────────────────────
let trackers: Tracker[] = $state([]);
let trackersLoading: boolean = $state(false);
let trackersError: string|null = $state(null);
// OAuth flow state
let oauthTrackerId: number|null = $state(null);
let oauthCallbackInput: string = $state("");
let oauthSubmitting: boolean = $state(false);
// Credentials flow state
let credsTrackerId: number|null = $state(null);
let credsUsername: string = $state("");
let credsPassword: string = $state("");
let credsSubmitting: boolean = $state(false);
// Logout state
let loggingOut: number|null = $state(null); // trackerId being logged out
async function loadTrackers() {
trackersLoading = true; trackersError = null;
try {
const res = await gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS);
trackers = res.trackers.nodes;
} catch (e: any) {
trackersError = e?.message ?? "Failed to load trackers";
} finally {
trackersLoading = false;
}
}
$effect(() => { if (tab === "tracking" && trackers.length === 0 && !trackersLoading) loadTrackers(); });
// OAuth: trackers with an authUrl use a browser redirect flow.
// User clicks "Connect", we open the auth URL in their browser.
// Suwayomi's redirect lands at suwayomi.org/tracker-oauth which shows
// the full callback URL. User pastes it back here.
async function startOAuth(tracker: Tracker) {
if (!tracker.authUrl) return;
oauthTrackerId = tracker.id;
oauthCallbackInput = "";
await openUrl(tracker.authUrl);
}
async function submitOAuth() {
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
oauthSubmitting = true;
try {
await gql(LOGIN_TRACKER_OAUTH, {
trackerId: oauthTrackerId,
callbackUrl: oauthCallbackInput.trim(),
});
await loadTrackers();
oauthTrackerId = null;
oauthCallbackInput = "";
} catch (e: any) {
trackersError = e?.message ?? "Login failed";
} finally {
oauthSubmitting = false;
}
}
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; }
// Credentials flow (Kitsu, MangaUpdates)
function startCredentials(tracker: Tracker) {
credsTrackerId = tracker.id;
credsUsername = "";
credsPassword = "";
}
async function submitCredentials() {
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
credsSubmitting = true;
try {
await gql(LOGIN_TRACKER_CREDENTIALS, {
trackerId: credsTrackerId,
username: credsUsername.trim(),
password: credsPassword.trim(),
});
await loadTrackers();
credsTrackerId = null;
credsUsername = "";
credsPassword = "";
} catch (e: any) {
trackersError = e?.message ?? "Login failed";
} finally {
credsSubmitting = false;
}
}
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; }
async function logoutTracker(trackerId: number) {
loggingOut = trackerId;
try {
await gql(LOGOUT_TRACKER, { trackerId });
await loadTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Logout failed";
} finally {
loggingOut = null;
}
}
// A tracker uses OAuth if it has an authUrl; otherwise credentials.
function usesOAuth(t: Tracker): boolean { return !!t.authUrl; }
// ── About / Updater state ─────────────────────────────────────────────────────
interface ReleaseInfo {
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
}
type UpdatePhase =
| "idle"
| "downloading"
| "ready" // downloaded, awaiting restart
| "error";
const IS_WINDOWS = navigator.userAgent.includes("Windows");
let appVersion = $state("…");
let releases = $state<ReleaseInfo[]>([]);
let releasesLoading = $state(false);
let releasesError = $state<string | null>(null);
let expandedTag = $state<string | null>(null);
// update install state
let updatePhase = $state<UpdatePhase>("idle");
let updateError = $state<string | null>(null);
let dlBytes = $state(0);
let dlTotal = $state<number | null>(null);
let targetTag = $state<string | null>(null); // tag being installed
let releasesLoaded = false; // plain var — not $state, so effect doesn't re-run on change
$effect(() => {
if (tab !== "about") return;
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
});
async function loadReleases() {
releasesLoading = true; releasesError = null;
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out after 10s")), 10_000)
);
const all = await Promise.race([
invoke<ReleaseInfo[]>("list_releases"),
timeout,
]);
// Filter out drafts / incomplete entries with no tag_name
releases = all.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
} catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e);
} finally {
releasesLoading = false;
}
}
// Normalise "v0.4.0" → "0.4.0" for comparison
function stripV(v: string) { return v.replace(/^v/, ""); }
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
function parseSemver(v: string): number[] {
return stripV(v).split(".").map(Number);
}
function compareSemver(a: string, b: string): number {
const pa = parseSemver(a), pb = parseSemver(b);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
}
return 0;
}
// True when releases are loaded and installed version is >= highest published tag
let onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === "…") return false;
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name));
return compareSemver(appVersion, sorted[0].tag_name) >= 0;
})());
function fmtDate(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// ── Download & install ────────────────────────────────────────────────────────
// Listen to progress events from Rust while this component is alive.
let unlistenProgress: (() => void) | undefined;
$effect(() => {
listen<{ downloaded: number; total: number | null }>("update-progress", (e) => {
dlBytes = e.payload.downloaded;
dlTotal = e.payload.total ?? null;
}).then(fn => { unlistenProgress = fn; });
return () => unlistenProgress?.();
});
async function installUpdate(release: ReleaseInfo) {
if (updatePhase === "downloading") return;
targetTag = release.tag_name;
updatePhase = "downloading";
updateError = null;
dlBytes = 0;
dlTotal = null;
try {
if (IS_WINDOWS) {
// Kill Suwayomi before installing — its JRE DLLs will be locked otherwise.
// kill_server blocks on the Rust side until java.exe is fully gone.
try { await invoke("kill_server"); } catch {}
// Windows: Tauri updater downloads + runs passive NSIS installer
await invoke("download_and_install_update");
updatePhase = "ready";
} else {
// Linux / macOS: open GitHub release page
await openUrl(release.html_url);
updatePhase = "idle";
targetTag = null;
}
} catch (e: any) {
updateError = e instanceof Error ? e.message : String(e);
updatePhase = "error";
}
}
async function restartNow() {
await invoke("restart_app");
}
function cancelUpdate() {
updatePhase = "idle";
updateError = null;
targetTag = null;
dlBytes = 0;
dlTotal = null;
}
function fmtProgress(): string {
if (dlTotal) {
return `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)`;
}
return fmtBytes(dlBytes);
}
function triggerSplash() {
splashTriggered = true;
setTimeout(() => splashTriggered = false, 200);
(window as any).__mokuShowSplash?.();
}
</script>
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Settings">
<div class="sidebar">
<p class="modal-title">Settings</p>
<nav class="nav">
{#each TABS as t}
<button class="nav-item" class:active={tab === t.id} onclick={() => tab = t.id}>
<t.icon size={14} weight="light" />
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="content">
<div class="content-header">
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
<button class="close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body" bind:this={contentBodyEl}>
{#if tab === "general"}
<div class="panel">
<div class="section">
<p class="section-title">Interface Scale</p>
<div class="scale-row">
<input type="range" min={50} max={200} step={5}
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input
type="number" min={50} max={200} step={1}
class="scale-val-input"
value={Math.round((store.settings.uiZoom ?? 1.5) * 100)}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; }
else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiZoom: 1.5 })} disabled={(store.settings.uiZoom ?? 1.5) === 1.5} title="Reset"></button>
</div>
<p class="scale-hint">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={Math.round((store.settings.uiZoom ?? 1.5) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each}
</p>
</div>
<div class="section">
<p class="section-title">Server</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
<input class="text-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Inactivity</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
<div class="select-wrap" id="idle-timeout">
<button class="select-btn" onclick={() => toggleSelect("idle-timeout")}>
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "idle-timeout"}
<div class="select-menu">
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
<button class="select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="section">
<p class="section-title">Integrations</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Discord Rich Presence</span><span class="toggle-desc">Show what you're reading in your Discord status</span></div>
<button role="switch" aria-checked={store.settings.discordRpc} aria-label="Discord Rich Presence" class="toggle" class:on={store.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !store.settings.discordRpc })}><span class="toggle-thumb"></span></button>
</label>
</div>
</div>
{:else if tab === "appearance"}
<div class="panel">
<div class="section">
<p class="section-title">Theme</p>
<div class="theme-grid">
{#each THEMES as theme}
{@const active = (store.settings.theme ?? "dark") === theme.id}
<button class="theme-card" class:active onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="theme-preview">
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
<div class="theme-preview-content">
<div class="theme-preview-accent" style="background:{theme.swatches[2]}"></div>
<div class="theme-preview-text" style="background:{theme.swatches[3]}55"></div>
<div class="theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
</div>
</div>
</div>
<div class="theme-card-info">
<span class="theme-card-label">{theme.label}</span>
<span class="theme-card-desc">{theme.description}</span>
</div>
{#if active}<span class="theme-card-check"></span>{/if}
</button>
{/each}
<!-- Custom theme cards -->
{#each store.settings.customThemes ?? [] as custom}
{@const active = store.settings.theme === custom.id}
<div class="theme-card custom-theme-card" class:active>
<button
class="custom-theme-select"
onclick={() => updateSettings({ theme: custom.id })}
title="Apply {custom.name}"
>
<div class="theme-preview">
<div class="theme-preview-bg" style="background:{custom.tokens['bg-base']}">
<div class="theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
<div class="theme-preview-content">
<div class="theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
<div class="theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
<div class="theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
</div>
</div>
</div>
<div class="theme-card-info">
<span class="theme-card-label">{custom.name}</span>
<span class="theme-card-desc custom-badge">Custom</span>
</div>
</button>
<div class="custom-theme-actions">
<button
class="custom-theme-edit-btn"
onclick={() => onOpenThemeEditor?.(custom.id)}
title="Edit theme"
>
<Pencil size={10} />
</button>
<button
class="custom-theme-delete-btn"
onclick={() => {
if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id);
}}
title="Delete theme"
>
<Trash size={10} />
</button>
</div>
{#if active}<span class="theme-card-check"></span>{/if}
</div>
{/each}
<!-- New Theme button -->
<button
class="theme-card new-theme-card"
onclick={() => onOpenThemeEditor?.(null)}
title="Create a custom theme"
>
<div class="new-theme-icon">
<Plus size={18} weight="light" />
</div>
<div class="theme-card-info">
<span class="theme-card-label">New Theme</span>
<span class="theme-card-desc">Create custom</span>
</div>
</button>
</div>
</div>
</div>
{:else if tab === "reader"}
<div class="panel">
<div class="section">
<p class="section-title">Page Layout</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
<div class="select-wrap" id="page-style">
<button class="select-btn" onclick={() => toggleSelect("page-style")}>
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "page-style"}
<div class="select-menu">
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
<button class="select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="select-wrap" id="reading-dir">
<button class="select-btn" onclick={() => toggleSelect("reading-dir")}>
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "reading-dir"}
<div class="select-menu">
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
<button class="select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Overlay bars</span><span class="toggle-desc">Top and bottom bars float over the page instead of pushing it</span></div>
<button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Fit &amp; Zoom</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
<div class="select-wrap" id="fit-mode">
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "fit-mode"}
<div class="select-menu">
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
<button class="select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Default zoom</span>
<span class="toggle-desc">Starting zoom when opening a chapter. 100% = fills the reader.</span>
</div>
<div class="scale-row">
<input type="range" min={10} max={400} step={5}
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })}
class="scale-slider" />
<input
type="number" min={10} max={400} step={5}
class="scale-val-input"
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; }
else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%"></button>
</div>
</div>
<p class="scale-hint">
{#each [50, 75, 100, 125, 150, 200] as v}
<button class="scale-preset"
class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v}
onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
{/each}
</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Behaviour</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
</label>
{#if !(store.settings.autoNextChapter ?? false)}
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
</label>
{/if}
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
<div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button>
<span class="step-val">{store.settings.preloadPages}</span>
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
</div>
{:else if tab === "library"}
<div class="panel">
<div class="section">
<p class="section-title">Display</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells may crop cover edges</span></div>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Chapters</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
<div class="select-wrap" id="sort-dir">
<button class="select-btn" onclick={() => toggleSelect("sort-dir")}>
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sort-dir"}
<div class="select-menu">
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
<button class="select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="section">
<p class="section-title">History</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading store.history</span><span class="toggle-desc">{store.history.length} entries stored</span></div>
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Full data cleanse</span>
<span class="toggle-desc">Removes store.history, stats, completed list, hero pins, and manga links</span>
</div>
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button>
</div>
</div>
</div>
{:else if tab === "performance"}
<div class="panel">
<div class="section">
<p class="section-title">Render Limit</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Items per page</span>
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button>
<span class="step-val">{store.settings.renderLimit ?? 48}</span>
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<p class="scale-hint">
{#each [12, 24, 48, 96, 200] as v}
<button class="scale-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</p>
</div>
<div class="section">
<p class="section-title">Rendering</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Idle / Splash Screen</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Interface</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Session Cache</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Cache entries</span>
<span class="toggle-desc">In-memory request cache for this session (library, sources, genre pages). Cleared on restart.</span>
</div>
<div class="perf-stat-group">
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="kb-reset" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Oldest entry</span></div>
<span class="perf-stat">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Newest entry</span></div>
<span class="perf-stat">{fmtAge(perfSnapshot.newestEntryMs)}</span>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Cached keys</span>
<span class="toggle-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
</div>
</div>
{/if}
</div>
</div>
{:else if tab === "keybinds"}
<div class="panel">
<div class="section">
<div class="section-title-row">
<p class="section-title">Keyboard shortcuts</p>
<button class="sec-action-btn" onclick={resetKeybinds}>Reset all</button>
</div>
<p class="kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="kb-list">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="kb-row">
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
<div class="kb-right">
<button class="kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? "Press key…" : store.settings.keybinds[k]}
</button>
<button class="kb-reset" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset"></button>
</div>
</div>
{/each}
</div>
</div>
</div>
{:else if tab === "storage"}
<div class="panel">
<div class="section">
<p class="section-title">Disk Usage</p>
{#if storageLoading}<p class="storage-loading">Reading filesystem</p>
{:else if storageError}<p class="storage-loading" style="color:var(--color-error)">{storageError}</p>
{:else if storageInfo}
{@const mangaBytes = storageInfo.manga_bytes}
{@const totalBytes = storageInfo.total_bytes}
{@const freeBytes = storageInfo.free_bytes}
{@const limitGb = store.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = mangaBytes + freeBytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0}
<div class="storage-bar-wrap">
<div class="storage-bar">
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div>
</div>
<div class="storage-bar-labels">
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span>
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span>
</div>
</div>
<div class="storage-legend">
<div class="storage-legend-row"><span class="storage-dot storage-dot-manga"></span><span class="storage-legend-label">Downloaded manga</span><span class="storage-legend-val">{fmtBytes(mangaBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-free"></span><span class="storage-legend-label">Drive free</span><span class="storage-legend-val">{fmtBytes(freeBytes)}</span></div>
<div class="storage-legend-row"><span class="storage-dot storage-dot-app"></span><span class="storage-legend-label">Drive total</span><span class="storage-legend-val">{fmtBytes(totalBytes)}</span></div>
</div>
<p class="storage-path-note">{storageInfo.path}</p>
{/if}
</div>
<div class="section">
<p class="section-title">Cache</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
</button>
</div>
</div>
<div class="section">
<p class="section-title">Storage Limit</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Limit download storage</span>
<span class="toggle-desc">
{store.settings.storageLimitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
</span>
</div>
{#if store.settings.storageLimitGb === null}
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={() => updateSettings({ storageLimitGb: 10 })}>
Set limit
</button>
{:else}
<div class="step-controls">
<button class="step-btn"
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })}
disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button>
<input
type="number" min="1" step="1"
class="storage-limit-input"
value={store.settings.storageLimitGb}
oninput={(e) => {
const n = parseFloat(e.currentTarget.value);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}}
/>
<span class="storage-limit-unit">GB</span>
<button class="step-btn"
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="kb-reset" title="Remove limit"
onclick={() => updateSettings({ storageLimitGb: null })}></button>
</div>
{/if}
</div>
</div>
</div>
{:else if tab === "folders"}
<div class="panel">
<div class="section">
<p class="section-title">Manage Folders</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Folders are stored as Suwayomi categories. Changes sync across all clients.</p>
{#if catsError}
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);color:var(--color-error);display:block">{catsError}</p>
{/if}
<div class="folder-create-row">
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
<button class="folder-create-btn" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
{#if catsLoading}
<p class="storage-loading">Loading folders</p>
{:else if store.categories.filter(c => c.id !== 0).length === 0}
<p class="storage-loading">No folders yet. Create one above.</p>
{:else}
{@const displayCats = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1;
return a.order - b.order;
})}
<div class="folder-list">
{#each displayCats as cat, i}
<div class="folder-row">
{#if editingId === cat.id}
<input class="text-input" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
<button class="kb-reset" onclick={commitEdit} title="Save"></button>
{:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="folder-row-name">{cat.name}</span>
<span class="folder-row-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<button
class="kb-reset"
class:folder-default-active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder — opens first when you visit Library"}
><Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} /></button>
<button
class="kb-reset"
class:folder-hidden={(store.settings.hiddenCategoryIds ?? []).includes(cat.id)}
onclick={() => toggleHiddenCategory(cat.id)}
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}
>{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}</button>
<button class="kb-reset" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button>
<button class="kb-reset" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button>
<button class="kb-reset" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="kb-reset folder-delete" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else if tab === "tracking"}
<div class="panel">
<div class="section">
<p class="section-title">Connected Trackers</p>
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2)">
Log in to sync your reading progress with external tracking services.
After connecting, use the Tracking panel inside any manga's detail page.
</p>
{#if trackersError}
<div class="tracker-error">{trackersError}</div>
{/if}
{#if trackersLoading}
<p class="storage-loading">Loading trackers</p>
{:else}
<div class="tracker-list">
{#each trackers as tracker}
<div class="tracker-row" class:tracker-row-active={tracker.isLoggedIn}>
<!-- Icon + name -->
<div class="tracker-identity">
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="tracker-logo" />
<div class="tracker-name-block">
<span class="tracker-label">{tracker.name}</span>
<span class="tracker-status-pill" class:pill-on={tracker.isLoggedIn}>
{tracker.isLoggedIn ? "Connected" : "Not connected"}
</span>
</div>
</div>
<!-- Action area -->
<div class="tracker-action">
{#if tracker.isLoggedIn}
<div class="tracker-connected-btns">
<button
class="danger-btn"
onclick={() => logoutTracker(tracker.id)}
disabled={loggingOut === tracker.id}
>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
</div>
{:else if oauthTrackerId === tracker.id}
<div class="oauth-flow">
<p class="oauth-hint">
Your browser opened the {tracker.name} login page. After authorising,
you'll land on a Suwayomi page — <strong>copy the full URL from your browser's address bar</strong>
(it starts with <code>https://suwayomi.org/...</code> and contains your token) and paste it below.
</p>
<input
class="oauth-input"
placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
bind:value={oauthCallbackInput}
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
use:focusEl
/>
<div class="oauth-btns">
<button class="step-btn" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
{oauthSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="kb-reset" onclick={cancelOAuth}>Cancel</button>
</div>
</div>
{:else if credsTrackerId === tracker.id}
<div class="oauth-flow">
<input
class="oauth-input"
placeholder="Username / Email"
bind:value={credsUsername}
onkeydown={(e) => e.key === "Escape" && cancelCredentials()}
use:focusEl
/>
<input
class="oauth-input"
type="password"
placeholder="Password"
bind:value={credsPassword}
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }}
/>
<div class="oauth-btns">
<button class="step-btn" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
{credsSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="kb-reset" onclick={cancelCredentials}>Cancel</button>
</div>
</div>
{:else}
<button
class="step-btn"
style="width:auto;padding:0 var(--sp-4)"
onclick={() => usesOAuth(tracker) ? startOAuth(tracker) : startCredentials(tracker)}
>
{usesOAuth(tracker) ? "Connect via browser →" : "Connect"}
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else if tab === "security"}
<div class="panel">
{#if secError}
<div class="sec-banner sec-banner-error">{secError}</div>
{/if}
<div class="section">
<div class="section-title-row">
<p class="section-title">Server Authentication</p>
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthEnabled}>
{store.settings.serverAuthEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
</div>
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showAuthPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<div class="sec-btn-row">
{#if store.settings.serverAuthEnabled}
<button class="sec-action-btn sec-action-danger" onclick={disableAuth} disabled={secLoading}>
{secLoading ? "Saving…" : "Disable"}
</button>
{/if}
<button class="sec-action-btn sec-action-primary" onclick={enableAuth} disabled={secLoading || !authUsername.trim() || !authPassword.trim()}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthEnabled ? "Update" : "Enable"}
</button>
</div>
</div>
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">App Lock</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">PIN lock</span>
<span class="toggle-desc">Require a PIN on launch and after idle timeout</span>
</div>
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} aria-label="Enable PIN lock" class="toggle" class:on={store.settings.appLockEnabled} onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="toggle-thumb"></span></button>
</label>
{#if store.settings.appLockEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">PIN</span>
<span class="toggle-desc">48 digits</span>
</div>
<div class="sec-pin-wrap">
<div class="sec-pin-row">
<input class="text-input sec-pin-input" type="password" inputmode="numeric" maxlength={8} value={pinInput}
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
onkeydown={(e) => e.key === "Enter" && commitPin()} placeholder="••••" autocomplete="off" />
<button class="sec-action-btn sec-action-primary"
onclick={commitPin}
disabled={pinInput.length > 0 && pinInput.length < 4}>
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
</button>
</div>
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
</div>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">SOCKS Proxy</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable SOCKS proxy</span>
<span class="toggle-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span>
</div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="toggle" class:on={socksEnabled} onclick={() => socksEnabled = !socksEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Version</span>
</div>
<div class="select-wrap" id="socks-ver">
<button class="select-btn" onclick={() => toggleSelect("socks-ver")}>
<span>SOCKS{socksVersion}</span>
<svg class="select-caret" class:open={selectOpen === "socks-ver"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "socks-ver"}
<div class="select-menu">
{#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]}
<button class="select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Host</span>
</div>
<input class="text-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Port</span>
</div>
<input class="text-input sec-port-input" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
<span class="toggle-desc">Optional</span>
</div>
<input class="text-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
<span class="toggle-desc">Optional</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showSocksPass ? "text" : "password"} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="sec-eye-btn" onclick={() => showSocksPass = !showSocksPass} title={showSocksPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showSocksPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "socks" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">FlareSolverr</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable FlareSolverr</span>
<span class="toggle-desc">Bypass Cloudflare challenges for sources that require it</span>
</div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="toggle" class:on={flareEnabled} onclick={() => flareEnabled = !flareEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">URL</span>
<span class="toggle-desc">FlareSolverr instance address</span>
</div>
<input class="text-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Timeout</span>
<span class="toggle-desc">Max wait per request, in seconds</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="step-val">{flareTimeout}s</span>
<button class="step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session name</span>
<span class="toggle-desc">Reuse browser session across requests</span>
</div>
<input class="text-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session TTL</span>
<span class="toggle-desc">Minutes before session is refreshed</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="step-val">{flareTtl}m</span>
<button class="step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Response fallback</span>
<span class="toggle-desc">Use FlareSolverr's response when the direct request fails</span>
</div>
<button role="switch" aria-checked={flareFallback} aria-label="Use as response fallback" class="toggle" class:on={flareFallback} onclick={() => flareFallback = !flareFallback}><span class="toggle-thumb"></span></button>
</label>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "flare" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
</div>
{:else if tab === "about"}
<div class="panel">
<!-- ── App identity ──────────────────────────────────────── -->
<div class="section">
<p class="section-title">Moku</p>
<div class="about-block">
<p class="about-line">A manga reader frontend for Suwayomi / Tachidesk.</p>
<p class="about-line" style="color:var(--text-faint);margin-top:var(--sp-2)">Built with Tauri + Svelte.</p>
</div>
</div>
<!-- ── Current version + in-progress update bar ──────────── -->
<div class="section">
<p class="section-title">Version</p>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Installed</span>
<span class="toggle-desc">v{appVersion}</span>
</div>
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
{releasesLoading ? "Loading…" : "Refresh"}
</button>
</div>
{#if onLatestVersion}
<div class="step-row">
<span class="toggle-desc" style="padding:0 var(--sp-3);color:var(--accent-fg)"> You're on the latest version.</span>
</div>
{/if}
<!-- active download progress -->
{#if updatePhase === "downloading" && IS_WINDOWS}
<div class="update-progress-wrap">
<div class="update-progress-bar">
<div class="update-progress-fill"
style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
</div>
<div class="update-progress-row">
<span class="update-progress-label">Downloading {targetTag ?? "update"}</span>
<span class="update-progress-val">{fmtProgress()}</span>
</div>
</div>
{/if}
<!-- ready to restart -->
{#if updatePhase === "ready"}
<div class="update-ready-row">
<span class="update-ready-label">
{targetTag} downloaded restart to finish installing.
</span>
<button class="update-action-btn primary" onclick={restartNow}>Restart now</button>
<button class="kb-reset" onclick={cancelUpdate} title="Dismiss"></button>
</div>
{/if}
<!-- error -->
{#if updatePhase === "error"}
<div class="update-error-row">
<span style="color:var(--color-error);font-family:var(--font-ui);font-size:var(--text-xs)">{updateError}</span>
<button class="kb-reset" onclick={cancelUpdate}>Dismiss</button>
</div>
{/if}
</div>
<!-- ── Release list ───────────────────────────────────────── -->
<div class="section">
<p class="section-title">Releases</p>
{#if releasesError}
<p class="storage-loading" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="storage-loading">Fetching releases</p>
{:else if releases.length === 0}
<p class="storage-loading">No releases found.</p>
{:else}
<div class="release-list">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === "downloading"}
<div class="release-row" class:current={isCurrent}>
<!-- header: tag + date + action -->
<div class="release-header">
<div class="release-meta">
<span class="release-tag">{release.tag_name}</span>
{#if isCurrent}
<span class="release-badge current-badge">installed</span>
{/if}
{#if release.published_at}
<span class="release-date">{fmtDate(release.published_at)}</span>
{/if}
</div>
<div class="release-actions">
<!-- changelog toggle -->
{#if release.body.trim()}
<button class="release-changelog-btn"
onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? "Hide" : "Changelog"}
</button>
{/if}
<!-- install / open -->
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="update-action-btn"
class:primary={!isInstalling}
disabled={updatePhase === "downloading"}
onclick={() => installUpdate(release)}>
{isInstalling ? "Downloading…" : "Install"}
</button>
{:else}
<button class="update-action-btn"
onclick={() => installUpdate(release)}>
Open on GitHub
</button>
{/if}
{/if}
</div>
</div>
<!-- expandable changelog -->
{#if isExpanded && release.body.trim()}
<div class="release-body">
<pre class="release-body-pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- ── Links ─────────────────────────────────────────────── -->
<div class="section">
<p class="section-title">Links</p>
<div class="about-block">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub </a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord </a>
</div>
</div>
</div>
{:else if tab === "devtools"}
<div class="panel">
<div class="section">
<p class="section-title">Splash Screen</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash dismiss with any click or key</span></div>
<button class="danger-btn" onclick={triggerSplash}
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
Show idle
</button>
</div>
</div>
<div class="section">
<p class="section-title">Build Info</p>
<div class="about-block">
<p class="about-line" style="font-family:monospace;font-size:11px;color:var(--text-faint)">Mode: {import.meta.env.MODE}</p>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<script module>
function focusInput(node: HTMLElement) { node.focus(); }
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script>
<style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); }
.modal { width: min(720px, calc(100vw - 48px)); height: min(600px, 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.15s ease both; box-shadow: 0 24px 64px rgba(0,0,0,0.6); }
.sidebar { width: 168px; flex-shrink: 0; background: var(--bg-base); border-right: 1px solid var(--border-dim); padding: var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); overflow-y: auto; }
.modal-title { font-family: var(--font-ui); font-size: var(--text-xs); 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; }
.nav-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 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; transition: background var(--t-fast), color var(--t-fast); }
.nav-item:hover { background: var(--bg-raised); color: var(--text-secondary); }
.nav-item.active { background: var(--accent-muted); color: var(--accent-fg); }
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.content-header { 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; }
.content-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body { flex: 1; overflow-y: auto; }
.panel { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4) var(--sp-6); }
.section { display: flex; flex-direction: column; gap: 1px; border-bottom: 1px solid var(--border-dim); padding-bottom: var(--sp-4); margin-bottom: var(--sp-2); }
.section:last-child { border-bottom: none; }
.section-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); cursor: default; transition: background var(--t-fast); }
.toggle-row:hover { background: var(--bg-raised); }
.toggle-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; margin-right: var(--sp-4); }
.toggle-label { font-size: var(--text-sm); color: var(--text-secondary); }
.toggle-desc { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.toggle { position: relative; width: 32px; height: 18px; border-radius: var(--radius-full); border: none; background: var(--bg-overlay); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); border: 1px solid var(--border-strong); }
.toggle.on { background: var(--accent); border-color: var(--accent); }
.toggle-thumb { 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); }
.toggle.on .toggle-thumb { transform: translateX(14px); background: var(--bg-void); }
.step-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); gap: var(--sp-3); }
.step-row:hover { background: var(--bg-raised); }
.step-controls { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.step-btn { font-family: var(--font-ui); font-size: var(--text-sm); width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; }
.step-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.step-btn:disabled { opacity: 0.3; cursor: default; }
.step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); min-width: 40px; text-align: center; }
.select-wrap { position: relative; flex-shrink: 0; }
.select-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; cursor: pointer; min-width: 130px; transition: border-color var(--t-base); }
.select-btn:hover { border-color: var(--border-strong); }
.select-caret { color: var(--text-faint); transition: transform var(--t-base); flex-shrink: 0; margin-left: auto; }
.select-caret.open { transform: rotate(180deg); }
.select-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 100%; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.4); animation: scaleIn 0.1s ease both; transform-origin: top right; }
.select-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.select-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
.select-option.active { color: var(--accent-fg); background: var(--accent-muted); }
.text-input { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; width: 200px; transition: border-color var(--t-base); flex-shrink: 0; }
.text-input:focus { border-color: var(--border-strong); }
.danger-btn { 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(--color-error); background: none; color: var(--color-error); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); }
.danger-btn:hover:not(:disabled) { background: var(--color-error-bg); }
.danger-btn:disabled { opacity: 0.3; cursor: default; }
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.scale-slider { flex: 1; }
.scale-val-input {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
width: 42px; text-align: center; padding: 3px 4px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); outline: none;
transition: border-color var(--t-base);
-moz-appearance: textfield;
}
.scale-val-input::-webkit-inner-spin-button,
.scale-val-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.scale-val-input:focus { border-color: var(--border-strong); }
.scale-pct { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); margin-left: calc(var(--sp-1) * -1); }
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; 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); }
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scale-preset.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
/* Theme */
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.theme-card { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; text-align: left; transition: border-color var(--t-base), box-shadow var(--t-base); position: relative; }
.theme-card:hover { border-color: var(--border-strong); }
.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.theme-preview { height: 70px; overflow: hidden; }
.theme-preview-bg { width: 100%; height: 100%; display: flex; }
.theme-preview-sidebar { width: 20%; height: 100%; flex-shrink: 0; }
.theme-preview-content { flex: 1; padding: 8px 6px; display: flex; flex-direction: column; gap: 5px; }
.theme-preview-accent { height: 6px; width: 50%; border-radius: 3px; }
.theme-preview-text { height: 4px; width: 100%; border-radius: 2px; }
.theme-card-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
.theme-card-label { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.theme-card-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
/* Keybinds */
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
.kb-list { display: flex; flex-direction: column; gap: 1px; }
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
.kb-row:hover { background: var(--bg-raised); }
.kb-label { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
.kb-right { display: flex; align-items: center; gap: var(--sp-2); }
.kb-bind { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-secondary); cursor: pointer; min-width: 90px; text-align: center; transition: border-color var(--t-base), color var(--t-base); }
.kb-bind:hover { border-color: var(--border-strong); }
.kb-bind.listening { border-color: var(--accent); color: var(--accent-fg); background: var(--accent-muted); animation: pulse 1s ease infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.6 } }
.kb-reset { font-size: var(--text-sm); color: var(--text-faint); padding: 3px 6px; border-radius: var(--radius-sm); border: 1px solid transparent; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.kb-reset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); background: var(--bg-overlay); }
.kb-reset:disabled { opacity: 0.3; cursor: default; }
/* Storage */
.storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); }
.storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.storage-bar-fill.warn { background: #d97706; }
.storage-bar-fill.critical { background: var(--color-error); }
.storage-bar-labels { display: flex; justify-content: space-between; }
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.storage-dot-manga { background: var(--accent); }
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
.storage-dot-app { background: var(--text-faint); }
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
/* Folders */
.folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
.folder-create-btn { 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); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
.folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); }
.folder-create-btn:disabled { opacity: 0.4; cursor: default; }
.folder-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-3); }
.folder-row { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.folder-row:hover { background: var(--bg-raised); }
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; 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); }
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
.folder-hidden { opacity: 0.35; }
.folder-default-active { color: var(--accent-fg) !important; }
/* About */
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
.about-line { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); }
/* Perf metrics */
.perf-stat-group { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
/* Storage limit */
.storage-limit-input {
width: 64px; text-align: center;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px 6px;
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
outline: none; transition: border-color var(--t-base);
-moz-appearance: textfield;
}
.storage-limit-input::-webkit-inner-spin-button,
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.storage-limit-input:focus { border-color: var(--border-strong); }
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
/* ── Release list ─────────────────────────────────────────────── */
.release-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-1); }
.release-row {
border-radius: var(--radius-md);
border: 1px solid transparent;
transition: background var(--t-fast), border-color var(--t-fast);
overflow: hidden;
}
.release-row:hover { background: var(--bg-raised); }
.release-row.current {
border-color: var(--accent-dim);
background: var(--accent-muted);
}
.release-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-2) var(--sp-3); gap: var(--sp-3);
}
.release-meta { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }
.release-tag { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.release-badge { font-family: var(--font-ui); font-size: 10px; padding: 1px 5px; border-radius: var(--radius-sm); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.current-badge { background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); }
.release-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.release-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.release-changelog-btn {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 2px 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);
}
.release-changelog-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.update-action-btn {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.update-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.update-action-btn.primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.update-action-btn.primary:hover:not(:disabled) { filter: brightness(1.1); }
.update-action-btn:disabled { opacity: 0.4; cursor: default; }
.release-body { padding: 0 var(--sp-3) var(--sp-3); }
.release-body-pre {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
white-space: pre-wrap; word-break: break-word; margin: 0;
max-height: 220px; overflow-y: auto;
}
/* ── Download progress bar ────────────────────────────────────── */
.update-progress-wrap { padding: var(--sp-1) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
.update-progress-bar { height: 4px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.update-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.update-progress-row { display: flex; justify-content: space-between; align-items: center; }
.update-progress-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.update-progress-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
/* ── Ready-to-restart bar ─────────────────────────────────────── */
.update-ready-row {
display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
padding: var(--sp-2) var(--sp-3);
background: var(--accent-muted); border-radius: var(--radius-md);
margin: 0 var(--sp-3) var(--sp-2);
}
.update-ready-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); flex: 1; }
.update-error-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
/* ── Tracker styles ──────────────────────────────────────────────────────── */
.tracker-list { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-3); }
.tracker-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: border-color var(--t-base); }
.tracker-row-active { border-color: var(--accent-dim); background: var(--accent-muted); }
.tracker-identity { display: flex; align-items: center; gap: var(--sp-3); flex-shrink: 0; }
.tracker-logo { width: 28px; height: 28px; border-radius: var(--radius-sm); object-fit: contain; flex-shrink: 0; }
.tracker-name-block { display: flex; flex-direction: column; gap: 3px; }
.tracker-label { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
.tracker-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); width: fit-content; }
.tracker-status-pill.pill-on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.tracker-action { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-2); flex: 1; min-width: 0; }
.tracker-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: 6px var(--sp-3); margin: 0 var(--sp-3) var(--sp-2); letter-spacing: var(--tracking-wide); }
.tracker-connected-btns { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
.oauth-flow { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; align-items: flex-end; }
.oauth-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); text-align: right; }
.oauth-hint strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.oauth-hint code { font-family: monospace; font-size: 10px; color: var(--text-muted); background: var(--bg-overlay); padding: 1px 4px; border-radius: 3px; }
.oauth-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 10px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
.oauth-input:focus { border-color: var(--border-focus); }
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
/* ── Security tab ───────────────────────────────────────────────────── */
.sec-banner { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); border-radius: var(--radius-sm); }
.sec-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
.section-title-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
.section-title-row .section-title { padding: 0; }
.sec-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); flex-shrink: 0; cursor: default; }
.sec-pill-on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.sec-field-wrap { position: relative; flex-shrink: 0; }
.sec-field-wrap .text-input { padding-right: 34px; }
.sec-eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; padding: 0; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.sec-eye-btn:hover { color: var(--text-muted); }
.sec-btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.sec-action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.sec-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
.sec-action-primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.sec-action-primary:hover:not(:disabled) { filter: brightness(1.1); }
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
.sec-action-danger:hover:not(:disabled) { background: var(--color-error-bg); }
.sec-pin-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.sec-pin-wrap .sec-pin-row { display: flex; align-items: center; gap: var(--sp-2); }
.sec-pin-input { width: 96px; text-align: center; letter-spacing: 0.25em; }
.sec-port-input { width: 88px; }
.sec-pin-error { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
/* ── Custom theme cards ─────────────────────────────────────────────── */
.custom-theme-card {
position: relative;
display: flex; flex-direction: column;
padding: 0; cursor: default;
}
.custom-theme-select {
flex: 1; text-align: left; cursor: pointer;
display: flex; flex-direction: column;
background: none; border: none; color: inherit;
font-family: inherit;
}
.custom-badge {
color: var(--accent-fg) !important;
}
.custom-theme-actions {
display: none;
position: absolute; top: 5px; left: 5px;
flex-direction: row; gap: 3px;
z-index: 1;
}
.custom-theme-card:hover .custom-theme-actions { display: flex; }
.custom-theme-edit-btn,
.custom-theme-delete-btn {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 3px;
font-size: 10px; cursor: pointer;
border: 1px solid var(--border-base);
background: var(--bg-overlay);
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.custom-theme-edit-btn { color: var(--text-muted); }
.custom-theme-edit-btn:hover { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.custom-theme-delete-btn { color: var(--text-faint); }
.custom-theme-delete-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
/* ── New theme button ───────────────────────────────────────────────── */
.new-theme-card {
display: flex; flex-direction: column;
border-style: dashed !important;
border-color: var(--border-base) !important;
background: transparent !important;
transition: border-color var(--t-base) !important, background var(--t-base) !important;
}
.new-theme-card:hover {
border-color: var(--accent-dim) !important;
background: var(--accent-muted) !important;
}
.new-theme-icon {
height: 70px; display: flex; align-items: center; justify-content: center;
color: var(--text-faint);
transition: color var(--t-base);
}
.new-theme-card:hover .new-theme-icon { color: var(--accent-fg); }
</style>