Finish phase 2

This commit is contained in:
Zerebos
2026-05-23 02:30:27 -04:00
parent 8cef79b2b4
commit f41f8a9c22
5 changed files with 177 additions and 32 deletions
+49
View File
@@ -0,0 +1,49 @@
const IDLE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
export function mountIdleDetection(
getTimeoutMinutes: () => number | undefined,
onIdle: () => void,
onActive: () => void,
): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let idle = false;
const markActive = () => {
if (!idle) return;
idle = false;
onActive();
};
const resetTimer = () => {
if (timer) clearTimeout(timer);
const timeoutMinutes = getTimeoutMinutes() ?? 5;
const timeoutMs = Math.max(0, timeoutMinutes) * 60 * 1000;
if (timeoutMs === 0) {
markActive();
return;
}
markActive();
timer = setTimeout(() => {
if (idle) return;
idle = true;
onIdle();
}, timeoutMs);
};
IDLE_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, resetTimer, { passive: true });
});
resetTimer();
return () => {
if (timer) clearTimeout(timer);
IDLE_EVENTS.forEach((eventName) => {
window.removeEventListener(eventName, resetTimer);
});
};
}
+14
View File
@@ -22,6 +22,20 @@ export function zoomDelta(e: KeyboardEvent, current: number): number | null {
return null;
}
export function mountZoomKey(getCurrent: () => number, onChange: (next: number) => void): () => void {
const handleKey = (event: KeyboardEvent) => {
const nextZoom = zoomDelta(event, getCurrent());
if (nextZoom === null) return;
onChange(nextZoom);
};
window.addEventListener('keydown', handleKey);
return () => {
window.removeEventListener('keydown', handleKey);
};
}
export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
}
+1
View File
@@ -8,4 +8,5 @@ export const appState = $state({
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '',
idle: false,
})
+25 -10
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import {
House, Books, MagnifyingGlass, ClockCounterClockwise,
House, Books, MagnifyingGlass,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
} from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
@@ -27,33 +26,45 @@
)
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP))
function isActive(path: string) {
if (path === '/') return $page.url.pathname === '/'
return $page.url.pathname === path || $page.url.pathname.startsWith(`${path}/`)
}
</script>
<aside class="root">
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home">
<a class="logo" href="/" title="Home" aria-label="Go to Home">
<div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
</button>
</a>
<nav class="nav">
{#if activeIndex >= 0}
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
{/if}
{#each TABS as tab}
<button
<a
class="tab"
class:active={activeIndex === TABS.indexOf(tab)}
class:active={isActive(tab.path)}
title={tab.label}
onclick={() => goto(tab.path)}
href={tab.path}
aria-current={isActive(tab.path) ? 'page' : undefined}
>
<tab.icon size={18} weight="light" />
</button>
</a>
{/each}
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => goto('/settings')} title="Settings">
<a
class="settings-btn"
class:active={isActive('/settings')}
href="/settings"
title="Settings"
aria-current={isActive('/settings') ? 'page' : undefined}
>
<GearSix size={18} weight="light" />
</button>
</a>
</div>
</aside>
@@ -80,6 +91,7 @@
margin-bottom: var(--sp-4);
border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base);
text-decoration: none;
}
.logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); }
@@ -140,6 +152,7 @@
border-radius: var(--radius-md);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
text-decoration: none;
}
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:active { transform: scale(0.88); }
@@ -167,7 +180,9 @@
border-radius: var(--radius-md);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
text-decoration: none;
}
.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; }
.settings-btn.active { color: var(--accent-fg); background: var(--accent-muted); transform: none; }
</style>
+88 -22
View File
@@ -1,5 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte'
import { page } from '$app/stores'
import { applyTheme } from '$lib/core/theme'
import { mountIdleDetection } from '$lib/core/ui/idle'
import { applyZoom, mountZoomKey } from '$lib/core/ui/zoom'
import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { notificationsState } from '$lib/state/notifications.svelte'
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
@@ -10,17 +16,17 @@
let { children } = $props()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting')
let splashVisible = $state(true)
let bypassed = $state(false)
const showApp = $derived(
appState.status === 'ready' ||
appState.status === 'auth' ||
bypassed
)
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const pathname = $derived($page.url.pathname as string)
const hideShellChrome = $derived(pathname === '/reader' || pathname.startsWith('/reader/'))
const ringFull = $derived(appState.status !== 'booting')
const showSplash = $derived((appState.status === 'booting' || appState.status === 'error') && !bypassed)
const showAuthGate = $derived(appState.status === 'auth')
const showShell = $derived(appState.status === 'ready' || bypassed)
const splashCards = $derived(settingsState.splashCards ?? true)
function onSplashReady() {
splashVisible = false
@@ -30,36 +36,89 @@
bypassed = true
splashVisible = false
}
onMount(() => {
applyTheme(settingsState.theme, settingsState.customThemes)
applyZoom(settingsState.uiZoom)
const stopZoomKey = mountZoomKey(
() => settingsState.uiZoom,
(nextZoom) => updateSettings({ uiZoom: nextZoom })
)
const stopIdleDetection = mountIdleDetection(
() => settingsState.idleTimeoutMin,
() => {
appState.idle = true
},
() => {
appState.idle = false
}
)
const handleResize = () => {
applyZoom(settingsState.uiZoom)
}
window.addEventListener('resize', handleResize, { passive: true })
let stopTauriScale: (() => void) | null = null
if (isTauri) {
void import('@tauri-apps/api/window').then(async ({ getCurrentWindow }) => {
stopTauriScale = await getCurrentWindow().onScaleChanged(() => {
applyZoom(settingsState.uiZoom)
})
})
}
return () => {
appState.idle = false
stopZoomKey()
stopIdleDetection()
window.removeEventListener('resize', handleResize)
stopTauriScale?.()
}
})
</script>
{#if splashVisible}
{#if showSplash && splashVisible}
<SplashScreen
mode="loading"
{ringFull}
failed={appState.status === 'error'}
showCards={splashCards}
onReady={onSplashReady}
onBypass={onSplashBypass}
onRetry={() => window.location.reload()}
/>
{/if}
{#if showApp}
<div class="frame">
<div class="shell">
{#if isTauri}
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
{/if}
<div class="body">
<Sidebar />
<main class="main">
{@render children()}
</main>
{#if showShell}
{#if hideShellChrome}
<main class="reader-main">
{@render children()}
</main>
{:else}
<div class="frame">
<div class="shell">
{#if isTauri}
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
{/if}
<div class="body">
<Sidebar />
<main class="main">
{@render children()}
</main>
</div>
</div>
</div>
</div>
{/if}
{/if}
<AuthGate />
{#if showAuthGate}
<AuthGate />
{/if}
<Toaster toasts={notificationsState.toasts} />
<style>
@@ -99,4 +158,11 @@
contain: layout style;
min-width: 0;
}
.reader-main {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
</style>