mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Reworked ENTIRE Project for Readability
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck } from "phosphor-svelte";
|
||||
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
|
||||
import { eventToKeybind } from "@core/keybinds/keybindEngine";
|
||||
import type { Keybinds } from "@types/settings";
|
||||
import "./Settings.css";
|
||||
|
||||
import GeneralSettings from "../sections/GeneralSettings.svelte";
|
||||
import AppearanceSettings from "../sections/AppearanceSettings.svelte";
|
||||
import ReaderSettings from "../sections/ReaderSettings.svelte";
|
||||
import LibrarySettings from "../sections/LibrarySettings.svelte";
|
||||
import PerformanceSettings from "../sections/PerformanceSettings.svelte";
|
||||
import KeybindsSettings from "../sections/KeybindsSettings.svelte";
|
||||
import StorageSettings from "../sections/StorageSettings.svelte";
|
||||
import FoldersSettings from "../sections/FoldersSettings.svelte";
|
||||
import TrackingSettings from "../sections/TrackingSettings.svelte";
|
||||
import SecuritySettings from "../sections/SecuritySettings.svelte";
|
||||
import ContentSettings from "../sections/ContentSettings.svelte";
|
||||
import AboutSettings from "../sections/AboutSettings.svelte";
|
||||
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
|
||||
|
||||
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
|
||||
let { onOpenThemeEditor }: Props = $props();
|
||||
|
||||
type Tab = "general"|"appearance"|"reader"|"library"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"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: "content", label: "Content", icon: ShieldCheck },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
||||
];
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
let tab: Tab = $state("general");
|
||||
let prevTabIndex = $state(0);
|
||||
let tabSlideDir = $state<"up"|"down">("down");
|
||||
let tabIconKey = $state(0);
|
||||
let contentBodyEl: HTMLDivElement;
|
||||
|
||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
|
||||
|
||||
function setTab(id: Tab) {
|
||||
if (anims) {
|
||||
const next = TABS.findIndex(t => t.id === id);
|
||||
tabSlideDir = next > prevTabIndex ? "down" : "up";
|
||||
prevTabIndex = next;
|
||||
tabIconKey++;
|
||||
}
|
||||
tab = id;
|
||||
}
|
||||
|
||||
function close() { setSettingsOpen(false); }
|
||||
|
||||
// Keybind capture
|
||||
let listeningKey: keyof Keybinds | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) close(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!listeningKey) return;
|
||||
const capture = (e: KeyboardEvent) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const bind = eventToKeybind(e);
|
||||
if (!bind) return;
|
||||
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey!]: bind } });
|
||||
listeningKey = null;
|
||||
};
|
||||
window.addEventListener("keydown", capture, true);
|
||||
return () => window.removeEventListener("keydown", capture, true);
|
||||
});
|
||||
|
||||
// Shared select dropdown state (passed to sections that need it)
|
||||
let selectOpen: string | null = $state(null);
|
||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
||||
|
||||
$effect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (selectOpen && !(e.target as HTMLElement).closest(".s-select")) selectOpen = null;
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="s-modal" role="dialog" aria-label="Settings">
|
||||
|
||||
<div class="s-sidebar">
|
||||
<p class="s-sidebar-title">Settings</p>
|
||||
<nav>
|
||||
{#each TABS as t}
|
||||
<button class="s-nav-item" class:active={tab === t.id} class:anims onclick={() => setTab(t.id)}>
|
||||
<span class="s-nav-icon"
|
||||
class:slide-down={anims && tab === t.id && tabSlideDir === "down"}
|
||||
class:slide-up={anims && tab === t.id && tabSlideDir === "up"}>
|
||||
{#key anims && tab === t.id ? tabIconKey : 0}
|
||||
<t.icon size={14} weight={tab === t.id ? "regular" : "light"} />
|
||||
{/key}
|
||||
</span>
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="s-content">
|
||||
<div class="s-content-header">
|
||||
<div class="s-content-header-left">
|
||||
<span class="s-header-icon"
|
||||
class:slide-down={anims && tabSlideDir === "down"}
|
||||
class:slide-up={anims && tabSlideDir === "up"}>
|
||||
{#key tabIconKey}
|
||||
{#each TABS as t}
|
||||
{#if t.id === tab}
|
||||
<t.icon size={13} weight="light" />
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
</span>
|
||||
<p class="s-content-title">{TABS.find(t => t.id === tab)?.label}</p>
|
||||
</div>
|
||||
<button class="s-close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="s-content-body" bind:this={contentBodyEl}>
|
||||
{#if tab === "general"}
|
||||
<GeneralSettings {selectOpen} {toggleSelect} />
|
||||
{:else if tab === "appearance"}
|
||||
<AppearanceSettings {onOpenThemeEditor} />
|
||||
{:else if tab === "reader"}
|
||||
<ReaderSettings {selectOpen} {toggleSelect} />
|
||||
{:else if tab === "library"}
|
||||
<LibrarySettings {selectOpen} {toggleSelect} />
|
||||
{:else if tab === "performance"}
|
||||
<PerformanceSettings />
|
||||
{:else if tab === "keybinds"}
|
||||
<KeybindsSettings bind:listeningKey />
|
||||
{:else if tab === "storage"}
|
||||
<StorageSettings {selectOpen} {toggleSelect} />
|
||||
{:else if tab === "folders"}
|
||||
<FoldersSettings />
|
||||
{:else if tab === "tracking"}
|
||||
<TrackingSettings />
|
||||
{:else if tab === "security"}
|
||||
<SecuritySettings {selectOpen} {toggleSelect} />
|
||||
{:else if tab === "content"}
|
||||
<ContentSettings />
|
||||
{:else if tab === "about"}
|
||||
<AboutSettings />
|
||||
{:else if tab === "devtools"}
|
||||
<DevtoolsSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,501 @@
|
||||
<script lang="ts">
|
||||
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
|
||||
import {
|
||||
store, updateSettings, saveCustomTheme, deleteCustomTheme,
|
||||
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
|
||||
} from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
editingId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { editingId = $bindable(null), onClose }: Props = $props();
|
||||
|
||||
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
|
||||
{ label: "Backgrounds", tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"] },
|
||||
{ label: "Borders", tokens: ["border-dim", "border-base", "border-strong", "border-focus"] },
|
||||
{ label: "Text", tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"] },
|
||||
{ label: "Accent", tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"] },
|
||||
{ label: "Semantic", tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"] },
|
||||
];
|
||||
|
||||
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
|
||||
"bg-void": "Void (deepest bg)",
|
||||
"bg-base": "Base",
|
||||
"bg-surface": "Surface",
|
||||
"bg-raised": "Raised",
|
||||
"bg-overlay": "Overlay",
|
||||
"bg-subtle": "Subtle",
|
||||
"border-dim": "Dim border",
|
||||
"border-base": "Base border",
|
||||
"border-strong": "Strong border",
|
||||
"border-focus": "Focus ring",
|
||||
"text-primary": "Primary text",
|
||||
"text-secondary": "Secondary text",
|
||||
"text-muted": "Muted text",
|
||||
"text-faint": "Faint text",
|
||||
"text-disabled": "Disabled text",
|
||||
"accent": "Accent",
|
||||
"accent-dim": "Accent dim",
|
||||
"accent-muted": "Accent muted",
|
||||
"accent-fg": "Accent foreground",
|
||||
"accent-bright": "Accent bright",
|
||||
"color-error": "Error",
|
||||
"color-error-bg": "Error background",
|
||||
"color-success": "Success",
|
||||
"color-info": "Info",
|
||||
"color-info-bg": "Info background",
|
||||
};
|
||||
|
||||
function loadInitial(): { name: string; tokens: ThemeTokens } {
|
||||
if (editingId) {
|
||||
const existing = store.settings.customThemes.find(t => t.id === editingId);
|
||||
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
|
||||
}
|
||||
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
|
||||
}
|
||||
|
||||
const initial = loadInitial();
|
||||
let themeName: string = $state(initial.name);
|
||||
let tokens: ThemeTokens = $state(initial.tokens);
|
||||
let saveStatus: "idle" | "saved" = $state("idle");
|
||||
let importError: string | null = $state(null);
|
||||
|
||||
function toCssVars(t: ThemeTokens): string {
|
||||
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const name = themeName.trim() || "Untitled Theme";
|
||||
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
|
||||
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
|
||||
saveCustomTheme(theme);
|
||||
updateSettings({ theme: id });
|
||||
editingId = id;
|
||||
saveStatus = "saved";
|
||||
setTimeout(() => (saveStatus = "idle"), 1800);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!editingId) { onClose(); return; }
|
||||
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
|
||||
deleteCustomTheme(editingId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const data: CustomTheme = {
|
||||
id: editingId ?? "custom:export",
|
||||
name: themeName.trim() || "Untitled Theme",
|
||||
tokens: { ...tokens },
|
||||
};
|
||||
const filename = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: "Theme JSON", accept: { "application/json": [".json"] } }],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(json);
|
||||
await writable.close();
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "file";
|
||||
inp.accept = ".json";
|
||||
inp.onchange = async () => {
|
||||
const file = inp.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
|
||||
if (typeof data.name === "string") themeName = data.name;
|
||||
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
|
||||
importError = null;
|
||||
} catch (e: any) {
|
||||
importError = e.message ?? "Could not parse theme file";
|
||||
setTimeout(() => (importError = null), 3000);
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
function resetToDefaults() { tokens = { ...DEFAULT_THEME_TOKENS }; }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div class="backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
|
||||
<div class="shell" role="dialog" aria-label="Theme editor" tabindex="0" style={toCssVars(tokens)} onclick={(e) => e.stopPropagation()}>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button class="icon-btn" onclick={onClose} title="Close editor">
|
||||
<ArrowLeft size={14} weight="bold" />
|
||||
</button>
|
||||
<input bind:value={themeName} class="name-input" placeholder="Theme name" maxlength={40} spellcheck={false} />
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if importError}
|
||||
<span class="import-err">{importError}</span>
|
||||
{/if}
|
||||
<button class="action-btn" onclick={handleImport} title="Import from JSON">
|
||||
<UploadSimple size={13} /><span>Import</span>
|
||||
</button>
|
||||
<button class="action-btn" onclick={handleExport} title="Export as JSON">
|
||||
<DownloadSimple size={13} /><span>Export</span>
|
||||
</button>
|
||||
<button class="action-btn ghost" onclick={resetToDefaults} title="Reset all to dark defaults">Reset</button>
|
||||
{#if editingId}
|
||||
<button class="action-btn danger" onclick={handleDelete} title="Delete theme">
|
||||
<Trash size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
|
||||
<FloppyDisk size={13} /><span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
|
||||
</button>
|
||||
<button class="icon-btn" onclick={onClose} title="Close">
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<aside class="preview-pane">
|
||||
<div class="pane-label">Live Preview</div>
|
||||
<div class="preview-ui" style={toCssVars(tokens)}>
|
||||
<div class="prv-sidebar">
|
||||
{#each [true, false, false, false] as active}
|
||||
<div class="prv-sb-dot" class:active></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="prv-main">
|
||||
<div class="prv-titlebar">
|
||||
<div class="prv-win-dots"><span></span><span></span><span></span></div>
|
||||
<div class="prv-win-title">Moku</div>
|
||||
</div>
|
||||
<div class="prv-content">
|
||||
<div class="prv-row">
|
||||
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
|
||||
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
|
||||
</div>
|
||||
<div class="prv-grid">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="prv-card" class:active-card={i === 0}>
|
||||
<div class="prv-cover"></div>
|
||||
<div class="prv-card-line"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="prv-reader"><div class="prv-page"></div></div>
|
||||
<div class="prv-toast">
|
||||
<div class="prv-toast-dot"></div>
|
||||
<div class="prv-toast-lines">
|
||||
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
|
||||
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatches" style={toCssVars(tokens)}>
|
||||
{#each ["bg-base","bg-surface","accent","accent-fg","text-primary","text-muted","color-error"] as v}
|
||||
<div class="swatch" style="background:var(--{v})" title={v}></div>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="editor-pane">
|
||||
{#each TOKEN_GROUPS as group}
|
||||
<div class="group">
|
||||
<div class="group-label">{group.label}</div>
|
||||
<div class="token-list">
|
||||
{#each group.tokens as token}
|
||||
<div class="token-row">
|
||||
<label class="color-swatch" style="background:{tokens[token]}" title="Pick colour">
|
||||
<input
|
||||
type="color"
|
||||
class="color-picker"
|
||||
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0, 7)}
|
||||
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
|
||||
/>
|
||||
</label>
|
||||
<span class="token-name">{TOKEN_LABELS[token]}</span>
|
||||
<span class="token-key">{token}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="hex-input"
|
||||
value={tokens[token]}
|
||||
spellcheck={false}
|
||||
oninput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
|
||||
}}
|
||||
onblur={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value.trim();
|
||||
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) (e.target as HTMLInputElement).value = tokens[token];
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.72);
|
||||
z-index: 200;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: backdropIn 0.14s ease both;
|
||||
}
|
||||
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
.shell {
|
||||
width: calc(100% - var(--sp-12)); max-width: 1100px;
|
||||
height: calc(100% - var(--sp-12)); max-height: 760px;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
animation: shellIn 0.2s cubic-bezier(0.22,1,0.36,1) both;
|
||||
}
|
||||
@keyframes shellIn {
|
||||
from { transform: translateY(10px) scale(0.99); opacity: 0 }
|
||||
to { transform: translateY(0) scale(1); opacity: 1 }
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: 0 var(--sp-4); height: 46px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.name-input {
|
||||
flex: 1; min-width: 0;
|
||||
background: none; border: none; outline: none;
|
||||
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 3px 0;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.name-input:focus { border-color: var(--border-focus); }
|
||||
.name-input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.import-err {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-error); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px var(--sp-2); 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);
|
||||
}
|
||||
.action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.action-btn.ghost { border-color: transparent; }
|
||||
.action-btn.ghost:hover { border-color: var(--border-dim); }
|
||||
.action-btn.danger { color: var(--color-error); border-color: transparent; }
|
||||
.action-btn.danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
|
||||
|
||||
.save-btn {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px var(--sp-3); border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-dim);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: filter var(--t-base), background var(--t-base);
|
||||
}
|
||||
.save-btn:hover { filter: brightness(1.12); }
|
||||
.save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
|
||||
|
||||
.body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
|
||||
|
||||
.preview-pane {
|
||||
width: 260px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
background: var(--bg-void);
|
||||
display: flex; flex-direction: column;
|
||||
padding: var(--sp-4); gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.pane-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-ui {
|
||||
flex: 1; min-height: 0;
|
||||
border-radius: var(--radius-lg); overflow: hidden;
|
||||
border: 1px solid var(--border-base);
|
||||
display: flex; background: var(--bg-void);
|
||||
}
|
||||
|
||||
.prv-sidebar {
|
||||
width: 34px; flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; padding: var(--sp-3) 0; gap: var(--sp-2);
|
||||
}
|
||||
.prv-sb-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--text-faint); opacity: 0.4;
|
||||
transition: background var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
|
||||
|
||||
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.prv-titlebar {
|
||||
height: 26px; flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
display: flex; align-items: center; padding: 0 var(--sp-2); gap: var(--sp-2);
|
||||
}
|
||||
.prv-win-dots { display: flex; gap: var(--sp-1); }
|
||||
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
|
||||
.prv-win-title { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); color: var(--text-faint); }
|
||||
|
||||
.prv-content {
|
||||
flex: 1; overflow: hidden;
|
||||
padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.prv-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.prv-bar { height: 3px; border-radius: 2px; }
|
||||
|
||||
.prv-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--sp-1); flex-shrink: 0; }
|
||||
.prv-card {
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); overflow: hidden;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.prv-card.active-card { border-color: var(--accent); }
|
||||
.prv-cover { height: 34px; background: var(--bg-overlay); }
|
||||
.prv-card-line { height: 3px; margin: 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
|
||||
|
||||
.prv-reader {
|
||||
flex: 1; min-height: 0;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
|
||||
|
||||
.prv-toast {
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
|
||||
}
|
||||
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||
.prv-toast-lines { flex: 1; }
|
||||
|
||||
.swatches { display: flex; gap: var(--sp-1); flex-wrap: wrap; flex-shrink: 0; }
|
||||
.swatch { width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
|
||||
|
||||
.editor-pane {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-6);
|
||||
}
|
||||
.editor-pane::-webkit-scrollbar { width: 4px; }
|
||||
.editor-pane::-webkit-scrollbar-track { background: transparent; }
|
||||
.editor-pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
|
||||
|
||||
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.group-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.token-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.token-row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.token-row:hover { background: var(--bg-raised); }
|
||||
|
||||
.color-swatch {
|
||||
width: 36px; height: 18px; border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
||||
cursor: pointer; position: relative; overflow: hidden; display: block;
|
||||
}
|
||||
.color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
|
||||
|
||||
.color-picker {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
opacity: 0; cursor: pointer; padding: 0; border: none;
|
||||
}
|
||||
|
||||
.token-name { flex: 1; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.token-key {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px;
|
||||
}
|
||||
|
||||
.hex-input {
|
||||
width: 82px; flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm); padding: 3px var(--sp-2);
|
||||
outline: none;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
|
||||
type UpdatePhase = "idle" | "downloading" | "ready" | "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);
|
||||
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);
|
||||
let releasesLoaded = false;
|
||||
|
||||
$effect(() => {
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
||||
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<{ downloaded: number; total: number | null }>("update-progress", (e) => {
|
||||
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null;
|
||||
}).then(fn => { unlisten = fn; });
|
||||
return () => unlisten?.();
|
||||
});
|
||||
|
||||
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]);
|
||||
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; }
|
||||
}
|
||||
|
||||
function stripV(v: string) { return v.replace(/^v/, ""); }
|
||||
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
|
||||
function parseSemver(v: string) { return stripV(v).split(".").map(Number); }
|
||||
function compareSemver(a: string, b: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
const 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) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B","KB","MB","GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function fmtProgress() {
|
||||
return dlTotal ? `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)` : fmtBytes(dlBytes);
|
||||
}
|
||||
|
||||
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) {
|
||||
try { await invoke("kill_server"); } catch {}
|
||||
await invoke("download_and_install_update");
|
||||
updatePhase = "ready";
|
||||
} else {
|
||||
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; }
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Moku</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-1)">
|
||||
<span class="s-label">A manga reader frontend for Suwayomi / Tachidesk.</span>
|
||||
<span class="s-desc">Built with Tauri + Svelte.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Version</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
|
||||
<button class="s-btn" onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
|
||||
{releasesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
{#if onLatestVersion}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--accent-fg)">✓ You're on the latest version.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === "downloading" && IS_WINDOWS}
|
||||
<div class="s-update-progress">
|
||||
<div class="s-update-bar">
|
||||
<div class="s-update-fill" style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
|
||||
</div>
|
||||
<div class="s-update-labels">
|
||||
<span>Downloading {targetTag ?? "update"}…</span>
|
||||
<span>{fmtProgress()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === "ready"}
|
||||
<div class="s-update-ready">
|
||||
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
|
||||
<button class="s-btn s-btn-accent" onclick={restartNow}>Restart now</button>
|
||||
<button class="s-btn-icon" onclick={cancelUpdate} title="Dismiss">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === "error"}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-error)">{updateError}</span>
|
||||
<button class="s-btn" onclick={cancelUpdate}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Releases</p>
|
||||
<div class="s-section-body">
|
||||
{#if releasesError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
|
||||
{:else if releasesLoading}
|
||||
<p class="s-empty">Fetching releases…</p>
|
||||
{:else if releases.length === 0}
|
||||
<p class="s-empty">No releases found.</p>
|
||||
{:else}
|
||||
<div class="s-release-scroll">
|
||||
{#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="s-release-row" class:current={isCurrent}>
|
||||
<div class="s-release-header">
|
||||
<div class="s-release-meta">
|
||||
<span class="s-release-tag">{release.tag_name}</span>
|
||||
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
|
||||
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if release.body.trim()}
|
||||
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
|
||||
{isExpanded ? "Hide" : "Changelog"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCurrent}
|
||||
{#if IS_WINDOWS}
|
||||
<button class="s-btn" class:s-btn-accent={!isInstalling}
|
||||
disabled={updatePhase === "downloading"} onclick={() => installUpdate(release)}>
|
||||
{isInstalling ? "Downloading…" : "Install"}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if isExpanded && release.body.trim()}
|
||||
<div class="s-release-body">
|
||||
<pre class="s-release-body pre">{release.body.trim()}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Links</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
|
||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { Pencil, Trash, Plus } from "phosphor-svelte";
|
||||
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onOpenThemeEditor?: (id?: string | null) => void;
|
||||
}
|
||||
|
||||
let { onOpenThemeEditor }: Props = $props();
|
||||
|
||||
const THEMES: { id: string; 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"] },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Theme</p>
|
||||
<div class="s-theme-grid">
|
||||
{#each THEMES as theme}
|
||||
{@const active = (store.settings.theme ?? "dark") === theme.id}
|
||||
<div class="s-theme-card" class:active>
|
||||
<button class="s-theme-card" style="border:none;padding:0;width:100%;display:block" onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
||||
<div class="s-theme-preview">
|
||||
<div class="s-theme-preview-bg" style="background:{theme.swatches[0]}">
|
||||
<div class="s-theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
||||
<div class="s-theme-preview-content">
|
||||
<div class="s-theme-preview-accent" style="background:{theme.swatches[2]}"></div>
|
||||
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}55"></div>
|
||||
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">{theme.label}</span>
|
||||
<span class="s-theme-desc">{theme.description}</span>
|
||||
</div>
|
||||
{#if active}<span class="s-theme-check">✓</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each store.settings.customThemes ?? [] as custom}
|
||||
{@const active = store.settings.theme === custom.id}
|
||||
<div class="s-theme-card" class:active>
|
||||
<div class="s-theme-actions">
|
||||
<button class="s-theme-action-btn edit" onclick={() => onOpenThemeEditor?.(custom.id)} title="Edit theme">
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button class="s-theme-action-btn delete"
|
||||
onclick={() => { if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id); }}
|
||||
title="Delete theme">
|
||||
<Trash size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<button style="border:none;padding:0;width:100%;display:block;background:none;cursor:pointer"
|
||||
onclick={() => updateSettings({ theme: custom.id })} title="Apply {custom.name}">
|
||||
<div class="s-theme-preview">
|
||||
<div class="s-theme-preview-bg" style="background:{custom.tokens['bg-base']}">
|
||||
<div class="s-theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
|
||||
<div class="s-theme-preview-content">
|
||||
<div class="s-theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
|
||||
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
|
||||
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">{custom.name}</span>
|
||||
<span class="s-theme-desc" style="color:var(--accent-fg)">Custom</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if active}<span class="s-theme-check">✓</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="s-theme-card s-theme-new" onclick={() => onOpenThemeEditor?.(null)} title="Create a custom theme">
|
||||
<Plus size={18} weight="light" />
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">New Theme</span>
|
||||
<span class="s-theme-desc">Create custom</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Tag } from "phosphor-svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { gql, thumbUrl } from "@api/client";
|
||||
import { GET_SOURCES } from "@api/queries/index";
|
||||
import type { Source } from "../../lib/types";
|
||||
|
||||
let contentSources: Source[] = $state([]);
|
||||
let contentSourcesLoading: boolean = $state(false);
|
||||
let newTagInput = $state("");
|
||||
let tagsRevealed = $state(false);
|
||||
let sourceSearch = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (contentSources.length === 0 && !contentSourcesLoading) loadContentSources();
|
||||
});
|
||||
|
||||
async function loadContentSources() {
|
||||
contentSourcesLoading = true;
|
||||
try {
|
||||
const d = await gql<{ sources: { nodes: Source[] } }>(GET_SOURCES);
|
||||
contentSources = d.sources.nodes.filter(s => s.id !== "0");
|
||||
} catch (e) { console.error(e); }
|
||||
finally { contentSourcesLoading = false; }
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const t = newTagInput.trim().toLowerCase();
|
||||
if (!t) return;
|
||||
const tags = store.settings.nsfwFilteredTags ?? [];
|
||||
if (!tags.includes(t)) updateSettings({ nsfwFilteredTags: [...tags, t] });
|
||||
newTagInput = "";
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
updateSettings({ nsfwFilteredTags: (store.settings.nsfwFilteredTags ?? []).filter(t => t !== tag) });
|
||||
}
|
||||
|
||||
function resetTags() {
|
||||
updateSettings({ nsfwFilteredTags: ["adult","mature","hentai","ecchi","erotic","pornograph","18+","smut","lemon","explicit","sexual violence"] });
|
||||
}
|
||||
|
||||
function toggleSourceAllowed(ids: string[]) {
|
||||
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
|
||||
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
|
||||
const allAllowed = ids.every(id => allowed.includes(id));
|
||||
if (allAllowed) {
|
||||
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) });
|
||||
} else {
|
||||
updateSettings({
|
||||
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
|
||||
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceBlocked(ids: string[]) {
|
||||
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
|
||||
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
|
||||
const allBlocked = ids.every(id => blocked.includes(id));
|
||||
if (allBlocked) {
|
||||
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) });
|
||||
} else {
|
||||
updateSettings({
|
||||
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
|
||||
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[]; }
|
||||
|
||||
const contentSourcesFiltered = $derived.by(() => {
|
||||
const q = sourceSearch.trim().toLowerCase();
|
||||
const filtered = q ? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q)) : contentSources;
|
||||
const map = new Map<string, ContentSourceGroup>();
|
||||
for (const s of filtered) {
|
||||
const key = s.name;
|
||||
if (!map.has(key)) map.set(key, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
|
||||
map.get(key)!.sources.push(s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Content Filter</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Show adult content</span><span class="s-desc">Sources and manga matching blocked tags are hidden when off</span></div>
|
||||
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show adult content" class="s-toggle" class:on={store.settings.showNsfw}
|
||||
onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Blocked Genre Tags
|
||||
<button class="s-btn" onclick={() => tagsRevealed = !tagsRevealed}>
|
||||
{tagsRevealed ? "Hide" : `Show (${(store.settings.nsfwFilteredTags ?? []).length})`}
|
||||
</button>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="padding-bottom:var(--sp-2)">
|
||||
<span class="s-desc">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
|
||||
</div>
|
||||
{#if tagsRevealed}
|
||||
<div class="s-tag-grid">
|
||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
||||
<span class="s-tag">
|
||||
<Tag size={10} weight="light" />
|
||||
{tag}
|
||||
<button class="s-tag-remove" onclick={() => removeTag(tag)} title="Remove tag">×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-tag-add">
|
||||
<input class="s-input full" placeholder="Add tag substring…" bind:value={newTagInput}
|
||||
onkeydown={(e) => { if (e.key === "Enter") addTag(); }} />
|
||||
<button class="s-btn s-btn-accent" onclick={addTag} disabled={!newTagInput.trim()}>
|
||||
<Plus size={13} weight="bold" /> Add
|
||||
</button>
|
||||
<button class="s-btn" onclick={resetTags}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Source Overrides</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Allow lets a source through even if flagged NSFW. Block always hides it.</span>
|
||||
</div>
|
||||
<div class="s-search-wrap">
|
||||
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
|
||||
</div>
|
||||
{#if contentSourcesLoading}
|
||||
<p class="s-empty">Loading sources…</p>
|
||||
{:else if contentSources.length === 0}
|
||||
<p class="s-empty">No sources found — check your server connection.</p>
|
||||
{:else}
|
||||
<div class="s-source-list">
|
||||
{#each contentSourcesFiltered as group (group.name)}
|
||||
{@const ids = group.sources.map(s => s.id)}
|
||||
{@const allowed = store.settings.nsfwAllowedSourceIds ?? []}
|
||||
{@const blocked = store.settings.nsfwBlockedSourceIds ?? []}
|
||||
{@const isAllowed = ids.every(id => allowed.includes(id))}
|
||||
{@const isBlocked = ids.every(id => blocked.includes(id))}
|
||||
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
|
||||
<img src={thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
|
||||
<div class="s-source-info">
|
||||
<span class="s-source-name">{group.name}</span>
|
||||
<span class="s-source-meta">{group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="s-source-actions">
|
||||
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
|
||||
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
|
||||
import { store, addToast } from "@store/state.svelte";
|
||||
import { cache } from "@core/cache/index";
|
||||
|
||||
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
|
||||
|
||||
let perfSnapshot = $state<PerfSnapshot | null>(null);
|
||||
let splashTriggered = $state(false);
|
||||
let expOpen = $state(false);
|
||||
let appVersion = $state("…");
|
||||
|
||||
$effect(() => {
|
||||
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
|
||||
refreshPerfMetrics();
|
||||
});
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
let entries = 0, oldest: number | null = null, 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 };
|
||||
}
|
||||
|
||||
function fmtAge(ts: number | null) {
|
||||
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`;
|
||||
}
|
||||
|
||||
function triggerSplash() {
|
||||
splashTriggered = true;
|
||||
setTimeout(() => splashTriggered = false, 200);
|
||||
(window as any).__mokuShowSplash?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Toasts</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
|
||||
<div class="s-dev-pill-group">
|
||||
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]}
|
||||
<button class="s-dev-pill {kind}" onclick={() => addToast({
|
||||
kind,
|
||||
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
|
||||
body: kind === "success" ? "3 new chapters across 2 series" : kind === "error" ? "Connection refused on port 4567" : kind === "info" ? "No new chapters found" : "Berserk · Ch. 372 ready to read",
|
||||
})}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Previews</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Idle splash</span><span class="s-desc">Dismiss with any click or key</span></div>
|
||||
<button class="s-btn" class:s-btn-accent={splashTriggered} onclick={triggerSplash}>Show</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => expOpen = !expOpen} aria-expanded={expOpen}>
|
||||
<span class="s-label">Experimental</span>
|
||||
<svg class="s-collapsible-caret" class:open={expOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if expOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
|
||||
<span class="s-desc">3D tilt cards — hover to preview</span>
|
||||
<div style="display:flex;gap:var(--sp-3)">
|
||||
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card}
|
||||
<ThreeDCard>
|
||||
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
|
||||
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
|
||||
<span style="font-size:10px;color:var(--text-faint)">{card.sub}</span>
|
||||
</div>
|
||||
</ThreeDCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Runtime</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-dev-grid">
|
||||
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{store.libraryFilter}</span>
|
||||
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{store.categories.filter(c => c.id !== 0).map(c => c.name).join(", ") || "none"}</span>
|
||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{store.history.length} entries</span>
|
||||
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? "—"} entries</span>
|
||||
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{store.toasts.length} queued</span>
|
||||
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
||||
<span class="s-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
|
||||
<span class="s-desc">Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh cache stats">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { GET_CATEGORIES } from "@api/queries/manga";
|
||||
import { CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
|
||||
import type { Category } from "@types";
|
||||
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte";
|
||||
|
||||
let catsLoading = $state(false);
|
||||
let catsError = $state<string | null>(null);
|
||||
let newFolderName = $state("");
|
||||
let editingId = $state<number | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
async function loadCategories() {
|
||||
catsLoading = true; catsError = null;
|
||||
try {
|
||||
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||
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) {
|
||||
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;
|
||||
if (newPos < 1 || newPos > sortable.length) return;
|
||||
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 });
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput(node: HTMLElement) { node.focus(); }
|
||||
|
||||
$effect(() => {
|
||||
if (!store.categories.length && !catsLoading) loadCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Manage Folders</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
||||
</div>
|
||||
{#if catsError}
|
||||
<div class="s-banner s-banner-error">{catsError}</div>
|
||||
{/if}
|
||||
<div class="s-folder-create">
|
||||
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
||||
onkeydown={(e) => e.key === "Enter" && createFolder()} />
|
||||
<button class="s-btn s-btn-accent" onclick={createFolder} disabled={!newFolderName.trim()}>
|
||||
<Plus size={13} weight="bold" /> Create
|
||||
</button>
|
||||
</div>
|
||||
{#if catsLoading}
|
||||
<p class="s-empty">Loading folders…</p>
|
||||
{:else if store.categories.filter(c => c.id !== 0).length === 0}
|
||||
<p class="s-empty">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;
|
||||
})}
|
||||
{#each displayCats as cat, i}
|
||||
<div class="s-folder-row">
|
||||
{#if editingId === cat.id}
|
||||
<input class="s-input full" bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
||||
onblur={commitEdit} use:focusInput />
|
||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="s-folder-name">{cat.name}</span>
|
||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
<button class="s-btn-icon"
|
||||
class:accent={(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"}>
|
||||
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||
</button>
|
||||
<button class="s-btn-icon"
|
||||
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="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
||||
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down">↓</button>
|
||||
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Interface Scale</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-slider-row">
|
||||
<input type="range" min={50} max={200} step={5}
|
||||
value={Math.round((store.settings.uiZoom ?? 1.0) * 100)}
|
||||
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
|
||||
class="s-slider" />
|
||||
<input type="number" min={50} max={200} step={1} class="s-slider-val"
|
||||
value={Math.round((store.settings.uiZoom ?? 1.0) * 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="s-slider-unit">%</span>
|
||||
<button class="s-btn-icon" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(store.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%">↺</button>
|
||||
</div>
|
||||
<div class="s-presets">
|
||||
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
|
||||
<button class="s-preset" class:active={Math.round((store.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Server</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Server URL</span><span class="s-desc">Base URL of your Suwayomi instance</span></div>
|
||||
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="s-toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Inactivity</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
|
||||
<div class="s-select" id="idle-timeout">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("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="s-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="s-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="s-select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); onToggleSelect("idle-timeout"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Integrations</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Discord Rich Presence</span><span class="s-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="s-toggle" class:on={store.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !store.settings.discordRpc })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Animations</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">QOL Animations</span><span class="s-desc">Hover lifts, active-tab transitions, and icon micro-animations</span></div>
|
||||
<button role="switch" aria-checked={store.settings.qolAnimations ?? true} aria-label="QOL Animations" class="s-toggle" class:on={store.settings.qolAnimations ?? true} onclick={() => updateSettings({ qolAnimations: !(store.settings.qolAnimations ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Language</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Preferred source language</span>
|
||||
<span class="s-desc">Used to pre-select languages in Search and deduplicate sources</span>
|
||||
</div>
|
||||
<input class="s-input" style="width:72px;text-align:center;text-transform:uppercase"
|
||||
value={store.settings.preferredExtensionLang ?? ""}
|
||||
oninput={(e) => updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })}
|
||||
placeholder="en" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings, resetKeybinds } from "@store/state.svelte";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "@core/keybinds";
|
||||
import type { Keybinds } from "@core/keybinds";
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Keyboard Shortcuts
|
||||
<button class="s-btn" onclick={resetKeybinds}>Reset all</button>
|
||||
</p>
|
||||
<p class="s-kb-hint">Click a binding to rebind, then press the new key combination.</p>
|
||||
<div class="s-section-body">
|
||||
{#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="s-kb-row">
|
||||
<span class="s-kb-label">{KEYBIND_LABELS[k]}</span>
|
||||
<div class="s-kb-right">
|
||||
<button class="s-kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
|
||||
{isListening ? "Press key…" : store.settings.keybinds[k]}
|
||||
</button>
|
||||
<button class="s-btn-icon" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings, clearHistory, wipeAllData } from "@store/state.svelte";
|
||||
import type { Settings } from "@types/settings";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Display</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
|
||||
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
|
||||
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Chapters</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
|
||||
<div class="s-select" id="sort-dir">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("sort-dir")}>
|
||||
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
|
||||
<svg class="s-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="s-select-menu">
|
||||
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); onToggleSelect("sort-dir"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">History</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{store.history.length} entries</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={store.history.length === 0}>Clear</button>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Wipe all data</span><span class="s-desc">History, stats, pins, and manga links</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={wipeAllData}>Wipe</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { cache } from "@core/cache";
|
||||
|
||||
interface PerfSnapshot {
|
||||
cacheEntries: number;
|
||||
cacheKeys: string[];
|
||||
oldestEntryMs: number | null;
|
||||
newestEntryMs: number | null;
|
||||
}
|
||||
|
||||
let perfSnapshot = $state<PerfSnapshot | null>(null);
|
||||
let clearing = $state(false);
|
||||
let cleared = $state(false);
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
let entries = 0, oldest: number | null = null, 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 };
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
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); refreshPerfMetrics(); });
|
||||
}
|
||||
|
||||
$effect(() => { refreshPerfMetrics(); });
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Render Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Items per page</span>
|
||||
<span class="s-desc">Lower = faster on large libraries</span>
|
||||
</div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}>−</button>
|
||||
<span class="s-step-val">{store.settings.renderLimit ?? 48}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-presets">
|
||||
{#each [12, 24, 48, 96, 200] as v}
|
||||
<button class="s-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Rendering</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">GPU acceleration</span><span class="s-desc">Uses the GPU for rendering; disable if you see visual glitches</span></div>
|
||||
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="s-toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Idle / Splash Screen</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Animated card background</span><span class="s-desc">Shows cover art cards floating in the background on the idle screen</span></div>
|
||||
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="s-toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Interface</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Compact sidebar</span><span class="s-desc">Collapses the sidebar to icons only</span></div>
|
||||
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="s-toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Session Cache</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Cache entries</span>
|
||||
<span class="s-desc">In-memory, cleared on restart</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<span class="s-step-val">{perfSnapshot?.cacheEntries ?? 0} entries</span>
|
||||
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Oldest entry</span></div>
|
||||
<span class="s-step-val">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Newest entry</span></div>
|
||||
<span class="s-step-val">{fmtAge(perfSnapshot.newestEntryMs)}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Cached keys</span>
|
||||
<span class="s-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Cache</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Image cache</span><span class="s-desc">Webview page image cache</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={handleClearCache} disabled={clearing}>
|
||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import type { Settings, FitMode } from "@types/settings";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Page Layout</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
|
||||
<div class="s-select" id="page-style">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("page-style")}>
|
||||
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
|
||||
<svg class="s-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="s-select-menu">
|
||||
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); onToggleSelect("page-style"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
||||
<div class="s-select" id="reading-dir">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("reading-dir")}>
|
||||
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
|
||||
<svg class="s-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="s-select-menu">
|
||||
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); onToggleSelect("reading-dir"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Page gap</span><span class="s-desc">Adds spacing between pages in single-page mode</span></div>
|
||||
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="s-toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Overlay bars</span><span class="s-desc">Floats the nav and chapter bars over the page instead of pushing content</span></div>
|
||||
<button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="s-toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars — ideal for touchscreens</span></div>
|
||||
<button role="switch" aria-checked={store.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={store.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(store.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Fit & Zoom</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
|
||||
<div class="s-select" id="fit-mode">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("fit-mode")}>
|
||||
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
|
||||
<svg class="s-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="s-select-menu">
|
||||
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); onToggleSelect("fit-mode"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-slider-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="s-slider" />
|
||||
<input type="number" min={10} max={400} step={5} class="s-slider-val"
|
||||
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="s-slider-unit">%</span>
|
||||
<button class="s-btn-icon" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%">↺</button>
|
||||
</div>
|
||||
<div class="s-presets">
|
||||
{#each [50, 75, 100, 125, 150, 200] as v}
|
||||
<button class="s-preset" class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v} onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
|
||||
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Behaviour</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks 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="s-toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
|
||||
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if !(store.settings.autoNextChapter ?? false)}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
|
||||
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
|
||||
<button role="switch" aria-checked={store.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={store.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(store.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}>−</button>
|
||||
<span class="s-step-val">{store.settings.preloadPages}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { authSession } from "@core/auth";
|
||||
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
|
||||
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
|
||||
|
||||
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void; }
|
||||
let { selectOpen, toggleSelect }: Props = $props();
|
||||
|
||||
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 secLoaded = $state(false);
|
||||
|
||||
let authMode = $state(store.settings.serverAuthMode ?? "NONE");
|
||||
let authUsername = $state(store.settings.serverAuthUser ?? "");
|
||||
let authPassword = $state("");
|
||||
|
||||
const authModeUnsupported = $derived(
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ||
|
||||
store.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!secLoaded) { secLoaded = true; loadServerSecurity(); }
|
||||
});
|
||||
|
||||
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 mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
authMode = mode; authUsername = s.authUsername;
|
||||
updateSettings({ serverAuthMode: mode, serverAuthUser: 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 {}
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) {
|
||||
secError = "Username and password are required for Basic Auth"; return;
|
||||
}
|
||||
secLoading = true; secError = null;
|
||||
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
||||
const newUser = authMode === "BASIC_AUTH" ? authUsername.trim() : "";
|
||||
const newPass = authMode === "BASIC_AUTH" ? authPassword.trim() : "";
|
||||
if (authMode === "BASIC_AUTH" && !prev.pass.trim())
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
try {
|
||||
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
|
||||
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
|
||||
if (authMode === "NONE") { authSession.clearTokens(); authPassword = ""; }
|
||||
showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
secError = e?.message ?? "Failed to save authentication settings";
|
||||
} finally { secLoading = false; }
|
||||
}
|
||||
|
||||
async function clearAuth() {
|
||||
secLoading = true; secError = null;
|
||||
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
|
||||
try {
|
||||
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
|
||||
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
|
||||
authMode = "NONE"; authUsername = ""; authPassword = "";
|
||||
authSession.clearTokens(); showSaved("auth");
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
|
||||
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 = ""; }
|
||||
}
|
||||
|
||||
const EyeOpen = `<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>`;
|
||||
const EyeClose = `<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>`;
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if secError}
|
||||
<div class="s-banner s-banner-error">{secError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Server Authentication
|
||||
<span class="s-pill" class:on={store.settings.serverAuthMode === "BASIC_AUTH"} class:warn={authModeUnsupported}>
|
||||
{store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" :
|
||||
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login — unsupported" :
|
||||
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login — unsupported" : "Disabled"}
|
||||
</span>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if authModeUnsupported}
|
||||
<div class="s-banner s-banner-warn">
|
||||
<strong>{store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"}</strong> is not supported — only <strong>Basic Auth</strong> works here. Switch your server to <code>basic_auth</code> and set the mode to <strong>Basic</strong>.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Suwayomi verifies requests</span></div>
|
||||
<div class="s-segment">
|
||||
{#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }] as opt}
|
||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if authMode !== "NONE"}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span></div>
|
||||
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1">{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if store.settings.serverAuthMode === "BASIC_AUTH"}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-btn-row">
|
||||
{#if store.settings.serverAuthMode !== "NONE"}
|
||||
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
|
||||
{secLoading ? "Saving…" : "Disable"}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">App Lock</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">PIN lock</span><span class="s-desc">Require a PIN on launch and after idle timeout</span></div>
|
||||
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} class="s-toggle" class:on={store.settings.appLockEnabled}
|
||||
onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if store.settings.appLockEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">PIN</span><span class="s-desc">4–8 digits</span></div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input" type="password" inputmode="numeric" maxlength={8} placeholder="4–8 digits"
|
||||
value={pinInput}
|
||||
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
|
||||
onkeydown={(e) => e.key === "Enter" && commitPin()}
|
||||
autocomplete="off" style="width:120px;letter-spacing:0.2em" />
|
||||
<button class="s-btn s-btn-accent" onclick={commitPin} disabled={pinInput.length > 0 && pinInput.length < 4}>
|
||||
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if pinError}<div class="s-row"><span class="s-pin-error">{pinError}</span></div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">SOCKS Proxy</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
|
||||
<button role="switch" aria-checked={socksEnabled} class="s-toggle" class:on={socksEnabled}
|
||||
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy(); }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if socksEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Version</span></div>
|
||||
<div class="s-select" id="socks-ver">
|
||||
<button class="s-select-btn" onclick={() => toggleSelect("socks-ver")}>
|
||||
<span>SOCKS{socksVersion}</span>
|
||||
<svg class="s-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="s-select-menu">
|
||||
{#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect("socks-ver"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Host</span></div>
|
||||
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Port</span></div>
|
||||
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
|
||||
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showSocksPass ? "text" : "password"} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
|
||||
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1">{@html showSocksPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
|
||||
{secLoading ? "Saving…" : secSaved === "socks" ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">FlareSolverr</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
|
||||
<button role="switch" aria-checked={flareEnabled} class="s-toggle" class:on={flareEnabled}
|
||||
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr(); }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if flareEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
|
||||
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}>−</button>
|
||||
<span class="s-step-val">{flareTimeout}s</span>
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
|
||||
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}>−</button>
|
||||
<span class="s-step-val">{flareTtl}m</span>
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
|
||||
<button role="switch" aria-checked={flareFallback} class="s-toggle" class:on={flareFallback}
|
||||
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
|
||||
{secLoading ? "Saving…" : secSaved === "flare" ? "Saved ✓" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,629 @@
|
||||
<script lang="ts">
|
||||
import { Trash, ClockCounterClockwise } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "@api/client";
|
||||
import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS, VALIDATE_BACKUP } from "@api/queries/manga";
|
||||
import { CREATE_BACKUP, RESTORE_BACKUP } from "@api/mutations/manga";
|
||||
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
||||
import { untrack } from "svelte";
|
||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||
|
||||
const isExternalServer = $derived.by(() => {
|
||||
const url = (store.settings.serverUrl ?? "http://localhost:4567").toLowerCase().trim();
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
||||
} catch { return false; }
|
||||
});
|
||||
|
||||
let storageInfo = $state<StorageInfo | null>(null);
|
||||
let storageLoading = $state(false);
|
||||
let storageError = $state<string | null>(null);
|
||||
|
||||
let downloadsPathInput = $state(store.settings.serverDownloadsPath ?? "");
|
||||
let localSourcePathInput = $state(store.settings.serverLocalSourcePath ?? "");
|
||||
let pathsSaving = $state(false);
|
||||
let pathsError = $state<string | null>(null);
|
||||
let pathsFieldError = $state<{ dl?: string; loc?: string }>({});
|
||||
let pathsSaved = $state(false);
|
||||
|
||||
let defaultDownloadsPath = $state("");
|
||||
$effect(() => {
|
||||
if (!isExternalServer) {
|
||||
invoke<string>("get_default_downloads_path").then(p => { defaultDownloadsPath = p; });
|
||||
} else {
|
||||
defaultDownloadsPath = "";
|
||||
}
|
||||
});
|
||||
|
||||
let confirmedDownloadsPath = $state(store.settings.serverDownloadsPath ?? "");
|
||||
let confirmedLocalSourcePath = $state(store.settings.serverLocalSourcePath ?? "");
|
||||
|
||||
let migrateFrom = $state<string | null>(null);
|
||||
let migrateTo = $state<string | null>(null);
|
||||
let migrating = $state(false);
|
||||
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null);
|
||||
let migrateError = $state<string | null>(null);
|
||||
let migrateUnlisten: (() => void) | null = null;
|
||||
|
||||
let extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]);
|
||||
let newScanDir = $state("");
|
||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
||||
let advStorageOpen = $state(false);
|
||||
let backupSectionOpen = $state(false);
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null;
|
||||
try {
|
||||
const pathData = await gql<{ settings: { downloadsPath: string; localSourcePath: string } }>(GET_DOWNLOADS_PATH);
|
||||
const dl = pathData.settings.downloadsPath ?? "";
|
||||
const loc = pathData.settings.localSourcePath ?? "";
|
||||
downloadsPathInput = dl; localSourcePathInput = loc;
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc;
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
|
||||
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return; }
|
||||
const effectiveDl = dl || defaultDownloadsPath;
|
||||
const dirsToScan: { path: string; label: string }[] = [];
|
||||
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? "Downloads" : "Downloads (default)" });
|
||||
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: "Local source" });
|
||||
for (const p of extraScanDirs) {
|
||||
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p });
|
||||
}
|
||||
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return; }
|
||||
const results = await Promise.allSettled(
|
||||
dirsToScan.map(d => invoke<StorageInfo>("get_storage_info", { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
|
||||
);
|
||||
multiStorageInfos = results
|
||||
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === "fulfilled")
|
||||
.map(r => r.value);
|
||||
storageInfo = multiStorageInfos[0] ?? null;
|
||||
} catch (e: any) {
|
||||
storageError = e instanceof Error ? e.message : String(e);
|
||||
} finally { storageLoading = false; }
|
||||
}
|
||||
|
||||
async function validatePath(path: string): Promise<string | null> {
|
||||
if (!path.trim()) return null;
|
||||
if (isExternalServer) return null;
|
||||
try {
|
||||
const exists = await invoke<boolean>("check_path_exists", { path: path.trim() });
|
||||
return exists ? null : "Directory does not exist";
|
||||
} catch { return "Could not check path"; }
|
||||
}
|
||||
|
||||
async function createDirectory(path: string): Promise<void> {
|
||||
if (isExternalServer) throw new Error("Cannot create directories on an external server");
|
||||
await invoke("create_directory", { path });
|
||||
}
|
||||
|
||||
async function savePaths() {
|
||||
const dl = downloadsPathInput.trim();
|
||||
const loc = localSourcePathInput.trim();
|
||||
pathsError = null; pathsFieldError = {};
|
||||
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)]);
|
||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return; }
|
||||
pathsSaving = true;
|
||||
try {
|
||||
await gql(SET_DOWNLOADS_PATH, { path: dl });
|
||||
if (loc) await gql(SET_LOCAL_SOURCE_PATH, { path: loc });
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
|
||||
if (!isExternalServer) {
|
||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath;
|
||||
const newDl = dl || defaultDownloadsPath;
|
||||
if (newDl && oldDl && newDl !== oldDl) {
|
||||
const hadContent = await invoke<boolean>("check_path_exists", { path: oldDl });
|
||||
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl; }
|
||||
}
|
||||
}
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc;
|
||||
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000);
|
||||
await fetchStorage();
|
||||
} catch (e: any) {
|
||||
pathsError = e?.message ?? "Failed to save paths";
|
||||
} finally { pathsSaving = false; }
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!migrateFrom || !migrateTo) return;
|
||||
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: "" };
|
||||
const { listen: tauriListen } = await import("@tauri-apps/api/event");
|
||||
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
|
||||
"migrate_progress", (e) => { migrateProgress = e.payload; }
|
||||
);
|
||||
try {
|
||||
await invoke("migrate_downloads", { src: migrateFrom, dst: migrateTo });
|
||||
migrateFrom = null; migrateTo = null; migrateProgress = null;
|
||||
await fetchStorage();
|
||||
} catch (e: any) {
|
||||
migrateError = e?.message ?? "Migration failed";
|
||||
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null; }
|
||||
}
|
||||
|
||||
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null; }
|
||||
|
||||
async function browseDownloadsFolder() {
|
||||
const picked = await invoke<string | null>("pick_downloads_folder");
|
||||
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; }
|
||||
}
|
||||
|
||||
function addExtraScanDir() {
|
||||
const dir = newScanDir.trim();
|
||||
if (!dir || extraScanDirs.includes(dir)) return;
|
||||
extraScanDirs = [...extraScanDirs, dir];
|
||||
updateSettings({ extraScanDirs }); newScanDir = ""; fetchStorage();
|
||||
}
|
||||
|
||||
function removeExtraScanDir(path: string) {
|
||||
extraScanDirs = extraScanDirs.filter(d => d !== path);
|
||||
updateSettings({ extraScanDirs }); 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]}`;
|
||||
}
|
||||
|
||||
let backupLoading = $state(false);
|
||||
let backupError = $state<string | null>(null);
|
||||
let backupList = $state<{ url: string; name: string; deleting?: boolean }[]>([]);
|
||||
|
||||
function loadBackupList() {
|
||||
try { backupList = JSON.parse(localStorage.getItem("moku_backups") ?? "[]"); } catch { backupList = []; }
|
||||
}
|
||||
function saveBackupList() {
|
||||
try { localStorage.setItem("moku_backups", JSON.stringify(backupList)); } catch {}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading = true; backupError = null;
|
||||
try {
|
||||
const res = await gql<{ createBackup: { url: string } }>(CREATE_BACKUP);
|
||||
const url = res.createBackup.url;
|
||||
const name = url.split("/").pop() ?? url;
|
||||
backupList = [{ url, name }, ...backupList]; saveBackupList();
|
||||
} catch (e: any) { backupError = e?.message ?? "Failed to create backup"; }
|
||||
finally { backupLoading = false; }
|
||||
}
|
||||
|
||||
async function deleteBackup(url: string) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b);
|
||||
try {
|
||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {};
|
||||
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
|
||||
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
|
||||
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
|
||||
await fetch(`${serverUrl}${url}`, { method: "DELETE", headers });
|
||||
backupList = backupList.filter(b => b.url !== url); saveBackupList();
|
||||
} catch (e: any) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b);
|
||||
backupError = (e as any)?.message ?? "Failed to delete backup";
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(backup: { url: string; name: string }) {
|
||||
try {
|
||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {};
|
||||
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
|
||||
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
|
||||
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
|
||||
const resp = await fetch(`${serverUrl}${backup.url}`, { headers });
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
if ("showSaveFilePicker" in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: backup.name,
|
||||
types: [{ description: "Backup file", accept: { "application/octet-stream": [".tachibk", ".proto.gz"] } }],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob); await writable.close();
|
||||
addToast({ kind: "success", title: "Backup saved", body: backup.name }); return;
|
||||
} catch (pickerErr: any) { if (pickerErr?.name === "AbortError") return; }
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl; a.download = backup.name;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
|
||||
addToast({ kind: "download", title: "Backup downloaded", body: backup.name });
|
||||
} catch (e: any) { backupError = e?.message ?? "Failed to download backup"; }
|
||||
}
|
||||
|
||||
let restoreLoading = $state(false);
|
||||
let restoreError = $state<string | null>(null);
|
||||
let restoreJobId = $state<string | null>(null);
|
||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null);
|
||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
let validateLoading = $state(false);
|
||||
let validateError = $state<string | null>(null);
|
||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null);
|
||||
let restoreFile = $state<File | null>(null);
|
||||
|
||||
function stopRestorePoll() {
|
||||
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null; }
|
||||
}
|
||||
|
||||
async function pollRestoreStatus(id: string) {
|
||||
try {
|
||||
const res = await gql<{ restoreStatus: typeof restoreStatus }>(GET_RESTORE_STATUS, { id });
|
||||
restoreStatus = res.restoreStatus;
|
||||
if (res.restoreStatus?.state === "SUCCESS" || res.restoreStatus?.state === "FAILURE") stopRestorePoll();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
||||
const form = new FormData();
|
||||
form.append("operations", JSON.stringify({ query, variables }));
|
||||
form.append("map", JSON.stringify({ "0": ["variables.backup"] }));
|
||||
form.append("0", file, file.name);
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { "Accept": "application/json" };
|
||||
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
|
||||
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
|
||||
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile) return;
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null;
|
||||
stopRestorePoll();
|
||||
try {
|
||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
|
||||
{ backup: null }
|
||||
);
|
||||
const resp = await fetch(`${serverUrl}/api/graphql`, { method: "POST", headers: buildAuthHeaders(), body: form });
|
||||
const json = await resp.json();
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
const result = json.data.restoreBackup;
|
||||
restoreJobId = result.id; restoreStatus = result.status;
|
||||
if (result.status?.state !== "SUCCESS" && result.status?.state !== "FAILURE")
|
||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500);
|
||||
} catch (e: any) { restoreError = e?.message ?? "Failed to start restore"; }
|
||||
finally { restoreLoading = false; }
|
||||
}
|
||||
|
||||
async function submitValidate() {
|
||||
if (!restoreFile) return;
|
||||
validateLoading = true; validateError = null; validateResult = null;
|
||||
try {
|
||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
||||
{ backup: null }
|
||||
);
|
||||
const resp = await fetch(`${serverUrl}/api/graphql`, { method: "POST", headers: buildAuthHeaders(), body: form });
|
||||
const json = await resp.json();
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
validateResult = json.data.validateBackup;
|
||||
} catch (e: any) { validateError = e?.message ?? "Failed to validate backup"; }
|
||||
finally { validateLoading = false; }
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
|
||||
$effect(() => { return () => stopRestorePoll(); });
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if migrateFrom && !isExternalServer}
|
||||
<div class="s-migrate-banner">
|
||||
<div class="s-migrate-body">
|
||||
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
|
||||
<span class="s-migrate-paths">{migrateFrom} → {migrateTo}</span>
|
||||
{#if migrateProgress && migrateProgress.total > 0}
|
||||
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
|
||||
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
|
||||
{/if}
|
||||
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
|
||||
</div>
|
||||
<div class="s-migrate-actions">
|
||||
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
|
||||
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : "Starting…") : "Move files"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Disk Usage
|
||||
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? "…" : "↻"}</button>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if storageLoading}
|
||||
<p class="s-empty">Reading filesystem…</p>
|
||||
{:else if storageError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
|
||||
{:else if isExternalServer}
|
||||
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
|
||||
{:else if multiStorageInfos.length > 0}
|
||||
{#each multiStorageInfos as info}
|
||||
{@const limitGb = store.settings.storageLimitGb ?? null}
|
||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||
{@const available = info.manga_bytes + info.free_bytes}
|
||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
|
||||
<div class="s-storage-wrap">
|
||||
<div class="s-storage-header">
|
||||
<span class="s-storage-label">{info.label}</span>
|
||||
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
|
||||
</div>
|
||||
<div class="s-storage-bar">
|
||||
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
|
||||
</div>
|
||||
<div class="s-storage-footer">
|
||||
<span>{info.path}</span>
|
||||
<span>{fmtBytes(info.free_bytes)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="s-empty">No download path configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Downloads Path</p>
|
||||
<div class="s-section-body">
|
||||
{#if isExternalServer}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row" style="gap:var(--sp-2)">
|
||||
<input class="s-input full" class:error={!!pathsFieldError.dl}
|
||||
bind:value={downloadsPathInput}
|
||||
placeholder={isExternalServer ? "Server default" : (defaultDownloadsPath || "Default location")}
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined }; }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if pathsFieldError.dl}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
|
||||
{/if}
|
||||
{#if pathsError}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if pathsFieldError.dl && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined }; }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? "Failed" }; }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
|
||||
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Storage Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Warn when limit is reached</span>
|
||||
<span class="s-desc">{store.settings.storageLimitGb === null ? "No limit set" : `Warn above ${store.settings.storageLimitGb} GB`}</span>
|
||||
</div>
|
||||
{#if store.settings.storageLimitGb === null}
|
||||
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
|
||||
{:else}
|
||||
<div class="s-stepper">
|
||||
<button class="s-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="s-slider-val" style="width:52px"
|
||||
value={store.settings.storageLimitGb}
|
||||
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }); }} />
|
||||
<span class="s-slider-unit">GB</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
||||
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
|
||||
<span class="s-label">Advanced</span>
|
||||
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if advStorageOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Local source path</span>
|
||||
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" class:error={!!pathsFieldError.loc}
|
||||
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} />
|
||||
{#if pathsFieldError.loc && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined }; }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? "Failed" }; }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each extraScanDirs as dir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
|
||||
<span class="s-desc">Extra scan directory</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Additional scan path</span>
|
||||
<span class="s-desc">Include an extra directory in disk usage readings</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && addExtraScanDir()} />
|
||||
<button class="s-btn" onclick={addExtraScanDir} disabled={!newScanDir.trim() || extraScanDirs.includes(newScanDir.trim())}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
|
||||
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
|
||||
<span class="s-label">Backup</span>
|
||||
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if backupSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Create backup</span>
|
||||
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
|
||||
{backupLoading ? "Creating…" : "Create backup"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backupError}
|
||||
<div class="s-banner s-banner-error">{backupError}</div>
|
||||
{/if}
|
||||
|
||||
{#if backupList.length === 0}
|
||||
<p class="s-empty">No backups yet — create one above.</p>
|
||||
{:else}
|
||||
{#each backupList as backup}
|
||||
<div class="s-folder-row">
|
||||
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
|
||||
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download">↓</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Restore from file</span>
|
||||
<span class="s-desc">{restoreFile ? restoreFile.name : "Select a .tachibk file"}</span>
|
||||
</div>
|
||||
<label class="s-btn" style="cursor:pointer">
|
||||
Browse
|
||||
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
|
||||
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null;
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if restoreFile}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
|
||||
{validateLoading ? "Checking…" : "Validate"}
|
||||
</button>
|
||||
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
|
||||
{restoreLoading ? "Restoring…" : "Restore"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validateError}
|
||||
<div class="s-banner s-banner-error">{validateError}</div>
|
||||
{/if}
|
||||
|
||||
{#if validateResult}
|
||||
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
|
||||
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
|
||||
{:else}
|
||||
{#if validateResult.missingSources.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
|
||||
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(", ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if validateResult.missingTrackers.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
|
||||
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(", ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if restoreError}
|
||||
<div class="s-banner s-banner-error">{restoreError}</div>
|
||||
{/if}
|
||||
|
||||
{#if restoreStatus}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">
|
||||
{restoreStatus.state === "SUCCESS" ? "✓ Restore complete" :
|
||||
restoreStatus.state === "FAILURE" ? "✗ Restore failed" : "Restoring…"}
|
||||
</span>
|
||||
{#if restoreStatus.totalManga > 0}
|
||||
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if restoreStatus.state !== "SUCCESS" && restoreStatus.state !== "FAILURE" && restoreStatus.totalManga > 0}
|
||||
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
|
||||
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { gql, thumbUrl } from "@api/client";
|
||||
import { GET_TRACKERS } from "@api/queries/tracking";
|
||||
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||
import type { Tracker } from "../../lib/types";
|
||||
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let trackersLoading = $state(false);
|
||||
let trackersError = $state<string | null>(null);
|
||||
let oauthTrackerId = $state<number | null>(null);
|
||||
let oauthCallbackInput = $state("");
|
||||
let oauthSubmitting = $state(false);
|
||||
let credsTrackerId = $state<number | null>(null);
|
||||
let credsUsername = $state("");
|
||||
let credsPassword = $state("");
|
||||
let credsSubmitting = $state(false);
|
||||
let loggingOut = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (trackers.length === 0 && !trackersLoading) loadTrackers();
|
||||
});
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
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 = ""; }
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Connected Trackers</p>
|
||||
<div class="s-section-body">
|
||||
{#if trackersError}
|
||||
<div class="s-banner s-banner-error">{trackersError}</div>
|
||||
{/if}
|
||||
{#if trackersLoading}
|
||||
<p class="s-empty">Loading trackers…</p>
|
||||
{:else}
|
||||
{#each trackers as tracker}
|
||||
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
|
||||
<div class="s-tracker-identity">
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="s-tracker-logo" />
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{tracker.name}</span>
|
||||
<span class="s-pill" class:on={tracker.isLoggedIn}>
|
||||
{tracker.isLoggedIn ? "Connected" : "Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-tracker-action">
|
||||
{#if tracker.isLoggedIn}
|
||||
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
|
||||
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
|
||||
{tracker.authUrl ? "Connect via browser →" : "Connect"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if oauthTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
|
||||
<input class="s-input full" 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="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
|
||||
{oauthSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if credsTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
|
||||
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
|
||||
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
|
||||
<div class="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
|
||||
{credsSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user