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; 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 { export function clampZoom(z: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000; 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', authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor', platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '', version: '',
idle: false,
}) })
+25 -10
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { import {
House, Books, MagnifyingGlass, ClockCounterClockwise, House, Books, MagnifyingGlass,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
} from 'phosphor-svelte' } from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg' import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
@@ -27,33 +26,45 @@
) )
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP)) 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> </script>
<aside class="root"> <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> <div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
</button> </a>
<nav class="nav"> <nav class="nav">
{#if activeIndex >= 0} {#if activeIndex >= 0}
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div> <div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
{/if} {/if}
{#each TABS as tab} {#each TABS as tab}
<button <a
class="tab" class="tab"
class:active={activeIndex === TABS.indexOf(tab)} class:active={isActive(tab.path)}
title={tab.label} title={tab.label}
onclick={() => goto(tab.path)} href={tab.path}
aria-current={isActive(tab.path) ? 'page' : undefined}
> >
<tab.icon size={18} weight="light" /> <tab.icon size={18} weight="light" />
</button> </a>
{/each} {/each}
</nav> </nav>
<div class="bottom"> <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" /> <GearSix size={18} weight="light" />
</button> </a>
</div> </div>
</aside> </aside>
@@ -80,6 +91,7 @@
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base); transition: opacity var(--t-base), transform var(--t-base);
text-decoration: none;
} }
.logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); } .logo:active { transform: scale(0.92); }
@@ -140,6 +152,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base); transition: color var(--t-base), background var(--t-base);
text-decoration: none;
} }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:active { transform: scale(0.88); } .tab:active { transform: scale(0.88); }
@@ -167,7 +180,9 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base), transform var(--t-slow); 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: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:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.settings-btn.active { color: var(--accent-fg); background: var(--accent-muted); transform: none; }
</style> </style>
+77 -11
View File
@@ -1,5 +1,11 @@
<script lang="ts"> <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 { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { notificationsState } from '$lib/state/notifications.svelte' import { notificationsState } from '$lib/state/notifications.svelte'
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte' import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte' import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
@@ -10,17 +16,17 @@
let { children } = $props() let { children } = $props()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting')
let splashVisible = $state(true) let splashVisible = $state(true)
let bypassed = $state(false) let bypassed = $state(false)
const showApp = $derived( const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
appState.status === 'ready' || const pathname = $derived($page.url.pathname as string)
appState.status === 'auth' || const hideShellChrome = $derived(pathname === '/reader' || pathname.startsWith('/reader/'))
bypassed 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() { function onSplashReady() {
splashVisible = false splashVisible = false
@@ -30,20 +36,70 @@
bypassed = true bypassed = true
splashVisible = false 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> </script>
{#if splashVisible} {#if showSplash && splashVisible}
<SplashScreen <SplashScreen
mode="loading" mode="loading"
{ringFull} {ringFull}
failed={appState.status === 'error'} failed={appState.status === 'error'}
showCards={splashCards}
onReady={onSplashReady} onReady={onSplashReady}
onBypass={onSplashBypass} onBypass={onSplashBypass}
onRetry={() => window.location.reload()} onRetry={() => window.location.reload()}
/> />
{/if} {/if}
{#if showApp} {#if showShell}
{#if hideShellChrome}
<main class="reader-main">
{@render children()}
</main>
{:else}
<div class="frame"> <div class="frame">
<div class="shell"> <div class="shell">
{#if isTauri} {#if isTauri}
@@ -57,9 +113,12 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
{/if} {/if}
<AuthGate /> {#if showAuthGate}
<AuthGate />
{/if}
<Toaster toasts={notificationsState.toasts} /> <Toaster toasts={notificationsState.toasts} />
<style> <style>
@@ -99,4 +158,11 @@
contain: layout style; contain: layout style;
min-width: 0; min-width: 0;
} }
.reader-main {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
</style> </style>