mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
chore: init svelte rewrite scaffold
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
/* GPU layer for main content area */
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { navPage, activeManga } from "../../store";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import Library from "../pages/Library.svelte";
|
||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||
import History from "../history/History.svelte";
|
||||
import Search from "../search/Search.svelte";
|
||||
import Explore from "../pages/Explore.svelte";
|
||||
import Downloads from "../downloads/Downloads.svelte";
|
||||
import Extensions from "../pages/Extensions.svelte";
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
{#if $activeManga}
|
||||
<SeriesDetail />
|
||||
{:else if $navPage === "library"}
|
||||
<Library />
|
||||
{:else if $navPage === "search"}
|
||||
<Search />
|
||||
{:else if $navPage === "history"}
|
||||
<History />
|
||||
{:else if $navPage === "explore" || $navPage === "sources"}
|
||||
<Explore />
|
||||
{:else if $navPage === "downloads"}
|
||||
<Downloads />
|
||||
{:else if $navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else}
|
||||
<Library />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
||||
.main {
|
||||
flex: 1; overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
transform: translateZ(0);
|
||||
contain: layout style;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useStore } from "../../store";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Library from "../pages/Library";
|
||||
import SeriesDetail from "../pages/SeriesDetail";
|
||||
import History from "../pages/History";
|
||||
import Search from "../pages/Search";
|
||||
import Explore from "../explore/Explore";
|
||||
import DownloadQueue from "../downloads/DownloadQueue";
|
||||
import ExtensionList from "../extensions/ExtensionList";
|
||||
import s from "./Layout.module.css";
|
||||
|
||||
export default function Layout() {
|
||||
const navPage = useStore((s) => s.navPage);
|
||||
const activeManga = useStore((s) => s.activeManga);
|
||||
|
||||
function renderContent() {
|
||||
if (activeManga) return <SeriesDetail />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
case "history": return <History />;
|
||||
case "sources": return <Explore />;
|
||||
case "explore": return <Explore />;
|
||||
case "downloads": return <DownloadQueue />;
|
||||
case "extensions": return <ExtensionList />;
|
||||
default: return <Library />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<Sidebar />
|
||||
<main className={s.main}>{renderContent()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
.root {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-void);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sp-4) 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--sp-3);
|
||||
overflow: visible;
|
||||
/* Explicit reset — prevents browser from injecting a default button background */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.logoIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--accent);
|
||||
mask-image: url("../../assets/moku-icon.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-image: url("../../assets/moku-icon.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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
width: 100%;
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
||||
|
||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
/* Prevent hover state from overriding active colour */
|
||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.bottom {
|
||||
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);
|
||||
}
|
||||
|
||||
.settingsBtn {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
/* Same explicit resets */
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||
}
|
||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store";
|
||||
import type { NavPage } from "../../store";
|
||||
|
||||
const TABS: { id: NavPage; label: string; path: string }[] = [
|
||||
{ id: "library", label: "Library", path: "M12 2H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h8M12 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-8M12 2v20" },
|
||||
{ id: "search", label: "Search", path: "M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" },
|
||||
{ id: "history", label: "History", path: "M12 8v4l3 3M3.05 11a9 9 0 1 0 .5-3" },
|
||||
{ id: "explore", label: "Explore", path: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 0v20M2 12h20" },
|
||||
{ id: "downloads", label: "Downloads", path: "M12 3v13M7 11l5 5 5-5M5 21h14" },
|
||||
{ id: "extensions", label: "Extensions", path: "M12 2l2 7h7l-5.5 4 2 7L12 16l-5.5 4 2-7L3 9h7z" },
|
||||
];
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
navPage.set(id);
|
||||
activeManga.set(null);
|
||||
genreFilter.set("");
|
||||
if (id !== "explore") activeSource.set(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
navPage.set("library");
|
||||
activeSource.set(null);
|
||||
activeManga.set(null);
|
||||
libraryFilter.set("library");
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="root">
|
||||
<button class="logo" on:click={goHome} title="Go to Library" aria-label="Go to Library">
|
||||
<div class="logo-icon" />
|
||||
</button>
|
||||
<nav class="nav">
|
||||
{#each TABS as tab}
|
||||
<button class="tab" class:active={$navPage === tab.id}
|
||||
title={tab.label} on:click={() => navigate(tab.id)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={tab.path} />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<button class="settings-btn" on:click={() => settingsOpen.set(true)} title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-void);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
.logo {
|
||||
width: 80px; height: 80px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: var(--sp-3);
|
||||
background: none; border: none; outline: none;
|
||||
cursor: pointer; border-radius: var(--radius-lg);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
padding: 0; appearance: none; -webkit-appearance: none;
|
||||
}
|
||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||
.logo:active { transform: scale(0.92); }
|
||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.logo-icon {
|
||||
width: 80px; height: 80px;
|
||||
background-color: var(--accent);
|
||||
mask-image: url("../../assets/moku-icon.svg");
|
||||
mask-repeat: no-repeat; mask-position: center; mask-size: contain;
|
||||
-webkit-mask-image: url("../../assets/moku-icon.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 {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2);
|
||||
}
|
||||
.tab {
|
||||
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;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.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: var(--accent-muted); }
|
||||
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.bottom {
|
||||
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;
|
||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||
}
|
||||
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useStore, type NavPage } from "../../store";
|
||||
import s from "./Sidebar.module.css";
|
||||
|
||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
setActiveManga(null);
|
||||
setGenreFilter("");
|
||||
if (id !== "explore") setActiveSource(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
setNavPage("library");
|
||||
setActiveSource(null);
|
||||
setActiveManga(null);
|
||||
setLibraryFilter("library");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={s.root}>
|
||||
{/* Logo click → back to library root */}
|
||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
||||
<div className={s.logoIcon} />
|
||||
</button>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} title={tab.label}
|
||||
onClick={() => navigate(tab.id)}
|
||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
||||
{tab.icon}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={s.bottom}>
|
||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
||||
<GearSix size={18} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
export const EXIT_MS = 320;
|
||||
export let mode: "loading" | "idle" = "loading";
|
||||
export let ringFull = false;
|
||||
export let failed = false;
|
||||
export let showCards = true;
|
||||
export let showFps = false;
|
||||
export let onReady: (() => void) | undefined = undefined;
|
||||
export let onRetry: (() => void) | undefined = undefined;
|
||||
export let onDismiss: (() => void) | undefined = undefined;
|
||||
</script>
|
||||
<div>SplashScreen stub</div>
|
||||
@@ -1,523 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/moku-icon.svg";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export type SplashMode = "loading" | "idle";
|
||||
export const EXIT_MS = 320;
|
||||
|
||||
interface Props {
|
||||
mode: SplashMode;
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
// ── Hash ──────────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Card definition ───────────────────────────────────────────────────────────
|
||||
interface CardDef {
|
||||
layer: 0 | 1 | 2;
|
||||
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; }
|
||||
|
||||
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;
|
||||
const COLS = 14;
|
||||
|
||||
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||
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 maxNudge = (laneW - w) / 2 - 2;
|
||||
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
layer: layer as 0 | 1 | 2,
|
||||
cx, 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 };
|
||||
}
|
||||
|
||||
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||
const STAMP_PAD = 6;
|
||||
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(logW * dpr);
|
||||
oc.height = Math.round(logH * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const x0 = STAMP_PAD;
|
||||
const 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;
|
||||
}
|
||||
|
||||
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||
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.15, "rgba(0,0,0,0)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
}
|
||||
|
||||
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||
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 cosDelta = Math.cos(delta);
|
||||
const sinDelta = Math.sin(delta);
|
||||
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.setTransform(
|
||||
cos * dpr, sin * dpr,
|
||||
-sin * dpr, cos * dpr,
|
||||
c.cx * dpr, cy * dpr,
|
||||
);
|
||||
// Draw stamp at its natural logical size.
|
||||
// The stamp was baked at (logical * dpr) physical pixels.
|
||||
// setTransform already applied dpr scaling, so drawing at logical size
|
||||
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
||||
const sw = stamps[i].width / dpr;
|
||||
const 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);
|
||||
}
|
||||
|
||||
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||
function Ring({ progress }: { progress: number }) {
|
||||
const r = 44, sw = 2, pad = 8;
|
||||
const size = (r + pad) * 2, c = r + pad;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
||||
return (
|
||||
<svg width={size} height={size} style={{
|
||||
position: "absolute", pointerEvents: "none",
|
||||
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||
}}>
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||
transform={`rotate(-90 ${c} ${c})`}
|
||||
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||
function FpsCounter() {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
function tick(now: number) {
|
||||
const arr = times.current;
|
||||
arr.push(now);
|
||||
if (arr.length > 60) arr.shift();
|
||||
if (arr.length > 1 && divRef.current) {
|
||||
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
||||
divRef.current.textContent = `${fps} fps`;
|
||||
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={divRef} style={{
|
||||
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||
color: "#4ade80",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 4, padding: "2px 7px",
|
||||
userSelect: "none", pointerEvents: "none",
|
||||
}}>-- fps</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Strategy: best of both worlds.
|
||||
//
|
||||
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
||||
// Cards fill the actual window shape correctly at any size.
|
||||
//
|
||||
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
||||
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
||||
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
||||
//
|
||||
// On every resize both are re-derived together so fullscreen, half-split,
|
||||
// monitor switch — all produce crisp, correctly-proportioned cards.
|
||||
//
|
||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
// ── Live render state ────────────────────────────────────────────────────
|
||||
// The frame loop only ever reads from `live`. syncSize builds a complete
|
||||
// replacement object off-thread then swaps it in one atomic assignment —
|
||||
// no frame ever sees a half-rebuilt state.
|
||||
interface RenderState {
|
||||
cards: ReturnType<typeof buildCards>["cards"];
|
||||
trigs: ReturnType<typeof buildCards>["trigs"];
|
||||
stamps: HTMLCanvasElement[];
|
||||
vignette: HTMLCanvasElement;
|
||||
CW: number; CH: number; scale: number;
|
||||
}
|
||||
let live: RenderState | null = null;
|
||||
|
||||
// Track what we last built so we skip no-op resize events.
|
||||
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
||||
// Debounce: if a new resize arrives while one is in-flight, we only
|
||||
// want the most recent result. A simple generation counter handles this.
|
||||
let buildGen = 0;
|
||||
|
||||
async function syncSize() {
|
||||
const gen = ++buildGen;
|
||||
|
||||
const [phys, scale] = await Promise.all([
|
||||
win.innerSize(),
|
||||
win.scaleFactor(),
|
||||
]);
|
||||
|
||||
// Another resize fired while we were awaiting — our result is stale.
|
||||
if (gen !== buildGen) return;
|
||||
|
||||
const physW = phys.width;
|
||||
const physH = phys.height;
|
||||
const logW = physW / scale;
|
||||
const logH = physH / scale;
|
||||
|
||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
||||
|
||||
// Build everything into a local staging object — nothing visible changes yet.
|
||||
const built = buildCards(logW, logH);
|
||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
||||
const vig = buildVignette(logW, logH, scale);
|
||||
|
||||
// One atomic swap — the frame loop immediately sees the complete new state.
|
||||
// Canvas dimensions are updated here too so they're always in sync with
|
||||
// the render state that uses them.
|
||||
canvas!.width = physW;
|
||||
canvas!.height = physH;
|
||||
live = {
|
||||
cards: built.cards, trigs: built.trigs,
|
||||
stamps, vignette: vig,
|
||||
CW: physW, CH: physH, scale,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
||||
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
||||
);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => syncSize());
|
||||
ro.observe(canvas);
|
||||
syncSize();
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
if (!live) return;
|
||||
if (t0 < 0) t0 = now;
|
||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
||||
drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={ref} style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none",
|
||||
width: "100%", height: "100%",
|
||||
}} />
|
||||
{showFps && <FpsCounter />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Static CSS ────────────────────────────────────────────────────────────────
|
||||
const STATIC_CSS = `
|
||||
@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} }
|
||||
`;
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function SplashScreen({
|
||||
mode, ringFull = false, failed = false,
|
||||
showCards = true, showFps = false,
|
||||
onReady, onRetry, onDismiss,
|
||||
}: Props) {
|
||||
const [dots, setDots] = useState("");
|
||||
const [ringProg, setRingProg] = useState(0.025);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const exitLock = useRef(false);
|
||||
|
||||
function triggerExit(cb?: () => void) {
|
||||
if (exitLock.current) return;
|
||||
exitLock.current = true;
|
||||
setExiting(true);
|
||||
setTimeout(() => cb?.(), EXIT_MS);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ringFull) return;
|
||||
setRingProg(1);
|
||||
const t = setTimeout(() => triggerExit(onReady), 650);
|
||||
return () => clearTimeout(t);
|
||||
}, [ringFull]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "idle" || !onDismiss) return;
|
||||
function handler() { triggerExit(onDismiss); }
|
||||
// Delay registering listeners by one frame so the event that triggered
|
||||
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
|
||||
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);
|
||||
window.removeEventListener("keydown", handler);
|
||||
window.removeEventListener("mousedown", handler);
|
||||
window.removeEventListener("touchstart", handler);
|
||||
};
|
||||
}, [mode, onDismiss]);
|
||||
|
||||
const isIdle = mode === "idle";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 9999,
|
||||
background: "var(--bg-base)", overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
cursor: isIdle ? "pointer" : "default",
|
||||
animation: exiting
|
||||
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||
}}>
|
||||
<style>{STATIC_CSS}</style>
|
||||
|
||||
{showCards && <CardCanvas showFps={showFps} />}
|
||||
|
||||
{isIdle ? (
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||
<div style={{
|
||||
position: "absolute", inset: -20, borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
<img src={logoUrl} alt="Moku" style={{
|
||||
width: 128, height: 128, borderRadius: 28,
|
||||
display: "block", position: "relative",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||
margin: 0, userSelect: "none",
|
||||
animation: "hintFade 3.5s ease-in-out infinite",
|
||||
}}>press any key to continue</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||
{!failed && <Ring progress={ringProg} />}
|
||||
<img src={logoUrl} alt="Moku"
|
||||
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||
zIndex: 1, userSelect: "none",
|
||||
}}>moku</p>
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
{failed ? (
|
||||
<>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||
Could not reach Suwayomi
|
||||
</p>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||
Make sure tachidesk-server is on your PATH
|
||||
</p>
|
||||
<button onClick={onRetry} style={{
|
||||
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||
color: "var(--text-muted)", cursor: "pointer",
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||
}}>Retry</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
/* Drag region covers the whole bar */
|
||||
-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;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
/* Controls must NOT be draggable */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
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);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.btnClose:hover {
|
||||
color: #fff;
|
||||
background: #c0392b;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
const win = getCurrentWindow();
|
||||
</script>
|
||||
|
||||
<div class="bar" data-tauri-drag-region>
|
||||
<span class="title" data-tauri-drag-region>Moku</span>
|
||||
<div class="controls">
|
||||
<button on:click={() => 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 on:click={() => 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" on:click={() => win.close()} 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>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
-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;
|
||||
-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: var(--bg-raised); }
|
||||
.close:hover { color: #fff; background: #c0392b; }
|
||||
</style>
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import s from "./TitleBar.module.css";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div className={s.bar} data-tauri-drag-region>
|
||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
className={s.btn}
|
||||
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" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={s.btn}
|
||||
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" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={[s.btn, s.btnClose].join(" ")}
|
||||
onClick={() => win.close()}
|
||||
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" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
.toaster {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5);
|
||||
right: var(--sp-5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
pointer-events: none;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||
pointer-events: all;
|
||||
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Kind variants */
|
||||
.toast_success { border-color: var(--accent-dim); }
|
||||
.toast_success .toastIcon { color: var(--accent-fg); }
|
||||
|
||||
.toast_error { border-color: var(--color-error); }
|
||||
.toast_error .toastIcon { color: var(--color-error); }
|
||||
|
||||
.toast_download .toastIcon { color: var(--accent-fg); }
|
||||
.toast_info .toastIcon { color: var(--text-muted); }
|
||||
|
||||
.toastIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toastTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toastSub {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { toasts, dismissToast } from "../../store";
|
||||
import type { Toast } from "../../store";
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function schedule(t: Toast) {
|
||||
if (timers.has(t.id)) return;
|
||||
const dur = t.duration ?? 3500;
|
||||
if (dur === 0) return;
|
||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
||||
}
|
||||
|
||||
$: $toasts.forEach(schedule);
|
||||
|
||||
onDestroy(() => timers.forEach(clearTimeout));
|
||||
|
||||
const icons: Record<Toast["kind"], string> = {
|
||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||
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 $toasts.length}
|
||||
<div class="toaster" aria-live="polite">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class="toast toast-{t.kind}" role="alert">
|
||||
<span class="icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={icons[t.kind]} />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
</div>
|
||||
<button class="close" on:click={() => dismissToast(t.id)} title="Dismiss">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toaster {
|
||||
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
||||
z-index: 9999; display: flex; flex-direction: column;
|
||||
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
||||
}
|
||||
.toast {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||
pointer-events: all; min-width: 220px;
|
||||
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
.toast-success { border-color: var(--accent-dim); }
|
||||
.toast-success .icon { color: var(--accent-fg); }
|
||||
.toast-error { border-color: var(--color-error); }
|
||||
.toast-error .icon { color: var(--color-error); }
|
||||
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
||||
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
||||
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
||||
.sub {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
||||
import { useStore } from "../../store";
|
||||
import s from "./Toaster.module.css";
|
||||
|
||||
export type ToastKind = "success" | "error" | "info" | "download";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
kind: ToastKind;
|
||||
title: string;
|
||||
body?: string;
|
||||
duration?: number; // ms, 0 = persistent
|
||||
}
|
||||
|
||||
// ── icons per kind ──────────────────────────────────────────────────────────
|
||||
|
||||
function ToastIcon({ kind }: { kind: ToastKind }) {
|
||||
const size = 15;
|
||||
const w = "light" as const;
|
||||
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
||||
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
||||
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
||||
return <Info size={size} weight={w} />;
|
||||
}
|
||||
|
||||
// ── individual toast ─────────────────────────────────────────────────────────
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismissToast = useStore((s) => s.dismissToast);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const duration = toast.duration ?? 3500;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration === 0) return;
|
||||
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [toast.id, duration]);
|
||||
|
||||
return (
|
||||
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
||||
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
||||
<div className={s.toastBody}>
|
||||
<p className={s.toastTitle}>{toast.title}</p>
|
||||
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
||||
</div>
|
||||
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── toaster container ────────────────────────────────────────────────────────
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useStore((s) => s.toasts);
|
||||
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={s.toaster} aria-live="polite">
|
||||
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user