feat(spaces): Spaces as workbench card + canonical /spaces route

Extract member management from /spaces/members into a reusable
workbench-card ListView so users can drop the surface into any scene.

- lib/modules/spaces/ListView.svelte — hint + invite + members + pending
  invitations, all theme-token driven
- APP_ICONS.spaces icon (three-silhouette cluster, teal→indigo)
- MANA_APPS entry id=spaces (beta tier, shared-space management)
- registerApp({ id: 'spaces' }) so the card is scene-droppable
- /spaces/+page.svelte as the new canonical route wrapper
- /spaces/members/+page.svelte kept as legacy alias
- SpaceSwitcher menu now links to /spaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 18:53:03 +02:00
parent 5924f4fac3
commit 88eca8a759
7 changed files with 555 additions and 492 deletions

View file

@ -1278,6 +1278,16 @@ registerApp({
],
});
registerApp({
id: 'spaces',
name: 'Spaces',
color: '#14b8a6',
icon: UserCircle,
views: {
list: { load: () => import('$lib/modules/spaces/ListView.svelte') },
},
});
registerApp({
id: 'quiz',
name: 'Quiz',

View file

@ -211,7 +211,7 @@
<div class="menu-divider"></div>
{#if active && active.type !== 'personal'}
<a class="menu-item menu-link" href="/spaces/members" onclick={closeDropdown} role="menuitem">
<a class="menu-item menu-link" href="/spaces" onclick={closeDropdown} role="menuitem">
<span class="icon-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16">
<path

View file

@ -0,0 +1,501 @@
<!--
Spaces app — workbench card.
Manages members + invitations of the currently active Space. Personal
spaces show a hint card only; shared spaces (brand/club/family/team/
practice) show the invite form, member list, and pending invitations.
Renders equally well inside the workbench or standalone at /spaces.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { getActiveSpace, authFetch } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
interface Member {
id: string;
userId: string;
role: string;
createdAt: string;
user?: { email?: string; name?: string | null; image?: string | null };
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
expiresAt: string;
inviterId: string;
}
let members = $state<Member[]>([]);
let invitations = $state<Invitation[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let inviteEmail = $state('');
let inviteRole = $state<'owner' | 'admin' | 'member'>('member');
let inviteSubmitting = $state(false);
let inviteError = $state<string | null>(null);
let inviteSuccess = $state<string | null>(null);
const activeSpace = $derived(getActiveSpace());
const canManage = $derived(activeSpace?.role === 'owner' || activeSpace?.role === 'admin');
async function refresh() {
if (!activeSpace) return;
loading = true;
loadError = null;
try {
const [memRes, invRes] = await Promise.all([
authFetch(
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`
),
authFetch(
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`
),
]);
if (memRes.ok) {
const data = (await memRes.json()) as { members?: Member[] } | Member[];
members = Array.isArray(data) ? data : (data.members ?? []);
}
if (invRes.ok) {
const data = (await invRes.json()) as Invitation[] | { invitations?: Invitation[] };
invitations = Array.isArray(data) ? data : (data.invitations ?? []);
}
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(() => {
void refresh();
});
async function submitInvite(e: Event) {
e.preventDefault();
if (!activeSpace) return;
if (inviteSubmitting) return;
if (!inviteEmail.trim() || !inviteEmail.includes('@')) {
inviteError = 'Bitte eine gültige E-Mail angeben';
return;
}
inviteSubmitting = true;
inviteError = null;
inviteSuccess = null;
try {
const res = await authFetch('/api/auth/organization/invite-member', {
method: 'POST',
body: JSON.stringify({
email: inviteEmail.trim(),
role: inviteRole,
organizationId: activeSpace.id,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `invite failed: ${res.status}`);
}
inviteSuccess = `Einladung an ${inviteEmail.trim()} gesendet`;
inviteEmail = '';
await refresh();
} catch (err) {
inviteError = err instanceof Error ? err.message : String(err);
} finally {
inviteSubmitting = false;
}
}
async function cancelInvitation(id: string) {
const res = await authFetch('/api/auth/organization/cancel-invitation', {
method: 'POST',
body: JSON.stringify({ invitationId: id }),
});
if (res.ok) await refresh();
}
async function removeMember(userId: string) {
if (!activeSpace) return;
if (!confirm('Mitglied wirklich entfernen?')) return;
const res = await authFetch('/api/auth/organization/remove-member', {
method: 'POST',
body: JSON.stringify({ memberIdOrEmail: userId, organizationId: activeSpace.id }),
});
if (res.ok) await refresh();
}
function relativeDate(iso: string): string {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'gerade eben';
if (sec < 3600) return `vor ${Math.floor(sec / 60)} min`;
if (sec < 86400) return `vor ${Math.floor(sec / 3600)} h`;
return d.toLocaleDateString('de-DE');
}
</script>
<div class="pane">
<header class="bar">
<div class="title">
<strong>Mitglieder</strong>
{#if activeSpace}
<span class="sub">
{activeSpace.name} · {SPACE_TYPE_LABELS.de[activeSpace.type]}
</span>
{/if}
</div>
</header>
{#if !activeSpace}
<p class="empty">Lade aktiven Space …</p>
{:else if activeSpace.type === 'personal'}
<div class="hint-card">
<p>
Dein Personal-Space kann keine weiteren Mitglieder haben — er ist bewusst nur für dich. Für
geteilte Bereiche (Familie, Team, Marke, Verein) lege einen neuen Space an und lade dann
hier ein.
</p>
</div>
{:else}
{#if canManage}
<section class="panel">
<h2>Einladen</h2>
<form onsubmit={submitInvite} class="invite-form">
<input
type="email"
bind:value={inviteEmail}
placeholder="name@beispiel.de"
required
disabled={inviteSubmitting}
/>
<select bind:value={inviteRole} disabled={inviteSubmitting}>
<option value="member">Mitglied</option>
<option value="admin">Admin</option>
</select>
<button type="submit" disabled={inviteSubmitting}>
{inviteSubmitting ? 'Sende …' : 'Einladen'}
</button>
</form>
{#if inviteError}<p class="error">{inviteError}</p>{/if}
{#if inviteSuccess}<p class="success">{inviteSuccess}</p>{/if}
</section>
{/if}
<section class="panel">
<h2>Mitglieder ({members.length})</h2>
{#if loading}
<p class="empty">Lädt …</p>
{:else if loadError}
<p class="error">{loadError}</p>
{:else if members.length === 0}
<p class="empty">Nur du bist Mitglied.</p>
{:else}
<ul class="member-list">
{#each members as m (m.id)}
<li class="member-row">
<div class="member-info">
<div class="avatar" aria-hidden="true">
{(m.user?.name ?? m.user?.email ?? '?').charAt(0).toUpperCase()}
</div>
<div>
<div class="member-name">{m.user?.name ?? m.user?.email ?? m.userId}</div>
{#if m.user?.email && m.user?.name}
<div class="member-meta">{m.user.email}</div>
{/if}
</div>
</div>
<div class="member-actions">
<span class="role-badge">{m.role}</span>
{#if canManage && m.role !== 'owner'}
<button
type="button"
class="remove-btn"
onclick={() => removeMember(m.userId)}
aria-label="Entfernen"
>
Entfernen
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if invitations.length > 0}
<section class="panel">
<h2>Offene Einladungen ({invitations.length})</h2>
<ul class="invite-list">
{#each invitations.filter((i) => i.status === 'pending') as inv (inv.id)}
<li class="invite-row">
<div>
<div class="invite-email">{inv.email}</div>
<div class="invite-meta">
{inv.role} · verschickt {relativeDate(inv.expiresAt)}
</div>
</div>
{#if canManage}
<button type="button" class="remove-btn" onclick={() => cancelInvitation(inv.id)}
>Stornieren</button
>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</div>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
color: hsl(var(--color-foreground));
}
.bar {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1rem;
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground, 0 0% 50%));
}
.hint-card {
background: hsl(var(--color-muted, 0 0% 97%));
border: 1px solid hsl(var(--color-border));
border-radius: 10px;
padding: 1rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground, 0 0% 50%));
line-height: 1.5;
}
.panel {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
padding: 1.125rem;
margin-bottom: 1rem;
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
}
.panel h2 {
font-size: 0.75rem;
font-weight: 500;
margin: 0 0 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 50%));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.invite-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.invite-form input[type='email'] {
flex: 1 1 200px;
}
.invite-form input,
.invite-form select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
transition: border-color 120ms ease;
}
.invite-form input::placeholder {
color: hsl(var(--color-muted-foreground, 0 0% 50%) / 0.7);
}
.invite-form input:focus,
.invite-form select:focus {
outline: none;
border-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
box-shadow: 0 0 0 3px
color-mix(
in srgb,
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 20%,
transparent
);
}
.invite-form button {
padding: 0.5rem 1.125rem;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border: 0;
border-radius: 8px;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: filter 120ms ease;
}
.invite-form button:hover:not(:disabled) {
filter: brightness(1.05);
}
.invite-form button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error {
color: hsl(0 70% 55%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
:global(.dark) .error {
color: hsl(0 80% 72%);
}
.success {
color: hsl(140 50% 40%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
:global(.dark) .success {
color: hsl(140 60% 65%);
}
.empty {
color: hsl(var(--color-muted-foreground, 0 0% 50%));
font-size: 0.875rem;
margin: 0;
}
.member-list,
.invite-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-row,
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border-radius: 10px;
background: hsl(var(--color-muted, 0 0% 97%));
border: 1px solid hsl(var(--color-border) / 0.5);
}
.member-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
flex-shrink: 0;
}
.member-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-meta,
.invite-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 55%));
}
.invite-email {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.role-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground, 0 0% 50%));
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 500;
}
.remove-btn {
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
color: hsl(var(--color-muted-foreground, 0 0% 55%));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.remove-btn:hover {
color: hsl(0 70% 55%);
border-color: color-mix(in srgb, hsl(0 70% 55%) 40%, transparent);
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
/**
* /spaces — Spaces top-level route.
*
* Renders the workbench-card ListView from `$lib/modules/spaces/`.
* Same component runs inside the workbench via registerApp and here
* as a standalone page.
*/
import ListView from '$lib/modules/spaces/ListView.svelte';
</script>
<ListView />

View file

@ -1,498 +1,13 @@
<script lang="ts">
/**
* /spaces/members — member management for the active Space.
* /spaces/members — legacy alias.
*
* Shows who is a member, pending invitations, and an Einladen form
* that posts to Better Auth's /organization/invite-member endpoint.
* Better Auth itself sends the invitation email (via the handler
* wired in services/mana-auth/src/auth/better-auth.config.ts).
*
* First shared-space surface — dogfoods the whole multi-tenancy
* pipeline end-to-end.
* The member-management UI moved to `/spaces` as the canonical route
* (and workbench card). This path stays as a passthrough so existing
* links (SpaceSwitcher, SpaceCreateDialog) keep working.
*/
import { onMount } from 'svelte';
import { PageHeader } from '@mana/shared-ui';
import { getActiveSpace, authFetch } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
interface Member {
id: string;
userId: string;
role: string;
createdAt: string;
user?: { email?: string; name?: string | null; image?: string | null };
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
expiresAt: string;
inviterId: string;
}
let members = $state<Member[]>([]);
let invitations = $state<Invitation[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let inviteEmail = $state('');
let inviteRole = $state<'owner' | 'admin' | 'member'>('member');
let inviteSubmitting = $state(false);
let inviteError = $state<string | null>(null);
let inviteSuccess = $state<string | null>(null);
const activeSpace = $derived(getActiveSpace());
const canManage = $derived(activeSpace?.role === 'owner' || activeSpace?.role === 'admin');
async function refresh() {
if (!activeSpace) return;
loading = true;
loadError = null;
try {
const [memRes, invRes] = await Promise.all([
authFetch(
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`
),
authFetch(
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`
),
]);
if (memRes.ok) {
const data = (await memRes.json()) as { members?: Member[] } | Member[];
members = Array.isArray(data) ? data : (data.members ?? []);
}
if (invRes.ok) {
const data = (await invRes.json()) as Invitation[] | { invitations?: Invitation[] };
invitations = Array.isArray(data) ? data : (data.invitations ?? []);
}
} catch (err) {
loadError = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(() => {
void refresh();
});
async function submitInvite(e: Event) {
e.preventDefault();
if (!activeSpace) return;
if (inviteSubmitting) return;
if (!inviteEmail.trim() || !inviteEmail.includes('@')) {
inviteError = 'Bitte eine gültige E-Mail angeben';
return;
}
inviteSubmitting = true;
inviteError = null;
inviteSuccess = null;
try {
const res = await authFetch('/api/auth/organization/invite-member', {
method: 'POST',
body: JSON.stringify({
email: inviteEmail.trim(),
role: inviteRole,
organizationId: activeSpace.id,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `invite failed: ${res.status}`);
}
inviteSuccess = `Einladung an ${inviteEmail.trim()} gesendet`;
inviteEmail = '';
await refresh();
} catch (err) {
inviteError = err instanceof Error ? err.message : String(err);
} finally {
inviteSubmitting = false;
}
}
async function cancelInvitation(id: string) {
const res = await authFetch('/api/auth/organization/cancel-invitation', {
method: 'POST',
body: JSON.stringify({ invitationId: id }),
});
if (res.ok) await refresh();
}
async function removeMember(userId: string) {
if (!activeSpace) return;
if (!confirm('Mitglied wirklich entfernen?')) return;
const res = await authFetch('/api/auth/organization/remove-member', {
method: 'POST',
body: JSON.stringify({ memberIdOrEmail: userId, organizationId: activeSpace.id }),
});
if (res.ok) await refresh();
}
function relativeDate(iso: string): string {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'gerade eben';
if (sec < 3600) return `vor ${Math.floor(sec / 60)} min`;
if (sec < 86400) return `vor ${Math.floor(sec / 3600)} h`;
return d.toLocaleDateString('de-DE');
}
import ListView from '$lib/modules/spaces/ListView.svelte';
</script>
<div class="container">
<PageHeader
title="Mitglieder"
description={activeSpace
? `${activeSpace.name} · ${SPACE_TYPE_LABELS.de[activeSpace.type]}`
: undefined}
backHref="/"
size="lg"
>
{#snippet breadcrumb()}
<a href="/">Workbench</a> <span class="crumb-sep"></span> Mitglieder verwalten
{/snippet}
</PageHeader>
{#if !activeSpace}
<p class="empty">Lade aktiven Space …</p>
{:else if activeSpace.type === 'personal'}
<div class="hint-card">
<p>
Dein Personal-Space kann keine weiteren Mitglieder haben — er ist bewusst nur für dich. Für
geteilte Bereiche (Familie, Team, Marke, Verein) lege einen neuen Space an und lade dann
hier ein.
</p>
</div>
{:else}
{#if canManage}
<section class="panel">
<h2>Einladen</h2>
<form onsubmit={submitInvite} class="invite-form">
<input
type="email"
bind:value={inviteEmail}
placeholder="name@beispiel.de"
required
disabled={inviteSubmitting}
/>
<select bind:value={inviteRole} disabled={inviteSubmitting}>
<option value="member">Mitglied</option>
<option value="admin">Admin</option>
</select>
<button type="submit" disabled={inviteSubmitting}>
{inviteSubmitting ? 'Sende …' : 'Einladen'}
</button>
</form>
{#if inviteError}<p class="error">{inviteError}</p>{/if}
{#if inviteSuccess}<p class="success">{inviteSuccess}</p>{/if}
</section>
{/if}
<section class="panel">
<h2>Mitglieder ({members.length})</h2>
{#if loading}
<p class="empty">Lädt …</p>
{:else if loadError}
<p class="error">{loadError}</p>
{:else if members.length === 0}
<p class="empty">Nur du bist Mitglied.</p>
{:else}
<ul class="member-list">
{#each members as m (m.id)}
<li class="member-row">
<div class="member-info">
<div class="avatar" aria-hidden="true">
{(m.user?.name ?? m.user?.email ?? '?').charAt(0).toUpperCase()}
</div>
<div>
<div class="member-name">{m.user?.name ?? m.user?.email ?? m.userId}</div>
{#if m.user?.email && m.user?.name}
<div class="member-meta">{m.user.email}</div>
{/if}
</div>
</div>
<div class="member-actions">
<span class="role-badge">{m.role}</span>
{#if canManage && m.role !== 'owner'}
<button
type="button"
class="remove-btn"
onclick={() => removeMember(m.userId)}
aria-label="Entfernen"
>
Entfernen
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if invitations.length > 0}
<section class="panel">
<h2>Offene Einladungen ({invitations.length})</h2>
<ul class="invite-list">
{#each invitations.filter((i) => i.status === 'pending') as inv (inv.id)}
<li class="invite-row">
<div>
<div class="invite-email">{inv.email}</div>
<div class="invite-meta">
{inv.role} · verschickt {relativeDate(inv.expiresAt)}
</div>
</div>
{#if canManage}
<button type="button" class="remove-btn" onclick={() => cancelInvitation(inv.id)}
>Stornieren</button
>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</div>
<style>
/* Theme-token driven — mirrors @mana/shared-ui Pill so panels and
inputs follow whatever theme is active (light / dark / variants)
instead of falling back to hardcoded white. */
.container {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem 4rem;
color: hsl(var(--color-foreground));
}
.crumb-sep {
margin: 0 0.25rem;
opacity: 0.5;
}
.hint-card {
background: hsl(var(--color-muted, 0 0% 97%));
border: 1px solid hsl(var(--color-border));
border-radius: 10px;
padding: 1rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground, 0 0% 50%));
line-height: 1.5;
}
.panel {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
padding: 1.125rem;
margin-bottom: 1rem;
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.04);
}
.panel h2 {
font-size: 0.75rem;
font-weight: 500;
margin: 0 0 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 50%));
text-transform: uppercase;
letter-spacing: 0.04em;
}
.invite-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.invite-form input[type='email'] {
flex: 1 1 200px;
}
.invite-form input,
.invite-form select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-input, var(--color-background, var(--color-card))));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
transition: border-color 120ms ease;
}
.invite-form input::placeholder {
color: hsl(var(--color-muted-foreground, 0 0% 50%) / 0.7);
}
.invite-form input:focus,
.invite-form select:focus {
outline: none;
border-color: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
box-shadow: 0 0 0 3px
color-mix(
in srgb,
var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%))) 20%,
transparent
);
}
.invite-form button {
padding: 0.5rem 1.125rem;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border: 0;
border-radius: 8px;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: filter 120ms ease;
}
.invite-form button:hover:not(:disabled) {
filter: brightness(1.05);
}
.invite-form button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.error {
color: hsl(0 70% 55%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
:global(.dark) .error {
color: hsl(0 80% 72%);
}
.success {
color: hsl(140 50% 40%);
font-size: 0.8125rem;
margin: 0.5rem 0 0;
}
:global(.dark) .success {
color: hsl(140 60% 65%);
}
.empty {
color: hsl(var(--color-muted-foreground, 0 0% 50%));
font-size: 0.875rem;
margin: 0;
}
.member-list,
.invite-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-row,
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border-radius: 10px;
background: hsl(var(--color-muted, 0 0% 97%));
border: 1px solid hsl(var(--color-border) / 0.5);
}
.member-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--pill-primary-color, hsl(var(--color-primary, 230 80% 55%)));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
flex-shrink: 0;
}
.member-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-meta,
.invite-meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground, 0 0% 55%));
}
.invite-email {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.role-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground, 0 0% 50%));
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 500;
}
.remove-btn {
padding: 0.25rem 0.625rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
color: hsl(var(--color-muted-foreground, 0 0% 55%));
font: inherit;
font-size: 0.75rem;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.remove-btn:hover {
color: hsl(0 70% 55%);
border-color: color-mix(in srgb, hsl(0 70% 55%) 40%, transparent);
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
}
</style>
<ListView />

View file

@ -260,6 +260,13 @@ export const APP_ICONS = {
// while still reading as "chronological" in the AI Workbench family.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="tl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#ea580c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#tl)"/><line x1="34" y1="22" x2="34" y2="78" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.55"/><circle cx="34" cy="30" r="5" fill="white"/><circle cx="34" cy="50" r="5" fill="white"/><circle cx="34" cy="70" r="5" fill="white"/><rect x="44" y="26" width="32" height="8" rx="2" fill="white" fill-opacity="0.95"/><rect x="44" y="46" width="26" height="8" rx="2" fill="white" fill-opacity="0.8"/><rect x="44" y="66" width="30" height="8" rx="2" fill="white" fill-opacity="0.9"/></svg>`
),
spaces: svgToDataUrl(
// Three people-silhouettes clustered in the tile — the Spaces primitive
// is about shared workspaces, so the icon emphasises "group". Teal→indigo
// gradient sits next to contacts (green) and chat (indigo) in the
// communication family without competing with either.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="sp" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#14b8a6"/><stop offset="100%" style="stop-color:#6366f1"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#sp)"/><circle cx="30" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M14 70c0-9 7-16 16-16s16 7 16 16v4H14v-4z" fill="white" fill-opacity="0.85"/><circle cx="70" cy="38" r="9" fill="white" fill-opacity="0.85"/><path d="M54 70c0-9 7-16 16-16s16 7 16 16v4H54v-4z" fill="white" fill-opacity="0.85"/><circle cx="50" cy="32" r="11" fill="white"/><path d="M30 76c0-11 9-20 20-20s20 9 20 20v4H30v-4z" fill="white"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -1105,6 +1105,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'beta',
},
{
id: 'spaces',
name: 'Spaces',
description: {
de: 'Geteilte Bereiche & Mitglieder',
en: 'Shared spaces & members',
},
longDescription: {
de: 'Verwalte den aktiven Space und seine Mitglieder — lade Personen per E-Mail ein, vergib Rollen (Admin/Mitglied) und widerrufe offene Einladungen. Personal-Spaces zeigen nur den Hinweis, dass sie bewusst nur für dich sind; geteilte Spaces (Familie, Team, Marke, Verein, Praxis) bekommen das volle Member-Management.',
en: 'Manage the active Space and its members — invite people by email, assign roles (admin/member) and revoke pending invitations. Personal spaces are single-user by design; shared spaces (family, team, brand, club, practice) get full member management.',
},
icon: APP_ICONS.spaces,
color: '#14b8a6',
comingSoon: false,
status: 'beta',
requiredTier: 'beta',
},
];
/**