mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(manacore/web): expand EventDetailModal with full feature set
Complete rewrite of the calendar event detail modal: - Color accent bar matching calendar color - Date with weekday + time range + duration display - Recurrence-aware delete dialog (this event / entire series) - Tags display - Copy to clipboard button - Created/updated metadata - Cleaner layout with better typography Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ece7d31b8
commit
5a7bc5e10d
1 changed files with 301 additions and 73 deletions
|
|
@ -13,8 +13,12 @@
|
|||
ArrowsClockwise,
|
||||
MapPin,
|
||||
TextAlignLeft,
|
||||
Tag,
|
||||
ShareNetwork,
|
||||
Copy,
|
||||
Check,
|
||||
} from '@manacore/shared-icons';
|
||||
import { format } from 'date-fns';
|
||||
import { format, differenceInDays, differenceInHours, differenceInMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -27,21 +31,42 @@
|
|||
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
||||
|
||||
let isEditing = $state(false);
|
||||
let showDeleteOptions = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
let calendarName = $derived(getCalendarById(calendarsCtx.value, event.calendarId)?.name);
|
||||
let calendarColor = $derived(getCalendarColor(calendarsCtx.value, event.calendarId));
|
||||
let isRecurring = $derived(!!event.recurrenceRule);
|
||||
let hasParent = $derived(!!event.parentEventId);
|
||||
|
||||
// Format time display
|
||||
function formatEventTime(ev: CalendarEvent): string {
|
||||
if (ev.isAllDay) return 'Ganztägig';
|
||||
const start = toDate(ev.startTime);
|
||||
const end = toDate(ev.endTime);
|
||||
return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`;
|
||||
const dateStr = format(start, 'EEEE, d. MMMM yyyy', { locale: de });
|
||||
const timeStr = `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
return `${dateStr}\n${timeStr}`;
|
||||
}
|
||||
|
||||
// Format duration
|
||||
function formatDuration(ev: CalendarEvent): string {
|
||||
if (ev.isAllDay) return '';
|
||||
const start = toDate(ev.startTime);
|
||||
const end = toDate(ev.endTime);
|
||||
const mins = differenceInMinutes(end, start);
|
||||
if (mins < 60) return `${mins} Min.`;
|
||||
const hours = differenceInHours(end, start);
|
||||
const remainMins = mins % 60;
|
||||
if (remainMins === 0) return `${hours} Std.`;
|
||||
return `${hours} Std. ${remainMins} Min.`;
|
||||
}
|
||||
|
||||
function formatRecurrence(rule: string): string {
|
||||
if (!rule) return '';
|
||||
if (rule.includes('FREQ=DAILY')) return 'Täglich';
|
||||
if (rule.includes('FREQ=WEEKLY')) {
|
||||
if (rule.includes('INTERVAL=2')) return 'Alle 2 Wochen';
|
||||
if (rule.includes('BYDAY=')) {
|
||||
const days = rule.match(/BYDAY=([A-Z,]+)/)?.[1];
|
||||
if (days) {
|
||||
|
|
@ -54,11 +79,10 @@
|
|||
SA: 'Sa',
|
||||
SU: 'So',
|
||||
};
|
||||
const translatedDays = days
|
||||
return `Wöchentlich (${days
|
||||
.split(',')
|
||||
.map((d) => dayMap[d] || d)
|
||||
.join(', ');
|
||||
return `Wöchentlich (${translatedDays})`;
|
||||
.join(', ')})`;
|
||||
}
|
||||
}
|
||||
return 'Wöchentlich';
|
||||
|
|
@ -73,18 +97,53 @@
|
|||
isEditing = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) return;
|
||||
await eventsStore.deleteEvent(event.id);
|
||||
async function handleDelete(mode: 'this' | 'all') {
|
||||
if (mode === 'this') {
|
||||
await eventsStore.deleteEvent(event.id);
|
||||
} else {
|
||||
// Delete all: if this has a parent, delete parent; otherwise delete this
|
||||
const targetId = event.parentEventId || event.id;
|
||||
await eventsStore.deleteEvent(targetId);
|
||||
}
|
||||
showDeleteOptions = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
if (isRecurring || hasParent) {
|
||||
showDeleteOptions = true;
|
||||
} else {
|
||||
if (confirm('Diesen Termin löschen?')) {
|
||||
eventsStore.deleteEvent(event.id);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
const start = toDate(event.startTime);
|
||||
const text = [
|
||||
event.title,
|
||||
event.isAllDay ? 'Ganztägig' : `${format(start, 'dd.MM.yyyy HH:mm')}`,
|
||||
event.location ? `Ort: ${event.location}` : '',
|
||||
event.description || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (showDeleteOptions) showDeleteOptions = false;
|
||||
else onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -93,19 +152,36 @@
|
|||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<!-- Color accent bar -->
|
||||
<div class="accent-bar" style="background-color: {calendarColor};"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title" class="modal-title">
|
||||
{isEditing ? 'Termin bearbeiten' : event.title}
|
||||
</h2>
|
||||
<div class="header-left">
|
||||
<h2 id="modal-title" class="modal-title">
|
||||
{isEditing ? 'Termin bearbeiten' : event.title}
|
||||
</h2>
|
||||
{#if !isEditing && calendarName}
|
||||
<span class="calendar-badge">
|
||||
<span class="calendar-dot" style="background-color: {calendarColor}"></span>
|
||||
{calendarName}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
{#if !isEditing}
|
||||
<button class="btn btn-ghost" onclick={() => (isEditing = true)}>
|
||||
<PencilSimple size={16} />
|
||||
Bearbeiten
|
||||
<button class="btn btn-ghost" onclick={copyToClipboard} title="Kopieren">
|
||||
{#if copied}<Check size={16} />{:else}<Copy size={16} />{/if}
|
||||
</button>
|
||||
<button class="btn btn-ghost text-destructive" onclick={handleDelete}>
|
||||
<button class="btn btn-ghost" onclick={() => (isEditing = true)} title="Bearbeiten">
|
||||
<PencilSimple size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost text-destructive"
|
||||
onclick={handleDeleteClick}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash size={16} />
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-close" onclick={onClose} aria-label="Schließen">
|
||||
|
|
@ -114,6 +190,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-content">
|
||||
{#if isEditing}
|
||||
<EventForm
|
||||
|
|
@ -124,61 +201,102 @@
|
|||
/>
|
||||
{:else}
|
||||
<div class="event-details">
|
||||
{#if calendarName}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<span class="calendar-dot" style="background-color: {calendarColor}"></span>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Kalender</span>
|
||||
<span class="detail-value">{calendarName}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time -->
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon"><Clock size={20} /></span>
|
||||
<span class="detail-icon"><Clock size={18} /></span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Zeit</span>
|
||||
<span class="detail-value">{formatEventTime(event)}</span>
|
||||
<span class="detail-value time-value">{formatEventTime(event)}</span>
|
||||
{#if !event.isAllDay}
|
||||
<span class="detail-meta">{formatDuration(event)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
{#if event.recurrenceRule}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon"><ArrowsClockwise size={20} /></span>
|
||||
<span class="detail-icon"><ArrowsClockwise size={18} /></span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Wiederholung</span>
|
||||
<span class="detail-value">{formatRecurrence(event.recurrenceRule)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Location -->
|
||||
{#if event.location}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon"><MapPin size={20} /></span>
|
||||
<span class="detail-icon"><MapPin size={18} /></span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Ort</span>
|
||||
<span class="detail-value">{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Description -->
|
||||
{#if event.description}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon"><TextAlignLeft size={20} /></span>
|
||||
<span class="detail-icon"><TextAlignLeft size={18} /></span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Beschreibung</span>
|
||||
<span class="detail-value description">{event.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if event.tagIds && event.tagIds.length > 0}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon"><Tag size={18} /></span>
|
||||
<div class="detail-content">
|
||||
<div class="tag-list">
|
||||
{#each event.tagIds as tagId}
|
||||
<span class="tag-badge">{tagId}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="detail-meta-row">
|
||||
<span
|
||||
>Erstellt: {format(new Date(event.createdAt), 'dd. MMM yyyy', { locale: de })}</span
|
||||
>
|
||||
{#if event.updatedAt !== event.createdAt}
|
||||
<span
|
||||
>· Bearbeitet: {format(new Date(event.updatedAt), 'dd. MMM yyyy', {
|
||||
locale: de,
|
||||
})}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence Delete Dialog -->
|
||||
{#if showDeleteOptions}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="delete-overlay" onclick={() => (showDeleteOptions = false)}>
|
||||
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="delete-title">Wiederkehrenden Termin löschen</h3>
|
||||
<p class="delete-text">Möchtest du nur diesen Termin oder die gesamte Serie löschen?</p>
|
||||
<div class="delete-actions">
|
||||
<button class="btn btn-outline" onclick={() => handleDelete('this')}>
|
||||
Nur diesen Termin
|
||||
</button>
|
||||
<button class="btn btn-destructive" onclick={() => handleDelete('all')}>
|
||||
Alle Termine der Serie
|
||||
</button>
|
||||
<button class="btn btn-ghost" onclick={() => (showDeleteOptions = false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
|
|
@ -204,10 +322,10 @@
|
|||
.modal-container {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
|
@ -226,44 +344,67 @@
|
|||
}
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.calendar-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.calendar-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.event-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
|
|
@ -285,31 +426,58 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
.detail-value.time-value {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.detail-value.description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.detail-meta-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
|
|
@ -326,7 +494,7 @@
|
|||
}
|
||||
|
||||
.btn-close {
|
||||
padding: 0.5rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
|
|
@ -337,11 +505,71 @@
|
|||
background: hsl(var(--color-error, 0 84% 60%) / 0.1);
|
||||
}
|
||||
|
||||
.calendar-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
color: hsl(var(--color-foreground));
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
background: hsl(var(--color-error, 0 84% 60%));
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-destructive:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Delete dialog */
|
||||
.delete-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 110;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.delete-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.delete-text {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue