diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 72cf0665e..6f2703a4b 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -2,17 +2,27 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import type { Snippet } from 'svelte'; - import { onMount, setContext } from 'svelte'; + import { onDestroy, setContext } from 'svelte'; import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte'; import SessionWarning from '$lib/components/SessionWarning.svelte'; import { locale } from 'svelte-i18n'; - import { PillNavigation, TagStrip, DragPreview, ActionZone } from '@manacore/shared-ui'; + import { + PillNavigation, + TagStrip, + DragPreview, + ActionZone, + QuickInputBar, + } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem, SpotlightAction, ContentSearcher, } from '@manacore/shared-ui'; + import type { InputBarAdapter } from '$lib/quick-input/types'; + import { getAdapterLoader } from '$lib/quick-input/registry'; + import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter'; + import { AuthGate } from '@manacore/shared-auth-ui'; import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte'; import { linkLocalStore, linkMutations } from '@manacore/shared-links'; import { manacoreStore } from '$lib/data/local-store'; @@ -39,30 +49,26 @@ import { SearchRegistry } from '$lib/search/registry'; import { registerAllProviders } from '$lib/search/providers'; import { initSharedUload } from '@manacore/shared-uload'; + import type { DragPayload } from '@manacore/shared-ui/dnd'; let { children }: { children: Snippet } = $props(); - // App switcher items (filtered by user's access tier) + // ── App switcher ──────────────────────────────────────── let appItems = $derived(getPillAppItems('manacore', undefined, undefined, authStore.user?.tier)); - let loading = $state(true); + // ── UI State ──────────────────────────────────────────── let isCollapsed = $state(false); let showShortcuts = $state(false); + let showOnboarding = $state(false); - // Get theme state + // ── Theme ─────────────────────────────────────────────── let isDark = $derived(theme.isDark); - - // Get pinned themes from user settings (extended themes only) let pinnedThemes = $derived( (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) ) ); - - // Visible themes in PillNav: default + pinned extended let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); - - // Theme variant dropdown items let themeVariantItems = $derived([ ...visibleThemes.map((variant) => ({ id: variant, @@ -79,11 +85,9 @@ active: false, }, ]); - - // Current theme variant label let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label); - // Language selector items + // ── i18n ──────────────────────────────────────────────── let currentLocale = $derived($locale || 'de'); function handleLocaleChange(newLocale: string) { setLocale(newLocale as any); @@ -94,21 +98,17 @@ ); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); - // User email for user dropdown - let userEmail = $derived(authStore.user?.email); + // ── User / Guest awareness ────────────────────────────── + let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : ''); - // Tags (local-first reactive query) + // ── Tags ──────────────────────────────────────────────── const allTags = useAllTags(); - - // TagStrip visibility let isTagStripVisible = $state(false); function handleTagStripToggle() { isTagStripVisible = !isTagStripVisible; } - // DnD: tag drop handler — set by child pages via context - import type { DragPayload } from '@manacore/shared-ui/dnd'; - + // ── DnD context ───────────────────────────────────────── let tagDropHandler = $state<((tagId: string, payload: DragPayload) => void) | null>(null); setContext('tagDropHandler', { set(handler: (tagId: string, payload: DragPayload) => void) { @@ -119,7 +119,7 @@ }, }); - // Navigation items for ManaCore + // ── Navigation ────────────────────────────────────────── const baseNavItems: PillNavItem[] = [ { href: '/home', label: 'Home', icon: 'home' }, { href: '/dashboard', label: 'Dashboard', icon: 'grid' }, @@ -139,13 +139,10 @@ }, ]; - // Only show admin link if user has admin role let isAdmin = $derived(authStore.user?.role === 'admin'); let navItems = $derived( isAdmin ? [...baseNavItems, { href: '/admin', label: 'Admin', icon: 'shield' }] : baseNavItems ); - - // Navigation shortcuts (Ctrl+1-5) const navRoutes = navItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { @@ -153,22 +150,17 @@ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } - - // ? key opens keyboard shortcuts if (event.key === '?' && !event.ctrlKey && !event.metaKey) { event.preventDefault(); showShortcuts = !showShortcuts; return; } - if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) { const num = parseInt(event.key); if (num >= 1 && num <= navRoutes.length) { event.preventDefault(); const route = navRoutes[num - 1]; - if (route) { - goto(route); - } + if (route) goto(route); } } } @@ -191,7 +183,7 @@ AppEvents.themeChanged(mode); } - // Unified sync manager — one sync engine for all apps + // ── Sync ──────────────────────────────────────────────── const SYNC_SERVER_URL = (typeof window !== 'undefined' && (window as Record).__PUBLIC_SYNC_SERVER_URL__) || @@ -201,14 +193,15 @@ async function handleSignOut() { unifiedSync?.stopAll(); + guestMode?.destroy(); await authStore.signOut(); goto('/login'); } - // Track initialization state - let isInitializing = $state(true); - let showOnboarding = $state(false); + // ── Guest Mode ────────────────────────────────────────── + let guestMode: { destroy: () => void } | null = null; + // ── Onboarding ────────────────────────────────────────── function handleOnboardingComplete() { onboardingStore.complete(); ManaCoreEvents.onboardingCompleted(); @@ -221,62 +214,58 @@ showOnboarding = false; } - onMount(async () => { - // Initialize auth store first - await authStore.initialize(); - - // Only after initialization is complete, check auth status - isInitializing = false; - - // Redirect to login if not authenticated - if (!authStore.isAuthenticated) { - goto('/login'); - return; - } - - // Initialize local-first databases (opens IndexedDB, seeds guest data) + // ── Auth Ready (replaces monolith onMount) ────────────── + async function handleAuthReady() { + // Phase A: Auth-independent — guests + authenticated await Promise.all([ manacoreStore.initialize(), tagLocalStore.initialize(), linkLocalStore.initialize(), ]); - - // Migrate data from legacy per-app databases (one-time, idempotent) await migrateFromLegacyDbs(); - - // Initialize shared-uload (opens uLoad IndexedDB for cross-app link creation) initSharedUload(); - - // Start unified sync — one engine for all apps via Dexie hooks - const getToken = () => authStore.getValidToken(); - unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken); - unifiedSync.startAll(); - - // Initialize dashboard from IndexedDB await dashboardStore.initialize(); - // Initialize collapsed state from localStorage - const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED); - if (savedCollapsed === 'true') { - isCollapsed = true; - collapsedStore.set(true); + // Restore nav collapsed state + if (typeof localStorage !== 'undefined') { + const savedCollapsed = localStorage.getItem(STORAGE_KEYS.NAV_COLLAPSED); + if (savedCollapsed === 'true') { + isCollapsed = true; + collapsedStore.set(true); + } } - // Load user settings from server (still needed for shared-theme sync) - userSettings.load().catch(() => {}); + // Phase B: Auth-dependent — sync, settings, onboarding + if (authStore.isAuthenticated) { + const getToken = () => authStore.getValidToken(); + unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken); + unifiedSync.startAll(); - // Load onboarding state and show wizard if needed - onboardingStore.load(); - if (onboardingStore.shouldShow) { - onboardingStore.start(); - ManaCoreEvents.onboardingStarted(); - showOnboarding = true; + userSettings.load().catch(() => {}); + + onboardingStore.load(); + if (onboardingStore.shouldShow) { + onboardingStore.start(); + ManaCoreEvents.onboardingStarted(); + showOnboarding = true; + } } - loading = false; + // Phase C: Guest mode — welcome modal + nudge + if (!authStore.isAuthenticated) { + guestMode = createGuestMode('manacore', { + nudgeDelayMinutes: 3, + onRegister: () => goto('/register'), + }); + } + } + + onDestroy(() => { + unifiedSync?.stopAll(); + guestMode?.destroy(); }); - // Cross-app search + // ── Search / Spotlight ─────────────────────────────────── const searchRegistry = new SearchRegistry(); registerAllProviders(searchRegistry); @@ -284,6 +273,32 @@ return searchRegistry.search(query, { signal }); }; + // ── QuickInputBar — context-aware adapter per module ───── + let inputBarAdapter = $state(createFallbackAdapter(searchRegistry)); + let activeModulePrefix = $state(null); + + $effect(() => { + const pathname = $page.url.pathname; + const moduleSlug = '/' + pathname.split('/')[1]; + + if (moduleSlug === activeModulePrefix) return; + + const loader = getAdapterLoader(pathname); + if (!loader) { + inputBarAdapter = createFallbackAdapter(searchRegistry); + activeModulePrefix = null; + return; + } + + const target = moduleSlug; + loader().then(({ createAdapter }) => { + if (target === '/' + $page.url.pathname.split('/')[1]) { + inputBarAdapter = createAdapter() as InputBarAdapter; + activeModulePrefix = target; + } + }); + }); + const spotlightActions: SpotlightAction[] = [ { id: 'home', label: 'Home', category: 'Navigation', onExecute: () => goto('/home') }, { @@ -311,18 +326,16 @@ -{#if isInitializing || loading || authStore.loading} -
-
-
-

Loading...

-
-
-{:else if authStore.isAuthenticated} - - {#if showOnboarding} + + + {#if showOnboarding && authStore.isAuthenticated}
@@ -351,7 +364,8 @@ showLanguageSwitcher={true} {languageItems} {currentLanguageLabel} - showLogout={true} + showLogout={authStore.isAuthenticated} + loginHref="/login" primaryColor="#6366f1" showAppSwitcher={true} {appItems} @@ -366,6 +380,26 @@ {contentSearcher} /> + + + {#if isTagStripVisible} {/if} - + @@ -393,10 +427,12 @@
- - + + {#if authStore.isAuthenticated} + + {/if} (showShortcuts = false)} /> -{/if} +
diff --git a/apps/manacore/apps/web/src/routes/+page.svelte b/apps/manacore/apps/web/src/routes/+page.svelte index e8f17437d..b847b8a48 100644 --- a/apps/manacore/apps/web/src/routes/+page.svelte +++ b/apps/manacore/apps/web/src/routes/+page.svelte @@ -1,15 +1,9 @@ diff --git a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte index 79ec2f077..ce93ce7aa 100644 --- a/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte +++ b/packages/shared-auth-ui/src/components/GuestWelcomeModal.svelte @@ -40,6 +40,7 @@ /** Default features per app (German) */ const defaultFeaturesDE: Record = { + manacore: ['Alle deine Apps an einem Ort', 'Quelloffen & unabhängig', 'Privat by Design'], contacts: ['Alle Kontakte an einem Ort', 'Quelloffen & unabhängig', 'Privat by Design'], chat: ['Dein persönlicher KI-Assistent', 'Quelloffen & unabhängig', 'Privat by Design'], todo: ['Organisiere deinen Alltag', 'Quelloffen & unabhängig', 'Privat by Design'], @@ -72,6 +73,7 @@ /** Default features per app (English) */ const defaultFeaturesEN: Record = { + manacore: ['All your apps in one place', 'Open-source & independent', 'Private by design'], contacts: ['All your contacts in one place', 'Open-source & independent', 'Private by design'], chat: ['Your personal AI assistant', 'Open-source & independent', 'Private by design'], todo: ['Organize your day', 'Open-source & independent', 'Private by design'], diff --git a/packages/shared-stores/src/guest-mode.svelte.ts b/packages/shared-stores/src/guest-mode.svelte.ts new file mode 100644 index 000000000..0a40d81d7 --- /dev/null +++ b/packages/shared-stores/src/guest-mode.svelte.ts @@ -0,0 +1,130 @@ +/** + * Guest Mode Composable + * + * Encapsulates welcome modal visibility, registration nudge timer, + * and bottom notification state for guest users. + * + * Usage: + * const guestMode = createGuestMode('manacore', { nudgeDelayMinutes: 3 }); + */ + +// Inline localStorage utilities (no external dependency needed) +function _shouldShowWelcome(appId: string): boolean { + if (typeof localStorage === 'undefined') return false; + return localStorage.getItem(`guest-welcome-seen-${appId}`) !== 'true'; +} + +function _markWelcomeSeen(appId: string): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(`guest-welcome-seen-${appId}`, 'true'); +} + +function _startSession(appId: string): void { + if (typeof localStorage === 'undefined') return; + const key = `guest-nudge-session-${appId}`; + if (!localStorage.getItem(key)) { + localStorage.setItem(key, Date.now().toString()); + } +} + +function _shouldShowNudge(appId: string, delayMinutes: number): boolean { + if (typeof localStorage === 'undefined') return false; + if (localStorage.getItem(`guest-nudge-dismissed-${appId}`) === 'true') return false; + const sessionStart = localStorage.getItem(`guest-nudge-session-${appId}`); + if (!sessionStart) return false; + return Date.now() - parseInt(sessionStart, 10) >= delayMinutes * 60 * 1000; +} + +function _dismissNudge(appId: string): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(`guest-nudge-dismissed-${appId}`, 'true'); +} + +export interface GuestModeNotification { + id: string; + message: string; + type: 'info' | 'warning' | 'error'; + action?: { label: string; icon?: unknown; onClick: () => void }; + dismissible?: boolean; + onDismiss?: () => void; +} + +export interface GuestModeOptions { + nudgeDelayMinutes?: number; + nudgeCheckIntervalMs?: number; + nudgeMessage?: string; + nudgeActionLabel?: string; + onRegister?: () => void; +} + +export interface GuestMode { + readonly showWelcome: boolean; + readonly notifications: GuestModeNotification[]; + dismissWelcome(): void; + destroy(): void; +} + +export function createGuestMode(appId: string, opts?: GuestModeOptions): GuestMode { + const nudgeDelay = opts?.nudgeDelayMinutes ?? 5; + const checkInterval = opts?.nudgeCheckIntervalMs ?? 30_000; + const nudgeMessage = + opts?.nudgeMessage ?? 'Gefällt es dir? Sichere deine Daten geräteübergreifend.'; + const nudgeActionLabel = opts?.nudgeActionLabel ?? 'Konto erstellen'; + + let showWelcome = $state(_shouldShowWelcome(appId)); + let notifications = $state([]); + let nudgeInterval: ReturnType | null = null; + + _startSession(appId); + + function checkNudge() { + if (_shouldShowNudge(appId, nudgeDelay)) { + notifications = [ + { + id: 'guest-nudge', + message: nudgeMessage, + type: 'info' as const, + action: { + label: nudgeActionLabel, + onClick: () => { + _dismissNudge(appId); + notifications = []; + opts?.onRegister?.(); + }, + }, + dismissible: true, + onDismiss: () => { + _dismissNudge(appId); + notifications = []; + }, + }, + ]; + if (nudgeInterval) { + clearInterval(nudgeInterval); + nudgeInterval = null; + } + } + } + + checkNudge(); + nudgeInterval = setInterval(checkNudge, checkInterval); + + return { + get showWelcome() { + return showWelcome; + }, + get notifications() { + return notifications; + }, + dismissWelcome() { + showWelcome = false; + _markWelcomeSeen(appId); + }, + destroy() { + if (nudgeInterval) { + clearInterval(nudgeInterval); + nudgeInterval = null; + } + }, + }; +} diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 5e06b5203..76def48c3 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -46,6 +46,7 @@ export { } from './tags-local.svelte'; export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links'; export { toggleField } from './toggle-field'; + export { createGuestMode, type GuestMode,