mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Finish phase 2
This commit is contained in:
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user