mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Restructure Repository for SvelteKit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } 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";
|
||||
import AutomationSettings from "../sections/AutomationSettings.svelte";
|
||||
|
||||
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
|
||||
let { onOpenThemeEditor }: Props = $props();
|
||||
|
||||
type Tab = "general"|"appearance"|"reader"|"library"|"automation"|"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: "automation", label: "Automation", icon: Robot },
|
||||
{ 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); }
|
||||
|
||||
let listeningKey: keyof Keybinds | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) { e.stopPropagation(); close(); } };
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
});
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
let selectOpen: string | null = $state(null);
|
||||
let closingSelect: string | null = $state(null);
|
||||
|
||||
const CLOSE_ANIM_MS = 120;
|
||||
|
||||
function closeSelect() {
|
||||
if (!selectOpen) return;
|
||||
closingSelect = selectOpen;
|
||||
selectOpen = null;
|
||||
setTimeout(() => { closingSelect = null; }, CLOSE_ANIM_MS);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
if (selectOpen === id) { closeSelect(); }
|
||||
else { closingSelect = null; selectOpen = id; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!selectOpen) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest(".s-select")) return;
|
||||
if (t.closest(".s-select-menu")) return;
|
||||
closeSelect();
|
||||
};
|
||||
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") { e.stopPropagation(); 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} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "appearance"}
|
||||
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
|
||||
{:else if tab === "reader"}
|
||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "library"}
|
||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "automation"}
|
||||
<AutomationSettings />
|
||||
{:else if tab === "performance"}
|
||||
<PerformanceSettings />
|
||||
{:else if tab === "keybinds"}
|
||||
<KeybindsSettings bind:listeningKey />
|
||||
{:else if tab === "storage"}
|
||||
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
|
||||
{:else if tab === "folders"}
|
||||
<FoldersSettings />
|
||||
{:else if tab === "tracking"}
|
||||
<TrackingSettings />
|
||||
{:else if tab === "security"}
|
||||
<SecuritySettings {selectOpen} {closingSelect} {toggleSelect} />
|
||||
{:else if tab === "content"}
|
||||
<ContentSettings />
|
||||
{:else if tab === "about"}
|
||||
<AboutSettings />
|
||||
{:else if tab === "devtools"}
|
||||
<DevtoolsSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,498 @@
|
||||
<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="button" tabindex="-1" aria-label="Close theme editor" 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()} onkeydown={(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);
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user