fix(web): add appReady gate to prevent auth race condition in all apps

In Svelte, child onMount fires before parent onMount. Pages that fetch
data in onMount race against the layout's authStore.initialize(). Added
appReady state gate to layouts so children don't mount until auth is
confirmed. Affects: todo, contacts, clock, photos, zitare, planta.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 12:15:35 +01:00
parent e3115b302d
commit 000b74af9f
7 changed files with 498 additions and 432 deletions

View file

@ -38,6 +38,9 @@
let { children } = $props();
// Auth gate - prevent children from mounting before auth is confirmed
let appReady = $state(false);
// CommandBar state
let commandBarOpen = $state(false);
@ -262,66 +265,75 @@
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
// Auth confirmed - allow children to render
appReady = true;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Clock"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#f59e0b"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
{#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}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Clock"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#f59e0b"
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>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
<!-- Onboarding Modal -->
{#if clockOnboarding.shouldShow}
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
</div>
<!-- Onboarding Modal -->
{#if clockOnboarding.shouldShow}
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
</div>
{/if}
<style>
.layout-container {

View file

@ -68,6 +68,9 @@
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 === '/');
@ -286,117 +289,126 @@
// 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} />
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Zum Inhalt springen
</a>
<!-- UI Elements (hidden in immersive mode) -->
{#if !contactsSettings.immersiveModeEnabled}
<!-- Floating Pill Navigation (at bottom) -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Contacts"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
onOpenInPanel={handleOpenInPanel}
ariaLabel="Hauptnavigation"
/>
<!-- TagStrip (above PillNav) -->
<TagStrip />
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)}
placeholder="Neuer Kontakt oder suchen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
searchText="Suchen"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
locale={$locale || 'de'}
appIcon="contacts"
bottomOffset={inputBarBottomOffset}
hasFabRight={showContactsToolbar}
/>
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar contacts={contactsStore.contacts} />
{/if}
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={contactsSettings.immersiveModeEnabled}
onToggle={() => contactsSettings.toggleImmersiveMode()}
/>
<!-- Main Content -->
<main
id="main-content"
class="main-content bg-background"
class:immersive={contactsSettings.immersiveModeEnabled}
>
<div class="content-wrapper" class:immersive={contactsSettings.immersiveModeEnabled}>
{@render children()}
</div>
</main>
<!-- Contact Detail Modal -->
{#if showContactModal && modalContactId}
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
{/if}
<!-- New Contact Modal -->
{#if newContactModalStore.isOpen}
<NewContactModal onClose={() => newContactModalStore.close()} />
{/if}
<!-- Onboarding Modal -->
{#if contactsOnboarding.shouldShow}
<MiniOnboardingModal store={contactsOnboarding} appName="Kontakte" appEmoji="👥" />
{/if}
{#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>
</SplitPaneContainer>
{:else}
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Zum Inhalt springen
</a>
<!-- UI Elements (hidden in immersive mode) -->
{#if !contactsSettings.immersiveModeEnabled}
<!-- Floating Pill Navigation (at bottom) -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Contacts"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
onOpenInPanel={handleOpenInPanel}
ariaLabel="Hauptnavigation"
/>
<!-- TagStrip (above PillNav) -->
<TagStrip />
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)}
placeholder="Neuer Kontakt oder suchen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
searchText="Suchen"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
locale={$locale || 'de'}
appIcon="contacts"
bottomOffset={inputBarBottomOffset}
hasFabRight={showContactsToolbar}
/>
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar contacts={contactsStore.contacts} />
{/if}
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={contactsSettings.immersiveModeEnabled}
onToggle={() => contactsSettings.toggleImmersiveMode()}
/>
<!-- Main Content -->
<main
id="main-content"
class="main-content bg-background"
class:immersive={contactsSettings.immersiveModeEnabled}
>
<div class="content-wrapper" class:immersive={contactsSettings.immersiveModeEnabled}>
{@render children()}
</div>
</main>
<!-- Contact Detail Modal -->
{#if showContactModal && modalContactId}
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
{/if}
<!-- New Contact Modal -->
{#if newContactModalStore.isOpen}
<NewContactModal onClose={() => newContactModalStore.close()} />
{/if}
<!-- Onboarding Modal -->
{#if contactsOnboarding.shouldShow}
<MiniOnboardingModal store={contactsOnboarding} appName="Kontakte" appEmoji="👥" />
{/if}
</div>
</SplitPaneContainer>
{/if}
<style>
.layout-container {

View file

@ -15,6 +15,9 @@
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');
@ -88,50 +91,59 @@
// Load initial data
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums(), tagStore.loadTags()]);
// Auth confirmed - allow children to render
appReady = true;
});
</script>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Photos"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
{userEmail}
settingsHref="/settings"
/>
{#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}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Photos"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
{userEmail}
settingsHref="/settings"
/>
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Album oder Tag suchen..."
emptyText="Nichts gefunden"
searchingText="Suche..."
locale={$locale || 'de'}
appIcon="search"
bottomOffset="70px"
/>
<!-- Quick Input Bar -->
<QuickInputBar
onSearch={handleInputSearch}
onSelect={handleInputSelect}
placeholder="Album oder Tag suchen..."
emptyText="Nichts gefunden"
searchingText="Suche..."
locale={$locale || 'de'}
appIcon="search"
bottomOffset="70px"
/>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {

View file

@ -10,6 +10,9 @@
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' },
@ -51,14 +54,20 @@
goto(`/plant/${item.id}`);
}
onMount(() => {
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 authStore.isAuthenticated}
{#if appReady}
<div class="layout-container">
<PillNavigation
items={navItems}

View file

@ -55,6 +55,9 @@
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 [];
@ -306,161 +309,170 @@
} catch {
// localStorage not available
}
// Auth confirmed - allow children to render
appReady = true;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<SplitPaneContainer>
<div class="layout-container">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Zum Inhalt springen
</a>
{#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}
<SplitPaneContainer>
<div class="layout-container">
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-primary focus:px-4 focus:py-2 focus:text-white"
>
Zum Inhalt springen
</a>
<!-- UI Elements (hidden in immersive mode) -->
{#if !todoSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
{#if !isPillNavCollapsed}
<PillNavigation
items={navItems}
prependElements={[viewTabGroup]}
currentPath={$page.url.pathname}
appName="Todo"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
themesHref="/themes"
spiralHref="/spiral"
onOpenInPanel={handleOpenInPanel}
ariaLabel="Hauptnavigation"
/>
<!-- UI Elements (hidden in immersive mode) -->
{#if !todoSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
{#if !isPillNavCollapsed}
<PillNavigation
items={navItems}
prependElements={[viewTabGroup]}
currentPath={$page.url.pathname}
appName="Todo"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
themesHref="/themes"
spiralHref="/spiral"
onOpenInPanel={handleOpenInPanel}
ariaLabel="Hauptnavigation"
/>
<!-- TagStrip (above PillNav, always visible when PillNav is open) -->
<TagStrip filterStripVisible={isFilterStripVisible} />
<!-- TagStrip (above PillNav, always visible when PillNav is open) -->
<TagStrip filterStripVisible={isFilterStripVisible} />
<!-- TaskFilters strip (shown when Filter pill is active in PillNav) -->
{#if isFilterStripVisible}
<TaskFilters
variant="strip"
selectedPriorities={viewStore.filterPriorities}
selectedProjectId={viewStore.filterProjectId}
selectedLabelIds={viewStore.filterLabelIds}
searchQuery={viewStore.filterSearchQuery}
onPrioritiesChange={(p: TaskPriority[]) => viewStore.setFilterPriorities(p)}
onProjectChange={(id: string | null) => viewStore.setFilterProjectId(id)}
onLabelsChange={(ids: string[]) => viewStore.setFilterLabelIds(ids)}
onSearchChange={(q: string) => viewStore.setFilterSearchQuery(q)}
onClearFilters={() => viewStore.clearFilters()}
sortBy={viewStore.sortBy}
onSortChange={(s: SortBy) => viewStore.setSort(s, viewStore.sortOrder)}
showSort={true}
showCompleted={true}
showKanbanNav={true}
isCompletedVisible={viewStore.showCompleted}
onToggleCompleted={() => viewStore.toggleShowCompleted()}
<!-- TaskFilters strip (shown when Filter pill is active in PillNav) -->
{#if isFilterStripVisible}
<TaskFilters
variant="strip"
selectedPriorities={viewStore.filterPriorities}
selectedProjectId={viewStore.filterProjectId}
selectedLabelIds={viewStore.filterLabelIds}
searchQuery={viewStore.filterSearchQuery}
onPrioritiesChange={(p: TaskPriority[]) => viewStore.setFilterPriorities(p)}
onProjectChange={(id: string | null) => viewStore.setFilterProjectId(id)}
onLabelsChange={(ids: string[]) => viewStore.setFilterLabelIds(ids)}
onSearchChange={(q: string) => viewStore.setFilterSearchQuery(q)}
onClearFilters={() => viewStore.clearFilters()}
sortBy={viewStore.sortBy}
onSortChange={(s: SortBy) => viewStore.setSort(s, viewStore.sortOrder)}
showSort={true}
showCompleted={true}
showKanbanNav={true}
isCompletedVisible={viewStore.showCompleted}
onToggleCompleted={() => viewStore.toggleShowCompleted()}
/>
{/if}
{/if}
<!-- Global Quick Input Bar - only on list and kanban views -->
{#if $page.url.pathname === '/' || $page.url.pathname === '/kanban'}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="Neue Aufgabe oder suchen..."
emptyText="Keine Aufgaben gefunden"
searchingText="Suche..."
searchText="Suchen"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
locale={$locale || 'de'}
appIcon="todo"
hasFabRight={true}
bottomOffset={isPillNavCollapsed ? '16px' : isFilterStripVisible ? '180px' : '110px'}
/>
{/if}
<!-- FAB to toggle PillNav visibility -->
<button
class="pillnav-fab"
onclick={handlePillNavToggle}
title={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'}
aria-label={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'}
>
{#if isPillNavCollapsed}
<!-- Menu icon -->
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<!-- Close icon -->
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
{/if}
<!-- Global Quick Input Bar - only on list and kanban views -->
{#if $page.url.pathname === '/' || $page.url.pathname === '/kanban'}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
placeholder="Neue Aufgabe oder suchen..."
emptyText="Keine Aufgaben gefunden"
searchingText="Suche..."
searchText="Suchen"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
locale={$locale || 'de'}
appIcon="todo"
hasFabRight={true}
bottomOffset={isPillNavCollapsed ? '16px' : isFilterStripVisible ? '180px' : '110px'}
/>
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={todoSettings.immersiveModeEnabled}
onToggle={() => todoSettings.toggleImmersiveMode()}
/>
<!-- FAB to toggle PillNav visibility -->
<button
class="pillnav-fab"
onclick={handlePillNavToggle}
title={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'}
aria-label={isPillNavCollapsed ? 'Navigation anzeigen' : 'Navigation ausblenden'}
>
{#if isPillNavCollapsed}
<!-- Menu icon -->
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<!-- Close icon -->
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={todoSettings.immersiveModeEnabled}
onToggle={() => todoSettings.toggleImmersiveMode()}
/>
<main
id="main-content"
class="main-content bg-background"
class:immersive={todoSettings.immersiveModeEnabled}
>
<div
class="content-wrapper"
class:full-width={$page.url.pathname === '/kanban'}
<main
id="main-content"
class="main-content bg-background"
class:immersive={todoSettings.immersiveModeEnabled}
>
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if todoOnboarding.shouldShow}
<MiniOnboardingModal store={todoOnboarding} appName="Todo" appEmoji="✅" />
{/if}
</div>
</SplitPaneContainer>
<div
class="content-wrapper"
class:full-width={$page.url.pathname === '/kanban'}
class:immersive={todoSettings.immersiveModeEnabled}
>
{@render children()}
</div>
</main>
<!-- Onboarding Modal -->
{#if todoOnboarding.shouldShow}
<MiniOnboardingModal store={todoOnboarding} appName="Todo" appEmoji="✅" />
{/if}
</div>
</SplitPaneContainer>
{/if}
<style>
.layout-container {

View file

@ -4,7 +4,6 @@
import { de } from 'date-fns/locale';
import { Sparkle, ArrowDown } from '@manacore/shared-icons';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { applyTaskFilters } from '$lib/utils/task-filters';
import TaskList from '$lib/components/TaskList.svelte';
@ -30,11 +29,6 @@
onMount(async () => {
viewStore.setToday();
// Wait for auth to be initialized (layout onMount runs after page onMount in Svelte)
while (!authStore.initialized) {
await new Promise((r) => setTimeout(r, 50));
}
try {
// Fetch tasks (works in both guest and authenticated mode)
await tasksStore.fetchAllTasks();

View file

@ -40,6 +40,9 @@
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);
@ -209,7 +212,10 @@
zitareSettings.togglePillNav();
}
onMount(() => {
onMount(async () => {
// Initialize auth state from stored tokens
await authStore.initialize();
// Initialize settings
zitareSettings.initialize();
@ -220,6 +226,9 @@
listsStore.loadLists();
}
// Auth confirmed - allow children to render
appReady = true;
// Add keyboard listener
window.addEventListener('keydown', handleKeydown);
@ -229,98 +238,104 @@
});
</script>
<div class="layout-container">
{#if !zitareSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
{#if !zitareSettings.pillNavCollapsed}
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Zitare"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
{#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}
<div class="layout-container">
{#if !zitareSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
{#if !zitareSettings.pillNavCollapsed}
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Zitare"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
{/if}
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onCreate={handleCreate}
onParseCreate={handleParseCreate}
placeholder={$_('search.placeholder')}
emptyText={$_('search.noResults')}
searchingText={$_('search.searching')}
createText={$_('search.create')}
deferSearch={true}
locale={$locale || 'de'}
appIcon="quote"
bottomOffset={inputBarBottomOffset}
hasFabRight={true}
/>
<!-- FAB to toggle PillNav visibility -->
<button
class="pillnav-fab"
onclick={handlePillNavToggle}
title={zitareSettings.pillNavCollapsed ? $_('nav.showNav') : $_('nav.hideNav')}
>
{#if zitareSettings.pillNavCollapsed}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
{/if}
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onCreate={handleCreate}
onParseCreate={handleParseCreate}
placeholder={$_('search.placeholder')}
emptyText={$_('search.noResults')}
searchingText={$_('search.searching')}
createText={$_('search.create')}
deferSearch={true}
locale={$locale || 'de'}
appIcon="quote"
bottomOffset={inputBarBottomOffset}
hasFabRight={true}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={zitareSettings.immersiveModeEnabled}
onToggle={() => zitareSettings.toggleImmersiveMode()}
/>
<!-- FAB to toggle PillNav visibility -->
<button
class="pillnav-fab"
onclick={handlePillNavToggle}
title={zitareSettings.pillNavCollapsed ? $_('nav.showNav') : $_('nav.hideNav')}
>
{#if zitareSettings.pillNavCollapsed}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={zitareSettings.immersiveModeEnabled}
onToggle={() => zitareSettings.toggleImmersiveMode()}
/>
<!-- Main content -->
<main class="main-content bg-background" class:immersive={zitareSettings.immersiveModeEnabled}>
<div class="content-wrapper" class:immersive={zitareSettings.immersiveModeEnabled}>
{@render children()}
</div>
</main>
</div>
<!-- Main content -->
<main class="main-content bg-background" class:immersive={zitareSettings.immersiveModeEnabled}>
<div class="content-wrapper" class:immersive={zitareSettings.immersiveModeEnabled}>
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {