mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19: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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
+88
-22
@@ -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,36 +36,89 @@
|
|||||||
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}
|
||||||
<div class="frame">
|
{#if hideShellChrome}
|
||||||
<div class="shell">
|
<main class="reader-main">
|
||||||
{#if isTauri}
|
{@render children()}
|
||||||
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
</main>
|
||||||
{/if}
|
{:else}
|
||||||
<div class="body">
|
<div class="frame">
|
||||||
<Sidebar />
|
<div class="shell">
|
||||||
<main class="main">
|
{#if isTauri}
|
||||||
{@render children()}
|
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
||||||
</main>
|
{/if}
|
||||||
|
<div class="body">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="main">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user