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:
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import logoUrl from "@assets/moku-icon-splash.svg";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { boot, submitLogin, bypassBoot } from "@store/boot.svelte";
|
||||
|
||||
let { onReady }: { onReady: () => void } = $props();
|
||||
|
||||
function handleLogin() {
|
||||
submitLogin(onReady);
|
||||
}
|
||||
|
||||
function handleBypass() {
|
||||
bypassBoot(onReady);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if boot.loginRequired}
|
||||
<div class="auth-overlay">
|
||||
<div class="auth-card anim-scale-in">
|
||||
<img src={logoUrl} alt="Moku" class="auth-logo" />
|
||||
<p class="auth-title">moku</p>
|
||||
<span class="auth-mode-badge">
|
||||
{store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Basic Auth"}
|
||||
</span>
|
||||
<p class="auth-host">{store.settings.serverUrl || "localhost:4567"}</p>
|
||||
|
||||
{#if boot.loginError}
|
||||
<p class="auth-error">{boot.loginError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="auth-fields">
|
||||
<input class="auth-input" type="text" placeholder="Username"
|
||||
bind:value={boot.loginUser} disabled={boot.loginBusy}
|
||||
autocomplete="username"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
<input class="auth-input" type="password" placeholder="Password"
|
||||
bind:value={boot.loginPass} disabled={boot.loginBusy}
|
||||
autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === "Enter" && handleLogin()} />
|
||||
</div>
|
||||
|
||||
<button class="auth-btn" onclick={handleLogin}
|
||||
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}>
|
||||
{boot.loginBusy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button class="auth-btn auth-btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); text-align: center; outline: none; }
|
||||
|
||||
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
|
||||
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
|
||||
.auth-mode-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.auth-host { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: -4px 0 0; }
|
||||
.auth-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin: 0; width: 100%; box-sizing: border-box; }
|
||||
.auth-fields { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; }
|
||||
.auth-input { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); outline: none; box-sizing: border-box; transition: border-color var(--t-base), box-shadow var(--t-base); font-family: inherit; }
|
||||
.auth-input:focus { border-color: var(--border-focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
.auth-input:disabled { opacity: 0.5; }
|
||||
.auth-btn { width: 100%; padding: 9px; border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-sm); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); }
|
||||
.auth-btn:hover:not(:disabled) { opacity: 0.85; }
|
||||
.auth-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.auth-btn--ghost { background: none; border-color: transparent; color: var(--text-faint); font-size: var(--text-xs); padding: 4px; }
|
||||
.auth-btn--ghost:hover:not(:disabled) { color: var(--text-muted); opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@store/state.svelte";
|
||||
import Sidebar from "@shared/chrome/Sidebar.svelte";
|
||||
import Library from "@features/library/components/Library.svelte";
|
||||
import SeriesDetail from "@features/series/components/SeriesDetail.svelte";
|
||||
import Home from "@features/home/components/Home.svelte";
|
||||
import Search from "@features/discover/components/Search.svelte";
|
||||
import GenreDrillPage from "@features/discover/components/GenreDrillPage.svelte";
|
||||
import Downloads from "@features/downloads/components/Downloads.svelte";
|
||||
import Extensions from "@features/extensions/components/Extensions.svelte";
|
||||
import Tracking from "@features/tracking/components/Tracking.svelte";
|
||||
import Recent from "@features/recent/components/Recent.svelte";
|
||||
</script>
|
||||
|
||||
<div class="frame">
|
||||
<div class="shell">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
{#if store.activeManga}
|
||||
<SeriesDetail />
|
||||
{:else if store.genreFilter}
|
||||
<GenreDrillPage />
|
||||
{:else if store.navPage === "home"}
|
||||
<Home />
|
||||
{:else if store.navPage === "library"}
|
||||
<Library />
|
||||
{:else if store.navPage === "search"}
|
||||
<Search />
|
||||
{:else if store.navPage === "history"}
|
||||
<Recent />
|
||||
{:else if store.navPage === "downloads"}
|
||||
<Downloads />
|
||||
{:else if store.navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else if store.navPage === "tracking"}
|
||||
<Tracking />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
|
||||
.shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); background-image: var(--bg-image); min-height: 0; min-width: 0; }
|
||||
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||
import { store } from "@store/state.svelte";
|
||||
import type { NavPage } from "@store/state.svelte";
|
||||
|
||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
||||
{ id: "home", label: "Home", icon: House },
|
||||
{ id: "library", label: "Library", icon: Books },
|
||||
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
||||
{ id: "history", label: "Recent", icon: ClockCounterClockwise },
|
||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
||||
];
|
||||
|
||||
const TAB_SIZE = 36;
|
||||
const TAB_GAP = 4;
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
const activeIndex = $derived(TABS.findIndex(t => t.id === store.navPage));
|
||||
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP));
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
store.navPage = id;
|
||||
store.activeManga = null;
|
||||
store.activeSource = null;
|
||||
store.genreFilter = "";
|
||||
store.searchQuery = "";
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
store.navPage = "home";
|
||||
store.activeSource = null;
|
||||
store.activeManga = null;
|
||||
store.libraryFilter = "library";
|
||||
store.genreFilter = "";
|
||||
store.searchQuery = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="root">
|
||||
<button class="logo" class:anims onclick={goHome} title="Home" aria-label="Go to Home">
|
||||
<div class="logo-icon"></div>
|
||||
</button>
|
||||
<nav class="nav">
|
||||
{#if activeIndex >= 0}
|
||||
<div class="slide-indicator" class:anims style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
|
||||
{/if}
|
||||
{#each TABS as tab}
|
||||
<button class="tab" class:active={store.navPage === tab.id} class:anims
|
||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
||||
<tab.icon size={18} weight="light" />
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<button class="settings-btn" class:anims onclick={() => store.settingsOpen = true} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.root { width: var(--sidebar-width); flex-shrink: 0; background: transparent; display: flex; flex-direction: column; align-items: center; padding: var(--sp-4) 0; overflow: hidden; min-height: 0; height: 100%; border-right: 1px solid var(--border-dim); }
|
||||
|
||||
.logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); background: none; border: none; outline: none; cursor: pointer; border-radius: var(--radius-lg); padding: 0; appearance: none; -webkit-appearance: none; }
|
||||
.logo:hover { opacity: 0.8; }
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.logo.anims { transition: opacity var(--t-base), transform var(--t-base); }
|
||||
.logo.anims:hover { transform: scale(0.96); }
|
||||
.logo.anims:active { transform: scale(0.92); }
|
||||
|
||||
.logo-icon { width: 52px; height: 52px; background-color: var(--accent); mask-image: url("@assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("@assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
||||
|
||||
.nav { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%; padding: 0 var(--sp-2); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.slide-indicator { position: absolute; width: 36px; height: 36px; border-radius: var(--radius-md); background: var(--accent-muted); pointer-events: none; top: 0; left: 50%; transform: translateX(-50%) translateY(0px); z-index: 0; }
|
||||
.slide-indicator.anims { transition: transform 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||
|
||||
.tab { position: relative; z-index: 1; width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; }
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.tab.active { color: var(--accent-fg); background: transparent; }
|
||||
.tab.active:hover { color: var(--accent-fg); background: transparent; }
|
||||
.tab.anims { transition: color var(--t-base), background var(--t-base); }
|
||||
.tab.anims:active { transform: scale(0.88); }
|
||||
|
||||
.bottom { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; width: 100%; padding: var(--sp-3) var(--sp-2) 0; border-top: 1px solid var(--border-dim); margin-top: var(--sp-3); }
|
||||
|
||||
.settings-btn { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; outline: none; cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none; }
|
||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.settings-btn.anims { transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||
.settings-btn.anims:hover { transform: rotate(30deg); }
|
||||
</style>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { store } from "@store/state.svelte";
|
||||
import logoUrl from "@assets/moku-icon-splash.svg";
|
||||
|
||||
interface Props {
|
||||
mode?: "loading" | "idle";
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
notConfigured?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onBypass?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = "loading", ringFull = false, failed = false,
|
||||
notConfigured = false, showCards = true, showFps = false,
|
||||
onReady, onRetry, onBypass, onDismiss,
|
||||
}: Props = $props();
|
||||
|
||||
const serverAuthActive = $derived(
|
||||
store.settings.serverAuthMode === "BASIC_AUTH" || store.settings.serverAuthMode === "UI_LOGIN"
|
||||
);
|
||||
|
||||
const lockEnabled = $derived(
|
||||
store.settings.appLockEnabled &&
|
||||
(store.settings.appLockPin?.length ?? 0) >= 4 &&
|
||||
(mode === "idle" || !serverAuthActive)
|
||||
);
|
||||
|
||||
let pinEntry = $state("");
|
||||
let pinShake = $state(false);
|
||||
let pinUnlocked = $state(false);
|
||||
let pinVisible = $state(false);
|
||||
let uiScale = $state(1);
|
||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||
|
||||
const logoLoadingSize = 140;
|
||||
const logoIdleSize = 128;
|
||||
const logoLockSize = 96;
|
||||
|
||||
const ringR = $derived(70);
|
||||
const ringPad = $derived(12);
|
||||
const ringSize = $derived((ringR + ringPad) * 2);
|
||||
const ringC = $derived(ringR + ringPad);
|
||||
const ringCirc = $derived(2 * Math.PI * ringR);
|
||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
||||
const ringTop = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||
const ringLeft = $derived(-((ringSize - logoLoadingSize) / 2));
|
||||
|
||||
function submitPin() {
|
||||
if (pinEntry === store.settings.appLockPin) {
|
||||
pinUnlocked = true;
|
||||
pinEntry = "";
|
||||
if (mode === "idle") triggerExit(onDismiss);
|
||||
} else {
|
||||
pinShake = true;
|
||||
pinEntry = "";
|
||||
setTimeout(() => (pinShake = false), 500);
|
||||
}
|
||||
}
|
||||
|
||||
function onPinKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") { submitPin(); return; }
|
||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
||||
if (/^\d$/.test(e.key)) {
|
||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
||||
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
|
||||
}
|
||||
}
|
||||
|
||||
const EXIT_MS = 320;
|
||||
const PHASE1_TARGET = 0.85;
|
||||
const PHASE1_MS = 3000;
|
||||
const PHASE2_TARGET = 0.95;
|
||||
const PHASE2_MS = 10000;
|
||||
|
||||
let dots = $state("");
|
||||
let ringProg = $state(0.025);
|
||||
let exiting = $state(false);
|
||||
let exitLock = false;
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock) return;
|
||||
exitLock = true;
|
||||
exiting = true;
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
let animFrame: number;
|
||||
let animStart: number | null = null;
|
||||
let animPhase = 1;
|
||||
|
||||
function animateRing(ts: number) {
|
||||
if (exitLock) return;
|
||||
if (animStart === null) animStart = ts;
|
||||
const elapsed = ts - animStart;
|
||||
if (animPhase === 1) {
|
||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
||||
} else {
|
||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
||||
}
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode === "loading" && !failed && !notConfigured) {
|
||||
animFrame = requestAnimationFrame(animateRing);
|
||||
return () => cancelAnimationFrame(animFrame);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull) {
|
||||
exitLock = false;
|
||||
exiting = false;
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(animFrame);
|
||||
ringProg = 1;
|
||||
if (lockEnabled && !pinUnlocked) {
|
||||
setTimeout(() => (pinVisible = true), 400);
|
||||
} else {
|
||||
setTimeout(() => triggerExit(onReady), 650);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const needsPin =
|
||||
(mode === "idle" && lockEnabled) ||
|
||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
||||
if (!needsPin) return;
|
||||
window.addEventListener("keydown", onPinKey);
|
||||
return () => window.removeEventListener("keydown", onPinKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
||||
});
|
||||
|
||||
const dotsInterval = setInterval(() => {
|
||||
dots = dots.length >= 3 ? "" : dots + ".";
|
||||
}, 420);
|
||||
|
||||
onMount(async () => {
|
||||
const win = getCurrentWindow();
|
||||
uiScale = await win.scaleFactor();
|
||||
|
||||
if (mode === "idle" && onDismiss) {
|
||||
if (lockEnabled) return () => clearInterval(dotsInterval);
|
||||
const handler = () => triggerExit(onDismiss);
|
||||
const t = setTimeout(() => {
|
||||
window.addEventListener("keydown", handler, { once: true });
|
||||
window.addEventListener("mousedown", handler, { once: true });
|
||||
window.addEventListener("touchstart", handler, { once: true });
|
||||
}, 200);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
clearInterval(dotsInterval);
|
||||
window.removeEventListener("keydown", handler);
|
||||
window.removeEventListener("mousedown", handler);
|
||||
window.removeEventListener("touchstart", handler);
|
||||
};
|
||||
}
|
||||
return () => clearInterval(dotsInterval);
|
||||
});
|
||||
|
||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
||||
|
||||
const LAYER_CFG = [
|
||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||
] as const;
|
||||
|
||||
const BUF = 80, COLS = 14;
|
||||
|
||||
function hash(n: number): number {
|
||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||
}
|
||||
|
||||
function buildCards(vw: number, vh: number) {
|
||||
const cards: CardDef[] = [];
|
||||
const laneW = vw / COLS;
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const seed = col * 31 + layer * 97 + 7;
|
||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
||||
const h = w * 1.44;
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||
w, h,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
speed,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
travel,
|
||||
yStart: vh + h / 2 + BUF / 2,
|
||||
angleStart: hash(seed + 3) * 50 - 25,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
const trigs: CardTrig[] = cards.map(c => ({
|
||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||
tiltRad: c.tilt * (Math.PI / 180),
|
||||
}));
|
||||
return { cards, trigs };
|
||||
}
|
||||
|
||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
const STAMP_PAD = 6;
|
||||
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
||||
const coverH = c.w * 0.72 * 1.05;
|
||||
const lineY0 = y0 + 3 + coverH + 5;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||
}
|
||||
return oc;
|
||||
}
|
||||
|
||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(vw * dpr);
|
||||
oc.height = Math.round(vh * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
}
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
||||
) {
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const c = cards[i];
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
||||
if (alpha < 0.005) continue;
|
||||
const cy = c.yStart - p * c.travel;
|
||||
const tg = trigs[i];
|
||||
const delta = tg.tiltRad * p;
|
||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
||||
function tickFps(now: number) {
|
||||
fpsFrames++;
|
||||
if (now - fpsLast >= 500) {
|
||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
||||
fpsFrames = 0;
|
||||
fpsLast = now;
|
||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
||||
}
|
||||
}
|
||||
|
||||
function mountCanvas(el: HTMLCanvasElement) {
|
||||
const win = getCurrentWindow();
|
||||
const ctx = el.getContext("2d")!;
|
||||
let live: RenderState | null = null;
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
||||
|
||||
async function syncSize() {
|
||||
const gen = ++buildGen;
|
||||
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
||||
if (gen !== buildGen) return;
|
||||
const logW = phys.width / scale, logH = phys.height / scale;
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||
const built = buildCards(logW, logH);
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||
const vig = buildVignette(logW, logH, scale);
|
||||
el.width = phys.width; el.height = phys.height;
|
||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(el);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1, paused = false;
|
||||
|
||||
function frame(now: number) {
|
||||
if (paused) { raf = 0; return; }
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = now;
|
||||
if (showFps) tickFps(now);
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||
}
|
||||
|
||||
function pause() { paused = true; t0 = -1; }
|
||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame); }
|
||||
|
||||
function onVisibility() { document.hidden ? pause() : resume(); }
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
||||
focused ? resume() : pause();
|
||||
});
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
unlistenFocus.then(f => f());
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
||||
{#if showCards}
|
||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||
{#if showFps}
|
||||
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if mode === "idle" && lockEnabled}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
||||
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
||||
</div>
|
||||
<div class="pin-card">
|
||||
<p class="pin-label">Enter PIN</p>
|
||||
<div class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if mode === "idle"}
|
||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||
<div class="logo-glow"></div>
|
||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
|
||||
</div>
|
||||
<p class="hint">press any key to continue</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||
{#if !failed && !notConfigured}
|
||||
<svg width={ringSize} height={ringSize}
|
||||
class="loading-ring"
|
||||
class:ring-hide={lockEnabled && pinVisible}
|
||||
style="position:absolute;top:0;left:0;pointer-events:none">
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{ringArc} {ringCirc}"
|
||||
transform="rotate(-90 {ringC} {ringC})"
|
||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||
</svg>
|
||||
{/if}
|
||||
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
||||
</div>
|
||||
<div class="bottom-area" style="z-index:1">
|
||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||
{#if failed || notConfigured}
|
||||
<div class="error-box anim-fade-up">
|
||||
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
||||
<div class="error-actions">
|
||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lockEnabled}
|
||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
||||
<div class="pin-card">
|
||||
<p class="pin-label">Enter PIN</p>
|
||||
<div class="pin-block">
|
||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
|
||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||
|
||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||
|
||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
||||
|
||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; }
|
||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
||||
.error-actions { display: flex; gap: 6px; }
|
||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
||||
|
||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
||||
.loading-ring { transition: opacity 0.5s ease; }
|
||||
.ring-hide { opacity: 0; }
|
||||
|
||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||
.pin-card { background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
|
||||
.pin-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin: 0; }
|
||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
||||
.pin-shake { animation: pinShake 0.42s ease; }
|
||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
const { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const os = platform();
|
||||
const isMac = os === "macos";
|
||||
const isWindows = os === "windows";
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
isFullscreen = await win.isFullscreen();
|
||||
const unlisten = await win.onResized(async () => {
|
||||
isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
return unlisten;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isFullscreen}
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
{#if isMac}<div class="mac-spacer"></div>{/if}
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
{#if !isMac}
|
||||
<div class="controls">
|
||||
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" /></svg>
|
||||
</button>
|
||||
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
|
||||
</button>
|
||||
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if isWindows}
|
||||
<div class="fullscreen-controls">
|
||||
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar { display: flex; align-items: center; justify-content: space-between; height: var(--titlebar-height); padding: 0 6px 0 var(--sp-4); background: transparent; flex-shrink: 0; user-select: none; -webkit-app-region: drag; }
|
||||
.mac-spacer { width: 70px; flex-shrink: 0; -webkit-app-region: drag; }
|
||||
.title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; opacity: 0.5; -webkit-app-region: drag; }
|
||||
.controls { display: flex; align-items: center; gap: 2px; -webkit-app-region: no-drag; }
|
||||
|
||||
button { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); -webkit-app-region: no-drag; }
|
||||
button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
.close:hover { color: #fff; background: #c0392b; }
|
||||
|
||||
.fullscreen-controls { position: fixed; top: 0; right: 0; z-index: 9999; display: flex; align-items: center; gap: 2px; padding: 4px; opacity: 0; transition: opacity 0.2s ease; -webkit-app-region: no-drag; }
|
||||
.fullscreen-controls:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { store, dismissToast } from "@store/state.svelte";
|
||||
import type { Toast } from "@store/state.svelte";
|
||||
|
||||
const EXIT_MS = 280;
|
||||
const leaving = new Set<string>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
let detail = $state<Toast | null>(null);
|
||||
|
||||
function schedule(t: Toast) {
|
||||
if (timers.has(t.id)) return;
|
||||
const dur = t.duration ?? 3500;
|
||||
if (dur === 0) return;
|
||||
timers.set(t.id, setTimeout(() => dismiss(t.id), dur));
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
if (leaving.has(id)) return;
|
||||
leaving.add(id);
|
||||
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id); }
|
||||
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`);
|
||||
if (!el) { finalize(id); return; }
|
||||
const h = el.offsetHeight;
|
||||
el.style.setProperty("--exit-h", `${h}px`);
|
||||
el.classList.add("leaving");
|
||||
setTimeout(() => finalize(id), EXIT_MS);
|
||||
}
|
||||
|
||||
function finalize(id: string) {
|
||||
leaving.delete(id);
|
||||
dismissToast(id);
|
||||
}
|
||||
|
||||
function openDetail(e: MouseEvent, t: Toast) {
|
||||
e.preventDefault();
|
||||
detail = t;
|
||||
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id); }
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detail = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const activeIds = new Set(store.toasts.map(t => t.id));
|
||||
store.toasts.forEach(schedule);
|
||||
for (const [id, timer] of timers) {
|
||||
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); }
|
||||
}
|
||||
if (detail && !activeIds.has(detail.id)) detail = null;
|
||||
});
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
success: "M20 6L9 17l-5-5",
|
||||
error: "M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
|
||||
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if store.toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each store.toasts as t (t.id)}
|
||||
<button class="toast toast-{t.kind}" data-toast-id={t.id} aria-label="{t.title}{t.body ? ': ' + t.body : ''}"
|
||||
onclick={() => dismiss(t.id)}
|
||||
oncontextmenu={(e) => openDetail(e, t)}
|
||||
>
|
||||
<div class="accent-bar"></div>
|
||||
<span class="icon">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons[t.kind]} />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="title">{t.title}</p>
|
||||
<p class="sub">{t.body ?? '\u00a0'}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail}
|
||||
<div class="detail-backdrop" role="presentation" onclick={closeDetail} oncontextmenu={(e) => e.preventDefault()}>
|
||||
<div class="detail-panel detail-{detail.kind}" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="detail-accent"></div>
|
||||
<div class="detail-body">
|
||||
<div class="detail-header">
|
||||
<span class="detail-kind">{detail.kind}</span>
|
||||
<button class="detail-close" onclick={closeDetail} aria-label="Close">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="detail-title">{detail.title}</p>
|
||||
{#if detail.body}
|
||||
<pre class="detail-text">{detail.body}</pre>
|
||||
{/if}
|
||||
<div class="detail-actions">
|
||||
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.title}${detail!.body ? '\n' + detail!.body : ''}`)}>
|
||||
Copy
|
||||
</button>
|
||||
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); closeDetail(); }}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toaster { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 9999; display: flex; flex-direction: column; gap: 5px; pointer-events: none; }
|
||||
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px var(--sp-3) 12px 0;
|
||||
border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events: all; width: 280px; overflow: hidden; cursor: pointer;
|
||||
font-family: inherit; font-size: inherit; color: inherit; text-align: left;
|
||||
will-change: transform, opacity;
|
||||
animation: slideIn 0.35s cubic-bezier(0.16,1,0.3,1) both;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.toast:hover { border-color: var(--border-base); box-shadow: 0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset; transform: translateX(-3px); }
|
||||
.toast:active { transform: translateX(0) scale(0.98); }
|
||||
|
||||
:global(.toast.leaving) { animation: slideOut 0.28s cubic-bezier(0.4,0,1,1) forwards !important; pointer-events: none; }
|
||||
|
||||
@keyframes slideIn { from { opacity:0; transform:translateX(20px) scale(0.96) } to { opacity:1; transform:translateX(0) scale(1) } }
|
||||
@keyframes slideOut {
|
||||
0% { opacity:1; transform:translateX(0) scale(1); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||
40% { opacity:0; transform:translateX(14px) scale(0.96); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||
100% { opacity:0; transform:translateX(14px) scale(0.96); max-height:0; margin-bottom:-5px; }
|
||||
}
|
||||
|
||||
.accent-bar { width: 3px; align-self: stretch; flex-shrink: 0; border-radius: 0 2px 2px 0; }
|
||||
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||
.toast-error .accent-bar { background: var(--color-error); }
|
||||
.toast-info .accent-bar { background: var(--text-faint); }
|
||||
.toast-download .accent-bar { background: var(--accent-fg); }
|
||||
|
||||
.icon { flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.toast-success .icon { color: var(--accent-fg); }
|
||||
.toast-error .icon { color: var(--color-error); }
|
||||
.toast-info .icon { color: var(--text-muted); }
|
||||
.toast-download .icon { color: var(--accent-fg); }
|
||||
|
||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||
.title { font-size: var(--text-xs); font-family: var(--font-ui); color: var(--text-secondary); font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.detail-backdrop {
|
||||
position: fixed; inset: 0; z-index: 10000;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.15s ease both;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
.detail-panel {
|
||||
display: flex; width: 420px; max-width: calc(100vw - 32px); max-height: 60vh;
|
||||
border-radius: var(--radius-lg); background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset;
|
||||
overflow: hidden;
|
||||
animation: popIn 0.2s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes popIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }
|
||||
|
||||
.detail-accent { width: 3px; flex-shrink: 0; }
|
||||
.detail-error .detail-accent { background: var(--color-error); }
|
||||
.detail-success .detail-accent { background: var(--accent-fg); }
|
||||
.detail-info .detail-accent { background: var(--text-faint); }
|
||||
.detail-download .detail-accent { background: var(--accent-fg); }
|
||||
|
||||
.detail-body { flex: 1; min-width: 0; display: flex; flex-direction: column; padding: var(--sp-3); gap: var(--sp-2); overflow: hidden; }
|
||||
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.detail-kind {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; color: var(--text-faint);
|
||||
}
|
||||
.detail-error .detail-kind { color: var(--color-error); }
|
||||
|
||||
.detail-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||||
background: none; border: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.detail-close:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
.detail-title {
|
||||
font-family: var(--font-ui); font-size: var(--text-sm);
|
||||
color: var(--text-secondary); font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-snug); word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
font-family: var(--font-mono, monospace); font-size: var(--text-xs);
|
||||
color: var(--text-muted); line-height: var(--leading-relaxed);
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
background: var(--bg-void); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3);
|
||||
scrollbar-width: thin;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||
.detail-copy, .detail-dismiss {
|
||||
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); cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.detail-copy {
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-muted);
|
||||
}
|
||||
.detail-copy:hover { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.detail-dismiss {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.detail-dismiss:hover { background: color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user