mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(onboarding): M2 — route guard + shell + Screen 1 (name)
- PATCH /api/v1/me/profile in mana-auth (name, image with 1–80 char
validation) — powers the Screen-1 save
- (app)/+layout.svelte:
* isOnboarding derived from pathname
* handleAuthReady loads onboardingStatus, redirects brand-new users
to /onboarding/name (fire-and-forget so sync/data-layer init keeps
running in parallel)
* chrome (PillNav, wallpaper, bottom-stack) hidden in onboarding mode;
AuthGate still wraps so the flow enforces authentication
- /onboarding/+layout.svelte: full-viewport shell with progress dots
(1/3, 2/3, 3/3) and a skip-all that marks the flow complete and
sends the user home
- /onboarding/+page.svelte: redirects bare entry to /onboarding/name
- /onboarding/name/+page.svelte: text input (1–40 chars), Enter = Weiter,
skip falls back to email local-part so Screen 2's greeting is never
empty
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a92e1168b
commit
5aecf8b90d
6 changed files with 689 additions and 229 deletions
|
|
@ -82,6 +82,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||
import { getPillAppItems } from '@mana/shared-branding';
|
||||
import { STORAGE_KEYS } from '$lib/config/storage-keys';
|
||||
import { SearchRegistry } from '$lib/search/registry';
|
||||
|
|
@ -109,6 +110,14 @@
|
|||
else setTimeout(cb, 0);
|
||||
}
|
||||
|
||||
// ── Onboarding mode ─────────────────────────────────────
|
||||
// When the user is on any /onboarding/* route the main chrome
|
||||
// (PillNav, wallpaper, bottom-stack) is hidden so the three
|
||||
// onboarding screens get the full viewport. The route guard
|
||||
// below also reads this flag to avoid redirecting a user who
|
||||
// is already inside the flow.
|
||||
let isOnboarding = $derived($page.url.pathname.startsWith('/onboarding'));
|
||||
|
||||
// ── App switcher ────────────────────────────────────────
|
||||
// Prefer the active Space's tier for gating — falls back to the user
|
||||
// tier only during the bootstrap window where no space has loaded.
|
||||
|
|
@ -311,15 +320,25 @@
|
|||
// apps now — no standalone routes. The user-menu dropdown links via
|
||||
// `spiralHref` / `creditsHref` / `profileHref` etc., all pointing to
|
||||
// `/?app=<id>` deep-links.
|
||||
let isOnWorkbench = $derived($page.url.pathname === '/');
|
||||
let baseNavItems = $derived<PillNavItem[]>([
|
||||
{
|
||||
href: '/',
|
||||
label: 'Workbench-Tabs',
|
||||
icon: 'tabs',
|
||||
iconOnly: true,
|
||||
onClick: handleBottomBarToggle,
|
||||
active: isBottomBarVisible,
|
||||
},
|
||||
isOnWorkbench
|
||||
? {
|
||||
href: '/',
|
||||
label: 'Workbench-Tabs',
|
||||
icon: 'tabs',
|
||||
iconOnly: true,
|
||||
onClick: handleBottomBarToggle,
|
||||
active: isBottomBarVisible,
|
||||
}
|
||||
: {
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
icon: 'home',
|
||||
iconOnly: true,
|
||||
onClick: () => goto('/'),
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: 'Suche',
|
||||
|
|
@ -626,6 +645,22 @@
|
|||
// value (0 on a fresh tab) until a sync actually runs.
|
||||
refreshPendingCount();
|
||||
|
||||
// Onboarding guard: brand-new users land on `/` after signup
|
||||
// but have `onboardingCompletedAt === null`. Redirect them
|
||||
// into the 3-screen flow. Fired non-blocking because the
|
||||
// earlier Phase A already initialized the data layer, so
|
||||
// leaving sync running in parallel during onboarding is
|
||||
// harmless (and useful — templates/+page.svelte writes a
|
||||
// scene at the end). Self-skips when already inside the
|
||||
// flow so the screens don't bounce each other.
|
||||
if (!isOnboarding) {
|
||||
void onboardingStatus.load().then(() => {
|
||||
if (onboardingStatus.needsOnboarding) {
|
||||
goto('/onboarding/name', { replaceState: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phase B-idle: settings + return-visit telemetry. Non-gating.
|
||||
idle(async () => {
|
||||
trackReturnVisit();
|
||||
|
|
@ -769,270 +804,278 @@
|
|||
appName="Mana"
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
>
|
||||
<div class="min-h-screen" class:bg-background={!wallpaperStore.hasWallpaper}>
|
||||
<WallpaperLayer config={wallpaperStore.effective} />
|
||||
{#if isOnboarding}
|
||||
<!-- Onboarding mode: clean slate — the onboarding shell layout
|
||||
renders a full-viewport container with its own progress dots
|
||||
and skip-all. Keep AuthGate wrapping so the screens still
|
||||
enforce the authenticated-user requirement. -->
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="min-h-screen" class:bg-background={!wallpaperStore.hasWallpaper}>
|
||||
<WallpaperLayer config={wallpaperStore.effective} />
|
||||
|
||||
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
|
||||
<!-- Bottom Stack: all fixed-bottom elements in one flex container.
|
||||
Hidden entirely when fullscreen mode is active (press "f"). -->
|
||||
{#if !isFullscreen}
|
||||
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
|
||||
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs).
|
||||
{#if !isFullscreen}
|
||||
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
|
||||
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs).
|
||||
Gated by isBottomBarVisible so the "workbench tabs" pill can
|
||||
toggle it without unmounting the owning page. -->
|
||||
{#if isBottomBarVisible && bottomBarStore.component}
|
||||
{@const BarComponent = bottomBarStore.component}
|
||||
<BarComponent {...bottomBarStore.props} />
|
||||
{/if}
|
||||
{#if isBottomBarVisible && bottomBarStore.component}
|
||||
{@const BarComponent = bottomBarStore.component}
|
||||
<BarComponent {...bottomBarStore.props} />
|
||||
{/if}
|
||||
|
||||
<!-- One-time encryption intro — sits at the top of the stack so
|
||||
<!-- One-time encryption intro — sits at the top of the stack so
|
||||
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
|
||||
Self-gates on isVaultUnlocked() so guests never see it.
|
||||
Lazy-loaded after idle (see $effects above). -->
|
||||
{#if EncryptionIntroBannerC}
|
||||
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
|
||||
<div class="bottom-stack-notification">
|
||||
<EncryptionIntroBanner />
|
||||
</div>
|
||||
{/if}
|
||||
{#if EncryptionIntroBannerC}
|
||||
{@const EncryptionIntroBanner = EncryptionIntroBannerC}
|
||||
<div class="bottom-stack-notification">
|
||||
<EncryptionIntroBanner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
|
||||
{#if syncBilling.paused}
|
||||
<div class="bottom-stack-notification">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/?app=credits&tab=packages"
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Credits aufladen
|
||||
</a>
|
||||
<a
|
||||
href="/?app=settings#cloud-sync"
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Sync-Einstellungen
|
||||
</a>
|
||||
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
|
||||
{#if syncBilling.paused}
|
||||
<div class="bottom-stack-notification">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/?app=credits&tab=packages"
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Credits aufladen
|
||||
</a>
|
||||
<a
|
||||
href="/?app=settings#cloud-sync"
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Sync-Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Guest notifications — combines the time-based nudge from
|
||||
<!-- Guest notifications — combines the time-based nudge from
|
||||
createGuestMode (one-shot after N minutes) with the
|
||||
event-driven prompts pushed by guestPrompt.requireAccount
|
||||
(e.g. server feature called while signed out, 401 from
|
||||
the auth-aware fetch). Both flow into the same bar so
|
||||
the user only ever sees one stripe instead of stacking. -->
|
||||
{#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
|
||||
<div class="bottom-stack-notification">
|
||||
<NotificationBar
|
||||
notifications={[...(guestMode?.notifications ?? []), ...guestPrompt.notifications]}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
|
||||
<div class="bottom-stack-notification">
|
||||
<NotificationBar
|
||||
notifications={[...(guestMode?.notifications ?? []), ...guestPrompt.notifications]}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Session expiry warning (auth only). Self-gates on the
|
||||
<!-- Session expiry warning (auth only). Self-gates on the
|
||||
secondsLeft countdown and only renders inside the stack
|
||||
when actually warning, so the wrapper is no-op otherwise.
|
||||
Lazy-loaded after idle. -->
|
||||
{#if authStore.isAuthenticated && SessionWarningC}
|
||||
{@const SessionWarning = SessionWarningC}
|
||||
<div class="bottom-stack-notification">
|
||||
<SessionWarning />
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStore.isAuthenticated && SessionWarningC}
|
||||
{@const SessionWarning = SessionWarningC}
|
||||
<div class="bottom-stack-notification">
|
||||
<SessionWarning />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cross-module automation suggestions. Lives in the (app)
|
||||
<!-- Cross-module automation suggestions. Lives in the (app)
|
||||
stack because automationsStore is an (app)-only module
|
||||
and the toast doesn't make sense on auth/landing pages
|
||||
anyway. Self-gates on visible state. Lazy-loaded after idle. -->
|
||||
{#if SuggestionToastC}
|
||||
{@const SuggestionToast = SuggestionToastC}
|
||||
<div class="bottom-stack-notification">
|
||||
<SuggestionToast />
|
||||
</div>
|
||||
{/if}
|
||||
{#if SuggestionToastC}
|
||||
{@const SuggestionToast = SuggestionToastC}
|
||||
<div class="bottom-stack-notification">
|
||||
<SuggestionToast />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Companion Brain pulse nudges — water reminders, streak
|
||||
<!-- Companion Brain pulse nudges — water reminders, streak
|
||||
warnings, morning summary etc. Self-gates on active nudges.
|
||||
Lazy-loaded after idle. -->
|
||||
{#if NudgeToastC}
|
||||
{@const NudgeToast = NudgeToastC}
|
||||
<div class="bottom-stack-notification">
|
||||
<NudgeToast />
|
||||
</div>
|
||||
{/if}
|
||||
{#if NudgeToastC}
|
||||
{@const NudgeToast = NudgeToastC}
|
||||
<div class="bottom-stack-notification">
|
||||
<NudgeToast />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
|
||||
{#if isQuickInputVisible}
|
||||
<QuickInputBar
|
||||
onSearch={inputBarAdapter.onSearch}
|
||||
onSelect={inputBarAdapter.onSelect}
|
||||
onParseCreate={inputBarAdapter.onParseCreate}
|
||||
onCreate={inputBarAdapter.onCreate}
|
||||
onSearchChange={inputBarAdapter.onSearchChange}
|
||||
placeholder={inputBarAdapter.placeholder}
|
||||
appIcon={inputBarAdapter.appIcon}
|
||||
emptyText={inputBarAdapter.emptyText}
|
||||
createText={inputBarAdapter.createText}
|
||||
deferSearch={inputBarAdapter.deferSearch}
|
||||
locale={$locale || 'de'}
|
||||
defaultOptions={inputBarAdapter.defaultOptions}
|
||||
selectedDefaultId={inputBarAdapter.selectedDefaultId}
|
||||
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
|
||||
onDefaultChange={inputBarAdapter.onDefaultChange}
|
||||
highlightPatterns={inputBarAdapter.highlightPatterns}
|
||||
positioning="static"
|
||||
injectedText={sttInjectedText}
|
||||
>
|
||||
{#snippet leftAction()}
|
||||
<button
|
||||
class="stt-mic-btn"
|
||||
class:recording={localStt.state === 'recording'}
|
||||
class:busy={localStt.state === 'loading' || localStt.state === 'transcribing'}
|
||||
onclick={() => localStt.toggle()}
|
||||
disabled={localStt.state === 'loading' || localStt.state === 'transcribing'}
|
||||
title={localStt.state === 'recording'
|
||||
? 'Aufnahme beenden'
|
||||
: localStt.state === 'transcribing'
|
||||
? 'Wird transkribiert…'
|
||||
: localStt.state === 'loading'
|
||||
? 'Modell wird geladen…'
|
||||
: 'Spracheingabe'}
|
||||
>
|
||||
{#if localStt.state === 'recording'}
|
||||
<Stop size={16} weight="fill" />
|
||||
{:else}
|
||||
<Microphone size={16} weight={localStt.state === 'idle' ? 'regular' : 'fill'} />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</QuickInputBar>
|
||||
{/if}
|
||||
<!-- QuickInputBar with inline nav toggle — gated by the "search" pill -->
|
||||
{#if isQuickInputVisible}
|
||||
<QuickInputBar
|
||||
onSearch={inputBarAdapter.onSearch}
|
||||
onSelect={inputBarAdapter.onSelect}
|
||||
onParseCreate={inputBarAdapter.onParseCreate}
|
||||
onCreate={inputBarAdapter.onCreate}
|
||||
onSearchChange={inputBarAdapter.onSearchChange}
|
||||
placeholder={inputBarAdapter.placeholder}
|
||||
appIcon={inputBarAdapter.appIcon}
|
||||
emptyText={inputBarAdapter.emptyText}
|
||||
createText={inputBarAdapter.createText}
|
||||
deferSearch={inputBarAdapter.deferSearch}
|
||||
locale={$locale || 'de'}
|
||||
defaultOptions={inputBarAdapter.defaultOptions}
|
||||
selectedDefaultId={inputBarAdapter.selectedDefaultId}
|
||||
defaultOptionLabel={inputBarAdapter.defaultOptionLabel}
|
||||
onDefaultChange={inputBarAdapter.onDefaultChange}
|
||||
highlightPatterns={inputBarAdapter.highlightPatterns}
|
||||
positioning="static"
|
||||
injectedText={sttInjectedText}
|
||||
>
|
||||
{#snippet leftAction()}
|
||||
<button
|
||||
class="stt-mic-btn"
|
||||
class:recording={localStt.state === 'recording'}
|
||||
class:busy={localStt.state === 'loading' || localStt.state === 'transcribing'}
|
||||
onclick={() => localStt.toggle()}
|
||||
disabled={localStt.state === 'loading' || localStt.state === 'transcribing'}
|
||||
title={localStt.state === 'recording'
|
||||
? 'Aufnahme beenden'
|
||||
: localStt.state === 'transcribing'
|
||||
? 'Wird transkribiert…'
|
||||
: localStt.state === 'loading'
|
||||
? 'Modell wird geladen…'
|
||||
: 'Spracheingabe'}
|
||||
>
|
||||
{#if localStt.state === 'recording'}
|
||||
<Stop size={16} weight="fill" />
|
||||
{:else}
|
||||
<Microphone size={16} weight={localStt.state === 'idle' ? 'regular' : 'fill'} />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</QuickInputBar>
|
||||
{/if}
|
||||
|
||||
<!-- TagStrip (between QuickInputBar and PillNav) -->
|
||||
{#if isTagStripVisible}
|
||||
<TagStrip
|
||||
tags={(allTags.value ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#3b82f6',
|
||||
}))}
|
||||
selectedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onClear={() => {}}
|
||||
onTagDrop={tagDropHandler ?? undefined}
|
||||
managementHref="/tags"
|
||||
loading={allTags.loading}
|
||||
positioning="static"
|
||||
/>
|
||||
{/if}
|
||||
<!-- TagStrip (between QuickInputBar and PillNav) -->
|
||||
{#if isTagStripVisible}
|
||||
<TagStrip
|
||||
tags={(allTags.value ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#3b82f6',
|
||||
}))}
|
||||
selectedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onClear={() => {}}
|
||||
onTagDrop={tagDropHandler ?? undefined}
|
||||
managementHref="/tags"
|
||||
loading={allTags.loading}
|
||||
positioning="static"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Dropdown-as-bar: shows the items of the currently opened
|
||||
<!-- Dropdown-as-bar: shows the items of the currently opened
|
||||
PillNavigation dropdown (theme / AI / sync / user) as
|
||||
horizontal pills directly above the PillNav. -->
|
||||
{#if activeBar}
|
||||
<PillDropdownBar
|
||||
items={activeBar.items}
|
||||
label={activeBar.label}
|
||||
icon={activeBar.icon}
|
||||
{#if activeBar}
|
||||
<PillDropdownBar
|
||||
items={activeBar.items}
|
||||
label={activeBar.label}
|
||||
icon={activeBar.icon}
|
||||
positioning="static"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- PillNav (bottom of stack) -->
|
||||
<PillNavigation
|
||||
onOpenBar={handleOpenBar}
|
||||
activeBarId={activeBar?.id ?? null}
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Mana"
|
||||
homeRoute="/"
|
||||
onLogout={handleSignOut}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
loginHref="/login"
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
showAppSwitcher={false}
|
||||
showAiTierSelector={true}
|
||||
aiTierItems={aiTier.items}
|
||||
currentAiTierLabel={aiTier.label}
|
||||
currentAiTierIcon={aiTier.icon}
|
||||
showSyncStatus={authStore.isAuthenticated}
|
||||
syncStatusItems={syncStatus.items}
|
||||
currentSyncLabel={syncStatus.label}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
profileHref="/?app=profile"
|
||||
spiralHref="/?app=spiral"
|
||||
creditsHref="/?app=credits"
|
||||
themesHref="/?app=themes"
|
||||
helpHref="/?app=help"
|
||||
{spotlightActions}
|
||||
{contentSearcher}
|
||||
positioning="static"
|
||||
/>
|
||||
{/if}
|
||||
>
|
||||
{#snippet startSlot()}
|
||||
{#if authStore.isAuthenticated}
|
||||
<SpaceSwitcher locale={$locale === 'en' ? 'en' : 'de'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PillNav (bottom of stack) -->
|
||||
<PillNavigation
|
||||
onOpenBar={handleOpenBar}
|
||||
activeBarId={activeBar?.id ?? null}
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Mana"
|
||||
homeRoute="/"
|
||||
onLogout={handleSignOut}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
loginHref="/login"
|
||||
primaryColor="hsl(var(--color-primary))"
|
||||
showAppSwitcher={false}
|
||||
showAiTierSelector={true}
|
||||
aiTierItems={aiTier.items}
|
||||
currentAiTierLabel={aiTier.label}
|
||||
currentAiTierIcon={aiTier.icon}
|
||||
showSyncStatus={authStore.isAuthenticated}
|
||||
syncStatusItems={syncStatus.items}
|
||||
currentSyncLabel={syncStatus.label}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
profileHref="/?app=profile"
|
||||
spiralHref="/?app=spiral"
|
||||
creditsHref="/?app=credits"
|
||||
themesHref="/?app=themes"
|
||||
helpHref="/?app=help"
|
||||
{spotlightActions}
|
||||
{contentSearcher}
|
||||
positioning="static"
|
||||
>
|
||||
{#snippet startSlot()}
|
||||
{#if authStore.isAuthenticated}
|
||||
<SpaceSwitcher locale={$locale === 'en' ? 'en' : 'de'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- DnD: floating preview -->
|
||||
<DragPreview />
|
||||
|
||||
<!-- DnD: floating preview -->
|
||||
<DragPreview />
|
||||
|
||||
<!-- Main content.
|
||||
<!-- Main content.
|
||||
Publish layout offsets as CSS variables so descendants (esp.
|
||||
ModuleShell in the carousel) can compute their available
|
||||
height against viewport + bottom chrome without prop
|
||||
drilling. `--workbench-top-offset` must match the vertical
|
||||
padding on the inner max-w-7xl wrapper below. -->
|
||||
<main
|
||||
style="padding-bottom: {bottomChromeHeight +
|
||||
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 1.5rem;"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
|
||||
{#if routeBlocked && routeAppId}
|
||||
<RouteTierGate
|
||||
appName={routeAppId.name}
|
||||
userTierLabel={routeTierLabels.user}
|
||||
requiredTierLabel={routeTierLabels.required}
|
||||
/>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<main
|
||||
style="padding-bottom: {bottomChromeHeight +
|
||||
8}px; --bottom-chrome-height: {bottomChromeHeight}px; --workbench-reserved-y: 1.5rem;"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
|
||||
{#if routeBlocked && routeAppId}
|
||||
<RouteTierGate
|
||||
appName={routeAppId.name}
|
||||
userTierLabel={routeTierLabels.user}
|
||||
requiredTierLabel={routeTierLabels.required}
|
||||
/>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Session expiry warning lives inside .bottom-stack now (see above)
|
||||
<!-- Session expiry warning lives inside .bottom-stack now (see above)
|
||||
so it doesn't end up obscured by the QuickInputBar like
|
||||
EncryptionIntroBanner used to be. -->
|
||||
|
||||
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
|
||||
{#if KeyboardShortcutsModalC}
|
||||
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
|
||||
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Keyboard shortcuts modal — loaded on first `?` press -->
|
||||
{#if KeyboardShortcutsModalC}
|
||||
{@const KeyboardShortcutsModal = KeyboardShortcutsModalC}
|
||||
<KeyboardShortcutsModal open={showShortcuts} onclose={() => (showShortcuts = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation Context Menu -->
|
||||
<ContextMenu
|
||||
|
|
|
|||
140
apps/mana/apps/web/src/routes/(app)/onboarding/+layout.svelte
Normal file
140
apps/mana/apps/web/src/routes/(app)/onboarding/+layout.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<!--
|
||||
Onboarding shell — wraps the three onboarding screens with a
|
||||
centered layout, progress dots, and a skip-all affordance.
|
||||
|
||||
Lives under (app)/ so the AuthGate in the parent layout keeps
|
||||
unauthenticated users out. The parent layout hides its own chrome
|
||||
(PillNav, bottom-stack) when the pathname starts with /onboarding,
|
||||
so this shell renders into a clean full-viewport container.
|
||||
|
||||
The skip-all button writes `onboardingCompletedAt = now()` via the
|
||||
shared store and navigates home. Individual screens can also call
|
||||
`onboardingStatus.markComplete()` themselves (templates/+page.svelte
|
||||
does this as part of its finish handler).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { X } from '@mana/shared-icons';
|
||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Map pathname → step index (0-based) so the progress dots light up.
|
||||
// Any unknown /onboarding/* path falls back to step 0.
|
||||
let currentStep = $derived.by(() => {
|
||||
const path = $page.url.pathname;
|
||||
if (path.startsWith('/onboarding/templates')) return 2;
|
||||
if (path.startsWith('/onboarding/look')) return 1;
|
||||
return 0; // /onboarding, /onboarding/name, or anything else
|
||||
});
|
||||
|
||||
async function handleSkipAll() {
|
||||
try {
|
||||
await onboardingStatus.markComplete();
|
||||
} catch (err) {
|
||||
console.warn('[onboarding] skip-all markComplete failed:', err);
|
||||
}
|
||||
await goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="onboarding-shell">
|
||||
<header class="onboarding-header">
|
||||
<div
|
||||
class="progress-dots"
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
aria-valuenow={currentStep + 1}
|
||||
>
|
||||
{#each [0, 1, 2] as step (step)}
|
||||
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="skip-all"
|
||||
onclick={handleSkipAll}
|
||||
aria-label="Onboarding überspringen"
|
||||
>
|
||||
<X size={14} />
|
||||
<span>Überspringen</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="onboarding-body">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.onboarding-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 28px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: hsl(var(--color-muted-foreground) / 0.25);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.dot.done {
|
||||
background: hsl(var(--color-primary) / 0.6);
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.skip-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.skip-all:hover {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.onboarding-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
12
apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!--
|
||||
/onboarding — bare entry point. The real first step is /onboarding/name;
|
||||
a user who lands here (typo, bookmark of the parent route) is forwarded.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto('/onboarding/name', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
221
apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte
Normal file
221
apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<!--
|
||||
Onboarding — Screen 1: Name.
|
||||
Persists the user's chosen display name via PATCH /api/v1/me/profile,
|
||||
then advances to /onboarding/look. Skip falls back to the email's
|
||||
local-part so the greeting on Screen 2 is never empty.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ArrowRight } from '@mana/shared-icons';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_AUTH_URL__;
|
||||
if (injected) return injected;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||
}
|
||||
|
||||
// Prefill: existing name (returning user revisiting) → email local-part
|
||||
// → empty. Trimmed so whitespace-only values don't count as "filled".
|
||||
let name = $state((authStore.user?.name ?? '').trim());
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let canSubmit = $derived(name.trim().length >= 1 && name.trim().length <= 40 && !saving);
|
||||
|
||||
async function saveName(value: string) {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
const res = await fetch(`${getAuthUrl()}/api/v1/me/profile`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`PATCH /me/profile → ${res.status}`);
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
await saveName(trimmed);
|
||||
await goto('/onboarding/look');
|
||||
} catch (err) {
|
||||
console.error('[onboarding/name] save failed:', err);
|
||||
error = 'Speichern fehlgeschlagen. Versuch es noch mal.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkip() {
|
||||
const fallback = (authStore.user?.email ?? '').split('@')[0] || 'du';
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
// Persist the fallback too so the user shows up as something
|
||||
// other than "User 1234" in admin UIs — cheap, idempotent.
|
||||
await saveName(fallback);
|
||||
} catch (err) {
|
||||
console.warn('[onboarding/name] skip-save failed:', err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
await goto('/onboarding/look');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && canSubmit) {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="hero">
|
||||
<h1>Wie willst du genannt werden?</h1>
|
||||
<p class="subtitle">Dein Name erscheint oben in der Navigation und in Nachrichten von Mana.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="z. B. Till"
|
||||
maxlength={40}
|
||||
autocomplete="given-name"
|
||||
autofocus
|
||||
aria-label="Dein Name"
|
||||
/>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={handleSkip} disabled={saving}>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={handleNext}
|
||||
disabled={!canSubmit}
|
||||
aria-label="Weiter zu Theme-Auswahl"
|
||||
>
|
||||
<span>Weiter</span>
|
||||
<ArrowRight size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface, var(--color-background)));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 1.0625rem;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -99,7 +99,7 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService));
|
|||
// ─── Me (GDPR) ──────────────────────────────────────────────
|
||||
|
||||
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
|
||||
app.route('/api/v1/me', createMeRoutes(userDataService));
|
||||
app.route('/api/v1/me', createMeRoutes(userDataService, db));
|
||||
|
||||
// ─── Encryption vault (per-user master key custody) ────────
|
||||
// Mounted under /me so it inherits the JWT middleware above and shows
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@
|
|||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { UserDataService } from '../services/user-data';
|
||||
import type { Database } from '../db/connection';
|
||||
import { users } from '../db/schema/auth';
|
||||
import { sendAccountDeletionEmail } from '../email/send';
|
||||
|
||||
export function createMeRoutes(userDataService: UserDataService) {
|
||||
export function createMeRoutes(userDataService: UserDataService, db: Database) {
|
||||
return (
|
||||
new Hono<{ Variables: { user: AuthUser } }>()
|
||||
|
||||
|
|
@ -57,5 +60,46 @@ export function createMeRoutes(userDataService: UserDataService) {
|
|||
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
// ─── Update profile (name, avatar) ──────────────────────
|
||||
// Minimal patch endpoint used by the onboarding flow and
|
||||
// Settings → Profile. JWT-based like the rest of /me/*; the
|
||||
// updated name only lands in the user's JWT on next mint, so
|
||||
// the caller is responsible for refreshing its in-memory
|
||||
// representation of authStore.user. See docs/plans/onboarding-flow.md.
|
||||
.patch('/profile', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
name?: unknown;
|
||||
image?: unknown;
|
||||
};
|
||||
|
||||
const patch: { name?: string; image?: string; updatedAt: Date } = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (typeof body.name === 'string') {
|
||||
const trimmed = body.name.trim();
|
||||
if (trimmed.length < 1 || trimmed.length > 80) {
|
||||
return c.json({ error: 'name must be 1–80 characters' }, 400);
|
||||
}
|
||||
patch.name = trimmed;
|
||||
}
|
||||
if (typeof body.image === 'string') {
|
||||
patch.image = body.image;
|
||||
}
|
||||
|
||||
if (!('name' in patch) && !('image' in patch)) {
|
||||
return c.json({ error: 'no fields to update' }, 400);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set(patch)
|
||||
.where(eq(users.id, user.userId))
|
||||
.returning({ id: users.id, name: users.name, image: users.image });
|
||||
|
||||
if (!updated) return c.json({ error: 'User not found' }, 404);
|
||||
return c.json({ name: updated.name, image: updated.image });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue