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:
Till-JS 2025-12-03 12:44:39 +01:00
parent ba746fce04
commit 80f8a0338e
23 changed files with 2597 additions and 826 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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;

View file

@ -7,7 +7,7 @@
}
function handleAddCalendar() {
goto('/calendars/new');
goto('/settings');
}
</script>

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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;

View 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;
},
};

View file

@ -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__';
},
};

View file

@ -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) {

View file

@ -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>

View file

@ -64,7 +64,8 @@
}
function handleEventClick(eventId: string) {
goto(`/event/${eventId}`);
// Navigate to calendar with event modal
goto(`/?event=${eventId}`);
}
</script>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}
/**