refactor(auth): centralize appReady pattern into AuthGate component

Replace copy-pasted appReady/loading/redirect logic in all 13 layouts
with a shared AuthGate component. Supports guest mode, onReady callback
for app-specific data loading, and configurable login redirect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 08:30:31 +01:00
parent 31af413b77
commit 336cfedd0b
15 changed files with 270 additions and 407 deletions

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, InputBarHelpModal, ImmersiveModeToggle } from '@manacore/shared-ui';
import {
@ -55,7 +54,7 @@
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -70,9 +69,6 @@
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// InputBar search - search events
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
@ -431,19 +427,7 @@
);
}
onMount(async () => {
// Initialize auth state from stored tokens
await authStore.initialize();
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Auth confirmed - allow children to render
appReady = true;
async function handleAuthReady() {
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -459,26 +443,20 @@
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
// Redirect to start page if on root and a custom start page is set (only if authenticated)
if (authStore.isAuthenticated) {
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
// Initialize mobile state
updateMobileState();
});
}
</script>
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<SplitPaneContainer>
<div class="layout-container">
<a
@ -598,7 +576,7 @@
<MiniOnboardingModal store={calendarOnboarding} appName="Kalender" appEmoji="📅" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
@ -24,14 +23,13 @@
import type { LayoutData } from './$types';
import { chatOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
// App switcher items
const appItems = getPillAppItems('chat');
let { children, data }: { children: any; data: LayoutData } = $props();
let isChecking = $state(true);
let isCollapsed = $state(false);
// Use theme store's isDark directly
@ -144,15 +142,7 @@
goto('/login');
}
// Initialize on mount - enforce login
onMount(async () => {
// Initialize auth and redirect if not authenticated
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
async function handleAuthReady() {
// Initialize theme
theme.initialize();
@ -176,24 +166,12 @@
if (currentPath === '/chat' && userSettings.startPage && userSettings.startPage !== '/chat') {
goto(userSettings.startPage, { replaceState: true });
}
isChecking = false;
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isChecking}
<!-- Loading state while checking auth -->
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating Pill Navigation -->
@ -245,7 +223,7 @@
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
@ -32,16 +31,13 @@
import { timersApi } from '$lib/api/timers';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
// App switcher items
const appItems = getPillAppItems('clock');
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// CommandBar state
let commandBarOpen = $state(false);
@ -235,14 +231,7 @@
goto('/login');
}
onMount(async () => {
// Initialize auth and redirect if not authenticated
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
async function handleAuthReady() {
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
if (savedCollapsed === 'true') {
@ -266,19 +255,12 @@
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
// Auth confirmed - allow children to render
appReady = true;
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -335,7 +317,7 @@
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui';
import {
@ -46,7 +45,7 @@
import { tagsStore } from '$lib/stores/tags.svelte';
import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -69,9 +68,6 @@
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// Show toolbar only on main contacts page
const showContactsToolbar = $derived($page.url.pathname === '/');
@ -268,14 +264,7 @@
previousOnboardingShow = showing;
});
onMount(async () => {
// Initialize auth and redirect if not authenticated
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
async function handleAuthReady() {
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -290,19 +279,12 @@
// Load tags (used by TagStrip and Quick-Create)
await tagsStore.fetchTags();
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
// Auth confirmed - allow children to render
appReady = true;
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
@ -410,7 +392,7 @@
</div>
</SplitPaneContainer>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation, QuickInputBar, DevBuildBadge } from '@manacore/shared-ui';
import type {
PillNavItem,
@ -27,7 +26,7 @@
import { playlistStore } from '$lib/stores/playlist.svelte';
import { projectStore } from '$lib/stores/project.svelte';
import { parseSongInput, formatParsedSongPreview } from '$lib/utils/song-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import MiniPlayer from '$lib/components/MiniPlayer.svelte';
import FullPlayer from '$lib/components/FullPlayer.svelte';
import QueuePanel from '$lib/components/QueuePanel.svelte';
@ -167,25 +166,14 @@
goto('/projects');
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
async function handleAuthReady() {
splitPanel.initialize();
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if !authStore.isAuthenticated}
<div class="min-h-screen flex items-center justify-center bg-background">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<SplitPaneContainer>
<div class="layout-container">
<a
@ -251,7 +239,7 @@
</div>
</SplitPaneContainer>
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -8,14 +8,9 @@
import { authStore } from '$lib/stores/auth.svelte';
import { mealsStore } from '$lib/stores/meals.svelte';
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { onMount } from 'svelte';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
let { children } = $props();
let loading = $state(true);
let appReady = $derived(!loading && !$i18nLoading);
// QuickInputBar handlers - search recent meals
async function handleSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
@ -55,46 +50,42 @@
});
goto(`/add?${params.toString()}`);
}
onMount(() => {
authStore.initialize().then(() => {
loading = false;
});
});
</script>
<svelte:head>
{#if appReady}
{#if !$i18nLoading}
<title>{$t('app.name')} - {$t('app.tagline')}</title>
{:else}
<title>NutriPhi</title>
{/if}
</svelte:head>
{#if !appReady}
<div class="flex min-h-screen items-center justify-center bg-background">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
{@render children()}
<AuthGate {authStore} {goto} allowGuest={true}>
{#if $i18nLoading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
{@render children()}
{#if authStore.isAuthenticated}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onParseCreate={handleParseCreate}
onCreate={handleCreate}
placeholder="Mahlzeit eingeben..."
emptyText="Keine Mahlzeiten gefunden"
searchingText="Suche..."
createText="Analysieren"
deferSearch={true}
locale="de"
appIcon="search"
bottomOffset="70px"
/>
{#if authStore.isAuthenticated}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onParseCreate={handleParseCreate}
onCreate={handleCreate}
placeholder="Mahlzeit eingeben..."
emptyText="Keine Mahlzeiten gefunden"
searchingText="Suche..."
createText="Analysieren"
deferSearch={true}
locale="de"
appIcon="search"
bottomOffset="70px"
/>
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
</AuthGate>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { _, locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
@ -12,13 +11,10 @@
import { tagStore } from '$lib/stores/tags.svelte';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
let isDark = $derived(theme.isDark);
let userEmail = $derived(authStore.user?.email || 'Menu');
@ -83,26 +79,12 @@
}
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Load initial data
async function handleAuthReady() {
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
// Auth confirmed - allow children to render
appReady = true;
});
}
</script>
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -145,7 +127,7 @@
</main>
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -21,7 +21,7 @@
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { pictureOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { viewMode, setViewMode } from '$lib/stores/view';
import type { ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
@ -60,34 +60,23 @@
theme.setMode(mode);
}
// Client-side auth check
$effect(() => {
if (authStore.initialized && !authStore.loading && !authStore.user) {
goto('/auth/login');
}
});
async function handleAuthReady() {
await userSettings.load();
// Load user settings when authenticated
$effect(() => {
if (authStore.initialized && authStore.user) {
userSettings.load().then(() => {
// Redirect to start page if on /app and a custom start page is set
const currentPath = window.location.pathname;
if (
currentPath === '/app' &&
userSettings.startPage &&
userSettings.startPage !== '/' &&
userSettings.startPage !== '/app'
) {
// Prepend /app if the start page doesn't include it
const targetPath = userSettings.startPage.startsWith('/app')
? userSettings.startPage
: `/app${userSettings.startPage}`;
goto(targetPath, { replaceState: true });
}
});
// Redirect to start page if on /app and a custom start page is set
const currentPath = window.location.pathname;
if (
currentPath === '/app' &&
userSettings.startPage &&
userSettings.startPage !== '/' &&
userSettings.startPage !== '/app'
) {
const targetPath = userSettings.startPage.startsWith('/app')
? userSettings.startPage
: `/app${userSettings.startPage}`;
goto(targetPath, { replaceState: true });
}
});
}
// Base navigation items (Mana is in user dropdown via manaHref)
const baseNavItems: PillNavItem[] = [
@ -236,16 +225,7 @@
<svelte:window on:keydown={handleKeyDown} />
{#if authStore.loading}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600">Loading...</p>
</div>
</div>
{:else if authStore.user}
<AuthGate {authStore} {goto} loginHref="/auth/login" onReady={handleAuthReady}>
<div class="min-h-screen" style="background-color: hsl(var(--color-background));">
<!-- PillNavigation (conditionally visible) -->
{#if $isUIVisible}
@ -297,7 +277,7 @@
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/auth/login" />
{/if}
</AuthGate>
<style>
.main-content {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import type { PillNavItem, QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
@ -12,13 +11,10 @@
resolvePlantData,
formatParsedPlantPreview,
} from '$lib/utils/plant-parser';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// Navigation items for Planta
const navItems: PillNavItem[] = [
{ href: '/dashboard', label: 'Meine Pflanzen', icon: 'document' },
@ -85,21 +81,9 @@
goto(`/plant/${plant.id}`);
}
}
onMount(async () => {
// Initialize auth state from stored tokens
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Auth confirmed - allow children to render
appReady = true;
});
</script>
{#if appReady}
<AuthGate {authStore} {goto}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -137,13 +121,7 @@
</main>
</div>
<SessionExpiredBanner locale="de" loginHref="/login" />
{:else}
<div class="flex min-h-screen items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,25 +1,20 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
import { onMount } from 'svelte';
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
let { children } = $props();
let loading = $state(true);
let appReady = $derived(!loading && !$i18nLoading);
onMount(async () => {
await Promise.all([authStore.initialize(), skillStore.initialize()]);
async function handleAuthReady() {
await skillStore.initialize();
await achievementStore.initialize();
loading = false;
});
}
</script>
<svelte:head>
@ -27,20 +22,22 @@
<meta name="description" content="Track your skills like a game. Level up in real life." />
</svelte:head>
{#if !appReady}
<div class="flex min-h-screen items-center justify-center bg-gray-900">
<div class="text-center">
<div class="mb-4 text-6xl">🌳</div>
<div class="text-xl text-gray-300">{$t('app.loading')}</div>
<AuthGate {authStore} allowGuest={true} onReady={handleAuthReady}>
{#if $i18nLoading}
<div class="flex min-h-screen items-center justify-center bg-gray-900">
<div class="text-center">
<div class="mb-4 text-6xl">🌳</div>
<div class="text-xl text-gray-300">Loading...</div>
</div>
</div>
{:else}
<div class="min-h-screen bg-gray-900 text-gray-100">
{@render children()}
</div>
</div>
{:else}
<div class="min-h-screen bg-gray-900 text-gray-100">
{@render children()}
</div>
{#if skilltreeOnboarding.shouldShow}
<MiniOnboardingModal store={skilltreeOnboarding} appName="SkillTree" appEmoji="🌳" />
{#if skilltreeOnboarding.shouldShow}
<MiniOnboardingModal store={skilltreeOnboarding} appName="SkillTree" appEmoji="🌳" />
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
<SessionExpiredBanner locale="de" loginHref="/login" />
{/if}
</AuthGate>

View file

@ -16,7 +16,7 @@
import { ToastContainer } from '@manacore/shared-ui';
import { storageOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import '../app.css';
// App switcher items
@ -24,7 +24,6 @@
let { children } = $props();
let loading = $state(true);
let isCollapsed = $state(false);
// Use theme store's isDark directly
@ -130,33 +129,24 @@
goto('/login');
}
async function handleAuthReady() {
// Initialize theme
theme.initialize();
// Load user settings
await userSettings.load();
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('storage-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
}
onMount(() => {
// Setup global error handling
const cleanupErrorHandler = setupGlobalErrorHandler();
// Initialize async operations
const init = async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
// Load user settings
await userSettings.load();
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('storage-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
@ -165,71 +155,58 @@
<ToastContainer />
{#if loading}
<div
class="flex min-h-screen items-center justify-center bg-background"
role="status"
aria-live="polite"
aria-busy="true"
>
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
aria-hidden="true"
></div>
<p class="text-muted-foreground">Laden...</p>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
{#if isAuthPage}
<!-- Auth pages without navigation -->
{@render children()}
{:else}
<!-- Navigation Layout -->
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Storage"
homeRoute="/files"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if storageOnboarding.shouldShow}
<MiniOnboardingModal store={storageOnboarding} appName="Storage" appEmoji="☁️" />
{/if}
</div>
</div>
{:else if isAuthPage}
<!-- Auth pages without navigation -->
{@render children()}
{:else}
<!-- Navigation Layout -->
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Storage"
homeRoute="/files"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if storageOnboarding.shouldShow}
<MiniOnboardingModal store={storageOnboarding} appName="Storage" appEmoji="☁️" />
{/if}
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui';
import {
@ -40,7 +39,7 @@
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
import { todoOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { TodoEvents } from '@manacore/shared-utils/analytics';
// App switcher items
@ -56,9 +55,6 @@
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// QuickInputBar search - search tasks
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
@ -277,14 +273,7 @@
goto('/login');
}
onMount(async () => {
// Initialize auth and redirect if not authenticated
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
async function handleAuthReady() {
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -310,19 +299,12 @@
} catch {
// localStorage not available
}
// Auth confirmed - allow children to render
appReady = true;
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<SplitPaneContainer>
<div class="layout-container">
<a
@ -474,7 +456,7 @@
</div>
</SplitPaneContainer>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale, _ } from 'svelte-i18n';
import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
@ -26,7 +25,7 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { QUOTES, type Quote } from '@zitare/content';
// App switcher items
@ -41,9 +40,6 @@
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -213,10 +209,7 @@
zitareSettings.togglePillNav();
}
onMount(async () => {
// Initialize auth state from stored tokens
await authStore.initialize();
async function handleAuthReady() {
// Initialize settings
zitareSettings.initialize();
@ -226,24 +219,12 @@
favoritesStore.load();
listsStore.loadLists();
}
// Auth confirmed - allow children to render
appReady = true;
// Add keyboard listener
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
});
}
</script>
{#if !appReady}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
{#if !zitareSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
@ -337,7 +318,7 @@
</main>
</div>
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>
.layout-container {

View file

@ -0,0 +1,86 @@
<!--
AuthGate - Centralized auth initialization and loading gate.
Handles:
- Auth store initialization
- Loading spinner while checking auth
- Redirect to login if not authenticated (unless allowGuest)
- Calling onReady callback after auth is confirmed
- Rendering children only when ready
Usage:
<AuthGate authStore={authStore} onReady={loadAppData}>
<AppContent />
</AuthGate>
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
/**
* Minimal interface that all app auth stores must satisfy.
* Every app's authStore (e.g. `$lib/stores/auth.svelte`) already matches this.
*/
interface AuthStoreInterface {
initialize(): Promise<void>;
readonly isAuthenticated: boolean;
}
interface Props {
/** The app's auth store instance (must have initialize() and isAuthenticated) */
authStore: AuthStoreInterface;
/** Path to redirect to when not authenticated (default: '/login') */
loginHref?: string;
/** If true, render children even when not authenticated (for guest-mode apps) */
allowGuest?: boolean;
/** Callback invoked after auth is confirmed, before children are rendered.
* Use this for loading app-specific data (projects, calendars, etc.) */
onReady?: () => void | Promise<void>;
/** SvelteKit goto function for client-side navigation. Falls back to window.location. */
goto?: (url: string, opts?: Record<string, unknown>) => unknown;
/** Content to render when ready */
children: Snippet;
}
let {
authStore,
loginHref = '/login',
allowGuest = false,
onReady,
goto: gotoFn,
children,
}: Props = $props();
let ready = $state(false);
function navigate(url: string) {
if (gotoFn) {
gotoFn(url);
} else if (typeof window !== 'undefined') {
window.location.href = url;
}
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated && !allowGuest) {
navigate(loginHref);
return;
}
if (onReady) {
await onReady();
}
ready = true;
});
</script>
{#if !ready}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
{@render children()}
{/if}

View file

@ -9,6 +9,7 @@ export { default as AppleSignInButton } from './components/AppleSignInButton.sve
export { default as GuestWelcomeModal } from './components/GuestWelcomeModal.svelte';
export { default as AuthGateModal } from './components/AuthGateModal.svelte';
export { default as SessionExpiredBanner } from './components/SessionExpiredBanner.svelte';
export { default as AuthGate } from './components/AuthGate.svelte';
// Utilities
export {