mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 15:19:40 +02:00
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:
parent
871a1c3bba
commit
b878ecfe1c
3 changed files with 715 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue