mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
cc37db8072
commit
10d4170ee8
10 changed files with 287 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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?.();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue