feat(calendar): add event context menu and fix event persistence

- Add reusable ContextMenu component to shared-ui with icon support
- Create EventContextMenu for calendar with actions:
  - Edit, Duplicate, Change Calendar, Change Color
  - Export to .ics, Delete with confirmation
- Integrate context menu in DayView, WeekView, and AgendaView
- Fix event creation not showing success/error feedback
- Fix events store to properly handle API response format
- Add API logging for debugging event operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-13 14:24:08 +01:00
parent cc37db8072
commit 10d4170ee8
10 changed files with 287 additions and 14 deletions

View file

@ -58,7 +58,10 @@ export function createApiClient(config: ApiClientConfig) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
const url = `${baseUrl}${apiPrefix}${endpoint}`;
console.log(`[API Client] ${method} ${url}`, { hasToken: !!authToken });
const response = await fetch(url, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,

View file

@ -23,7 +23,14 @@ export async function getEvents(params: QueryEventsParams) {
if (params.search) {
searchParams.set('search', params.search);
}
console.log('[Calendar API] Fetching events:', params);
const result = await fetchApi<{ events: CalendarEvent[] }>(`/events?${searchParams.toString()}`);
console.log(
'[Calendar API] Fetch events result:',
result.data?.events?.length,
'events',
result.error
);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
@ -57,11 +64,14 @@ export async function getEventsByCalendar(calendarId: string) {
}
export async function createEvent(data: CreateEventInput) {
console.log('[Calendar API] Creating event:', data);
const result = await fetchApi<{ event: CalendarEvent }>('/events', {
method: 'POST',
body: data,
});
console.log('[Calendar API] Create event result:', result);
if (result.error || !result.data) {
console.error('[Calendar API] Create event failed:', result.error);
return { data: null, error: result.error };
}
return { data: result.data.event, error: null };

View file

@ -2,6 +2,8 @@
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '@calendar/shared';
@ -65,6 +67,18 @@
onEventClick(event);
}
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
eventContextMenuStore.show(event, e.clientX, e.clientY);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
</script>
<div class="agenda-view">
@ -90,7 +104,11 @@
<div class="events-for-date">
{#each group.events as event}
<button class="event-item" onclick={() => handleEventClick(event)}>
<button
class="event-item"
onclick={() => handleEventClick(event)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
@ -154,6 +172,8 @@
{/if}
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<style>
.agenda-view {
padding: 1rem;

View file

@ -5,7 +5,9 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -719,6 +721,20 @@
goto(`/event/new?start=${startTime.toISOString()}`);
}
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
// Don't show context menu for draft events
if (eventsStore.isDraftEvent(event.id)) return;
eventContextMenuStore.show(event, e.clientX, e.clientY);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
</script>
<div class="day-view">
@ -808,6 +824,7 @@
: getEventStyle(event)}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
role="button"
tabindex="0"
>
@ -910,6 +927,9 @@
</div>
</div>
<!-- Event Context Menu -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<style>
.day-view {
display: flex;

View file

@ -5,7 +5,9 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -301,6 +303,19 @@
}
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (eventsStore.isDraftEvent(event.id)) return;
eventContextMenuStore.show(event, e.clientX, e.clientY);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
// ========== Drag & Drop Functions ==========
function getDayFromX(clientX: number): Date | null {
@ -970,6 +985,7 @@
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
>
<!-- Top resize handle -->
<div
@ -1086,6 +1102,8 @@
</div>
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<style>
.week-view {
display: flex;

View file

@ -0,0 +1,198 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { Pencil, Copy, Trash, Palette, CalendarBlank, Export } from '@manacore/shared-icons';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onEdit?: (event: CalendarEvent) => void;
}
let { onEdit }: Props = $props();
// Build menu items based on target event
let menuItems = $derived.by((): ContextMenuItem[] => {
const event = eventContextMenuStore.targetEvent;
if (!event) return [];
return [
{
id: 'edit',
label: 'Bearbeiten',
icon: Pencil,
shortcut: 'E',
action: () => handleEdit(),
},
{
id: 'duplicate',
label: 'Duplizieren',
icon: Copy,
shortcut: 'D',
action: () => handleDuplicate(),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'change-calendar',
label: 'Kalender wechseln',
icon: CalendarBlank,
action: () => handleChangeCalendar(),
disabled: calendarsStore.calendars.length <= 1,
},
{
id: 'change-color',
label: 'Farbe ändern',
icon: Palette,
action: () => handleChangeColor(),
},
{
id: 'export',
label: 'Exportieren (.ics)',
icon: Export,
action: () => handleExport(),
},
{
id: 'divider-2',
label: '',
type: 'divider',
},
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger',
action: () => handleDelete(),
},
];
});
function handleEdit() {
const event = eventContextMenuStore.targetEvent;
if (event && onEdit) {
onEdit(event);
}
}
async function handleDuplicate() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
try {
await eventsStore.createEvent({
calendarId: event.calendarId,
title: `${event.title} (Kopie)`,
description: event.description ?? undefined,
location: event.location ?? undefined,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
color: event.color ?? undefined,
});
toastStore.success('Termin dupliziert');
} catch (error) {
console.error('Error duplicating event:', error);
toastStore.error('Fehler beim Duplizieren');
}
}
function handleChangeCalendar() {
// TODO: Implement calendar picker modal
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// For now, cycle through calendars
const calendars = calendarsStore.calendars;
const currentIndex = calendars.findIndex((c) => c.id === event.calendarId);
const nextIndex = (currentIndex + 1) % calendars.length;
const nextCalendar = calendars[nextIndex];
if (nextCalendar) {
eventsStore.updateEvent(event.id, { calendarId: nextCalendar.id });
toastStore.success(`Verschoben nach "${nextCalendar.name}"`);
}
}
function handleChangeColor() {
// TODO: Implement color picker modal
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// For now, cycle through some predefined colors
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
const currentIndex = colors.indexOf(event.color || '');
const nextIndex = (currentIndex + 1) % colors.length;
eventsStore.updateEvent(event.id, { color: colors[nextIndex] });
toastStore.success('Farbe geändert');
}
function handleExport() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// Generate simple ICS content
const startDate =
typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime;
const endDate = typeof event.endTime === 'string' ? new Date(event.endTime) : event.endTime;
const formatDate = (date: Date) => {
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Manacore//Calendar//DE
BEGIN:VEVENT
UID:${event.id}
DTSTART:${formatDate(startDate)}
DTEND:${formatDate(endDate)}
SUMMARY:${event.title}
${event.description ? `DESCRIPTION:${event.description}` : ''}
${event.location ? `LOCATION:${event.location}` : ''}
END:VEVENT
END:VCALENDAR`;
const blob = new Blob([icsContent], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`;
link.click();
URL.revokeObjectURL(url);
toastStore.success('Termin exportiert');
}
async function handleDelete() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
if (confirm(`Möchten Sie "${event.title}" wirklich löschen?`)) {
try {
await eventsStore.deleteEvent(event.id);
toastStore.success('Termin gelöscht');
} catch (error) {
console.error('Error deleting event:', error);
toastStore.error('Fehler beim Löschen');
}
}
}
function handleClose() {
eventContextMenuStore.hide();
}
</script>
<ContextMenu
visible={eventContextMenuStore.visible}
x={eventContextMenuStore.x}
y={eventContextMenuStore.y}
items={menuItems}
onClose={handleClose}
/>

View file

@ -423,11 +423,16 @@
onUpdated?.();
} else {
// Create new event
await eventsStore.createEvent(eventData);
const result = await eventsStore.createEvent(eventData);
if (result.error) {
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
return;
}
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
toast.success('Termin erstellt');
onCreated?.();
}

View file

@ -48,9 +48,10 @@ export const eventsStore = {
error = result.error.message;
toastStore.error(`Termine konnten nicht geladen werden: ${result.error.message}`);
} else {
// API returns { events: [...] }
const data = result.data as { events: CalendarEvent[] } | null;
events = data?.events || [];
// API returns events array directly (already extracted in api/events.ts)
const eventsData = result.data as CalendarEvent[] | null;
console.log('[Events Store] Loaded events:', eventsData?.length, eventsData);
events = eventsData || [];
loadedRange = { start: startDate, end: endDate };
}
@ -121,11 +122,8 @@ export const eventsStore = {
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
if (result.error) {
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
} else if (result.data) {
if (result.data) {
events = [...events, result.data];
toastStore.success('Termin erstellt');
}
return result;

View file

@ -111,7 +111,7 @@
>
{#if item.icon}
<span class="item-icon">
{@render item.icon()}
<item.icon size={16} />
</span>
{/if}
<span class="item-label">{item.label}</span>

View file

@ -1,12 +1,13 @@
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
export interface ContextMenuItem {
/** Unique identifier for the item */
id: string;
/** Display label */
label: string;
/** Icon snippet to render */
icon?: Snippet;
/** Icon component to render (Phosphor icon or any Svelte component) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: Component<any>;
/** Keyboard shortcut hint */
shortcut?: string;
/** Whether the item is disabled */