mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
✨ feat(calendar): add event detail modal and structured location fields
- Fix API response extraction for events and calendars (fixes Invalid Date) - Convert event detail page to URL-based modal overlay (/?event=<id>) - Add structured location fields (street, postal code, city, country) - Add location details toggle in EventForm and QuickEventOverlay - Add YearView component - Improve calendar views with URL-based event navigation - Move calendar settings to settings page
This commit is contained in:
parent
ba746fce04
commit
80f8a0338e
23 changed files with 2597 additions and 826 deletions
|
|
@ -14,17 +14,25 @@ export async function getCalendar(id: string) {
|
|||
}
|
||||
|
||||
export async function createCalendar(data: CreateCalendarInput) {
|
||||
return fetchApi<Calendar>('/calendars', {
|
||||
const result = await fetchApi<{ calendar: Calendar }>('/calendars', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function updateCalendar(id: string, data: UpdateCalendarInput) {
|
||||
return fetchApi<Calendar>(`/calendars/${id}`, {
|
||||
const result = await fetchApi<{ calendar: Calendar }>(`/calendars/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function deleteCalendar(id: string) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ export async function getEvents(params: QueryEventsParams) {
|
|||
}
|
||||
|
||||
export async function getEvent(id: string) {
|
||||
return fetchApi<CalendarEvent>(`/events/${id}`);
|
||||
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function getEventsByCalendar(calendarId: string) {
|
||||
|
|
@ -31,17 +35,25 @@ export async function getEventsByCalendar(calendarId: string) {
|
|||
}
|
||||
|
||||
export async function createEvent(data: CreateEventInput) {
|
||||
return fetchApi<CalendarEvent>('/events', {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>('/events', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function updateEvent(id: string, data: UpdateEventInput) {
|
||||
return fetchApi<CalendarEvent>(`/events/${id}`, {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { isNavCollapsed } from '$lib/stores/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
};
|
||||
|
||||
// Views to show in selector
|
||||
const visibleViews: CalendarViewType[] = ['day', '5day', 'week', '10day', '14day', 'month'];
|
||||
const visibleViews: CalendarViewType[] = ['day', '5day', 'week', '10day', '14day', 'month', 'year'];
|
||||
|
||||
// Format title based on view type
|
||||
let title = $derived.by(() => {
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<header class="calendar-header">
|
||||
<header class="calendar-header" class:nav-collapsed={$isNavCollapsed}>
|
||||
<div class="header-left">
|
||||
<button class="today-btn" onclick={() => viewStore.goToToday()}>
|
||||
Heute
|
||||
|
|
@ -123,9 +124,14 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
transition: padding-left 300ms ease;
|
||||
}
|
||||
|
||||
.calendar-header.nav-collapsed {
|
||||
padding-left: 4rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
|
|
@ -174,7 +180,7 @@
|
|||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
function handleAddCalendar() {
|
||||
goto('/calendars/new');
|
||||
goto('/settings');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@
|
|||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
const SNAP_MINUTES = 15; // snap to 15-minute intervals
|
||||
|
|
@ -176,11 +182,18 @@
|
|||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event
|
||||
eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
|
@ -260,10 +273,18 @@
|
|||
newEnd.setSeconds(0, 0);
|
||||
}
|
||||
|
||||
eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
|
@ -325,16 +346,21 @@
|
|||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
function handleSlotClick(hour: number) {
|
||||
function handleSlotClick(hour: number, e: MouseEvent) {
|
||||
// Don't create event if dragging or resizing
|
||||
if (isDragging || isResizing) return;
|
||||
|
||||
const startTime = new Date(viewStore.currentDate);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
|
||||
if (onQuickCreate) {
|
||||
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -377,7 +403,7 @@
|
|||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
onclick={() => handleSlotClick(hour)}
|
||||
onclick={(e) => handleSlotClick(hour, e)}
|
||||
aria-label={`${hour}:00 Uhr`}
|
||||
></button>
|
||||
{/each}
|
||||
|
|
@ -397,13 +423,16 @@
|
|||
{#each timedEvents as event}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
class:draft={isDraft}
|
||||
data-event-id={event.id}
|
||||
style={isBeingDragged ? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};` : isBeingResized ? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};` : getEventStyle(event)}
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
|
|
@ -426,7 +455,7 @@
|
|||
'HH:mm'
|
||||
)}
|
||||
</span>
|
||||
<span class="event-title">{event.title}</span>
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
|
||||
{#if event.location}
|
||||
<span class="event-location">{event.location}</span>
|
||||
{/if}
|
||||
|
|
@ -593,6 +622,21 @@
|
|||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%, 100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resize Handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,17 @@
|
|||
getMinutes,
|
||||
differenceInMinutes,
|
||||
addMinutes,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
|
||||
// Get all days to display in the month grid (including days from prev/next months)
|
||||
let allCalendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewStore.currentDate);
|
||||
|
|
@ -143,10 +151,18 @@
|
|||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
|
@ -167,11 +183,30 @@
|
|||
return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown
|
||||
}
|
||||
|
||||
function handleDayClick(day: Date) {
|
||||
function handleDayClick(day: Date, e: MouseEvent) {
|
||||
// Don't navigate if dragging
|
||||
if (isDragging) return;
|
||||
viewStore.setDate(day);
|
||||
viewStore.setViewType('day');
|
||||
|
||||
// If onQuickCreate callback provided, open quick event overlay
|
||||
if (onQuickCreate) {
|
||||
// Set start time to current hour (or 9 AM if in the past)
|
||||
const now = new Date();
|
||||
let startTime = setMinutes(setHours(day, now.getHours()), 0);
|
||||
|
||||
// If the selected day is today and current hour is reasonable, use next hour
|
||||
if (isSameDay(day, now)) {
|
||||
startTime = setMinutes(setHours(day, now.getHours() + 1), 0);
|
||||
} else {
|
||||
// For other days, default to 9 AM
|
||||
startTime = setMinutes(setHours(day, 9), 0);
|
||||
}
|
||||
|
||||
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
// Fallback: navigate to day view
|
||||
viewStore.setDate(day);
|
||||
viewStore.setViewType('day');
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
|
|
@ -182,7 +217,7 @@
|
|||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
goto(`/event/${event.id}`);
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
function handleMoreClick(day: Date, e: MouseEvent) {
|
||||
|
|
@ -213,8 +248,8 @@
|
|||
class:today={isToday(day)}
|
||||
class:drop-target={isDropTarget}
|
||||
use:bindDayCellRef={day}
|
||||
onclick={() => handleDayClick(day)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day)}
|
||||
onclick={(e) => handleDayClick(day, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
|
|
@ -225,12 +260,15 @@
|
|||
<div class="day-events">
|
||||
{#each getEventsForDay(day) as event}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
<div
|
||||
class="event-pill"
|
||||
class:dragging={isBeingDragged}
|
||||
class:draft={isDraft}
|
||||
data-event-id={event.id}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
|
|
@ -241,10 +279,15 @@
|
|||
? new Date(event.startTime)
|
||||
: event.startTime,
|
||||
'HH:mm'
|
||||
)}-{format(
|
||||
typeof event.endTime === 'string'
|
||||
? new Date(event.endTime)
|
||||
: event.endTime,
|
||||
'HH:mm'
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="event-title">{event.title}</span>
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
|
|
@ -379,6 +422,21 @@
|
|||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.event-pill.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%, 100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.9;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@
|
|||
// Props
|
||||
interface Props {
|
||||
dayCount: 5 | 10 | 14;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
}
|
||||
let { dayCount }: Props = $props();
|
||||
let { dayCount, onQuickCreate }: Props = $props();
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -164,16 +165,21 @@
|
|||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number) {
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
// Don't create new event if dragging
|
||||
if (isDragging || isResizing) return;
|
||||
|
||||
const startTime = new Date(day);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
|
||||
if (onQuickCreate) {
|
||||
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Drag & Drop Functions ==========
|
||||
|
|
@ -278,11 +284,18 @@
|
|||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event via store
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event via store (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isDragging = false;
|
||||
|
|
@ -374,11 +387,18 @@
|
|||
newStart = setMinutes(newStart, newMins);
|
||||
}
|
||||
|
||||
// Update event via store
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event via store (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isResizing = false;
|
||||
|
|
@ -465,7 +485,7 @@
|
|||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
onclick={() => handleSlotClick(day, hour)}
|
||||
onclick={(e) => handleSlotClick(day, hour, e)}
|
||||
aria-label={`${format(day, 'EEEE', { locale: currentDateLocale })} ${settingsStore.formatHour(hour)}`}
|
||||
></button>
|
||||
{/each}
|
||||
|
|
@ -486,10 +506,13 @@
|
|||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
class:draft={isDraft}
|
||||
data-event-id={event.id}
|
||||
style={isBeingDragged
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: isBeingResized
|
||||
|
|
@ -498,9 +521,9 @@
|
|||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/event/${event.id}`)}
|
||||
title={`${formatEventTime(event.startTime)} - ${event.title}`}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
|
||||
title={`${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}: ${event.title || (isDraft ? '(Neuer Termin)' : '')}`}
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
|
|
@ -513,10 +536,10 @@
|
|||
|
||||
{#if columnClass !== 'very-compact'}
|
||||
<span class="event-time">
|
||||
{formatEventTime(event.startTime)}
|
||||
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="event-title">{event.title}</span>
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
|
|
@ -796,6 +819,21 @@
|
|||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%, 100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.event-card.drag-ghost {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import {
|
||||
format,
|
||||
eachDayOfInterval,
|
||||
|
|
@ -22,6 +21,12 @@
|
|||
import { de, enUS, fr, es, it } from 'date-fns/locale';
|
||||
import { locale } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
|
||||
|
|
@ -101,11 +106,6 @@
|
|||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Quick Event Overlay State
|
||||
let showQuickEvent = $state(false);
|
||||
let quickEventStartTime = $state<Date | null>(null);
|
||||
let quickEventPosition = $state({ x: 0, y: 0 });
|
||||
|
||||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
setTimeout(() => { hasMoved = false; }, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/event/${event.id}`);
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
|
|
@ -175,15 +175,11 @@
|
|||
const startTime = new Date(day);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
|
||||
// Show quick event overlay at click position
|
||||
quickEventStartTime = startTime;
|
||||
quickEventPosition = { x: e.clientX, y: e.clientY };
|
||||
showQuickEvent = true;
|
||||
}
|
||||
|
||||
function closeQuickEvent() {
|
||||
showQuickEvent = false;
|
||||
quickEventStartTime = null;
|
||||
if (onQuickCreate) {
|
||||
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Drag & Drop Functions ==========
|
||||
|
|
@ -288,11 +284,18 @@
|
|||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event via store
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event via store (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isDragging = false;
|
||||
|
|
@ -384,11 +387,18 @@
|
|||
newStart = setMinutes(newStart, newMins);
|
||||
}
|
||||
|
||||
// Update event via store
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
// Update event via store (use updateDraftEvent for draft events)
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state
|
||||
isResizing = false;
|
||||
|
|
@ -450,7 +460,7 @@
|
|||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/event/${event.id}`)}
|
||||
onclick={() => goto(`/?event=${event.id}`)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
|
|
@ -499,7 +509,7 @@
|
|||
<button
|
||||
class="all-day-block-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/event/${event.id}`)}
|
||||
onclick={() => goto(`/?event=${event.id}`)}
|
||||
>
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
|
|
@ -509,10 +519,13 @@
|
|||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
class:draft={isDraft}
|
||||
data-event-id={event.id}
|
||||
style={isBeingDragged
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: isBeingResized
|
||||
|
|
@ -521,8 +534,8 @@
|
|||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/event/${event.id}`)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
|
|
@ -534,9 +547,9 @@
|
|||
></div>
|
||||
|
||||
<span class="event-time">
|
||||
{formatEventTime(event.startTime)}
|
||||
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
|
||||
</span>
|
||||
<span class="event-title">{event.title}</span>
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
|
|
@ -781,6 +794,21 @@
|
|||
border: 2px dashed white;
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%, 100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.9;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,411 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
parseISO,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
|
||||
// Derived values
|
||||
let year = $derived(viewStore.currentDate.getFullYear());
|
||||
|
||||
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));
|
||||
|
||||
// Week day headers
|
||||
const weekDaysFromMonday = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const weekDaysFromSunday = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
let weekDays = $derived(
|
||||
settingsStore.weekStartsOn === 1 ? weekDaysFromMonday : weekDaysFromSunday
|
||||
);
|
||||
|
||||
// Context menu state
|
||||
let contextMenu = $state<{ visible: boolean; x: number; y: number; date: Date | null }>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
date: null,
|
||||
});
|
||||
|
||||
// Context menu options
|
||||
const viewOptions: { type: CalendarViewType; label: string }[] = [
|
||||
{ type: 'day', label: 'Tagesansicht' },
|
||||
{ type: 'week', label: 'Wochenansicht' },
|
||||
{ type: 'month', label: 'Monatsansicht' },
|
||||
];
|
||||
|
||||
// Precompute event counts for performance
|
||||
let eventCountsByDay = $derived.by(() => {
|
||||
const counts = new Map<string, number>();
|
||||
const events = eventsStore.events ?? [];
|
||||
|
||||
for (const event of events) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const key = format(start, 'yyyy-MM-dd');
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function getMonthDays(month: Date): Date[] {
|
||||
const monthStart = startOfMonth(month);
|
||||
const monthEnd = endOfMonth(month);
|
||||
const calendarStart = startOfWeek(monthStart, {
|
||||
weekStartsOn: settingsStore.weekStartsOn,
|
||||
});
|
||||
const calendarEnd = endOfWeek(monthEnd, {
|
||||
weekStartsOn: settingsStore.weekStartsOn,
|
||||
});
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
}
|
||||
|
||||
function getEventCount(day: Date): number {
|
||||
const key = format(day, 'yyyy-MM-dd');
|
||||
return eventCountsByDay.get(key) || 0;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handleDayClick(day: Date, e: MouseEvent) {
|
||||
if (onQuickCreate) {
|
||||
const startTime = setMinutes(setHours(day, 9), 0);
|
||||
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
viewStore.setDate(day);
|
||||
viewStore.setViewType('day');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDayContextMenu(day: Date, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu = {
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
date: day,
|
||||
};
|
||||
}
|
||||
|
||||
function handleContextMenuSelect(viewType: CalendarViewType) {
|
||||
if (contextMenu.date) {
|
||||
viewStore.setDate(contextMenu.date);
|
||||
viewStore.setViewType(viewType);
|
||||
}
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = { visible: false, x: 0, y: 0, date: null };
|
||||
}
|
||||
|
||||
function handleMonthClick(month: Date) {
|
||||
viewStore.setDate(month);
|
||||
viewStore.setViewType('month');
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent, day: Date) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDayClick(day, e as unknown as MouseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Close context menu on click outside
|
||||
function handleWindowClick() {
|
||||
if (contextMenu.visible) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Close context menu on Escape
|
||||
function handleWindowKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && contextMenu.visible) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeyDown} />
|
||||
|
||||
<div class="year-view" role="grid" aria-label="Jahresansicht {year}">
|
||||
{#each months as month}
|
||||
<div class="mini-month" role="gridcell">
|
||||
<button
|
||||
class="month-header"
|
||||
onclick={() => handleMonthClick(month)}
|
||||
aria-label="Gehe zu {format(month, 'MMMM yyyy', { locale: de })}"
|
||||
>
|
||||
{format(month, 'MMMM', { locale: de })}
|
||||
</button>
|
||||
|
||||
<div class="weekday-row">
|
||||
{#each weekDays as day}
|
||||
<span class="weekday">{day}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="days-grid" role="grid" aria-label={format(month, 'MMMM', { locale: de })}>
|
||||
{#each getMonthDays(month) as day}
|
||||
{@const eventCount = getEventCount(day)}
|
||||
<button
|
||||
class="day"
|
||||
class:other-month={!isSameMonth(day, month)}
|
||||
class:today={isToday(day)}
|
||||
class:has-events={eventCount > 0}
|
||||
class:has-many-events={eventCount > 3}
|
||||
role="gridcell"
|
||||
tabindex="0"
|
||||
aria-label="{format(day, 'd. MMMM', { locale: de })}{eventCount > 0
|
||||
? `, ${eventCount} Termine`
|
||||
: ''}"
|
||||
onclick={(e) => handleDayClick(day, e)}
|
||||
oncontextmenu={(e) => handleDayContextMenu(day, e)}
|
||||
onkeydown={(e) => handleKeyDown(e, day)}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
{#if contextMenu.visible && contextMenu.date}
|
||||
<div
|
||||
class="context-menu"
|
||||
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
||||
role="menu"
|
||||
aria-label="Ansicht wählen"
|
||||
>
|
||||
<div class="context-menu-header">
|
||||
{format(contextMenu.date, 'd. MMMM yyyy', { locale: de })}
|
||||
</div>
|
||||
{#each viewOptions as option}
|
||||
<button
|
||||
class="context-menu-item"
|
||||
role="menuitem"
|
||||
onclick={() => handleContextMenuSelect(option.type)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.year-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
padding-bottom: 8rem;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mini-month {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.month-header {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.month-header:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.weekday-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day.today:hover {
|
||||
background: hsl(var(--color-primary) / 0.8);
|
||||
}
|
||||
|
||||
.day.has-events::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.day.today.has-events::after {
|
||||
background: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.day.has-many-events::after {
|
||||
width: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
min-width: 160px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem;
|
||||
animation: context-menu-in 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes context-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 1200px) {
|
||||
.year-view {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.year-view {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.mini-month {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.month-header {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.year-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import EventForm from './EventForm.svelte';
|
||||
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { eventId, onClose }: Props = $props();
|
||||
|
||||
let event = $state<CalendarEvent | null>(null);
|
||||
let loading = $state(true);
|
||||
let isEditing = $state(false);
|
||||
|
||||
// Load event data
|
||||
$effect(() => {
|
||||
loadEvent();
|
||||
});
|
||||
|
||||
async function loadEvent() {
|
||||
loading = true;
|
||||
const result = await api.getEvent(eventId);
|
||||
|
||||
if (result.error || !result.data) {
|
||||
toast.error('Termin nicht gefunden');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
event = result.data;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleSave(data: UpdateEventInput) {
|
||||
if (!event) return;
|
||||
|
||||
const result = await eventsStore.updateEvent(event.id, data);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Speichern: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Termin aktualisiert');
|
||||
isEditing = false;
|
||||
// Reload event to get updated data
|
||||
await loadEvent();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!event) return;
|
||||
|
||||
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await eventsStore.deleteEvent(event.id);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Löschen: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Termin gelöscht');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isEditing) {
|
||||
isEditing = false;
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
if (event.isAllDay) {
|
||||
return 'Ganztägig';
|
||||
}
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`;
|
||||
}
|
||||
|
||||
// Get calendar info for the event
|
||||
let calendarName = $derived(
|
||||
event ? calendarsStore.calendars.find(c => c.id === event!.calendarId)?.name : undefined
|
||||
);
|
||||
let calendarColor = $derived(
|
||||
event ? calendarsStore.getColor(event.calendarId) : '#3b82f6'
|
||||
);
|
||||
|
||||
// Format recurrence rule to human readable text
|
||||
function formatRecurrence(rule: string): string {
|
||||
if (!rule) return '';
|
||||
// Basic RRULE parsing
|
||||
if (rule.includes('FREQ=DAILY')) return 'Täglich';
|
||||
if (rule.includes('FREQ=WEEKLY')) {
|
||||
if (rule.includes('BYDAY=')) {
|
||||
const days = rule.match(/BYDAY=([A-Z,]+)/)?.[1];
|
||||
if (days) {
|
||||
const dayMap: Record<string, string> = {
|
||||
MO: 'Mo', TU: 'Di', WE: 'Mi', TH: 'Do', FR: 'Fr', SA: 'Sa', SU: 'So'
|
||||
};
|
||||
const translatedDays = days.split(',').map(d => dayMap[d] || d).join(', ');
|
||||
return `Wöchentlich (${translatedDays})`;
|
||||
}
|
||||
}
|
||||
return 'Wöchentlich';
|
||||
}
|
||||
if (rule.includes('FREQ=MONTHLY')) return 'Monatlich';
|
||||
if (rule.includes('FREQ=YEARLY')) return 'Jährlich';
|
||||
return 'Wiederkehrend';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
{#if loading}
|
||||
<div class="modal-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
{:else if event}
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title" class="modal-title">
|
||||
{isEditing ? 'Termin bearbeiten' : event.title}
|
||||
</h2>
|
||||
<div class="modal-actions">
|
||||
{#if !isEditing}
|
||||
<button class="btn btn-ghost" onclick={() => (isEditing = true)}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button class="btn btn-ghost text-destructive" onclick={handleDelete}>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-close" onclick={onClose} aria-label="Schließen">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
{#if isEditing}
|
||||
<EventForm mode="edit" {event} onSave={handleSave} onCancel={handleCancel} />
|
||||
{:else}
|
||||
<div class="event-details">
|
||||
<!-- Kalender -->
|
||||
{#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}
|
||||
|
||||
<!-- Zeit -->
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Zeit</span>
|
||||
<span class="detail-value">{formatEventTime(event)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wiederholung -->
|
||||
{#if event.recurrenceRule}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Wiederholung</span>
|
||||
<span class="detail-value">{formatRecurrence(event.recurrenceRule)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ort -->
|
||||
{#if event.location || event.metadata?.locationDetails}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Ort</span>
|
||||
{#if event.location}
|
||||
<span class="detail-value">{event.location}</span>
|
||||
{/if}
|
||||
{#if event.metadata?.locationDetails}
|
||||
{@const loc = event.metadata.locationDetails}
|
||||
<div class="address-details">
|
||||
{#if loc.street}
|
||||
<span class="address-line">{loc.street}</span>
|
||||
{/if}
|
||||
{#if loc.postalCode || loc.city}
|
||||
<span class="address-line">
|
||||
{loc.postalCode ? loc.postalCode + ' ' : ''}{loc.city || ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if loc.country}
|
||||
<span class="address-line">{loc.country}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Videokonferenz -->
|
||||
{#if event.metadata?.conferenceUrl}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Videokonferenz</span>
|
||||
<a href={event.metadata.conferenceUrl} target="_blank" rel="noopener noreferrer" class="detail-link">
|
||||
Beitreten
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Event-URL -->
|
||||
{#if event.metadata?.url}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Link</span>
|
||||
<a href={event.metadata.url} target="_blank" rel="noopener noreferrer" class="detail-link">
|
||||
{new URL(event.metadata.url).hostname}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Beschreibung -->
|
||||
{#if event.description}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Beschreibung</span>
|
||||
<span class="detail-value description">{event.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
{#if event.metadata?.attendees && event.metadata.attendees.length > 0}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Teilnehmer ({event.metadata.attendees.length})</span>
|
||||
<div class="attendees-list">
|
||||
{#each event.metadata.attendees as attendee}
|
||||
<div class="attendee">
|
||||
<span class="attendee-name">{attendee.name || attendee.email}</span>
|
||||
{#if attendee.status}
|
||||
<span class="attendee-status" class:accepted={attendee.status === 'accepted'} class:declined={attendee.status === 'declined'} class:tentative={attendee.status === 'tentative'}>
|
||||
{attendee.status === 'accepted' ? '✓' : attendee.status === 'declined' ? '✗' : '?'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
animation: fade-in 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-up 200ms ease;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.event-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
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.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.detail-value.description {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.text-destructive:hover {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
}
|
||||
|
||||
/* Calendar dot */
|
||||
.calendar-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.detail-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Attendees */
|
||||
.attendees-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.attendee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attendee-name {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.attendee-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.attendee-status.accepted {
|
||||
background: hsl(var(--color-success) / 0.15);
|
||||
color: hsl(var(--color-success));
|
||||
}
|
||||
|
||||
.attendee-status.declined {
|
||||
background: hsl(var(--color-error) / 0.15);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.attendee-status.tentative {
|
||||
background: hsl(var(--color-warning) / 0.15);
|
||||
color: hsl(var(--color-warning));
|
||||
}
|
||||
|
||||
/* Address details */
|
||||
.address-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.address-line {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput, LocationDetails } from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -24,6 +24,23 @@
|
|||
event?.metadata?.allDayDisplayMode || 'default'
|
||||
);
|
||||
|
||||
// Location details state
|
||||
let showLocationDetails = $state(false);
|
||||
let locationStreet = $state(event?.metadata?.locationDetails?.street || '');
|
||||
let locationPostalCode = $state(event?.metadata?.locationDetails?.postalCode || '');
|
||||
let locationCity = $state(event?.metadata?.locationDetails?.city || '');
|
||||
let locationCountry = $state(event?.metadata?.locationDetails?.country || '');
|
||||
|
||||
// Auto-expand location details if any field is filled
|
||||
$effect(() => {
|
||||
if (event?.metadata?.locationDetails) {
|
||||
const details = event.metadata.locationDetails;
|
||||
if (details.street || details.postalCode || details.city || details.country) {
|
||||
showLocationDetails = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set default calendar when calendars are loaded
|
||||
$effect(() => {
|
||||
if (!calendarId && calendarsStore.defaultCalendar?.id) {
|
||||
|
|
@ -77,10 +94,36 @@
|
|||
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
|
||||
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
|
||||
|
||||
// Build metadata with display mode if not default
|
||||
const metadata = isAllDay && allDayDisplayMode !== 'default'
|
||||
? { ...(event?.metadata || {}), allDayDisplayMode: allDayDisplayMode as 'header' | 'block' }
|
||||
: event?.metadata;
|
||||
// Build location details if any field is filled
|
||||
const locationDetails: LocationDetails | undefined =
|
||||
(locationStreet.trim() || locationPostalCode.trim() || locationCity.trim() || locationCountry.trim())
|
||||
? {
|
||||
street: locationStreet.trim() || undefined,
|
||||
postalCode: locationPostalCode.trim() || undefined,
|
||||
city: locationCity.trim() || undefined,
|
||||
country: locationCountry.trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build metadata
|
||||
let metadata = { ...(event?.metadata || {}) };
|
||||
|
||||
// Add display mode if not default
|
||||
if (isAllDay && allDayDisplayMode !== 'default') {
|
||||
metadata.allDayDisplayMode = allDayDisplayMode as 'header' | 'block';
|
||||
} else {
|
||||
delete metadata.allDayDisplayMode;
|
||||
}
|
||||
|
||||
// Add location details
|
||||
if (locationDetails) {
|
||||
metadata.locationDetails = locationDetails;
|
||||
} else {
|
||||
delete metadata.locationDetails;
|
||||
}
|
||||
|
||||
// Only include metadata if it has properties
|
||||
const finalMetadata = Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
|
||||
const data: CreateEventInput | UpdateEventInput = {
|
||||
title: title.trim(),
|
||||
|
|
@ -90,7 +133,7 @@
|
|||
startTime: startDateTime.toISOString(),
|
||||
endTime: endDateTime.toISOString(),
|
||||
calendarId,
|
||||
metadata,
|
||||
metadata: finalMetadata,
|
||||
};
|
||||
|
||||
submitting = true;
|
||||
|
|
@ -203,8 +246,70 @@
|
|||
id="location"
|
||||
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
|
||||
bind:value={location}
|
||||
placeholder="Ort hinzufügen"
|
||||
placeholder="Ortsname oder Beschreibung"
|
||||
/>
|
||||
|
||||
<!-- Toggle for address details -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors self-start"
|
||||
onclick={() => showLocationDetails = !showLocationDetails}
|
||||
>
|
||||
<svg class="w-4 h-4 transition-transform" class:rotate-90={showLocationDetails} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{showLocationDetails ? 'Adressdetails ausblenden' : 'Adressdetails hinzufügen'}
|
||||
</button>
|
||||
|
||||
<!-- Address detail fields -->
|
||||
{#if showLocationDetails}
|
||||
<div class="flex flex-col gap-3 p-3 bg-muted/50 rounded-lg border border-border mt-1">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="street" class="text-xs font-medium text-muted-foreground">Straße</label>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors text-sm"
|
||||
bind:value={locationStreet}
|
||||
placeholder="Musterstraße 123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex flex-col gap-1 w-1/3">
|
||||
<label for="postalCode" class="text-xs font-medium text-muted-foreground">PLZ</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postalCode"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors text-sm"
|
||||
bind:value={locationPostalCode}
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<label for="city" class="text-xs font-medium text-muted-foreground">Stadt</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors text-sm"
|
||||
bind:value={locationCity}
|
||||
placeholder="Musterstadt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="country" class="text-xs font-medium text-muted-foreground">Land</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors text-sm"
|
||||
bind:value={locationCountry}
|
||||
placeholder="Deutschland"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -72,9 +72,7 @@ export const calendarsStore = {
|
|||
const result = await api.createCalendar(data);
|
||||
|
||||
if (result.data) {
|
||||
// API returns { calendar: {...} }
|
||||
const responseData = result.data as { calendar: Calendar };
|
||||
calendars = [...calendars, responseData.calendar];
|
||||
calendars = [...calendars, result.data];
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -87,9 +85,7 @@ export const calendarsStore = {
|
|||
const result = await api.updateCalendar(id, data);
|
||||
|
||||
if (result.data) {
|
||||
// API returns { calendar: {...} }
|
||||
const responseData = result.data as { calendar: Calendar };
|
||||
calendars = getCalendarsArray().map((c) => (c.id === id ? responseData.calendar : c));
|
||||
calendars = getCalendarsArray().map((c) => (c.id === id ? result.data! : c));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
54
apps/calendar/apps/web/src/lib/stores/eventModal.svelte.ts
Normal file
54
apps/calendar/apps/web/src/lib/stores/eventModal.svelte.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Event Modal Store - Manages event detail modal state with URL support
|
||||
*/
|
||||
|
||||
let selectedEventId = $state<string | null>(null);
|
||||
|
||||
export const eventModalStore = {
|
||||
get eventId() {
|
||||
return selectedEventId;
|
||||
},
|
||||
|
||||
get isOpen() {
|
||||
return selectedEventId !== null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the event detail modal
|
||||
*/
|
||||
open(eventId: string) {
|
||||
selectedEventId = eventId;
|
||||
// Update URL without full navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('event', eventId);
|
||||
window.history.pushState({}, '', url.toString());
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the event detail modal
|
||||
*/
|
||||
close() {
|
||||
selectedEventId = null;
|
||||
// Remove event param from URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('event');
|
||||
window.history.pushState({}, '', url.toString());
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize from URL (call on page mount)
|
||||
*/
|
||||
initFromUrl(searchParams: URLSearchParams) {
|
||||
const eventId = searchParams.get('event');
|
||||
if (eventId) {
|
||||
selectedEventId = eventId;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset state (for cleanup)
|
||||
*/
|
||||
reset() {
|
||||
selectedEventId = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -12,6 +12,9 @@ let loading = $state(false);
|
|||
let error = $state<string | null>(null);
|
||||
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
|
||||
|
||||
// Draft event for quick create (temporary event shown in grid before saving)
|
||||
let draftEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
export const eventsStore = {
|
||||
// Getters - always return safe values
|
||||
get events() {
|
||||
|
|
@ -23,6 +26,9 @@ export const eventsStore = {
|
|||
get error() {
|
||||
return error;
|
||||
},
|
||||
get draftEvent() {
|
||||
return draftEvent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch events for a date range
|
||||
|
|
@ -51,14 +57,14 @@ export const eventsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Get events for a specific day
|
||||
* Get events for a specific day (including draft event)
|
||||
*/
|
||||
getEventsForDay(date: Date) {
|
||||
getEventsForDay(date: Date, includeDraft = true) {
|
||||
// Safety check: ensure events is an array (Svelte 5 runes safety)
|
||||
const currentEvents = events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
return currentEvents.filter((event) => {
|
||||
const result = currentEvents.filter((event) => {
|
||||
const eventStart =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
|
@ -74,6 +80,17 @@ export const eventsStore = {
|
|||
// For timed events, check if event starts on this day
|
||||
return isSameDay(date, eventStart);
|
||||
});
|
||||
|
||||
// Include draft event if it exists and is on this day
|
||||
if (includeDraft && draftEvent) {
|
||||
const draftStart =
|
||||
typeof draftEvent.startTime === 'string' ? parseISO(draftEvent.startTime) : draftEvent.startTime;
|
||||
if (isSameDay(date, draftStart)) {
|
||||
result.push(draftEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -101,9 +118,7 @@ export const eventsStore = {
|
|||
const result = await api.createEvent(data);
|
||||
|
||||
if (result.data) {
|
||||
// API returns { event: {...} }
|
||||
const responseData = result.data as { event: CalendarEvent };
|
||||
events = [...events, responseData.event];
|
||||
events = [...events, result.data];
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -116,9 +131,7 @@ export const eventsStore = {
|
|||
const result = await api.updateEvent(id, data);
|
||||
|
||||
if (result.data) {
|
||||
// API returns { event: {...} }
|
||||
const responseData = result.data as { event: CalendarEvent };
|
||||
events = events.map((e) => (e.id === id ? responseData.event : e));
|
||||
events = events.map((e) => (e.id === id ? result.data! : e));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -155,4 +168,58 @@ export const eventsStore = {
|
|||
events = [];
|
||||
loadedRange = null;
|
||||
},
|
||||
|
||||
// ========== Draft Event Methods ==========
|
||||
|
||||
/**
|
||||
* Create a draft event (shown immediately in grid, not saved yet)
|
||||
*/
|
||||
createDraftEvent(data: Partial<CalendarEvent>) {
|
||||
draftEvent = {
|
||||
id: '__draft__',
|
||||
calendarId: data.calendarId || '',
|
||||
userId: '',
|
||||
title: data.title || '',
|
||||
description: data.description || null,
|
||||
location: data.location || null,
|
||||
startTime: data.startTime || new Date().toISOString(),
|
||||
endTime: data.endTime || new Date().toISOString(),
|
||||
isAllDay: data.isAllDay || false,
|
||||
timezone: data.timezone || null,
|
||||
recurrenceRule: null,
|
||||
recurrenceEndDate: null,
|
||||
recurrenceExceptions: null,
|
||||
parentEventId: null,
|
||||
color: data.color || null,
|
||||
status: 'confirmed',
|
||||
externalId: null,
|
||||
metadata: data.metadata || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as CalendarEvent;
|
||||
return draftEvent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the draft event (when user changes time by dragging)
|
||||
*/
|
||||
updateDraftEvent(data: Partial<CalendarEvent>) {
|
||||
if (draftEvent) {
|
||||
draftEvent = { ...draftEvent, ...data };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the draft event (on cancel or after saving)
|
||||
*/
|
||||
clearDraftEvent() {
|
||||
draftEvent = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an event is the draft event
|
||||
*/
|
||||
isDraftEvent(eventId: string) {
|
||||
return eventId === '__draft__';
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@
|
|||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kalender', icon: 'calendar' },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ href: '/calendars', label: 'Meine Kalender', icon: 'folder' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
|
@ -242,10 +241,12 @@
|
|||
|
||||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
padding-top: 100px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.main-content.sidebar-mode {
|
||||
|
|
@ -257,6 +258,8 @@
|
|||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
|
|
@ -11,13 +12,54 @@
|
|||
import DayView from '$lib/components/calendar/DayView.svelte';
|
||||
import MonthView from '$lib/components/calendar/MonthView.svelte';
|
||||
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
|
||||
import YearView from '$lib/components/calendar/YearView.svelte';
|
||||
import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
|
||||
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
|
||||
import { format, addMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
let initialized = $state(false);
|
||||
|
||||
// Quick event overlay state
|
||||
let showQuickCreate = $state(false);
|
||||
let quickCreateDate = $state<Date>(new Date());
|
||||
|
||||
// Event modal state (local state for reactivity)
|
||||
let selectedEventId = $state<string | null>(null);
|
||||
|
||||
// Derive modal open state from URL
|
||||
let modalEventId = $derived($page.url.searchParams.get('event'));
|
||||
|
||||
function handleQuickCreate(date: Date, position: { x: number; y: number }) {
|
||||
quickCreateDate = date;
|
||||
|
||||
// Create draft event immediately so it appears in the grid
|
||||
const defaultCalendar = calendarsStore.defaultCalendar;
|
||||
const endTime = addMinutes(date, settingsStore.defaultEventDuration);
|
||||
|
||||
eventsStore.createDraftEvent({
|
||||
calendarId: defaultCalendar?.id || '',
|
||||
title: '',
|
||||
startTime: date.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
isAllDay: false,
|
||||
});
|
||||
|
||||
showQuickCreate = true;
|
||||
}
|
||||
|
||||
function handleQuickCreateClose() {
|
||||
showQuickCreate = false;
|
||||
eventsStore.clearDraftEvent();
|
||||
}
|
||||
|
||||
function handleEventCreated() {
|
||||
// Event is automatically added to store, draft is cleared
|
||||
eventsStore.clearDraftEvent();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
|
|
@ -29,6 +71,11 @@
|
|||
initialized = true;
|
||||
});
|
||||
|
||||
function handleEventModalClose() {
|
||||
// Remove event param from URL
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
|
||||
// Refetch events when view changes
|
||||
$effect(() => {
|
||||
if (initialized && authStore.isAuthenticated) {
|
||||
|
|
@ -108,22 +155,41 @@
|
|||
|
||||
<div class="calendar-content">
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView />
|
||||
<DayView onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} />
|
||||
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView />
|
||||
<WeekView onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} />
|
||||
<MultiDayView dayCount={10} onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} />
|
||||
<MultiDayView dayCount={14} onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView />
|
||||
<MonthView onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView onQuickCreate={handleQuickCreate} />
|
||||
{:else}
|
||||
<WeekView />
|
||||
<WeekView onQuickCreate={handleQuickCreate} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Event Overlay -->
|
||||
{#if showQuickCreate}
|
||||
<QuickEventOverlay
|
||||
startTime={quickCreateDate}
|
||||
onClose={handleQuickCreateClose}
|
||||
onCreated={handleEventCreated}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Event Detail Modal -->
|
||||
{#if modalEventId}
|
||||
<EventDetailModal
|
||||
eventId={modalEventId}
|
||||
onClose={handleEventModalClose}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@
|
|||
}
|
||||
|
||||
function handleEventClick(eventId: string) {
|
||||
goto(`/event/${eventId}`);
|
||||
// Navigate to calendar with event modal
|
||||
goto(`/?event=${eventId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
<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 { toast } from '$lib/stores/toast';
|
||||
import type { Calendar } from '@calendar/shared';
|
||||
|
||||
let editingCalendar = $state<Calendar | null>(null);
|
||||
let showNewForm = $state(false);
|
||||
let newCalendarName = $state('');
|
||||
let newCalendarColor = $state('#3b82f6');
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreateCalendar() {
|
||||
if (!newCalendarName.trim()) return;
|
||||
|
||||
const result = await calendarsStore.createCalendar({
|
||||
name: newCalendarName.trim(),
|
||||
color: newCalendarColor,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender erstellt');
|
||||
newCalendarName = '';
|
||||
showNewForm = false;
|
||||
}
|
||||
|
||||
async function handleDeleteCalendar(calendar: Calendar) {
|
||||
if (!confirm(`Möchten Sie "${calendar.name}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await calendarsStore.deleteCalendar(calendar.id);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender gelöscht');
|
||||
}
|
||||
|
||||
async function handleUpdateCalendar(calendar: Calendar, name: string, color: string) {
|
||||
const result = await calendarsStore.updateCalendar(calendar.id, { name, color });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender aktualisiert');
|
||||
editingCalendar = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Kalender | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="calendars-page">
|
||||
<header class="page-header">
|
||||
<h1>Meine Kalender</h1>
|
||||
<button class="btn btn-primary" onclick={() => (showNewForm = true)}> Neuer Kalender </button>
|
||||
</header>
|
||||
|
||||
{#if showNewForm}
|
||||
<div class="card new-calendar-form">
|
||||
<h2>Neuer Kalender</h2>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreateCalendar();
|
||||
}}
|
||||
>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Kalender Name"
|
||||
bind:value={newCalendarName}
|
||||
/>
|
||||
<input type="color" class="color-input" bind:value={newCalendarColor} />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => (showNewForm = false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={!newCalendarName.trim()}>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="calendar-list">
|
||||
{#each calendarsStore.calendars as calendar}
|
||||
<div class="calendar-card card">
|
||||
{#if editingCalendar?.id === calendar.id}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value;
|
||||
const color = (form.elements.namedItem('color') as HTMLInputElement).value;
|
||||
handleUpdateCalendar(calendar, name, color);
|
||||
}}
|
||||
>
|
||||
<div class="form-row">
|
||||
<input type="text" name="name" class="input" value={calendar.name} />
|
||||
<input type="color" name="color" class="color-input" value={calendar.color} />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => (editingCalendar = null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary"> Speichern </button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="calendar-info">
|
||||
<span class="color-dot" style="background-color: {calendar.color}"></span>
|
||||
<span class="calendar-name">{calendar.name}</span>
|
||||
{#if calendar.isDefault}
|
||||
<span class="badge">Standard</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => (editingCalendar = calendar)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{#if !calendar.isDefault}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-destructive"
|
||||
onclick={() => handleDeleteCalendar(calendar)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if calendarsStore.calendars.length === 0}
|
||||
<div class="empty-state card">
|
||||
<p>Keine Kalender vorhanden</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendars-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.new-calendar-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.new-calendar-form h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 48px;
|
||||
height: 42px;
|
||||
padding: 4px;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calendar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-sm);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,191 +2,42 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import EventForm from '$lib/components/event/EventForm.svelte';
|
||||
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
|
||||
let event = $state<CalendarEvent | null>(null);
|
||||
let loading = $state(true);
|
||||
let isEditing = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to main calendar page with event modal
|
||||
onMount(() => {
|
||||
const eventId = $page.params.id;
|
||||
const result = await api.getEvent(eventId);
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Termin nicht gefunden');
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
event = result.data;
|
||||
loading = false;
|
||||
goto(`/?event=${eventId}`, { replaceState: true });
|
||||
});
|
||||
|
||||
async function handleSave(data: UpdateEventInput) {
|
||||
if (!event) return;
|
||||
|
||||
const result = await eventsStore.updateEvent(event.id, data);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Speichern: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Termin aktualisiert');
|
||||
isEditing = false;
|
||||
event = result.data;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!event) return;
|
||||
|
||||
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await eventsStore.deleteEvent(event.id);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Löschen: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Termin gelöscht');
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (isEditing) {
|
||||
isEditing = false;
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{event?.title || 'Termin'} | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
{#if loading}
|
||||
<div class="loading">Laden...</div>
|
||||
{:else if event}
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1 class="page-title">{isEditing ? 'Termin bearbeiten' : event.title}</h1>
|
||||
{#if !isEditing}
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" onclick={() => (isEditing = true)}> Bearbeiten </button>
|
||||
<button class="btn btn-ghost text-destructive" onclick={handleDelete}> Löschen </button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isEditing}
|
||||
<EventForm mode="edit" {event} onSave={handleSave} onCancel={handleCancel} />
|
||||
{:else}
|
||||
<div class="event-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">Zeit</span>
|
||||
<span class="value">
|
||||
{#if event.isAllDay}
|
||||
Ganztägig
|
||||
{:else}
|
||||
{new Date(event.startTime).toLocaleString('de-DE')} -
|
||||
{new Date(event.endTime).toLocaleString('de-DE')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if event.location}
|
||||
<div class="detail-row">
|
||||
<span class="label">Ort</span>
|
||||
<span class="value">{event.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if event.description}
|
||||
<div class="detail-row">
|
||||
<span class="label">Beschreibung</span>
|
||||
<span class="value">{event.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="detail-row">
|
||||
<button class="btn btn-ghost" onclick={() => goto('/')}> Zurück zum Kalender </button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="redirect-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.event-details {
|
||||
.redirect-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: hsl(var(--color-error));
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@
|
|||
import { theme } from '$lib/stores/theme';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { settingsStore, type WeekStartDay, type TimeFormat, type AllDayDisplayMode } from '$lib/stores/settings.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import { GlobalSettingsSection } from '@manacore/shared-ui';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import type { CalendarViewType, Calendar } from '@calendar/shared';
|
||||
|
||||
// Calendar management state
|
||||
let editingCalendar = $state<Calendar | null>(null);
|
||||
let showNewCalendarForm = $state(false);
|
||||
let newCalendarName = $state('');
|
||||
let newCalendarColor = $state('#3b82f6');
|
||||
|
||||
// Get current locale from svelte-i18n
|
||||
import { locale } from 'svelte-i18n';
|
||||
|
|
@ -23,6 +31,52 @@
|
|||
await userSettings.load();
|
||||
});
|
||||
|
||||
// Calendar management functions
|
||||
async function handleCreateCalendar() {
|
||||
if (!newCalendarName.trim()) return;
|
||||
|
||||
const result = await calendarsStore.createCalendar({
|
||||
name: newCalendarName.trim(),
|
||||
color: newCalendarColor,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender erstellt');
|
||||
newCalendarName = '';
|
||||
showNewCalendarForm = false;
|
||||
}
|
||||
|
||||
async function handleDeleteCalendar(calendar: Calendar) {
|
||||
if (!confirm(`Möchten Sie "${calendar.name}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await calendarsStore.deleteCalendar(calendar.id);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender gelöscht');
|
||||
}
|
||||
|
||||
async function handleUpdateCalendar(calendar: Calendar, name: string, color: string) {
|
||||
const result = await calendarsStore.updateCalendar(calendar.id, { name, color });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Kalender aktualisiert');
|
||||
editingCalendar = null;
|
||||
}
|
||||
|
||||
function handleThemeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
|
@ -96,6 +150,101 @@
|
|||
<h1>Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<!-- Meine Kalender -->
|
||||
<section class="settings-section card">
|
||||
<div class="calendars-header">
|
||||
<h2>Meine Kalender</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick={() => (showNewCalendarForm = true)}>
|
||||
Neuer Kalender
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showNewCalendarForm}
|
||||
<div class="new-calendar-form">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreateCalendar();
|
||||
}}
|
||||
>
|
||||
<div class="calendar-form-row">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Kalender Name"
|
||||
bind:value={newCalendarName}
|
||||
/>
|
||||
<input type="color" class="color-input" bind:value={newCalendarColor} />
|
||||
</div>
|
||||
<div class="calendar-form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => (showNewCalendarForm = false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={!newCalendarName.trim()}>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="calendar-list">
|
||||
{#each calendarsStore.calendars as calendar}
|
||||
<div class="calendar-card">
|
||||
{#if editingCalendar?.id === calendar.id}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value;
|
||||
const color = (form.elements.namedItem('color') as HTMLInputElement).value;
|
||||
handleUpdateCalendar(calendar, name, color);
|
||||
}}
|
||||
>
|
||||
<div class="calendar-form-row">
|
||||
<input type="text" name="name" class="input" value={calendar.name} />
|
||||
<input type="color" name="color" class="color-input" value={calendar.color} />
|
||||
</div>
|
||||
<div class="calendar-form-actions">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => (editingCalendar = null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary"> Speichern </button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="calendar-info">
|
||||
<span class="color-dot" style="background-color: {calendar.color}"></span>
|
||||
<span class="calendar-name">{calendar.name}</span>
|
||||
{#if calendar.isDefault}
|
||||
<span class="badge">Standard</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => (editingCalendar = calendar)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{#if !calendar.isDefault}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-destructive"
|
||||
onclick={() => handleDeleteCalendar(calendar)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if calendarsStore.calendars.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Keine Kalender vorhanden</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sprache -->
|
||||
<section class="settings-section card">
|
||||
<h2>Sprache</h2>
|
||||
|
|
@ -703,4 +852,107 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Calendar management styles */
|
||||
.calendars-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.calendars-header h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.new-calendar-form {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.calendar-form-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.calendar-form-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 48px;
|
||||
height: 42px;
|
||||
padding: 4px;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.calendar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-sm);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,20 @@ export interface EventAttendee {
|
|||
*/
|
||||
export type AllDayDisplayMode = 'header' | 'block';
|
||||
|
||||
/**
|
||||
* Structured location/address details
|
||||
*/
|
||||
export interface LocationDetails {
|
||||
/** Street address */
|
||||
street?: string;
|
||||
/** Postal/ZIP code */
|
||||
postalCode?: string;
|
||||
/** City/Town */
|
||||
city?: string;
|
||||
/** Country */
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event metadata stored in JSONB
|
||||
*/
|
||||
|
|
@ -30,6 +44,8 @@ export interface EventMetadata {
|
|||
tags?: string[];
|
||||
/** Override for all-day display mode (uses global setting if not set) */
|
||||
allDayDisplayMode?: AllDayDisplayMode;
|
||||
/** Structured location details */
|
||||
locationDetails?: LocationDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue