feat(calendar): add calendar sharing UI and service error boundaries

Calendar Sharing:
- Add shares store with full lifecycle (share, accept/decline, remove, permissions)
- Add missing API methods (getInvitations, getSharedWithMe)
- Create /settings/sharing page with:
  - Pending invitations section with accept/decline
  - "Shared with me" section
  - Per-calendar share management (expandable list, add/remove shares)
  - Share modal with email, permission selection
- Add link from main settings page

Error Boundaries:
- Create ServiceStatusBanner component for graceful degradation
- Integrate into main calendar page for Todo and Birthday services
- Shows warning when service unavailable with retry button
- Uses existing serviceAvailable flags from stores

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 21:11:15 +01:00
parent 9df908d838
commit 3bace08b7a
6 changed files with 818 additions and 2 deletions

View file

@ -35,8 +35,16 @@ export async function updateShare(shareId: string, data: UpdateShareInput) {
});
}
export async function deleteShare(shareId: string) {
return fetchApi<void>(`/shares/${shareId}`, {
export async function deleteShare(calendarId: string, shareId: string) {
return fetchApi<void>(`/calendars/${calendarId}/shares/${shareId}`, {
method: 'DELETE',
});
}
export async function getInvitations() {
return fetchApi<CalendarShare[]>('/shares/invitations');
}
export async function getSharedWithMe() {
return fetchApi<CalendarShare[]>('/shares/shared-with-me');
}

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { Warning, ArrowsClockwise } from '@manacore/shared-icons';
interface Props {
serviceName: string;
available: boolean;
error?: string | null;
onRetry?: () => void;
}
let { serviceName, available, error = null, onRetry }: Props = $props();
</script>
{#if !available}
<div class="service-banner" role="alert">
<div class="banner-content">
<Warning size={16} />
<span>
{serviceName} ist nicht erreichbar
{#if error}
<span class="error-detail">({error})</span>
{/if}
</span>
</div>
{#if onRetry}
<button class="retry-btn" onclick={onRetry}>
<ArrowsClockwise size={14} />
Erneut versuchen
</button>
{/if}
</div>
{/if}
<style>
.service-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: hsl(38 92% 50% / 0.1);
border: 1px solid hsl(38 92% 50% / 0.3);
border-radius: 0.5rem;
font-size: 0.8125rem;
color: hsl(38 92% 50%);
}
.banner-content {
display: flex;
align-items: center;
gap: 0.375rem;
}
.error-detail {
opacity: 0.7;
font-size: 0.75rem;
}
.retry-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid hsl(38 92% 50% / 0.3);
background: transparent;
color: hsl(38 92% 50%);
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.retry-btn:hover {
background: hsl(38 92% 50% / 0.15);
}
</style>

View file

@ -0,0 +1,143 @@
/**
* Calendar Shares Store - Manages calendar sharing and invitations
*/
import type { CalendarShare, CalendarShareWithDetails } from '@calendar/shared';
import * as api from '$lib/api/shares';
import { toastStore } from '@manacore/shared-ui';
// State
let shares = $state<Map<string, CalendarShare[]>>(new Map());
let invitations = $state<CalendarShare[]>([]);
let sharedWithMe = $state<CalendarShare[]>([]);
let loading = $state(false);
export const sharesStore = {
get loading() {
return loading;
},
get invitations() {
return invitations;
},
get sharedWithMe() {
return sharedWithMe;
},
getSharesForCalendar(calendarId: string): CalendarShare[] {
return shares.get(calendarId) || [];
},
async fetchSharesForCalendar(calendarId: string) {
const result = await api.getShares(calendarId);
if (result.data) {
const arr = Array.isArray(result.data) ? result.data : [];
shares = new Map(shares).set(calendarId, arr);
}
return result;
},
async fetchInvitations() {
const result = await api.getInvitations();
if (result.data) {
invitations = Array.isArray(result.data) ? result.data : [];
}
return result;
},
async fetchSharedWithMe() {
const result = await api.getSharedWithMe();
if (result.data) {
sharedWithMe = Array.isArray(result.data) ? result.data : [];
}
return result;
},
async shareCalendar(calendarId: string, email: string, permission: 'read' | 'write' | 'admin') {
const result = await api.createShare(calendarId, { calendarId, email, permission });
if (result.error) {
toastStore.error(`Freigabe fehlgeschlagen: ${result.error.message}`);
} else {
toastStore.success(`Kalender mit ${email} geteilt`);
await this.fetchSharesForCalendar(calendarId);
}
return result;
},
async createShareLink(calendarId: string, permission: 'read' | 'write') {
const result = await api.createShare(calendarId, {
calendarId,
permission,
createLink: true,
});
if (result.error) {
toastStore.error(`Link-Erstellung fehlgeschlagen: ${result.error.message}`);
} else {
toastStore.success('Freigabe-Link erstellt');
await this.fetchSharesForCalendar(calendarId);
}
return result;
},
async acceptInvitation(shareId: string) {
const result = await api.acceptShare(shareId);
if (result.error) {
toastStore.error(`Annahme fehlgeschlagen: ${result.error.message}`);
} else {
toastStore.success('Einladung angenommen');
invitations = invitations.filter((i) => i.id !== shareId);
await this.fetchSharedWithMe();
}
return result;
},
async declineInvitation(shareId: string) {
const result = await api.declineShare(shareId);
if (result.error) {
toastStore.error(`Ablehnung fehlgeschlagen: ${result.error.message}`);
} else {
invitations = invitations.filter((i) => i.id !== shareId);
}
return result;
},
async removeShare(calendarId: string, shareId: string) {
const result = await api.deleteShare(calendarId, shareId);
if (result.error) {
toastStore.error(`Entfernen fehlgeschlagen: ${result.error.message}`);
} else {
toastStore.success('Freigabe entfernt');
const current = shares.get(calendarId) || [];
shares = new Map(shares).set(
calendarId,
current.filter((s) => s.id !== shareId)
);
}
return result;
},
async updatePermission(shareId: string, permission: 'read' | 'write' | 'admin') {
const result = await api.updateShare(shareId, { permission });
if (result.error) {
toastStore.error(`Aktualisierung fehlgeschlagen: ${result.error.message}`);
}
return result;
},
clear() {
shares = new Map();
invitations = [];
sharedWithMe = [];
},
};

View file

@ -5,9 +5,12 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import ServiceStatusBanner from '$lib/components/ServiceStatusBanner.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import type { CalendarEvent } from '@calendar/shared';
import { addMinutes } from 'date-fns';
@ -163,6 +166,23 @@
<title>{$_('app.name')}</title>
</svelte:head>
<div class="service-banners">
<ServiceStatusBanner
serviceName="Todo-Service"
available={todosStore.serviceAvailable}
error={todosStore.error}
onRetry={() => todosStore.fetchTodos()}
/>
{#if settingsStore.showBirthdays}
<ServiceStatusBanner
serviceName="Geburtstage (Kontakte)"
available={birthdaysStore.serviceAvailable}
error={birthdaysStore.error}
onRetry={() => birthdaysStore.fetchBirthdays(true)}
/>
{/if}
</div>
<div class="calendar-layout">
<!-- Desktop: Left Sidebar -->
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
@ -401,4 +421,15 @@
width: 220px;
}
}
.service-banners {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0 0.5rem;
}
.service-banners:empty {
display: none;
}
</style>

View file

@ -385,6 +385,33 @@
</SettingsCard>
</SettingsSection>
<!-- Kalender-Freigaben -->
<SettingsSection title="Kalender-Freigaben">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{/snippet}
<SettingsCard>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Teile Kalender mit anderen Nutzern oder verwalte Einladungen.
</p>
<a
href="/settings/sharing"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors self-start"
>
Freigaben verwalten
</a>
</div>
</SettingsCard>
</SettingsSection>
<!-- Global App Settings (synced across all apps) -->
<GlobalSettingsSection
{userSettings}

View file

@ -0,0 +1,531 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { sharesStore } from '$lib/stores/shares.svelte';
import {
CaretLeft,
Plus,
Trash,
UserPlus,
Users,
Link,
CheckCircle,
X,
EnvelopeSimple,
} from '@manacore/shared-icons';
import { Modal, Input } from '@manacore/shared-ui';
import { PERMISSION_DESCRIPTIONS, type SharePermission } from '@calendar/shared';
// Share form state
let showShareForm = $state(false);
let selectedCalendarId = $state('');
let shareEmail = $state('');
let sharePermission = $state<SharePermission>('read');
let isSharing = $state(false);
// Active calendar for viewing shares
let viewingCalendarId = $state<string | null>(null);
const PERMISSION_LABELS: Record<SharePermission, string> = {
read: 'Lesen',
write: 'Lesen & Bearbeiten',
admin: 'Administrator',
};
async function handleShare() {
if (!shareEmail.trim() || !selectedCalendarId) return;
isSharing = true;
await sharesStore.shareCalendar(selectedCalendarId, shareEmail.trim(), sharePermission);
isSharing = false;
showShareForm = false;
shareEmail = '';
}
async function handleRemoveShare(calendarId: string, shareId: string) {
if (!confirm('Freigabe wirklich entfernen?')) return;
await sharesStore.removeShare(calendarId, shareId);
}
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
await Promise.all([
calendarsStore.calendars.length === 0 ? calendarsStore.fetchCalendars() : Promise.resolve(),
sharesStore.fetchInvitations(),
sharesStore.fetchSharedWithMe(),
]);
});
</script>
<svelte:head>
<title>Kalender-Freigaben - Einstellungen</title>
</svelte:head>
<div class="page-container">
<header class="header">
<a href="/settings" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Freigaben</h1>
<button onclick={() => (showShareForm = true)} class="add-button" aria-label="Kalender teilen">
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Pending Invitations -->
{#if sharesStore.invitations.length > 0}
<section class="section">
<h2 class="section-title">
<EnvelopeSimple size={18} />
Einladungen ({sharesStore.invitations.length})
</h2>
{#each sharesStore.invitations as invite (invite.id)}
<div class="share-card">
<div class="share-info">
<span class="share-name">Kalender-Einladung</span>
<span class="share-detail">
{PERMISSION_LABELS[invite.permission]} Zugriff
</span>
</div>
<div class="share-actions">
<button
class="btn btn-sm btn-primary"
onclick={() => sharesStore.acceptInvitation(invite.id)}
>
<CheckCircle size={14} />
Annehmen
</button>
<button
class="btn btn-sm btn-ghost"
onclick={() => sharesStore.declineInvitation(invite.id)}
>
<X size={14} />
</button>
</div>
</div>
{/each}
</section>
{/if}
<!-- Shared With Me -->
{#if sharesStore.sharedWithMe.length > 0}
<section class="section">
<h2 class="section-title">
<Users size={18} />
Mit mir geteilt
</h2>
{#each sharesStore.sharedWithMe as share (share.id)}
<div class="share-card">
<div class="share-info">
<span class="share-name">Geteilter Kalender</span>
<span class="share-detail">{PERMISSION_LABELS[share.permission]}</span>
</div>
</div>
{/each}
</section>
{/if}
<!-- My Calendars & Their Shares -->
<section class="section">
<h2 class="section-title">
<UserPlus size={18} />
Meine Kalender teilen
</h2>
{#each calendarsStore.calendars as calendar (calendar.id)}
<div class="calendar-card">
<button
class="calendar-header"
onclick={() => {
if (viewingCalendarId === calendar.id) {
viewingCalendarId = null;
} else {
viewingCalendarId = calendar.id;
sharesStore.fetchSharesForCalendar(calendar.id);
}
}}
>
<div class="calendar-info">
<div class="calendar-color" style="background-color: {calendar.color}"></div>
<span>{calendar.name}</span>
</div>
<span class="expand-icon">{viewingCalendarId === calendar.id ? '▾' : '▸'}</span>
</button>
{#if viewingCalendarId === calendar.id}
{@const calShares = sharesStore.getSharesForCalendar(calendar.id)}
<div class="shares-list">
{#if calShares.length === 0}
<p class="empty-text">Noch nicht geteilt</p>
{:else}
{#each calShares as share (share.id)}
<div class="share-item">
<div class="share-item-info">
<span class="share-email">
{share.sharedWithEmail || 'Link-Freigabe'}
</span>
<span class="share-permission">
{PERMISSION_LABELS[share.permission]}
</span>
{#if share.status === 'pending'}
<span class="share-status pending">Ausstehend</span>
{/if}
</div>
<button
class="remove-btn"
onclick={() => handleRemoveShare(calendar.id, share.id)}
title="Freigabe entfernen"
>
<Trash size={14} />
</button>
</div>
{/each}
{/if}
<button
class="add-share-btn"
onclick={() => {
selectedCalendarId = calendar.id;
showShareForm = true;
}}
>
<Plus size={14} />
Person hinzufügen
</button>
</div>
{/if}
</div>
{/each}
</section>
</div>
<!-- Share Modal -->
<Modal
visible={showShareForm}
onClose={() => (showShareForm = false)}
title="Kalender teilen"
maxWidth="sm"
>
<div class="share-form">
<div class="form-field">
<label>Kalender</label>
<select bind:value={selectedCalendarId} class="select-input">
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
<div class="form-field">
<label>E-Mail-Adresse</label>
<Input bind:value={shareEmail} placeholder="name@example.com" />
</div>
<div class="form-field">
<label>Berechtigung</label>
<select bind:value={sharePermission} class="select-input">
<option value="read">{PERMISSION_LABELS.read}</option>
<option value="write">{PERMISSION_LABELS.write}</option>
<option value="admin">{PERMISSION_LABELS.admin}</option>
</select>
</div>
</div>
{#snippet footer()}
<div class="modal-footer">
<button class="btn btn-secondary" onclick={() => (showShareForm = false)}>Abbrechen</button>
<button
class="btn btn-primary"
onclick={handleShare}
disabled={isSharing || !shareEmail.trim() || !selectedCalendarId}
>
{isSharing ? 'Teile...' : 'Teilen'}
</button>
</div>
{/snippet}
</Modal>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
}
.section {
margin-bottom: 1.5rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.75rem;
}
.share-card,
.calendar-card {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--card));
margin-bottom: 0.5rem;
overflow: hidden;
}
.share-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.share-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.share-name {
font-weight: 500;
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.share-detail {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.share-actions {
display: flex;
gap: 0.375rem;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.calendar-header:hover {
background: hsl(var(--muted) / 0.3);
}
.calendar-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.calendar-color {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
}
.expand-icon {
color: hsl(var(--muted-foreground));
}
.shares-list {
padding: 0 1rem 0.75rem;
border-top: 1px solid hsl(var(--border));
}
.share-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.share-item + .share-item {
border-top: 1px solid hsl(var(--border) / 0.5);
}
.share-item-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.share-email {
color: hsl(var(--foreground));
}
.share-permission {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
}
.share-status.pending {
font-size: 0.6875rem;
color: hsl(38 92% 50%);
font-weight: 500;
}
.empty-text {
padding: 0.75rem 0;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
.remove-btn {
display: flex;
padding: 0.375rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
}
.remove-btn:hover {
color: hsl(0 84% 60%);
background: hsl(0 84% 60% / 0.1);
}
.add-share-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0;
border: none;
background: transparent;
color: hsl(var(--primary));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.add-share-btn:hover {
text-decoration: underline;
}
/* Form */
.share-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-field label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.select-input {
padding: 0.625rem 0.875rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-secondary {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.btn-ghost {
background: transparent;
color: hsl(var(--muted-foreground));
}
.btn-ghost:hover {
background: hsl(var(--muted));
}
</style>