mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
9df908d838
commit
3bace08b7a
6 changed files with 818 additions and 2 deletions
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
143
apps/calendar/apps/web/src/lib/stores/shares.svelte.ts
Normal file
143
apps/calendar/apps/web/src/lib/stores/shares.svelte.ts
Normal 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 = [];
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue