mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 08:49:39 +02:00
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:
parent
071d2178ea
commit
3b5f77dd86
18 changed files with 3436 additions and 499 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue