feat(manacore/web): port calendar UI components from standalone app

Extract the monolithic calendar page into proper component architecture
ported from the original calendar app. Adds WeekView, MonthView,
AgendaView, EventCard, EventDetailModal, EventForm, CalendarHeader,
and MiniCalendar as separate components. Includes composables for
drag-to-create, event drag & drop, resize, current time indicator,
and keyboard shortcuts. Adds desktop sidebar with mini calendar and
calendar list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 11:05:49 +02:00
parent 071d2178ea
commit 3b5f77dd86
18 changed files with 3436 additions and 499 deletions

View file

@ -0,0 +1,302 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarViewStore } from '../stores/view.svelte';
import { eventsStore } from '../stores/events.svelte';
import {
filterEventsByVisibleCalendars,
getEventsInRange,
sortEventsByTime,
getCalendarColor,
} from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import { toDate } from '../utils/event-date-helpers';
import { format, parseISO, isToday, isTomorrow, startOfDay, addMonths } from 'date-fns';
import { de } from 'date-fns/locale';
import { CalendarBlank, MapPin, CaretRight } from '@manacore/shared-icons';
interface Props {
onEventClick?: (event: CalendarEvent) => void;
}
let { onEventClick }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let rangeEvents = $derived.by(() => {
const visible = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value);
return getEventsInRange(
visible,
calendarViewStore.currentDate,
addMonths(calendarViewStore.currentDate, 3)
);
});
let groupedEvents = $derived.by(() => {
const currentEvents = rangeEvents ?? [];
if (!Array.isArray(currentEvents)) return [];
const startDate = startOfDay(calendarViewStore.currentDate);
const groups: Map<string, CalendarEvent[]> = new Map();
for (const event of currentEvents) {
const start = toDate(event.startTime);
if (start < startDate) continue;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = toDate(a.startTime);
const bStart = toDate(b.startTime);
return aStart.getTime() - bStart.getTime();
}),
}))
.filter((group) => group.events.length > 0);
});
function formatDateHeader(date: Date) {
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(event: CalendarEvent) {
onEventClick?.(event);
}
// Inline title editing
function handleTitleBlur(event: CalendarEvent, el: HTMLSpanElement) {
const trimmed = (el.textContent || '').trim();
if (trimmed && trimmed !== event.title) {
eventsStore.updateEvent(event.id, { title: trimmed });
} else {
el.textContent = event.title;
}
}
function handleTitleKeydown(e: KeyboardEvent, event: CalendarEvent) {
const target = e.target as HTMLSpanElement;
if (e.key === 'Enter') {
e.preventDefault();
target.blur();
} else if (e.key === 'Escape') {
target.textContent = event.title;
target.blur();
}
}
</script>
<div class="agenda-view">
{#if groupedEvents.length === 0}
<div class="empty-state">
<CalendarBlank size={64} />
<p>Keine Termine in diesem Zeitraum</p>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
<div class="events-for-date">
{#each group.events as event}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="event-item">
<div
class="color-bar"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(toDate(event.startTime), 'HH:mm')} - {format(
toDate(event.endTime),
'HH:mm'
)}
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="event-title agenda-event-title"
contenteditable="true"
role="textbox"
spellcheck="true"
onkeydown={(e) => handleTitleKeydown(e, event)}
onblur={(e) => handleTitleBlur(event, e.target as HTMLSpanElement)}
onclick={(e) => e.stopPropagation()}
>
{event.title}
</span>
{#if event.location}
<div class="event-location">
<MapPin size={14} />
{event.location}
</div>
{/if}
</div>
<button
class="expand-btn"
onclick={() => handleEventClick(event)}
title="Details öffnen"
aria-label="Details öffnen"
>
<CaretRight size={16} />
</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.agenda-view {
padding: 1rem;
max-width: 700px;
margin: 0 auto;
height: 100%;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.empty-state p {
font-size: 1rem;
margin-top: 1rem;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.date-header.today {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
.events-for-date {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.event-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
text-align: left;
width: 100%;
background: hsl(var(--color-card));
}
.color-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
min-height: 2.5rem;
}
.event-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.event-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
white-space: normal;
word-break: break-word;
cursor: text;
outline: none;
border-radius: 0.25rem;
padding: 0.0625rem 0.125rem;
margin: -0.0625rem -0.125rem;
}
.event-location {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
margin-top: 0.25rem;
border: none;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
color: hsl(var(--color-muted-foreground));
}
.expand-btn:hover {
opacity: 1;
background: hsl(var(--color-muted));
}
</style>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { calendarViewStore } from '../stores/view.svelte';
import type { CalendarViewType } from '../types';
import { CaretLeft, CaretRight, Plus } from '@manacore/shared-icons';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
onNewEvent: () => void;
}
let { onNewEvent }: Props = $props();
let headerLabel = $derived.by(() => {
if (calendarViewStore.viewType === 'month') {
return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de });
}
return format(calendarViewStore.currentDate, "'KW' w — MMMM yyyy", { locale: de });
});
const viewLabels: Record<CalendarViewType, string> = {
week: 'Woche',
month: 'Monat',
agenda: 'Agenda',
};
</script>
<header class="calendar-header">
<div class="header-left">
<h1 class="header-label">{headerLabel}</h1>
<div class="nav-buttons">
<button onclick={() => calendarViewStore.goToPrevious()} class="nav-btn" aria-label="Zurück">
<CaretLeft size={18} />
</button>
<button onclick={() => calendarViewStore.goToToday()} class="today-btn"> Heute </button>
<button onclick={() => calendarViewStore.goToNext()} class="nav-btn" aria-label="Weiter">
<CaretRight size={18} />
</button>
</div>
</div>
<div class="header-right">
<div class="view-switcher">
{#each ['week', 'month', 'agenda'] as CalendarViewType[] as view}
<button
onclick={() => calendarViewStore.setViewType(view)}
class="view-btn"
class:active={calendarViewStore.viewType === view}
>
{viewLabels[view]}
</button>
{/each}
</div>
<button onclick={onNewEvent} class="new-event-btn">
<Plus size={16} />
Termin
</button>
</div>
</header>
<style>
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid hsl(var(--color-border));
padding: 0.75rem 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-label {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
white-space: nowrap;
}
.nav-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-btn {
padding: 0.375rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.nav-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.today-btn {
padding: 0.25rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.today-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.view-switcher {
display: flex;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
overflow: hidden;
background: hsl(var(--color-card));
}
.view-btn {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.view-btn:hover {
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.new-event-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: none;
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.new-event-btn:hover {
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import type { CalendarEvent } from '../types';
import { eventsStore } from '../stores/events.svelte';
interface Props {
event: CalendarEvent;
style: string;
color: string;
isDragging?: boolean;
isResizing?: boolean;
isDraggingSource?: boolean;
formattedTime: string;
onClick?: (event: CalendarEvent, e: MouseEvent) => void;
onPointerDown?: (event: CalendarEvent, e: PointerEvent) => void;
onContextMenu?: (event: CalendarEvent, e: MouseEvent) => void;
onResizeStart?: (event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) => void;
}
let {
event,
style,
color,
isDragging = false,
isResizing = false,
isDraggingSource = false,
formattedTime,
onClick,
onPointerDown,
onContextMenu,
onResizeStart,
}: Props = $props();
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
function handleClick(e: MouseEvent) {
if (isDragging || isResizing || isDraft) {
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(event, e);
}
function handlePointerDown(e: PointerEvent) {
onPointerDown?.(event, e);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isDraft) return;
onContextMenu?.(event, e);
}
function handleResizeTop(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'top', e);
}
function handleResizeBottom(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'bottom', e);
}
function handleKeydown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isDraft) {
e.preventDefault();
onClick?.(event, e as unknown as MouseEvent);
}
}
</script>
<div
class="event-card"
class:dragging={isDragging && !isDraggingSource}
class:dragging-source={isDraggingSource}
class:resizing={isResizing}
class:draft={isDraft}
data-event-id={event.id}
{style}
style:background-color={color}
role="button"
tabindex="0"
aria-label={event.title || 'Neuer Termin'}
onpointerdown={handlePointerDown}
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
>
{#if onResizeStart}
<div
class="resize-handle top"
onpointerdown={handleResizeTop}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
<span class="event-time">{formattedTime}</span>
<span class="event-title">{event.title || (isDraft ? 'Neuer Termin' : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
{#if onResizeStart}
<div
class="resize-handle bottom"
onpointerdown={handleResizeBottom}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
</div>
<style>
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm, 4px);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
color: white;
}
.event-card:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.event-card:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed hsl(var(--color-primary) / 0.6);
outline-offset: -2px;
}
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time,
.event-card.dragging-source .event-location {
opacity: 0.5;
}
.event-card.draft {
border: 2px dashed hsl(var(--color-primary) / 0.6);
background-color: hsl(var(--color-primary) / 0.3) !important;
}
.event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: block;
font-size: 0.6rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 20px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '';
width: 32px;
height: 4px;
background: rgba(255, 255, 255, 0.5);
border-radius: 2px;
}
.resize-handle.top {
top: -6px;
border-radius: var(--radius-sm, 4px) var(--radius-sm, 4px) 0 0;
}
.resize-handle.bottom {
bottom: -6px;
border-radius: 0 0 var(--radius-sm, 4px) var(--radius-sm, 4px);
}
.event-card:hover .resize-handle,
.event-card.resizing .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.15);
}
.event-card:hover .resize-handle::after,
.event-card.resizing .resize-handle::after {
background: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -0,0 +1,347 @@
<script lang="ts">
import { getContext } from 'svelte';
import { eventsStore } from '../stores/events.svelte';
import { getCalendarById, getCalendarColor } from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import EventForm from './EventForm.svelte';
import { toDate } from '../utils/event-date-helpers';
import {
PencilSimple,
Trash,
X,
Clock,
ArrowsClockwise,
MapPin,
TextAlignLeft,
} from '@manacore/shared-icons';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
event: CalendarEvent;
onClose: () => void;
}
let { event, onClose }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let isEditing = $state(false);
let calendarName = $derived(getCalendarById(calendarsCtx.value, event.calendarId)?.name);
let calendarColor = $derived(getCalendarColor(calendarsCtx.value, event.calendarId));
function formatEventTime(ev: CalendarEvent): string {
if (ev.isAllDay) return 'Ganztägig';
const start = toDate(ev.startTime);
const end = toDate(ev.endTime);
return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`;
}
function formatRecurrence(rule: string): string {
if (!rule) return '';
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';
}
async function handleSave(data: Parameters<typeof eventsStore.updateEvent>[1]) {
await eventsStore.updateEvent(event.id, data);
isEditing = false;
}
async function handleDelete() {
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) return;
await eventsStore.deleteEvent(event.id);
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<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)}>
<PencilSimple size={16} />
Bearbeiten
</button>
<button class="btn btn-ghost text-destructive" onclick={handleDelete}>
<Trash size={16} />
Löschen
</button>
{/if}
<button class="btn btn-ghost btn-close" onclick={onClose} aria-label="Schließen">
<X size={20} />
</button>
</div>
</div>
<div class="modal-content">
{#if isEditing}
<EventForm
mode="edit"
{event}
onSave={(data) => handleSave(data)}
onCancel={() => (isEditing = false)}
/>
{:else}
<div class="event-details">
{#if calendarName}
<div class="detail-row">
<span class="detail-icon">
<span class="calendar-dot" style="background-color: {calendarColor}"></span>
</span>
<div class="detail-content">
<span class="detail-label">Kalender</span>
<span class="detail-value">{calendarName}</span>
</div>
</div>
{/if}
<div class="detail-row">
<span class="detail-icon"><Clock size={20} /></span>
<div class="detail-content">
<span class="detail-label">Zeit</span>
<span class="detail-value">{formatEventTime(event)}</span>
</div>
</div>
{#if event.recurrenceRule}
<div class="detail-row">
<span class="detail-icon"><ArrowsClockwise size={20} /></span>
<div class="detail-content">
<span class="detail-label">Wiederholung</span>
<span class="detail-value">{formatRecurrence(event.recurrenceRule)}</span>
</div>
</div>
{/if}
{#if event.location}
<div class="detail-row">
<span class="detail-icon"><MapPin size={20} /></span>
<div class="detail-content">
<span class="detail-label">Ort</span>
<span class="detail-value">{event.location}</span>
</div>
</div>
{/if}
{#if event.description}
<div class="detail-row">
<span class="detail-icon"><TextAlignLeft size={20} /></span>
<div class="detail-content">
<span class="detail-label">Beschreibung</span>
<span class="detail-value description">{event.description}</span>
</div>
</div>
{/if}
</div>
{/if}
</div>
</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-card));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 12px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
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-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, 8px);
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, 0 84% 60%));
}
.text-destructive:hover {
background: hsl(var(--color-error, 0 84% 60%) / 0.1);
}
.calendar-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-top: 4px;
}
</style>

View file

@ -0,0 +1,317 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getDefaultCalendar } from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import { toDate } from '../utils/event-date-helpers';
import { format, addMinutes } from 'date-fns';
interface Props {
mode: 'create' | 'edit';
event?: CalendarEvent;
initialStartTime?: Date | null;
initialEndTime?: Date | null;
onSave: (data: Record<string, unknown>) => void;
onCancel: () => void;
}
let { mode, event, initialStartTime, initialEndTime, onSave, onCancel }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let title = $state(event?.title || '');
let description = $state(event?.description || '');
let location = $state(event?.location || '');
let isAllDay = $state(event?.isAllDay || false);
let calendarId = $state(event?.calendarId || '');
let recurrenceRule = $state(event?.recurrenceRule || '');
// Date/time fields
let startDate = $state('');
let startTime = $state('');
let endDate = $state('');
let endTime = $state('');
$effect(() => {
const defaultCal = getDefaultCalendar(calendarsCtx.value);
if (!calendarId && defaultCal?.id) {
calendarId = defaultCal.id;
}
});
$effect(() => {
if (event) {
const start = toDate(event.startTime);
const end = toDate(event.endTime);
startDate = format(start, 'yyyy-MM-dd');
startTime = format(start, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
} else if (initialStartTime) {
const end = initialEndTime || addMinutes(initialStartTime, 60);
startDate = format(initialStartTime, 'yyyy-MM-dd');
startTime = format(initialStartTime, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
} else {
const now = new Date();
now.setMinutes(0, 0, 0);
const end = addMinutes(now, 60);
startDate = format(now, 'yyyy-MM-dd');
startTime = format(now, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
endTime = format(end, 'HH:mm');
}
});
let submitting = $state(false);
function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
const data: Record<string, unknown> = {
title: title.trim(),
description: description.trim() || null,
location: location.trim() || null,
isAllDay,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
recurrenceRule: recurrenceRule || null,
};
if (mode === 'create') {
data.calendarId = calendarId;
}
submitting = true;
onSave(data);
}
// Calendar options
let calendarOptions = $derived(calendarsCtx.value.filter((c) => c.isVisible));
// Recurrence options
const recurrenceOptions = [
{ value: '', label: 'Keine Wiederholung' },
{ value: 'FREQ=DAILY', label: 'Täglich' },
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
];
</script>
<form
onsubmit={handleSubmit}
class="event-form"
aria-label={mode === 'create' ? 'Termin erstellen' : 'Termin bearbeiten'}
>
<div class="field">
<label for="title" class="label">Titel *</label>
<input
type="text"
id="title"
class="input"
bind:value={title}
placeholder="Terminname eingeben"
required
/>
</div>
{#if mode === 'create' && calendarOptions.length > 1}
<div class="field">
<label for="calendar" class="label">Kalender</label>
<select id="calendar" class="input" bind:value={calendarId}>
{#each calendarOptions as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
{/if}
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={isAllDay} class="checkbox" />
<span>Ganztägig</span>
</label>
</div>
<div class="field-row">
<div class="field flex-1">
<label for="startDate" class="label">Beginn</label>
<input type="date" id="startDate" class="input" bind:value={startDate} required />
</div>
{#if !isAllDay}
<div class="field flex-1">
<label for="startTime" class="label">Uhrzeit</label>
<input type="time" id="startTime" class="input" bind:value={startTime} required />
</div>
{/if}
</div>
<div class="field-row">
<div class="field flex-1">
<label for="endDate" class="label">Ende</label>
<input type="date" id="endDate" class="input" bind:value={endDate} required />
</div>
{#if !isAllDay}
<div class="field flex-1">
<label for="endTime" class="label">Uhrzeit</label>
<input type="time" id="endTime" class="input" bind:value={endTime} required />
</div>
{/if}
</div>
<div class="field">
<label for="recurrence" class="label">Wiederholung</label>
<select id="recurrence" class="input" bind:value={recurrenceRule}>
{#each recurrenceOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="location" class="label">Ort</label>
<input
type="text"
id="location"
class="input"
bind:value={location}
placeholder="Ort eingeben..."
/>
</div>
<div class="field">
<label for="description" class="label">Beschreibung</label>
<textarea
id="description"
class="input textarea"
rows="3"
bind:value={description}
placeholder="Beschreibung hinzufügen"
></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick={onCancel}> Abbrechen </button>
<button type="submit" class="btn btn-primary" disabled={submitting || !title.trim()}>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>
</div>
</form>
<style>
.event-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-row {
display: flex;
gap: 1rem;
}
.flex-1 {
flex: 1;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color 0.15s ease;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 1px hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.textarea {
resize: vertical;
min-height: 5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.checkbox {
width: 1rem;
height: 1rem;
accent-color: hsl(var(--color-primary));
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border));
}
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: none;
}
.btn-secondary {
background: transparent;
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
}
.btn-secondary:hover {
background: hsl(var(--color-muted));
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,164 @@
<script lang="ts">
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
isSameDay,
addMonths,
subMonths,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CaretLeft, CaretRight } from '@manacore/shared-icons';
interface Props {
selectedDate: Date;
onDateSelect: (date: Date) => void;
}
let { selectedDate, onDateSelect }: Props = $props();
let currentMonth = $state(new Date());
let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
});
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
</script>
<div class="mini-calendar">
<div class="calendar-header">
<button
class="nav-btn"
onclick={() => (currentMonth = subMonths(currentMonth, 1))}
aria-label="Vorheriger Monat"
>
<CaretLeft size={16} />
</button>
<span class="month-label">{format(currentMonth, 'MMMM yyyy', { locale: de })}</span>
<button
class="nav-btn"
onclick={() => (currentMonth = addMonths(currentMonth, 1))}
aria-label="Nächster Monat"
>
<CaretRight size={16} />
</button>
</div>
<div class="weekday-row">
{#each weekDays as day}
<span class="weekday">{day}</span>
{/each}
</div>
<div class="days-grid">
{#each calendarDays as day}
<button
class="day"
class:other-month={!isSameMonth(day, currentMonth)}
class:today={isToday(day)}
class:selected={isSameDay(day, selectedDate)}
onclick={() => onDateSelect(day)}
>
{format(day, 'd')}
</button>
{/each}
</div>
</div>
<style>
.mini-calendar {
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 12px);
background: hsl(var(--color-card));
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.month-label {
font-weight: 600;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.nav-btn {
padding: 0.25rem;
border: none;
background: transparent;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.nav-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.weekday-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 0.25rem;
}
.weekday {
text-align: center;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
cursor: pointer;
font-size: 0.75rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
transition: all 0.15s ease;
}
.day:hover {
background: hsl(var(--color-muted));
}
.day.other-month {
color: hsl(var(--color-muted-foreground));
opacity: 0.5;
}
.day.today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.day.selected {
border: 2px solid hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,418 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarViewStore } from '../stores/view.svelte';
import { eventsStore } from '../stores/events.svelte';
import {
filterEventsByVisibleCalendars,
getEventsForDay,
getEventsInRange,
getCalendarColor,
} from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import { toDate } from '../utils/event-date-helpers';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
isSameDay,
differenceInMinutes,
addMinutes,
setHours,
setMinutes,
getHours,
getMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
onEventClick?: (event: CalendarEvent) => void;
onDayClick?: (day: Date) => void;
}
let { onEventClick, onDayClick }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let allCalendarDays = $derived.by(() => {
const monthStart = startOfMonth(calendarViewStore.currentDate);
const monthEnd = endOfMonth(calendarViewStore.currentDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
});
let rangeEvents = $derived.by(() => {
if (allCalendarDays.length === 0) return [];
const visible = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value);
return getEventsInRange(
visible,
allCalendarDays[0],
allCalendarDays[allCalendarDays.length - 1]
);
});
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
let weeks = $derived.by(() => {
const result: Date[][] = [];
for (let i = 0; i < allCalendarDays.length; i += 7) {
result.push(allCalendarDays.slice(i, i + 7));
}
return result;
});
// Drag & Drop
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragTargetDay = $state<Date | null>(null);
let dayCellRefs = $state<Map<string, HTMLElement>>(new Map());
function bindDayCellRef(node: HTMLElement, day: Date) {
const key = format(day, 'yyyy-MM-dd');
dayCellRefs.set(key, node);
return {
destroy() {
dayCellRefs.delete(key);
},
};
}
function getDayFromPoint(x: number, y: number): Date | null {
for (const [key, el] of dayCellRefs) {
const rect = el.getBoundingClientRect();
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
return new Date(key);
}
}
return null;
}
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
isDragging = true;
draggedEvent = event;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isDragging || !draggedEvent) return;
dragTargetDay = getDayFromPoint(e.clientX, e.clientY);
}
function handleDragEnd(e: PointerEvent) {
if (!isDragging || !draggedEvent) {
cleanup();
return;
}
const targetDay = getDayFromPoint(e.clientX, e.clientY);
if (targetDay) {
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
let newStart = new Date(targetDay);
newStart.setHours(getHours(start), getMinutes(start), 0, 0);
const newEnd = addMinutes(newStart, duration);
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();
}
function cleanup() {
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
}
function getEventsForDayFiltered(day: Date): CalendarEvent[] {
return getEventsForDay(rangeEvents, day).slice(0, 3);
}
function getAllEventsForDay(day: Date): CalendarEvent[] {
return getEventsForDay(rangeEvents, day);
}
function handleDayClick(day: Date, e: MouseEvent) {
if (isDragging) return;
if (onDayClick) {
onDayClick(day);
} else {
calendarViewStore.setDate(day);
calendarViewStore.setViewType('week');
}
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
if (isDragging) {
e.preventDefault();
e.stopPropagation();
return;
}
e.stopPropagation();
onEventClick?.(event);
}
function handleMoreClick(day: Date, e: MouseEvent) {
e.stopPropagation();
calendarViewStore.setDate(day);
calendarViewStore.setViewType('week');
}
</script>
<div class="month-view">
<!-- Week day headers -->
<div class="weekday-headers" role="row">
{#each weekDays as day}
<div class="weekday-header" role="columnheader">{day}</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="calendar-grid" role="grid" aria-label="Monatsansicht">
{#each weeks as week}
<div class="week-row" role="row">
{#each week as day}
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<div
class="day-cell"
class:other-month={!isSameMonth(day, calendarViewStore.currentDate)}
class:today={isToday(day)}
class:drop-target={isDropTarget}
use:bindDayCellRef={day}
onclick={(e) => handleDayClick(day, e)}
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
role="gridcell"
tabindex="0"
aria-selected={isToday(day)}
>
<div class="day-header">
<span class="day-number" class:today={isToday(day)}>
{format(day, 'd')}
</span>
</div>
<div class="day-events">
{#each getEventsForDayFiltered(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-pill"
class:dragging={isBeingDragged}
data-event-id={event.id}
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => handleEventClick(event, e)}
role="button"
tabindex="0"
aria-label={event.title}
>
{#if !event.isAllDay}
<span class="event-time">{format(toDate(event.startTime), 'HH:mm')}</span>
{/if}
<span class="event-title">{event.title}</span>
</div>
{/each}
{#if getAllEventsForDay(day).length > 3}
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
+{getAllEventsForDay(day).length - 3} weitere
</button>
{/if}
</div>
</div>
{/each}
</div>
{/each}
</div>
</div>
<style>
.month-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.weekday-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
position: sticky;
top: 0;
z-index: 10;
}
.weekday-header {
padding: 0.75rem;
text-align: center;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
}
.calendar-grid {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.week-row {
flex: 1 1 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: 0;
}
.day-cell {
border: 1px solid hsl(var(--color-border));
border-top: none;
border-left: none;
padding: 0.25rem;
background: transparent;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
transition: background-color 0.15s ease;
min-height: 0;
overflow: hidden;
}
.day-cell:first-child {
border-left: 1px solid hsl(var(--color-border));
}
.day-cell:hover {
background-color: hsl(var(--color-muted) / 0.3);
}
.day-cell.today {
background-color: hsl(var(--color-primary) / 0.1);
}
.day-cell.other-month {
opacity: 0.5;
}
.day-cell.drop-target {
background-color: hsl(var(--color-primary) / 0.2) !important;
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.day-number {
font-size: 0.875rem;
font-weight: 500;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
.day-number.today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.day-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.event-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
font-size: 0.75rem;
color: white;
border-radius: var(--radius-sm, 4px);
border: none;
cursor: grab;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
touch-action: none;
user-select: none;
transition:
transform 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
.event-pill:hover {
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.event-pill.dragging {
cursor: grabbing;
opacity: 0.5;
transform: scale(0.95);
}
.event-time {
font-size: 0.65rem;
opacity: 0.9;
}
.event-title {
overflow: hidden;
text-overflow: ellipsis;
}
.more-events {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
text-align: left;
}
.more-events:hover {
color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,515 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { calendarViewStore } from '../stores/view.svelte';
import { eventsStore } from '../stores/events.svelte';
import {
getEventsForDay,
getEventsInRange,
filterEventsByVisibleCalendars,
getCalendarColor,
getDefaultCalendar,
} from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import {
useVisibleHours,
useCurrentTimeIndicator,
useEventDragDrop,
useDragToCreate,
useCalendarKeyboard,
} from '../composables';
import { toDate } from '../utils/event-date-helpers';
import { HOUR_HEIGHT_PX } from '../utils/constants';
import EventCard from './EventCard.svelte';
import {
format,
eachDayOfInterval,
isToday,
isSameDay,
differenceInMinutes,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
onEventClick?: (event: CalendarEvent) => void;
onQuickCreate?: (startTime: Date, endTime: Date) => void;
}
let { onEventClick, onQuickCreate }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value));
let viewRange = $derived({
start: startOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
end: endOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
});
let rangeEvents = $derived(getEventsInRange(visibleEvents, viewRange.start, viewRange.end));
let days = $derived(eachDayOfInterval({ start: viewRange.start, end: viewRange.end }));
const visibleHours = useVisibleHours();
const timeIndicator = useCurrentTimeIndicator();
let hours = $derived(visibleHours.hours);
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
const minutesToPercent = visibleHours.minutesToPercent;
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
let daysContainerEl: HTMLDivElement;
let timeGridEl: HTMLDivElement;
const eventDragDrop = useEventDragDrop(() => ({
containerEl: daysContainerEl,
days,
firstVisibleHour,
lastVisibleHour,
totalVisibleHours,
hourHeight: HOUR_HEIGHT_PX,
minutesToPercent,
}));
const dragToCreate = useDragToCreate(() => ({
containerEl: daysContainerEl,
days,
firstVisibleHour,
lastVisibleHour,
totalVisibleHours,
hourHeight: HOUR_HEIGHT_PX,
minutesToPercent,
isOtherOperationActive: () => eventDragDrop.isDragging || eventDragDrop.isResizing,
onCreateEnd: (startTime, endTime, _position) => {
if (onQuickCreate) {
onQuickCreate(startTime, endTime);
}
},
}));
const keyboard = useCalendarKeyboard([
{
isActive: () => eventDragDrop.isDragging || eventDragDrop.isResizing,
cancel: eventDragDrop.cancel,
},
{ isActive: () => dragToCreate.isCreating, cancel: dragToCreate.cancel },
]);
$effect(() => keyboard.setup());
onMount(() => {
if (!timeGridEl) return;
const now = new Date();
const currentMinutesFromMidnight = now.getHours() * 60 + now.getMinutes();
const viewportHeight = timeGridEl.clientHeight;
const effectiveMinutes = Math.max(0, currentMinutesFromMidnight - firstVisibleHour * 60);
const currentTimePixels = (effectiveMinutes / 60) * HOUR_HEIGHT_PX;
const scrollPosition = currentTimePixels - viewportHeight / 3;
timeGridEl.scrollTo({
top: Math.max(0, scrollPosition),
behavior: 'instant',
});
});
function getTimedEventsForDay(day: Date): CalendarEvent[] {
const dayEvents = getEventsForDay(rangeEvents, day);
return dayEvents.filter((e) => !e.isAllDay);
}
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
const dayEvents = getEventsForDay(rangeEvents, day);
return dayEvents.filter((e) => e.isAllDay);
}
let hasAnyAllDayEvents = $derived(days.some((day) => getAllDayEventsForDay(day).length > 0));
function getEventStyle(event: CalendarEvent) {
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
return `top: ${top}%; height: ${height}%;`;
}
function formatEventTimeRange(event: CalendarEvent): string {
const start = toDate(event.startTime);
const end = toDate(event.endTime);
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
if (eventDragDrop.isDragging || eventDragDrop.isResizing || eventDragDrop.hasMoved) {
e.preventDefault();
e.stopPropagation();
setTimeout(() => eventDragDrop.resetHasMoved(), 100);
return;
}
onEventClick?.(event);
}
function formatHour(hour: number): string {
return `${hour.toString().padStart(2, '0')}:00`;
}
</script>
<div class="week-view">
<!-- Sticky header -->
<div class="sticky-header">
<!-- Day headers -->
<div class="day-headers" role="row">
<div class="time-gutter"></div>
{#each days as day}
<div
class="day-header"
class:today={isToday(day)}
role="columnheader"
aria-label={format(day, 'EEEE, d. MMMM', { locale: de })}
>
<span class="day-name">{format(day, 'EEE', { locale: de })}</span>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
</div>
{/each}
</div>
<!-- All-day events row -->
{#if hasAnyAllDayEvents}
<div class="all-day-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="all-day-cell">
{#each getAllDayEventsForDay(day) as event}
<button
class="all-day-event"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
onclick={() => onEventClick?.(event)}
aria-label="{event.title} - Ganztägig"
>
{event.title}
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<!-- Time grid -->
<div
class="time-grid scrollbar-thin"
bind:this={timeGridEl}
role="grid"
aria-label="Wochenansicht"
>
<!-- Time column -->
<div class="time-column">
{#each hours as hour}
<div class="time-label">
{formatHour(hour)}
</div>
{/each}
</div>
<!-- Day columns -->
<div class="days-container" bind:this={daysContainerEl}>
{#each days as day}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(day)}
class:creating={dragToCreate.isCreating &&
dragToCreate.createTargetDay &&
isSameDay(day, dragToCreate.createTargetDay)}
onpointerdown={dragToCreate.startCreate}
>
{#each hours as hour}
<div class="hour-slot" role="button" tabindex="-1"></div>
{/each}
<!-- Timed events -->
{#each getTimedEventsForDay(day) as event (event.id)}
{@const isBeingDragged =
eventDragDrop.isDragging && eventDragDrop.draggedEvent?.id === event.id}
{@const isBeingResized =
eventDragDrop.isResizing && eventDragDrop.resizeEvent?.id === event.id}
{@const isCrossDayDrag =
isBeingDragged &&
eventDragDrop.dragTargetDay !== null &&
!isSameDay(day, eventDragDrop.dragTargetDay)}
<EventCard
{event}
style={isBeingDragged && !isCrossDayDrag
? `top: ${eventDragDrop.dragPreviewTop}%; height: ${eventDragDrop.dragPreviewHeight}%;`
: isBeingResized
? `top: ${eventDragDrop.resizePreviewTop}%; height: ${eventDragDrop.resizePreviewHeight}%;`
: getEventStyle(event)}
color={getCalendarColor(calendarsCtx.value, event.calendarId)}
isDragging={isBeingDragged && !isCrossDayDrag}
isDraggingSource={isCrossDayDrag}
isResizing={isBeingResized}
formattedTime={isBeingResized
? eventDragDrop.getResizePreviewTime()
: formatEventTimeRange(event)}
onClick={handleEventClick}
onPointerDown={eventDragDrop.startDrag}
onResizeStart={eventDragDrop.startResize}
/>
{/each}
<!-- Drag preview for cross-day dragging -->
{#if eventDragDrop.isDragging && eventDragDrop.draggedEvent && eventDragDrop.dragTargetDay && isSameDay(day, eventDragDrop.dragTargetDay) && !getTimedEventsForDay(day).some((e) => e.id === eventDragDrop.draggedEvent!.id)}
<EventCard
event={eventDragDrop.draggedEvent}
style="top: {eventDragDrop.dragPreviewTop}%; height: {eventDragDrop.dragPreviewHeight}%;"
color={getCalendarColor(calendarsCtx.value, eventDragDrop.draggedEvent.calendarId)}
isDragging={true}
formattedTime={formatEventTimeRange(eventDragDrop.draggedEvent)}
/>
{/if}
<!-- Create preview (drag-to-create) -->
{#if dragToCreate.isCreating && dragToCreate.createTargetDay && isSameDay(day, dragToCreate.createTargetDay)}
<div
class="create-preview"
style="top: {dragToCreate.createPreviewTop}%; height: {dragToCreate.createPreviewHeight}%; background-color: {getCalendarColor(
calendarsCtx.value,
getDefaultCalendar(calendarsCtx.value)?.id || ''
)};"
>
<span class="preview-time">{dragToCreate.getCreatePreviewTime()}</span>
<span class="preview-title">(Neuer Termin)</span>
</div>
{/if}
<!-- Current time indicator -->
{#if isToday(day)}
<div class="time-indicator" style="top: {currentTimePosition}%">
<span class="time-indicator-label">{format(timeIndicator.now, 'HH:mm')}</span>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
<style>
.week-view {
display: flex;
flex-direction: column;
height: 100%;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background: hsl(var(--color-background));
}
.day-headers {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
}
.time-gutter {
width: 60px;
flex-shrink: 0;
}
.day-header {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
border-left: 1px solid hsl(var(--color-border));
transition: background-color 150ms ease;
}
.day-name {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
}
.day-number {
font-size: 1.25rem;
font-weight: 600;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
.day-number.today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
/* All-day events row */
.all-day-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
min-height: 32px;
}
.all-day-cell {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px;
border-left: 1px solid hsl(var(--color-border));
}
.all-day-event {
width: 100%;
padding: 2px 6px;
font-size: 0.75rem;
color: white;
border-radius: var(--radius-sm, 4px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
cursor: pointer;
transition: opacity 0.15s ease;
text-align: left;
}
.all-day-event:hover {
opacity: 0.85;
}
/* Time grid */
.time-grid {
flex: 1;
display: flex;
overflow-y: auto;
}
.time-column {
width: 60px;
flex-shrink: 0;
padding-top: 1rem;
}
.time-label {
height: 60px;
padding-right: 0.5rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
position: relative;
top: 0.25em;
}
.days-container {
flex: 1;
display: flex;
position: relative;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--color-border));
padding-top: 1rem;
}
.day-column.today {
background: hsl(var(--color-primary) / 0.05);
}
.hour-slot {
height: 60px;
width: 100%;
border: none;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
background: transparent;
cursor: pointer;
}
.hour-slot:hover {
background: hsl(var(--color-muted) / 0.3);
}
/* Create preview */
.create-preview {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm, 4px);
text-align: left;
z-index: 50;
overflow: hidden;
color: white;
opacity: 0.85;
border: 2px dashed rgba(255, 255, 255, 0.5);
pointer-events: none;
}
.create-preview .preview-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.create-preview .preview-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.8;
}
.day-column.creating {
cursor: ns-resize;
}
/* Current time indicator */
.time-indicator {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: hsl(var(--color-error, 0 84% 60%));
z-index: 2;
}
.time-indicator::before {
content: '';
position: absolute;
left: -4px;
top: -4px;
width: 10px;
height: 10px;
background: hsl(var(--color-error, 0 84% 60%));
border-radius: 50%;
}
.time-indicator-label {
position: absolute;
right: 4px;
bottom: 3px;
font-size: 0.6rem;
font-weight: 600;
color: hsl(var(--color-error, 0 84% 60%));
line-height: 1;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,8 @@
export { default as WeekView } from './WeekView.svelte';
export { default as MonthView } from './MonthView.svelte';
export { default as AgendaView } from './AgendaView.svelte';
export { default as EventCard } from './EventCard.svelte';
export { default as EventDetailModal } from './EventDetailModal.svelte';
export { default as EventForm } from './EventForm.svelte';
export { default as CalendarHeader } from './CalendarHeader.svelte';
export { default as MiniCalendar } from './MiniCalendar.svelte';

View file

@ -0,0 +1,4 @@
export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte';
export { useEventDragDrop } from './useEventDragDrop.svelte';
export { useDragToCreate } from './useDragToCreate.svelte';
export { useCalendarKeyboard } from './useCalendarKeyboard.svelte';

View file

@ -0,0 +1,27 @@
/**
* Calendar Keyboard Handling Composable
*/
export interface CancellableOperation {
isActive: () => boolean;
cancel: () => void;
}
export function useCalendarKeyboard(operations: CancellableOperation[]) {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
const activeOperation = operations.find((op) => op.isActive());
if (activeOperation) {
e.preventDefault();
activeOperation.cancel();
}
}
}
function setup() {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
return { setup, handleKeyDown };
}

View file

@ -0,0 +1,169 @@
/**
* Drag-to-Create Composable
* Click-and-drag on the calendar grid to create new events
*/
import { DEFAULT_EVENT_DURATION_MINUTES } from '../utils/constants';
import { formatTime, getSnapMinutes, getDayFromX, getMinutesFromY } from '../utils/drag-helpers';
export interface DragToCreateConfig {
containerEl: HTMLElement | null;
days: Date[];
firstVisibleHour: number;
lastVisibleHour: number;
totalVisibleHours: number;
hourHeight: number;
minutesToPercent: (minutes: number) => number;
snapMinutes?: number;
isOtherOperationActive: () => boolean;
onCreateEnd?: (startTime: Date, endTime: Date, position: { x: number; y: number }) => void;
}
export function useDragToCreate(getConfig: () => DragToCreateConfig) {
let isCreating = $state(false);
let createTargetDay = $state<Date | null>(null);
let createStartMinutes = $state(0);
let createEndMinutes = $state(0);
let createPreviewTop = $state(0);
let createPreviewHeight = $state(0);
let hasMoved = $state(false);
function dayFromX(clientX: number): Date | null {
const config = getConfig();
return getDayFromX(clientX, config.containerEl, config.days);
}
function minutesFromY(clientY: number): number {
const config = getConfig();
return getMinutesFromY(
clientY,
config.containerEl,
config.totalVisibleHours,
config.hourHeight,
config.firstVisibleHour,
config.snapMinutes
);
}
function updatePreview() {
const config = getConfig();
createPreviewTop = config.minutesToPercent(createStartMinutes);
const duration = createEndMinutes - createStartMinutes;
createPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
}
function startCreate(e: PointerEvent) {
const config = getConfig();
if (config.isOtherOperationActive()) return;
const target = e.target as HTMLElement;
if (
target.closest(
'.event-card, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle'
)
) {
return;
}
e.preventDefault();
const day = dayFromX(e.clientX);
if (!day) return;
const minutes = minutesFromY(e.clientY);
const snap = getSnapMinutes(config.snapMinutes);
const snappedMinutes = Math.round(minutes / snap) * snap;
isCreating = true;
hasMoved = false;
createTargetDay = day;
createStartMinutes = snappedMinutes;
createEndMinutes = snappedMinutes + DEFAULT_EVENT_DURATION_MINUTES;
updatePreview();
document.addEventListener('pointermove', handleCreateMove);
document.addEventListener('pointerup', handleCreateEnd);
}
function handleCreateMove(e: PointerEvent) {
if (!isCreating) return;
hasMoved = true;
const config = getConfig();
const snap = getSnapMinutes(config.snapMinutes);
const day = dayFromX(e.clientX);
if (day) createTargetDay = day;
const minutes = minutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / snap) * snap;
if (snappedMinutes >= createStartMinutes) {
createEndMinutes = Math.max(snappedMinutes, createStartMinutes + snap);
} else {
createEndMinutes = createStartMinutes + snap;
createStartMinutes = snappedMinutes;
}
createStartMinutes = Math.max(config.firstVisibleHour * 60, createStartMinutes);
createEndMinutes = Math.min(config.lastVisibleHour * 60, createEndMinutes);
updatePreview();
}
function handleCreateEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
if (!isCreating || !createTargetDay) {
isCreating = false;
return;
}
const startTime = new Date(createTargetDay);
startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0);
const endTime = new Date(createTargetDay);
endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0);
isCreating = false;
createTargetDay = null;
hasMoved = false;
const config = getConfig();
config.onCreateEnd?.(startTime, endTime, { x: e.clientX, y: e.clientY });
}
function getCreatePreviewTime(): string {
return `${formatTime(Math.floor(createStartMinutes / 60), createStartMinutes % 60)} - ${formatTime(Math.floor(createEndMinutes / 60), createEndMinutes % 60)}`;
}
function cancel() {
if (isCreating) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
isCreating = false;
createTargetDay = null;
hasMoved = false;
}
}
return {
get isCreating() {
return isCreating;
},
get createTargetDay() {
return createTargetDay;
},
get createPreviewTop() {
return createPreviewTop;
},
get createPreviewHeight() {
return createPreviewHeight;
},
startCreate,
cancel,
getCreatePreviewTime,
};
}

View file

@ -0,0 +1,353 @@
/**
* Event Drag & Drop + Resize Composable
*/
import type { CalendarEvent } from '../types';
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { toDate } from '../utils/event-date-helpers';
import { eventsStore } from '../stores/events.svelte';
import { formatTime, getDayFromX, getMinutesFromY } from '../utils/drag-helpers';
export interface EventDragDropConfig {
containerEl: HTMLElement | null;
days: Date[];
firstVisibleHour: number;
lastVisibleHour: number;
totalVisibleHours: number;
hourHeight: number;
snapMinutes?: number;
minutesToPercent: (minutes: number) => number;
}
export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
let isResizing = $state(false);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let resizeOffsetMinutes = $state(0);
let hasMoved = $state(false);
function dayFromX(clientX: number): Date | null {
const config = getConfig();
return getDayFromX(clientX, config.containerEl, config.days);
}
function minutesFromY(clientY: number): number {
const config = getConfig();
return getMinutesFromY(
clientY,
config.containerEl,
config.totalVisibleHours,
config.hourHeight,
config.firstVisibleHour,
config.snapMinutes
);
}
// ========== Drag ==========
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isDragging = true;
draggedEvent = event;
hasMoved = false;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const duration = differenceInMinutes(end, start);
const startMinutes = start.getHours() * 60 + start.getMinutes();
dragPreviewTop = config.minutesToPercent(startMinutes);
dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
dragTargetDay = start;
const clickMinutes = minutesFromY(e.clientY);
dragOffsetMinutes = clickMinutes - startMinutes;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isDragging || !draggedEvent) return;
const config = getConfig();
hasMoved = true;
const newDay = dayFromX(e.clientX);
const newMinutes = minutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
);
dragPreviewTop = config.minutesToPercent(clampedMinutes);
if (newDay) {
dragTargetDay = newDay;
}
}
async function handleDragEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
cleanupDrag();
return;
}
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
const newMinutes = minutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
const newHours = Math.floor(clampedMinutes / 60);
const newMins = clampedMinutes % 60;
let newStart = new Date(dragTargetDay);
newStart = setHours(newStart, newHours);
newStart = setMinutes(newStart, newMins);
const newEnd = addMinutes(newStart, duration);
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(),
});
}
cleanupDrag();
}
function cleanupDrag() {
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
hasMoved = false;
}
// ========== Resize ==========
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isResizing = true;
resizeEvent = event;
resizeEdge = edge;
hasMoved = false;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = config.minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
const clickMinutes = minutesFromY(e.clientY);
resizeOffsetMinutes = edge === 'top' ? clickMinutes - startMinutes : clickMinutes - endMinutes;
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
const config = getConfig();
hasMoved = true;
const currentMinutes = minutesFromY(e.clientY);
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
} else {
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = config.minutesToPercent(newStartMinutes);
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
}
}
async function handleResizeEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
cleanupResize();
return;
}
const config = getConfig();
const currentMinutes = minutesFromY(e.clientY);
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
let newStart = resizeOriginalStart;
let newEnd = resizeOriginalEnd;
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
newEnd = setMinutes(newEnd, newMins);
} else {
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
newStart = setHours(new Date(resizeOriginalStart), newHours);
newStart = setMinutes(newStart, newMins);
}
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(),
});
}
cleanupResize();
}
function cleanupResize() {
isResizing = false;
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
}
function cancel() {
if (isDragging || isResizing) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanupDrag();
cleanupResize();
}
}
function getResizePreviewTime(): string {
if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return '';
const config = getConfig();
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
const previewStartMinutes =
(resizePreviewTop / 100) * config.totalVisibleHours * 60 + config.firstVisibleHour * 60;
const previewEndMinutes =
previewStartMinutes + (resizePreviewHeight / 100) * config.totalVisibleHours * 60;
let startMin: number;
let endMin: number;
if (resizeEdge === 'top') {
startMin = Math.round(previewStartMinutes);
endMin = origEndMinutes;
} else {
startMin = origStartMinutes;
endMin = Math.round(previewEndMinutes);
}
return `${formatTime(Math.floor(startMin / 60), startMin % 60)} - ${formatTime(Math.floor(endMin / 60), endMin % 60)}`;
}
return {
get isDragging() {
return isDragging;
},
get draggedEvent() {
return draggedEvent;
},
get dragTargetDay() {
return dragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
},
get isResizing() {
return isResizing;
},
get resizeEvent() {
return resizeEvent;
},
get resizePreviewTop() {
return resizePreviewTop;
},
get resizePreviewHeight() {
return resizePreviewHeight;
},
get hasMoved() {
return hasMoved;
},
resetHasMoved() {
hasMoved = false;
},
startDrag,
startResize,
cancel,
getResizePreviewTime,
};
}

View file

@ -0,0 +1,59 @@
/**
* useVisibleHours + useCurrentTimeIndicator composables
* Provides hour filtering and time-to-position calculations.
*/
const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i);
export function useVisibleHours() {
// No hour filtering in manacore yet — show all 24 hours
const firstVisibleHour = 0;
const lastVisibleHour = 24;
const totalVisibleHours = 24;
function minutesToPercent(minutes: number): number {
const adjustedMinutes = minutes - firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
}
function percentToMinutes(percent: number): number {
return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
}
return {
get hours() {
return ALL_HOURS;
},
get firstVisibleHour() {
return firstVisibleHour;
},
get lastVisibleHour() {
return lastVisibleHour;
},
get totalVisibleHours() {
return totalVisibleHours;
},
minutesToPercent,
percentToMinutes,
};
}
export function useCurrentTimeIndicator() {
let now = $state(new Date());
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
return {
get now() {
return now;
},
get currentMinutes() {
return now.getHours() * 60 + now.getMinutes();
},
};
}

View file

@ -0,0 +1,9 @@
/**
* Shared calendar constants
*/
export const HOUR_HEIGHT_PX = 60;
export const SNAP_INTERVAL_MINUTES = 15;
export const DEFAULT_EVENT_DURATION_MINUTES = 60;
export const MIN_EVENT_HEIGHT_PERCENT = 1.5;
export const MAX_MONTH_VIEW_EVENTS = 3;

View file

@ -0,0 +1,56 @@
/**
* Shared drag/drop utility functions
*/
import { SNAP_INTERVAL_MINUTES } from './constants';
export function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
export function getSnapMinutes(snapMinutes?: number): number {
return snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
export function snapToGrid(minutes: number, snapMinutes?: number): number {
const snap = getSnapMinutes(snapMinutes);
return Math.round(minutes / snap) * snap;
}
export function getDayFromX(
clientX: number,
containerEl: HTMLElement | null,
days: Date[]
): Date | null {
if (!containerEl) return null;
const rect = containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < days.length) {
return days[dayIndex];
}
return null;
}
export function getMinutesFromY(
clientY: number,
containerEl: HTMLElement | null,
totalVisibleHours: number,
hourHeight: number,
firstVisibleHour: number,
snapMinutes?: number
): number {
if (!containerEl) return 0;
const rect = containerEl.getBoundingClientRect();
const scrollTop = containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes = (relativeY / (totalVisibleHours * hourHeight)) * totalVisibleHours * 60;
const totalMinutes = visibleMinutes + firstVisibleHour * 60;
return snapToGrid(totalMinutes, snapMinutes);
}

View file

@ -0,0 +1,17 @@
/**
* Event Date Helpers
*/
import { parseISO } from 'date-fns';
export function toDate(value: string | Date): Date {
return typeof value === 'string' ? parseISO(value) : value;
}
export function getEventStart(event: { startTime: string | Date }): Date {
return toDate(event.startTime);
}
export function getEventEnd(event: { endTime: string | Date }): Date {
return toDate(event.endTime);
}

View file

@ -7,24 +7,20 @@
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import {
getDefaultCalendar,
getEventsForDay,
getEventsInRange,
filterEventsByVisibleCalendars,
sortEventsByTime,
getCalendarColor,
} from '$lib/modules/calendar/queries';
import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types';
import {
format,
addMinutes,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameDay,
isToday,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CaretLeft, CaretRight, Plus, ShareNetwork } from '@manacore/shared-icons';
import CalendarHeader from '$lib/modules/calendar/components/CalendarHeader.svelte';
import WeekView from '$lib/modules/calendar/components/WeekView.svelte';
import MonthView from '$lib/modules/calendar/components/MonthView.svelte';
import AgendaView from '$lib/modules/calendar/components/AgendaView.svelte';
import MiniCalendar from '$lib/modules/calendar/components/MiniCalendar.svelte';
import EventDetailModal from '$lib/modules/calendar/components/EventDetailModal.svelte';
import EventForm from '$lib/modules/calendar/components/EventForm.svelte';
import { ShareNetwork } from '@manacore/shared-icons';
import { ShareModal } from '@manacore/shared-uload';
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
@ -41,21 +37,13 @@
}
function handleTagDrop(event: CalendarEvent, payload: DragPayload) {
const tagData = payload.data as TagDragData;
const tagData = payload.data as unknown as TagDragData;
const current = event.tagIds ?? [];
if (!current.includes(tagData.id)) {
eventsStore.updateTagIds(event.id, [...current, tagData.id]);
}
}
function tagNotAlreadyOnEvent(event: CalendarEvent) {
return (payload: DragPayload) => {
const tagData = payload.data as TagDragData;
return !(event.tagIds ?? []).includes(tagData.id);
};
}
// Register passive handler for task→tag direction
const tagDropCtx = getContext<{
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
clear: () => void;
@ -64,7 +52,6 @@
onMount(() => {
tagDropCtx?.set(async (tagId: string, payload: DragPayload) => {
const data = payload.data as { id: string };
// Check if dropped item is an event
if (payload.type === 'event') {
const event = eventsCtx.value.find((e) => e.id === data.id);
if (!event) return;
@ -77,508 +64,129 @@
return () => tagDropCtx?.clear();
});
// Filtered events based on visible calendars
let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value));
// ── Event interactions ──────────────────────────────────
let selectedEvent = $state<CalendarEvent | null>(null);
let showCreateForm = $state(false);
let createStartTime = $state<Date | null>(null);
let createEndTime = $state<Date | null>(null);
// Current view range events
let rangeEvents = $derived(
sortEventsByTime(
getEventsInRange(
visibleEvents,
calendarViewStore.viewRange.start,
calendarViewStore.viewRange.end
)
)
);
// Week days for the week view
let weekDays = $derived(
eachDayOfInterval({
start: startOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
end: endOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
})
);
// Event form state
let showEventForm = $state(false);
let editingEvent = $state<CalendarEvent | null>(null);
let newTitle = $state('');
let newDate = $state('');
let newStartTime = $state('10:00');
let newEndTime = $state('11:00');
let newAllDay = $state(false);
let newLocation = $state('');
function openNewEvent(date?: Date) {
const d = date ?? new Date();
editingEvent = null;
newTitle = '';
newDate = format(d, 'yyyy-MM-dd');
newStartTime = '10:00';
newEndTime = '11:00';
newAllDay = false;
newLocation = '';
showEventForm = true;
function handleEventClick(event: CalendarEvent) {
selectedEvent = event;
}
function openEditEvent(event: CalendarEvent) {
editingEvent = event;
newTitle = event.title;
newDate = format(new Date(event.startTime), 'yyyy-MM-dd');
newStartTime = format(new Date(event.startTime), 'HH:mm');
newEndTime = format(new Date(event.endTime), 'HH:mm');
newAllDay = event.isAllDay;
newLocation = event.location ?? '';
showEventForm = true;
function handleNewEvent() {
createStartTime = null;
createEndTime = null;
showCreateForm = true;
}
async function handleSaveEvent() {
function handleQuickCreate(startTime: Date, endTime: Date) {
createStartTime = startTime;
createEndTime = endTime;
showCreateForm = true;
}
async function handleCreateSave(data: Record<string, unknown>) {
const defaultCal = getDefaultCalendar(calendarsCtx.value);
const startTime = newAllDay ? `${newDate}T00:00:00` : `${newDate}T${newStartTime}:00`;
const endTime = newAllDay ? `${newDate}T23:59:59` : `${newDate}T${newEndTime}:00`;
if (editingEvent) {
await eventsStore.updateEvent(editingEvent.id, {
title: newTitle,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
isAllDay: newAllDay,
location: newLocation || null,
});
} else {
await eventsStore.createEvent({
calendarId: defaultCal?.id || '',
title: newTitle,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
isAllDay: newAllDay,
location: newLocation || null,
});
}
showEventForm = false;
await eventsStore.createEvent({
calendarId: (data.calendarId as string) || defaultCal?.id || '',
title: data.title as string,
description: (data.description as string) || null,
startTime: data.startTime as string,
endTime: data.endTime as string,
isAllDay: data.isAllDay as boolean,
location: (data.location as string) || null,
recurrenceRule: (data.recurrenceRule as string) || null,
});
showCreateForm = false;
}
async function handleDeleteEvent() {
if (!editingEvent) return;
await eventsStore.deleteEvent(editingEvent.id);
showEventForm = false;
}
// Share modal state
// Share modal
let shareEvent = $state<CalendarEvent | null>(null);
let shareUrl = $derived(
shareEvent
? `${typeof window !== 'undefined' ? window.location.origin : ''}/calendar?event=${shareEvent.id}`
: ''
);
// Hours for the week grid
const hours = Array.from({ length: 24 }, (_, i) => i);
let headerLabel = $derived.by(() => {
if (calendarViewStore.viewType === 'month') {
return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de });
}
return format(calendarViewStore.currentDate, "'KW' w — MMMM yyyy", { locale: de });
});
</script>
<svelte:head>
<title>Kalender - ManaCore</title>
</svelte:head>
<div class="flex h-full flex-col">
<div class="calendar-page">
<!-- Header -->
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-foreground">{headerLabel}</h1>
<div class="flex items-center gap-1">
<button
onclick={() => calendarViewStore.goToPrevious()}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<CaretLeft size={18} />
</button>
<button
onclick={() => calendarViewStore.goToToday()}
class="rounded-lg px-3 py-1 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
Heute
</button>
<button
onclick={() => calendarViewStore.goToNext()}
class="rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<CaretRight size={18} />
</button>
</div>
</div>
<CalendarHeader onNewEvent={handleNewEvent} />
<div class="flex items-center gap-2">
<!-- View Type Switcher -->
<div class="flex rounded-lg border border-border bg-card">
{#each ['week', 'month', 'agenda'] as view}
<button
onclick={() => calendarViewStore.setViewType(view as 'week' | 'month' | 'agenda')}
class="px-3 py-1.5 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg {calendarViewStore.viewType ===
view
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
{view === 'week' ? 'Woche' : view === 'month' ? 'Monat' : 'Agenda'}
</button>
{/each}
</div>
<!-- Main content area -->
<div class="calendar-content">
<!-- Sidebar (desktop only) -->
<aside class="calendar-sidebar">
<MiniCalendar
selectedDate={calendarViewStore.currentDate}
onDateSelect={(date) => {
calendarViewStore.setDate(date);
if (calendarViewStore.viewType === 'month') {
calendarViewStore.setViewType('week');
}
}}
/>
<!-- New Event Button -->
<button
onclick={() => openNewEvent()}
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Termin
</button>
</div>
</header>
<!-- View Content -->
<div class="flex-1 overflow-auto">
{#if calendarViewStore.viewType === 'week'}
<!-- Week View -->
<div class="week-grid">
<!-- Day Headers -->
<div
class="sticky top-0 z-10 grid grid-cols-[60px_repeat(7,1fr)] border-b border-border bg-card"
>
<div class="p-2"></div>
{#each weekDays as day}
<button
onclick={() => {
calendarViewStore.setDate(day);
}}
class="border-l border-border p-2 text-center {isToday(day) ? 'bg-primary/10' : ''}"
>
<div class="text-xs text-muted-foreground">
{format(day, 'EEE', { locale: de })}
</div>
<div
class="text-lg font-semibold {isToday(day) ? 'text-primary' : 'text-foreground'}"
>
{format(day, 'd')}
</div>
</button>
{/each}
</div>
<!-- Time Grid -->
<div class="relative">
{#each hours as hour}
<div class="grid grid-cols-[60px_repeat(7,1fr)] border-b border-border/50">
<div class="p-1 pr-2 text-right text-xs text-muted-foreground">
{hour.toString().padStart(2, '0')}:00
</div>
{#each weekDays as day}
<button
onclick={() => {
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
openNewEvent(d);
}}
class="h-12 border-l border-border/50 hover:bg-muted/50 transition-colors relative"
>
<!-- Render events at this slot -->
{#each getEventsForDay(visibleEvents, day).filter((e) => {
const h = new Date(e.startTime).getHours();
return h === hour && !e.isAllDay;
}) as event}
<div
class="absolute inset-x-0.5 top-0 z-10 rounded px-1 py-0.5 text-xs text-white truncate cursor-pointer"
style="background-color: {getCalendarColor(
calendarsCtx.value,
event.calendarId
)}"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
openEditEvent(event);
}}
onkeydown={(e) => e.key === 'Enter' && openEditEvent(event)}
>
{event.title}
</div>
{/each}
</button>
{/each}
</div>
{/each}
</div>
</div>
{:else if calendarViewStore.viewType === 'month'}
<!-- Month View -->
<div class="p-4">
<div
class="grid grid-cols-7 gap-px rounded-lg border border-border bg-border overflow-hidden"
>
<!-- Day name headers -->
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as dayName}
<div class="bg-card p-2 text-center text-xs font-medium text-muted-foreground">
{dayName}
</div>
{/each}
<!-- Calendar days -->
{#each eachDayOfInterval( { start: startOfWeek( calendarViewStore.viewRange.start, { weekStartsOn: 1 } ), end: endOfWeek( calendarViewStore.viewRange.end, { weekStartsOn: 1 } ) } ) as day}
<button
onclick={() => {
calendarViewStore.setDate(day);
calendarViewStore.setViewType('week');
}}
class="min-h-[80px] bg-card p-1 text-left hover:bg-muted/50 transition-colors {day.getMonth() !==
calendarViewStore.currentDate.getMonth()
? 'opacity-40'
: ''}"
>
<div
class="mb-1 text-xs font-medium {isToday(day)
? 'flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground'
: 'text-foreground'}"
>
{format(day, 'd')}
</div>
{#each getEventsForDay(visibleEvents, day).slice(0, 3) as event}
<div
class="mb-0.5 truncate rounded px-1 text-[10px] text-white"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
>
{event.title}
</div>
{/each}
{#if getEventsForDay(visibleEvents, day).length > 3}
<div class="text-[10px] text-muted-foreground">
+{getEventsForDay(visibleEvents, day).length - 3} weitere
</div>
{/if}
</button>
{/each}
</div>
</div>
{:else}
<!-- Agenda View -->
<div class="mx-auto max-w-2xl p-4">
{#if rangeEvents.length === 0}
<div class="py-16 text-center">
<p class="text-lg text-muted-foreground">Keine Termine in den nächsten 30 Tagen</p>
<button
onclick={() => openNewEvent()}
class="mt-4 text-sm text-primary hover:underline"
>
Termin erstellen
</button>
<!-- Calendar list -->
<div class="sidebar-section">
<h3 class="sidebar-title">Kalender</h3>
{#each calendarsCtx.value as cal (cal.id)}
<div class="calendar-item">
<div class="calendar-dot" style="background-color: {cal.color}"></div>
<span class="calendar-name" class:muted={!cal.isVisible}>{cal.name}</span>
</div>
{:else}
{@const groupedByDate = rangeEvents.reduce(
(acc, event) => {
const key = format(new Date(event.startTime), 'yyyy-MM-dd');
if (!acc[key]) acc[key] = [];
acc[key].push(event);
return acc;
},
{} as Record<string, CalendarEvent[]>
)}
{#each Object.entries(groupedByDate) as [dateKey, dayEvents]}
<div class="mb-6">
<h3 class="mb-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{format(new Date(dateKey), 'EEEE, d. MMMM', { locale: de })}
{#if isToday(new Date(dateKey))}
<span class="ml-2 text-primary">Heute</span>
{/if}
</h3>
<div class="space-y-2">
{#each dayEvents as event (event.id)}
<button
onclick={() => openEditEvent(event)}
class="flex w-full items-start gap-3 rounded-lg border border-border bg-card p-3 text-left hover:border-primary/50 transition-colors"
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(event, payload),
canDrop: tagNotAlreadyOnEvent(event),
}}
>
<div
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style="background-color: {getCalendarColor(
calendarsCtx.value,
event.calendarId
)}"
></div>
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">{event.title}</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
{#if event.isAllDay}
Ganztägig
{:else}
{format(new Date(event.startTime), 'HH:mm')} {format(
new Date(event.endTime),
'HH:mm'
)}
{/if}
{#if event.location}
<span class="ml-2">📍 {event.location}</span>
{/if}
</div>
{#if getEventTags(event).length > 0}
<div class="mt-1 flex gap-1">
{#each getEventTags(event).slice(0, 3) as tag (tag.id)}
<span
class="inline-flex rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/each}
{/if}
{/each}
<a href="/calendar/calendars" class="sidebar-link">Verwalten</a>
</div>
{/if}
</aside>
<!-- Calendar view -->
<main class="calendar-main">
{#if calendarViewStore.viewType === 'week'}
<WeekView onEventClick={handleEventClick} onQuickCreate={handleQuickCreate} />
{:else if calendarViewStore.viewType === 'month'}
<MonthView
onEventClick={handleEventClick}
onDayClick={(day) => {
calendarViewStore.setDate(day);
calendarViewStore.setViewType('week');
}}
/>
{:else}
<AgendaView onEventClick={handleEventClick} />
{/if}
</main>
</div>
</div>
<!-- Event Form Modal -->
{#if showEventForm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
<h2 class="mb-4 text-xl font-semibold text-foreground">
{editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'}
</h2>
<!-- Event Detail Modal -->
{#if selectedEvent}
<EventDetailModal event={selectedEvent} onClose={() => (selectedEvent = null)} />
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleSaveEvent();
}}
class="space-y-4"
>
<div>
<label for="event-title" class="mb-1 block text-sm font-medium text-foreground">
Titel
</label>
<input
id="event-title"
type="text"
bind:value={newTitle}
placeholder="Termin-Titel"
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="event-date" class="mb-1 block text-sm font-medium text-foreground">
Datum
</label>
<input
id="event-date"
type="date"
bind:value={newDate}
required
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={newAllDay} class="rounded" />
Ganztägig
</label>
{#if !newAllDay}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="event-start" class="mb-1 block text-sm font-medium text-foreground"
>Von</label
>
<input
id="event-start"
type="time"
bind:value={newStartTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="event-end" class="mb-1 block text-sm font-medium text-foreground"
>Bis</label
>
<input
id="event-end"
type="time"
bind:value={newEndTime}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{/if}
<div>
<label for="event-location" class="mb-1 block text-sm font-medium text-foreground">
Ort (optional)
</label>
<input
id="event-location"
type="text"
bind:value={newLocation}
placeholder="Ort eingeben..."
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3 pt-2">
{#if editingEvent}
<button
type="button"
onclick={handleDeleteEvent}
class="rounded-lg px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors"
>
Löschen
</button>
<button
type="button"
onclick={() => {
shareEvent = editingEvent;
showEventForm = false;
}}
class="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted transition-colors"
title="Kurzlink teilen"
>
<ShareNetwork size={16} />
Teilen
</button>
{/if}
<div class="flex-1"></div>
<button
type="button"
onclick={() => (showEventForm = false)}
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newTitle.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Speichern
</button>
</div>
</form>
<!-- Create Event Modal -->
{#if showCreateForm}
<div class="modal-backdrop" role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="modal-backdrop-inner"
onclick={(e) => e.target === e.currentTarget && (showCreateForm = false)}
>
<div class="modal-container" role="dialog" aria-modal="true">
<h2 class="modal-title">Neuer Termin</h2>
<EventForm
mode="create"
initialStartTime={createStartTime}
initialEndTime={createEndTime}
onSave={handleCreateSave}
onCancel={() => (showCreateForm = false)}
/>
</div>
</div>
</div>
{/if}
@ -594,10 +202,151 @@
/>
<style>
.week-grid {
min-height: 100%;
.calendar-page {
display: flex;
flex-direction: column;
height: 100%;
}
.calendar-content {
flex: 1;
display: flex;
min-height: 0;
}
.calendar-sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid hsl(var(--color-border));
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
/* Hide sidebar on small screens */
@media (max-width: 768px) {
.calendar-sidebar {
display: none;
}
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.sidebar-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.calendar-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
}
.calendar-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.calendar-name {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.calendar-name.muted {
opacity: 0.5;
text-decoration: line-through;
}
.sidebar-link {
font-size: 0.75rem;
color: hsl(var(--color-primary));
text-decoration: none;
margin-top: 0.25rem;
}
.sidebar-link:hover {
text-decoration: underline;
}
.calendar-main {
flex: 1;
min-width: 0;
overflow: hidden;
}
/* Modal styles */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
}
.modal-backdrop-inner {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fade-in 150ms ease;
}
.modal-container {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 12px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 1.5rem;
animation: slide-up 200ms ease;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* DnD styles */
:global(.mana-drop-target-hover) {
outline: 2px solid var(--color-primary, #6366f1);
outline-offset: -2px;