Chore: Port over Settings (Barely Works)

This commit is contained in:
Youwes09
2026-05-24 20:31:46 -05:00
parent ae5d9748c7
commit d9a9427e3b
87 changed files with 8821 additions and 615 deletions
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { appState } from '$lib/state/app.svelte'
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
function handleBypass() {
bypassBoot(appState.authMode, boot.loginUser)
}
</script>
{#if appState.status === 'auth'}
<div class="overlay">
<div class="card anim-scale-in">
<img src={logoUrl} alt="Moku" class="logo" />
<p class="title">moku</p>
<span class="mode-badge">
{appState.authMode === 'UI_LOGIN' ? 'UI Login' : 'Basic Auth'}
</span>
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
{#if boot.loginError}
<p class="error">{boot.loginError}</p>
{/if}
<div class="fields">
<input
class="input"
type="text"
placeholder="Username"
bind:value={boot.loginUser}
disabled={boot.loginBusy}
autocomplete="username"
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/>
<input
class="input"
type="password"
placeholder="Password"
bind:value={boot.loginPass}
disabled={boot.loginBusy}
autocomplete="current-password"
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/>
</div>
<button
class="btn"
onclick={submitLogin}
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}
>
{boot.loginBusy ? 'Signing in…' : 'Sign in'}
</button>
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
</div>
</div>
{/if}
<style>
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
.logo { width:56px; height:56px; border-radius:14px; display:block; }
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
.error { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--color-error); background:var(--color-error-bg); border:1px solid var(--color-error); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); width:100%; box-sizing:border-box; }
.fields { display:flex; flex-direction:column; gap:var(--sp-2); width:100%; }
.input { width:100%; background:var(--bg-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); padding:8px 12px; font-size:var(--text-sm); color:var(--text-primary); outline:none; box-sizing:border-box; transition:border-color var(--t-base), box-shadow var(--t-base); font-family:inherit; }
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
.input:disabled { opacity:0.5; }
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
.btn:hover:not(:disabled) { opacity:0.85; }
.btn:disabled { opacity:0.35; cursor:default; }
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { app } from '$lib/state/app.svelte'
import {
House, Books, MagnifyingGlass, ClockCounterClockwise,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
} from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
const TABS = [
{ path: '/', label: 'Home', icon: House },
{ path: '/library', label: 'Library', icon: Books },
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
] as const
const TAB_SIZE = 36
const TAB_GAP = 4
const activeIndex = $derived(
TABS.findIndex(t => {
if (t.path === '/') return $page.url.pathname === '/'
return $page.url.pathname.startsWith(t.path)
})
)
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP))
</script>
<aside class="root">
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home">
<div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
</button>
<nav class="nav">
{#if activeIndex >= 0}
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
{/if}
{#each TABS as tab}
<button
class="tab"
class:active={activeIndex === TABS.indexOf(tab)}
title={tab.label}
onclick={() => goto(tab.path)}
>
<tab.icon size={18} weight="light" />
</button>
{/each}
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => app.setSettingsOpen(true)} title="Settings">
<GearSix size={18} weight="light" />
</button>
</div>
</aside>
<style>
.root {
width: var(--sidebar-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--sp-4) 0;
height: 100%;
border-right: 1px solid var(--border-dim);
overflow: hidden;
}
.logo {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--sp-4);
border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base);
}
.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: 28px;
height: 28px;
background-color: var(--accent);
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
filter: drop-shadow(0 0 8px rgba(107,143,107,0.35));
pointer-events: none;
}
.nav {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding: 0 var(--sp-2);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }
.indicator {
position: absolute;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--accent-muted);
pointer-events: none;
top: 0;
left: 50%;
z-index: 0;
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
.tab {
position: relative;
z-index: 1;
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:active { transform: scale(0.88); }
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.tab.active { color: var(--accent-fg); }
.tab.active:hover { background: transparent; }
.bottom {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: var(--sp-3) var(--sp-2) 0;
border-top: 1px solid var(--border-dim);
margin-top: var(--sp-3);
}
.settings-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-faint);
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>
@@ -0,0 +1,220 @@
<script lang="ts">
import { onMount } from 'svelte'
import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/components/chrome/splashCanvas'
interface Props {
mode?: 'loading' | 'idle'
ringFull?: boolean
failed?: boolean
notConfigured?: boolean
showCards?: boolean
onReady?: () => void
onRetry?: () => void
onBypass?: () => void
onDismiss?: () => void
}
let {
mode = 'loading',
ringFull = false,
failed = false,
notConfigured = false,
showCards = true,
onReady,
onRetry,
onBypass,
onDismiss,
}: Props = $props()
const EXIT_MS = 320
const RING_R = 70
const RING_PAD = 12
const { size: ringSize, c: ringC, circ: ringCirc } = ringGeometry(RING_R, RING_PAD)
const LOGO_LOADING = 140
const LOGO_IDLE = 128
let dots = $state('')
let ringProg = $state(0.025)
let exiting = $state(false)
let exitLock = false
let pinEntry = $state('')
let pinShake = $state(false)
let pinVisible = $state(false)
let pinUnlocked = $state(false)
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
function triggerExit(cb?: () => void) {
if (exitLock) return
exitLock = true
exiting = true
setTimeout(() => cb?.(), EXIT_MS)
}
function submitPin(correctPin: string) {
if (pinEntry === correctPin) {
pinUnlocked = true
pinEntry = ''
if (mode === 'idle') triggerExit(onDismiss)
} else {
pinShake = true
pinEntry = ''
setTimeout(() => (pinShake = false), 500)
}
}
function onPinKey(e: KeyboardEvent, correctPin: string, pinLen: number) {
if (e.key === 'Enter') { submitPin(correctPin); return }
if (e.key === 'Backspace') { pinEntry = pinEntry.slice(0, -1); return }
if (/^\d$/.test(e.key)) {
pinEntry = (pinEntry + e.key).slice(0, 8)
if (pinEntry.length >= pinLen) submitPin(correctPin)
}
}
$effect(() => {
if (!ringFull) {
exitLock = false
exiting = false
return
}
if (failed || notConfigured) return
triggerExit(onReady)
})
$effect(() => {
if (pinUnlocked && mode !== 'idle') triggerExit(onReady)
})
onMount(() => {
const stopDots = setInterval(() => {
dots = dots.length >= 3 ? '' : dots + '.'
}, 420)
if (mode === 'loading' && !failed && !notConfigured) {
const stopAnim = animateRingProgress(p => (ringProg = p))
return () => { clearInterval(stopDots); stopAnim() }
}
if (mode === 'idle' && onDismiss) {
const handler = () => triggerExit(onDismiss)
const t = setTimeout(() => {
window.addEventListener('keydown', handler, { once: true })
window.addEventListener('mousedown', handler, { once: true })
window.addEventListener('touchstart', handler, { once: true })
}, 200)
return () => {
clearTimeout(t)
clearInterval(stopDots)
window.removeEventListener('keydown', handler)
window.removeEventListener('mousedown', handler)
window.removeEventListener('touchstart', handler)
}
}
return () => clearInterval(stopDots)
})
</script>
<div class="splash" class:exiting>
{#if showCards}
<canvas
style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%"
use:mountCardCanvas
></canvas>
{/if}
{#if mode === 'idle'}
<div class="center">
<div class="logo-wrap" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;margin-bottom:32px">
<div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;border-radius:28px" />
</div>
<p class="hint">press any key to continue</p>
</div>
{:else}
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
{#if !failed && !notConfigured}
<svg
width={ringSize}
height={ringSize}
class="ring"
class:ring-hide={pinVisible}
style="position:absolute;top:0;left:0;pointer-events:none"
>
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--border-base)" stroke-width="2"/>
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--accent)" stroke-width="2"
stroke-linecap="round"
stroke-dasharray="{ringArc} {ringCirc}"
transform="rotate(-90 {ringC} {ringC})"
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)"
/>
</svg>
{/if}
<img src={logoUrl} alt="Moku" style="width:{LOGO_LOADING}px;height:{LOGO_LOADING}px;border-radius:32px;display:block;position:relative"/>
</div>
<div class="bottom-area">
<div class="status-slot" class:status-slot-hide={pinVisible}>
{#if failed || notConfigured}
<div class="error-box anim-fade-up">
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
<div class="error-actions">
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
</div>
</div>
{:else}
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
{/if}
</div>
</div>
{/if}
</div>
<style>
.splash {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--bg-base);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both;
}
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
.center { z-index:1; display:flex; flex-direction:column; align-items:center; }
.logo-wrap { position:relative; }
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; display:block; position:relative; }
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
.ring { transition:opacity 0.5s ease; }
.ring-hide { opacity:0; }
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; position:relative; }
.status-slot { display:flex; align-items:center; justify-content:center; transition:opacity 0.35s ease; position:absolute; }
.status-slot-hide { opacity:0; pointer-events:none; }
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
.error-actions { display:flex; gap:6px; }
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
</style>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { onMount } from 'svelte'
import { detectOs } from '$lib/components/chrome/titlebarOs'
import type { OsKind } from '$lib/components/chrome/titlebarOs'
let { onClose }: { onClose: () => void } = $props()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
let os: OsKind = $state('unknown')
let isFullscreen = $state(false)
onMount(async () => {
if (!isTauri) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
os = await detectOs()
isFullscreen = await win.isFullscreen()
const unlisten = await win.onResized(async () => {
isFullscreen = await win.isFullscreen()
})
return unlisten
})
const isMac = $derived(os === 'macos')
const isWindows = $derived(os === 'windows')
async function minimize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().minimize()
}
async function toggleMaximize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().toggleMaximize()
}
async function exitFullscreen() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().setFullscreen(false)
}
</script>
{#if !isFullscreen}
<div class="bar" data-tauri-drag-region>
{#if isMac}<div class="mac-spacer" data-tauri-drag-region></div>{/if}
<span class="title" data-tauri-drag-region>Moku</span>
{#if !isMac}
<div class="controls">
<button onclick={minimize} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button onclick={toggleMaximize} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
{/if}
</div>
{:else if isWindows}
<div class="fullscreen-controls">
<button onclick={exitFullscreen} title="Exit Fullscreen" aria-label="Exit Fullscreen">
<svg width="10" height="10" viewBox="0 0 10 10">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
{/if}
<style>
.bar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--titlebar-height);
padding: 0 6px 0 var(--sp-4);
background: transparent;
flex-shrink: 0;
user-select: none;
}
.mac-spacer { width: 70px; flex-shrink: 0; }
.title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
opacity: 0.5;
}
.controls { display: flex; align-items: center; gap: 2px; }
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);
}
button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
.close:hover { color: #fff; background: #c0392b; }
.fullscreen-controls {
position: fixed;
top: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
opacity: 0;
transition: opacity var(--t-base);
}
.fullscreen-controls:hover { opacity: 1; }
</style>
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
import { dismissToast } from '$lib/state/notifications.svelte'
import type { Toast } from '$lib/state/notifications.svelte'
let { toasts }: { toasts: Toast[] } = $props()
const EXIT_MS = 280
const leaving = new Set<string>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
let detail = $state<Toast | null>(null)
function schedule(t: Toast) {
if (timers.has(t.id)) return
const dur = t.duration ?? 3500
if (dur === 0) return
timers.set(t.id, setTimeout(() => dismiss(t.id), dur))
}
function dismiss(id: string) {
if (leaving.has(id)) return
leaving.add(id)
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id) }
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`)
if (!el) { finalize(id); return }
el.style.setProperty('--exit-h', `${el.offsetHeight}px`)
el.classList.add('leaving')
setTimeout(() => finalize(id), EXIT_MS)
}
function finalize(id: string) {
leaving.delete(id)
dismissToast(id)
}
function openDetail(e: MouseEvent, t: Toast) {
e.preventDefault()
detail = t
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id) }
}
function onBackdropKey(e: KeyboardEvent) {
if (e.key === 'Escape') detail = null
}
$effect(() => {
const activeIds = new Set(toasts.map(t => t.id))
toasts.forEach(schedule)
for (const [id, timer] of timers) {
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id) }
}
if (detail && !activeIds.has(detail.id)) detail = null
})
const icons: Record<Toast['kind'], string> = {
success: 'M20 6L9 17l-5-5',
error: 'M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
info: 'M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z',
download: 'M12 3v13M7 11l5 5 5-5M5 21h14',
}
</script>
{#if toasts.length}
<div class="toaster" aria-live="polite">
{#each toasts as t (t.id)}
<button
class="toast toast-{t.kind}"
data-toast-id={t.id}
aria-label="{t.message}{t.detail ? ': ' + t.detail : ''}"
onclick={() => dismiss(t.id)}
oncontextmenu={(e) => openDetail(e, t)}
>
<div class="accent-bar"></div>
<span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={icons[t.kind]}/>
</svg>
</span>
<div class="body">
<p class="message">{t.message}</p>
<p class="sub">{t.detail ?? '\u00a0'}</p>
</div>
</button>
{/each}
</div>
{/if}
{#if detail}
<div
class="detail-backdrop"
role="presentation"
onclick={() => (detail = null)}
onkeydown={onBackdropKey}
>
<div
class="detail-panel detail-{detail.kind}"
role="dialog"
aria-modal="true"
aria-label={detail.message}
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="detail-accent"></div>
<div class="detail-body">
<div class="detail-header">
<span class="detail-kind">{detail.kind}</span>
<button class="detail-close" onclick={() => (detail = null)} aria-label="Close">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="detail-message">{detail.message}</p>
{#if detail.detail}
<pre class="detail-text">{detail.detail}</pre>
{/if}
<div class="detail-actions">
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.message}${detail!.detail ? '\n' + detail!.detail : ''}`)}>
Copy
</button>
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); detail = null }}>
Dismiss
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.toaster { position:fixed; bottom:var(--sp-5); right:var(--sp-5); z-index:9999; display:flex; flex-direction:column; gap:5px; pointer-events:none; }
.toast {
display:flex; align-items:center; gap:10px; padding:12px var(--sp-3) 12px 0;
border-radius:var(--radius-md); background:var(--bg-raised); border:1px solid var(--border-dim);
box-shadow:0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events:all; width:280px; overflow:hidden; cursor:pointer;
font-family:inherit; font-size:inherit; color:inherit; text-align:left;
will-change:transform, opacity;
animation:slideIn 0.35s cubic-bezier(0.16,1,0.3,1) both;
transition:border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.toast:hover { border-color:var(--border-base); box-shadow:0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset; transform:translateX(-3px); }
.toast:active { transform:translateX(0) scale(0.98); }
:global(.toast.leaving) { animation:slideOut 0.28s cubic-bezier(0.4,0,1,1) forwards !important; pointer-events:none; }
@keyframes slideIn { from { opacity:0; transform:translateX(20px) scale(0.96) } to { opacity:1; transform:translateX(0) scale(1) } }
@keyframes slideOut {
0% { opacity:1; transform:translateX(0) scale(1); max-height:var(--exit-h,80px); margin-bottom:0; }
40% { opacity:0; transform:translateX(14px) scale(0.96); max-height:var(--exit-h,80px); margin-bottom:0; }
100% { opacity:0; transform:translateX(14px) scale(0.96); max-height:0; margin-bottom:-5px; }
}
.accent-bar { width:3px; align-self:stretch; flex-shrink:0; border-radius:0 2px 2px 0; }
.toast-success .accent-bar { background:var(--accent-fg); }
.toast-error .accent-bar { background:var(--color-error); }
.toast-info .accent-bar { background:var(--text-faint); }
.toast-download .accent-bar { background:var(--accent-fg); }
.icon { flex-shrink:0; display:flex; align-items:center; justify-content:center; }
.toast-success .icon { color:var(--accent-fg); }
.toast-error .icon { color:var(--color-error); }
.toast-info .icon { color:var(--text-muted); }
.toast-download .icon { color:var(--accent-fg); }
.body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
.message { font-size:var(--text-xs); font-family:var(--font-ui); color:var(--text-secondary); font-weight:var(--weight-medium); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sub { font-family:var(--font-ui); font-size:var(--text-2xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.detail-backdrop { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.45); display:flex; align-items:center; justify-content:center; animation:fadeIn 0.15s ease both; }
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
.detail-panel { display:flex; width:420px; max-width:calc(100vw - 32px); max-height:60vh; border-radius:var(--radius-lg); background:var(--bg-raised); border:1px solid var(--border-base); box-shadow:0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset; overflow:hidden; animation:popIn 0.2s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes popIn { from { opacity:0; transform:scale(0.95) } to { opacity:1; transform:scale(1) } }
.detail-accent { width:3px; flex-shrink:0; }
.detail-error .detail-accent { background:var(--color-error); }
.detail-success .detail-accent { background:var(--accent-fg); }
.detail-info .detail-accent { background:var(--text-faint); }
.detail-download .detail-accent { background:var(--accent-fg); }
.detail-body { flex:1; min-width:0; display:flex; flex-direction:column; padding:var(--sp-3); gap:var(--sp-2); overflow:hidden; }
.detail-header { display:flex; align-items:center; justify-content:space-between; }
.detail-kind { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--text-faint); }
.detail-error .detail-kind { color:var(--color-error); }
.detail-close { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:var(--radius-sm); background:none; border:none; color:var(--text-faint); cursor:pointer; transition:color var(--t-fast), background var(--t-fast); }
.detail-close:hover { color:var(--text-primary); background:var(--bg-overlay); }
.detail-message { font-family:var(--font-ui); font-size:var(--text-sm); color:var(--text-secondary); font-weight:var(--weight-medium); line-height:var(--leading-snug); word-break:break-word; }
.detail-text { flex:1; min-height:0; overflow-y:auto; font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-muted); line-height:var(--leading-base); white-space:pre-wrap; word-break:break-all; background:var(--bg-void); border:1px solid var(--border-dim); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); scrollbar-width:thin; margin:0; }
.detail-actions { display:flex; gap:var(--sp-2); margin-top:var(--sp-1); }
.detail-copy, .detail-dismiss { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wide); padding:5px var(--sp-3); border-radius:var(--radius-sm); cursor:pointer; transition:color var(--t-base), background var(--t-base), border-color var(--t-base); }
.detail-copy { border:1px solid var(--border-dim); background:none; color:var(--text-muted); }
.detail-copy:hover { color:var(--text-primary); border-color:var(--border-strong); background:var(--bg-overlay); }
.detail-dismiss { border:1px solid color-mix(in srgb, var(--color-error) 40%, transparent); background:color-mix(in srgb, var(--color-error) 10%, transparent); color:var(--color-error); }
.detail-dismiss:hover { background:color-mix(in srgb, var(--color-error) 18%, transparent); }
</style>
+171
View File
@@ -0,0 +1,171 @@
const CARD_COUNT = 18
const CARD_W = 52
const CARD_H = 72
const CARD_RADIUS = 6
const DRIFT_SPEED = 0.018
interface Card {
x: number
y: number
vx: number
vy: number
rot: number
vrot: number
opacity: number
scale: number
hue: number
}
function makeCard(w: number, h: number): Card {
const side = Math.floor(Math.random() * 4)
const margin = 80
let x = 0, y = 0
if (side === 0) { x = Math.random() * w; y = -margin }
if (side === 1) { x = w + margin; y = Math.random() * h }
if (side === 2) { x = Math.random() * w; y = h + margin }
if (side === 3) { x = -margin; y = Math.random() * h }
const cx = w / 2, cy = h / 2
const dx = cx - x, dy = cy - y
const len = Math.sqrt(dx * dx + dy * dy) || 1
const spd = 0.12 + Math.random() * 0.1
return {
x,
y,
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
rot: Math.random() * Math.PI * 2,
vrot: (Math.random() - 0.5) * 0.006,
opacity: 0.025 + Math.random() * 0.055,
scale: 0.7 + Math.random() * 0.7,
hue: 120 + Math.random() * 40,
}
}
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
ctx.save()
ctx.globalAlpha = c.opacity
ctx.translate(c.x, c.y)
ctx.rotate(c.rot)
ctx.scale(c.scale, c.scale)
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
const x = -w / 2, y = -h / 2
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
ctx.lineWidth = 1 / c.scale
ctx.stroke()
const grad = ctx.createLinearGradient(x, y, x, y + h)
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
ctx.fillStyle = grad
ctx.fill()
ctx.restore()
}
export function mountCardCanvas(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d')!
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
let raf = 0
let running = true
function resize() {
const dpr = window.devicePixelRatio || 1
canvas.width = canvas.offsetWidth * dpr
canvas.height = canvas.offsetHeight * dpr
ctx.scale(dpr, dpr)
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
}
function tick() {
if (!running) return
const w = canvas.offsetWidth, h = canvas.offsetHeight
ctx.clearRect(0, 0, w, h)
for (const c of cards) {
c.x += c.vx
c.y += c.vy
c.rot += c.vrot
const pad = 120
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
Object.assign(c, makeCard(w, h))
}
drawCard(ctx, c)
}
raf = requestAnimationFrame(tick)
}
const ro = new ResizeObserver(resize)
ro.observe(canvas)
resize()
tick()
return {
destroy() {
running = false
cancelAnimationFrame(raf)
ro.disconnect()
},
}
}
export function ringGeometry(r: number, pad: number) {
const size = (r + pad) * 2
const c = size / 2
const circ = 2 * Math.PI * r
return { size, c, circ }
}
const RING_STEPS = [
{ target: 0.15, duration: 400 },
{ target: 0.45, duration: 800 },
{ target: 0.72, duration: 600 },
{ target: 0.88, duration: 1000 },
{ target: 0.96, duration: 700 },
]
export function animateRingProgress(onProgress: (p: number) => void): () => void {
let current = 0.025
let stepIdx = 0
let start = performance.now()
let raf = 0
let stopped = false
function ease(t: number) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
}
function tick(now: number) {
if (stopped) return
if (stepIdx >= RING_STEPS.length) return
const step = RING_STEPS[stepIdx]
const elapsed = now - start
const t = Math.min(elapsed / step.duration, 1)
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
current = from + (step.target - from) * ease(t)
onProgress(current)
if (t >= 1) {
stepIdx++
start = now
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => { stopped = true; cancelAnimationFrame(raf) }
}
+14
View File
@@ -0,0 +1,14 @@
export type OsKind = 'macos' | 'windows' | 'linux' | 'unknown'
export async function detectOs(): Promise<OsKind> {
try {
const { platform } = await import('@tauri-apps/plugin-os')
const p = await platform()
if (p === 'macos') return 'macos'
if (p === 'windows') return 'windows'
if (p === 'linux') return 'linux'
return 'unknown'
} catch {
return 'unknown'
}
}