chore: init svelte rewrite scaffold

This commit is contained in:
Youwes09
2026-03-18 22:29:03 -05:00
parent bf38e00cf3
commit 0e87c51801
69 changed files with 1249 additions and 17097 deletions
-15
View File
@@ -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;
}
+44
View File
@@ -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>
-36
View File
@@ -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>
);
}
-107
View File
@@ -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; }
+122
View File
@@ -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>
-62
View File
@@ -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>
);
}
+12
View File
@@ -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>
-523
View File
@@ -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>
);
}
-55
View File
@@ -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;
}
+67
View File
@@ -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>
-46
View File
@@ -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>
);
}
-85
View File
@@ -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); }
+91
View File
@@ -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>
-69
View File
@@ -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
);
}