feat(spaces): Space-Switcher + Create-Dialog in (app) layout

First user-visible surface for the Spaces foundation. Two components:

SpaceSwitcher (header dropdown)
- Shows the active space name + type badge
- Opens a dropdown listing all user's spaces with per-type color chips
  (brand / club / family / team / practice / personal)
- Click on a space → /organization/set-active + full page reload so
  every liveQuery re-evaluates against the new active space
- "+ Neuer Space" entry at the bottom opens the Create dialog

SpaceCreateDialog (modal)
- Type picker with description per type (excluding personal — that one
  is auto-created at signup and never chosen manually)
- Name input + live slug preview (same slugifier as the server)
- Conditional fields: voiceDoc for brand/club, uid + legalEntity for
  brand/club/practice
- POSTs to /api/auth/organization/create with metadata.type, then
  /set-active and reload. beforeCreateOrganization hook rejects
  malformed metadata server-side.

Placement: compact bar at the top of the (app) max-w-7xl wrapper, only
rendered when authenticated. Zero changes to PillNavigation so the rest
of the nav surface stays untouched.

Reactivity note: the switcher full-reloads on set-active because the
scoped-db wrapper doesn't yet invalidate liveQueries on active-space
change. A reactive-invalidation path can replace the reload once the
wrapper is used across enough modules to make the UX friction matter.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 18:16:47 +02:00
parent 871a1c3bba
commit b878ecfe1c
3 changed files with 715 additions and 0 deletions

View file

@ -0,0 +1,401 @@
<script lang="ts">
/**
* SpaceCreateDialog — modal for creating a new Space.
*
* Renders a type picker, a name input, a slug preview, and
* (for brand/club/practice) an optional "UID / legal entity" field.
* POSTs to Better Auth's /organization/create with the proper
* metadata.type payload, then activates the new org and reloads
* the page so every live query repaints against the new scope.
*/
import { SPACE_TYPES, SPACE_TYPE_LABELS, SPACE_TYPE_DESCRIPTIONS } from '@mana/shared-branding';
import type { SpaceType } from '@mana/shared-types';
import { loadActiveSpace } from '$lib/data/scope';
interface Props {
open: boolean;
locale?: 'de' | 'en';
onClose: () => void;
}
let { open = $bindable(), locale = 'de', onClose }: Props = $props();
let type = $state<SpaceType>('brand');
let name = $state('');
let slug = $state('');
let slugTouched = $state(false);
let voiceDoc = $state('');
let uid = $state('');
let legalEntity = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
/**
* Keep `slug` in sync with `name` until the user edits the slug
* directly. A minimal slugifier — lowercase alphanumerics + hyphens,
* trim dashes, cap at 32 chars. The server generates its own if we
* leave this blank, but showing a live preview is friendlier.
*/
const derivedSlug = $derived(
slugTouched
? slug
: name
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 32)
);
function close() {
if (submitting) return;
open = false;
error = null;
onClose();
}
function handleKey(e: KeyboardEvent) {
if (open && e.key === 'Escape') close();
}
async function submit(e: Event) {
e.preventDefault();
if (submitting) return;
if (!name.trim()) {
error = locale === 'de' ? 'Bitte einen Namen angeben' : 'Name required';
return;
}
submitting = true;
error = null;
const metadata: Record<string, unknown> = { type };
if (voiceDoc.trim()) metadata.voiceDoc = voiceDoc.trim();
if (uid.trim()) metadata.uid = uid.trim();
if (legalEntity.trim()) metadata.legalEntity = legalEntity.trim();
try {
const res = await fetch('/api/auth/organization/create', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
slug: derivedSlug || undefined,
metadata,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `create failed: ${res.status}`);
}
const created = (await res.json()) as { id: string };
// Activate the new space so the user lands inside it on reload.
await fetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: created.id }),
});
await loadActiveSpace({ force: true });
if (typeof window !== 'undefined') window.location.reload();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
submitting = false;
}
}
const showBrandExtras = $derived(type === 'brand' || type === 'club');
const showBusinessExtras = $derived(type === 'brand' || type === 'club' || type === 'practice');
</script>
<svelte:window onkeydown={handleKey} />
{#if open}
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Dialog schließen"
onclick={close}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ' ? close() : null)}
></div>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="space-create-title">
<form onsubmit={submit}>
<header>
<h2 id="space-create-title">
{locale === 'de' ? 'Neuer Space' : 'New space'}
</h2>
<button type="button" class="close" onclick={close} aria-label="Schließen">×</button>
</header>
<fieldset class="type-picker">
<legend>{locale === 'de' ? 'Typ' : 'Type'}</legend>
<div class="type-grid">
{#each SPACE_TYPES as t (t)}
{#if t !== 'personal'}
<label class="type-option" class:active={type === t}>
<input type="radio" name="type" value={t} bind:group={type} />
<span class="type-name">{SPACE_TYPE_LABELS[locale][t]}</span>
<span class="type-desc">{SPACE_TYPE_DESCRIPTIONS[locale][t]}</span>
</label>
{/if}
{/each}
</div>
</fieldset>
<label class="field">
<span>{locale === 'de' ? 'Name' : 'Name'}</span>
<input
type="text"
bind:value={name}
placeholder={type === 'brand' ? 'Edisconet' : ''}
required
/>
</label>
<label class="field">
<span>{locale === 'de' ? 'URL-Kürzel' : 'URL slug'}</span>
<input
type="text"
value={derivedSlug}
oninput={(e) => {
slug = (e.currentTarget as HTMLInputElement).value;
slugTouched = true;
}}
placeholder="my-space"
pattern="[a-z0-9-]*"
/>
<small class="hint">
{locale === 'de'
? 'mana.how/@' + (derivedSlug || '…')
: 'mana.how/@' + (derivedSlug || '…')}
</small>
</label>
{#if showBrandExtras}
<label class="field">
<span>{locale === 'de' ? 'Brand-Voice (optional)' : 'Brand voice (optional)'}</span>
<textarea
bind:value={voiceDoc}
rows="3"
placeholder={locale === 'de'
? 'Tonalität, Lieblings-Wörter, verbotene Wörter …'
: 'Tone, preferred words, banned words …'}
></textarea>
</label>
{/if}
{#if showBusinessExtras}
<label class="field">
<span>{locale === 'de' ? 'UID / MwSt (optional)' : 'UID / VAT (optional)'}</span>
<input type="text" bind:value={uid} placeholder="CHE-123.456.789" />
</label>
<label class="field">
<span>{locale === 'de' ? 'Rechtsform (optional)' : 'Legal entity (optional)'}</span>
<input type="text" bind:value={legalEntity} placeholder="GmbH / AG / Verein …" />
</label>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
<footer>
<button type="button" class="secondary" onclick={close} disabled={submitting}>
{locale === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
<button type="submit" class="primary" disabled={submitting || !name.trim()}>
{submitting
? locale === 'de'
? 'Erstelle …'
: 'Creating …'
: locale === 'de'
? 'Erstellen'
: 'Create'}
</button>
</footer>
</form>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
border: 0;
}
.dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(540px, 92vw);
max-height: 86vh;
overflow-y: auto;
background: var(--color-surface-1, white);
color: var(--color-text, inherit);
border-radius: var(--radius-lg, 10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
z-index: 201;
}
form {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
h2 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.close {
background: transparent;
border: 0;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, hsl(0 0% 45%));
}
.type-picker {
border: 0;
padding: 0;
margin: 0;
}
.type-picker legend {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted, hsl(0 0% 45%));
margin-bottom: 0.5rem;
}
.type-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.type-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 120ms ease;
}
.type-option.active {
border-color: var(--color-primary, hsl(230 80% 50%));
background: var(--color-surface-2, hsl(230 80% 97%));
}
.type-option input {
display: none;
}
.type-name {
font-weight: 500;
font-size: 0.875rem;
}
.type-desc {
font-size: 0.75rem;
color: var(--color-text-muted, hsl(0 0% 45%));
line-height: 1.35;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field span {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted, hsl(0 0% 45%));
}
.field input,
.field textarea {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
background: var(--color-surface-1, white);
color: var(--color-text, inherit);
font: inherit;
font-size: 0.875rem;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: var(--color-primary, hsl(230 80% 50%));
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted, hsl(0 0% 50%));
}
.error {
padding: 0.5rem 0.625rem;
background: hsl(0 70% 96%);
color: hsl(0 60% 40%);
border-radius: var(--radius-md, 6px);
font-size: 0.8125rem;
}
footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
footer button {
padding: 0.5rem 1rem;
border-radius: var(--radius-md, 6px);
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.primary {
background: var(--color-primary, hsl(230 80% 50%));
color: white;
}
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondary {
background: transparent;
border-color: var(--color-border, hsl(0 0% 88%));
color: var(--color-text, inherit);
}
.secondary:hover:not(:disabled) {
background: var(--color-surface-2, hsl(0 0% 96%));
}
</style>

View file

@ -0,0 +1,302 @@
<script lang="ts">
/**
* SpaceSwitcher — dropdown that shows the active Space and lets the
* user switch to another one or create a new Space.
*
* Minimal and self-contained so it can be dropped anywhere in the
* header without touching the PillNavigation's own config surface.
* Uses plain <button> + absolute-positioned <div> for the dropdown
* — sufficient for Phase 1; can migrate to a shared-ui Popover
* primitive later if the pattern repeats.
*/
import { getActiveSpace, loadActiveSpace, type ActiveSpace } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType } from '@mana/shared-types';
import SpaceCreateDialog from './SpaceCreateDialog.svelte';
interface Props {
/** BCP47 locale for type labels. Falls back to 'de'. */
locale?: 'de' | 'en';
}
let { locale = 'de' }: Props = $props();
let open = $state(false);
let createOpen = $state(false);
let spaces = $state<ActiveSpace[]>([]);
let loadingList = $state(false);
let switching = $state(false);
let loadError = $state<string | null>(null);
const active = $derived(getActiveSpace());
function typeLabel(type: ActiveSpace['type']): string {
return SPACE_TYPE_LABELS[locale][type];
}
async function openDropdown() {
open = true;
loadingList = true;
loadError = null;
try {
const res = await fetch('/api/auth/organization/list', { credentials: 'include' });
if (!res.ok) throw new Error(`list failed: ${res.status}`);
const raw = (await res.json()) as Array<{
id: string;
slug?: string | null;
name: string;
metadata?: unknown;
}>;
spaces = raw.map((o) => {
const meta = (o.metadata ?? {}) as { type?: unknown };
const type = isSpaceType(meta.type) ? meta.type : 'personal';
return {
id: o.id,
slug: o.slug ?? '',
name: o.name,
type,
role: 'member', // real role comes via /get-active-member; not needed for display
};
});
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loadingList = false;
}
}
async function switchTo(id: string) {
if (switching) return;
if (id === active?.id) {
open = false;
return;
}
switching = true;
try {
const res = await fetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: id }),
});
if (!res.ok) throw new Error(`set-active failed: ${res.status}`);
await loadActiveSpace({ force: true });
// Full reload so every liveQuery re-evaluates against the new active
// space. A reactive-invalidation path would require each liveQuery
// to re-subscribe on spaceId change; revisit once the scope wrapper
// is used widely enough for that to matter.
if (typeof window !== 'undefined') window.location.reload();
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
switching = false;
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onkeydown={handleKey} />
<div class="space-switcher" class:open>
<button
type="button"
class="trigger"
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open ? (open = false) : openDropdown())}
>
<span class="name">{active?.name ?? '…'}</span>
{#if active}
<span class="type-badge" data-type={active.type}>{typeLabel(active.type)}</span>
{/if}
<span class="chev" aria-hidden="true"></span>
</button>
{#if open}
<div class="dropdown" role="menu" aria-label={locale === 'de' ? 'Spaces' : 'Spaces'}>
{#if loadingList}
<div class="empty">{locale === 'de' ? 'Lädt …' : 'Loading …'}</div>
{:else if loadError}
<div class="error">{loadError}</div>
{:else if spaces.length === 0}
<div class="empty">
{locale === 'de' ? 'Keine Spaces' : 'No spaces'}
</div>
{:else}
{#each spaces as space (space.id)}
<button
type="button"
class="item"
class:active={space.id === active?.id}
aria-current={space.id === active?.id ? 'true' : undefined}
onclick={() => switchTo(space.id)}
disabled={switching}
>
<span class="item-name">{space.name}</span>
<span class="type-badge" data-type={space.type}>{typeLabel(space.type)}</span>
</button>
{/each}
{/if}
<hr />
<button
type="button"
class="item create"
onclick={() => {
open = false;
createOpen = true;
}}
>
+ {locale === 'de' ? 'Neuer Space' : 'New space'}
</button>
</div>
{/if}
</div>
<SpaceCreateDialog bind:open={createOpen} {locale} onClose={() => (createOpen = false)} />
<style>
.space-switcher {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-radius: var(--radius-md, 6px);
background: var(--color-surface-2, transparent);
border: 1px solid var(--color-border, hsl(0 0% 88%));
color: var(--color-text, inherit);
font-size: 0.875rem;
cursor: pointer;
min-width: 8rem;
transition: background-color 120ms ease;
}
.trigger:hover {
background: var(--color-surface-3, hsl(0 0% 96%));
}
.name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 10rem;
}
.chev {
font-size: 0.75rem;
opacity: 0.6;
}
.type-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm, 4px);
background: var(--color-surface-3, hsl(0 0% 92%));
color: var(--color-text-muted, hsl(0 0% 45%));
text-transform: uppercase;
letter-spacing: 0.02em;
}
.type-badge[data-type='brand'] {
background: hsl(260 70% 94%);
color: hsl(260 60% 35%);
}
.type-badge[data-type='club'] {
background: hsl(160 50% 92%);
color: hsl(160 60% 28%);
}
.type-badge[data-type='family'] {
background: hsl(30 80% 92%);
color: hsl(30 60% 35%);
}
.type-badge[data-type='team'] {
background: hsl(210 60% 92%);
color: hsl(210 60% 32%);
}
.type-badge[data-type='practice'] {
background: hsl(340 50% 92%);
color: hsl(340 55% 38%);
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 14rem;
background: var(--color-surface-1, white);
border: 1px solid var(--color-border, hsl(0 0% 88%));
border-radius: var(--radius-md, 6px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
padding: 0.25rem;
z-index: 50;
display: flex;
flex-direction: column;
gap: 2px;
}
.dropdown hr {
border: 0;
border-top: 1px solid var(--color-border, hsl(0 0% 88%));
margin: 0.25rem 0;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius-sm, 4px);
background: transparent;
border: 0;
color: var(--color-text, inherit);
font-size: 0.875rem;
text-align: left;
cursor: pointer;
text-decoration: none;
}
.item:hover:not(:disabled) {
background: var(--color-surface-2, hsl(0 0% 95%));
}
.item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.item.active {
background: var(--color-surface-2, hsl(0 0% 95%));
font-weight: 600;
}
.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item.create {
color: var(--color-primary, hsl(230 80% 50%));
}
.empty,
.error {
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-muted, hsl(0 0% 45%));
}
.error {
color: hsl(0 60% 45%);
}
</style>

View file

@ -49,6 +49,7 @@
import { useAiTierItems } from '$lib/components/layout/use-ai-tier-items.svelte';
import { useSyncStatusItems } from '$lib/components/layout/use-sync-status-items.svelte';
import RouteTierGate from '$lib/components/layout/RouteTierGate.svelte';
import SpaceSwitcher from '$lib/components/layout/SpaceSwitcher.svelte';
import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte';
import { Microphone, Stop } from '@mana/shared-icons';
import {
@ -988,6 +989,11 @@
class="pt-2"
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if authStore.isAuthenticated}
<div class="space-bar">
<SpaceSwitcher locale={$locale === 'en' ? 'en' : 'de'} />
</div>
{/if}
{#if routeBlocked && routeAppId}
<RouteTierGate
appName={routeAppId.name}
@ -1037,6 +1043,12 @@
<ToastContainer />
<style>
.space-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.bottom-stack {
position: fixed;
bottom: 0;