From 0f2aae631d61167b44cb29713ff783ae8dbb4efe Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:42:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(calendar):=20add=20drag=20&=20?= =?UTF-8?q?drop=20and=20page-level=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drag & drop for events in all 6 calendar views (Day, Week, Month, 5/10/14-day) - Add resize handles for adjusting event start/end times - Remove internal scroll containers for page-level scrolling - Add 15-minute snap-to-grid for time adjustments - Add view type selector in CalendarHeader - Add sidebar collapsed state management --- apps/calendar/apps/web/src/app.css | 6 +- .../components/calendar/CalendarHeader.svelte | 42 +- .../lib/components/calendar/DayView.svelte | 383 +++++++++++++++- .../lib/components/calendar/MonthView.svelte | 157 ++++++- .../components/calendar/MultiDayView.svelte | 413 +++++++++++++++++- .../lib/components/calendar/WeekView.svelte | 57 ++- .../web/src/lib/stores/settings.svelte.ts | 7 + .../apps/web/src/routes/+layout.svelte | 18 +- .../calendar/apps/web/src/routes/+page.svelte | 22 +- 9 files changed, 1013 insertions(+), 92 deletions(-) diff --git a/apps/calendar/apps/web/src/app.css b/apps/calendar/apps/web/src/app.css index 31d956b21..2c0478245 100644 --- a/apps/calendar/apps/web/src/app.css +++ b/apps/calendar/apps/web/src/app.css @@ -30,9 +30,9 @@ --transition-slow: 300ms ease; /* Calendar-specific */ - --hour-height: 60px; - --day-header-height: 48px; - --time-column-width: 64px; + --hour-height: 48px; + --day-header-height: 40px; + --time-column-width: 56px; } } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte index 4e9c6e46c..0b74ce8ce 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte @@ -81,15 +81,28 @@
- - + +
+ + + + + +
{#each visibleViews as type} @@ -168,7 +181,12 @@ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } - .weekdays-toggle { + .filter-toggles { + display: flex; + gap: 0.5rem; + } + + .filter-toggle { padding: 0.5rem 0.75rem; border: 1px solid hsl(var(--color-border)); background: transparent; @@ -180,12 +198,12 @@ transition: all 150ms ease; } - .weekdays-toggle:hover { + .filter-toggle:hover { background: hsl(var(--color-muted)); color: hsl(var(--color-foreground)); } - .weekdays-toggle.active { + .filter-toggle.active { background: hsl(var(--color-primary)); color: hsl(var(--color-primary-foreground)); border-color: hsl(var(--color-primary)); diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index b3b36225a..90e885ba9 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -2,22 +2,44 @@ import { viewStore } from '$lib/stores/view.svelte'; import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { settingsStore } from '$lib/stores/settings.svelte'; import { goto } from '$app/navigation'; import { format, isToday, parseISO, differenceInMinutes, + addMinutes, + setHours, + setMinutes, } from 'date-fns'; import { de } from 'date-fns/locale'; - let hours = Array.from({ length: 24 }, (_, i) => i); + // Constants + const HOUR_HEIGHT = 60; // pixels per hour + const SNAP_MINUTES = 15; // snap to 15-minute intervals + + // Generate hours (0-23 or 7-23 depending on setting) + let allHours = Array.from({ length: 24 }, (_, i) => i); + let hours = $derived( + settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours + ); + + // Calculate visible hours range for positioning + let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0); + let totalVisibleHours = $derived(24 - firstVisibleHour); + + // Helper to convert minutes to percentage position (accounting for hidden hours) + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } // Current time indicator position let now = $state(new Date()); let currentTimePosition = $derived.by(() => { const minutes = now.getHours() * 60 + now.getMinutes(); - return (minutes / (24 * 60)) * 100; + return minutesToPercent(minutes); }); // Update current time every minute @@ -36,6 +58,208 @@ eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay) ); + // ============================================================================ + // Drag & Drop State + // ============================================================================ + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragOffsetMinutes = $state(0); + let dragPreviewTop = $state(0); + let dragPreviewHeight = $state(0); + let dayColumnRef = $state(null); + + // ============================================================================ + // Resize State + // ============================================================================ + let isResizing = $state(false); + let resizeEvent = $state(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeOriginalStart = $state(null); + let resizeOriginalEnd = $state(null); + let resizePreviewTop = $state(0); + let resizePreviewHeight = $state(0); + + // ============================================================================ + // Helper Functions + // ============================================================================ + function getMinutesFromY(y: number): number { + if (!dayColumnRef) return 0; + const rect = dayColumnRef.getBoundingClientRect(); + const scrollTop = dayColumnRef.parentElement?.scrollTop || 0; + const relativeY = y - rect.top + scrollTop; + // Account for hidden early hours + const visibleMinutes = (relativeY / (totalVisibleHours * HOUR_HEIGHT)) * totalVisibleHours * 60; + const totalMinutes = visibleMinutes + firstVisibleHour * 60; + // Snap to 15-minute intervals + return Math.round(totalMinutes / SNAP_MINUTES) * SNAP_MINUTES; + } + + function snapToGrid(minutes: number): number { + return Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES; + } + + // ============================================================================ + // Drag Handlers + // ============================================================================ + function startDrag(event: any, e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const duration = differenceInMinutes(end, start); + + const clickMinutes = getMinutesFromY(e.clientY); + dragOffsetMinutes = clickMinutes - startMinutes; + + isDragging = true; + draggedEvent = event; + dragPreviewTop = minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); + } + + function handleDragMove(e: PointerEvent) { + if (!isDragging || !draggedEvent) return; + + const mouseMinutes = getMinutesFromY(e.clientY); + const newStartMinutes = snapToGrid(mouseMinutes - dragOffsetMinutes); + const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(newStartMinutes, 24 * 60 - 15)); + + dragPreviewTop = minutesToPercent(clampedMinutes); + } + + function handleDragEnd(e: PointerEvent) { + if (!isDragging || !draggedEvent) { + cleanup(); + return; + } + + const mouseMinutes = getMinutesFromY(e.clientY); + const newStartMinutes = snapToGrid(mouseMinutes - dragOffsetMinutes); + const clampedMinutes = Math.max(0, Math.min(newStartMinutes, 24 * 60 - 30)); + + const start = typeof draggedEvent.startTime === 'string' ? parseISO(draggedEvent.startTime) : draggedEvent.startTime; + const end = typeof draggedEvent.endTime === 'string' ? parseISO(draggedEvent.endTime) : draggedEvent.endTime; + const duration = differenceInMinutes(end, start); + + // Create new start time on same day + let newStart = new Date(viewStore.currentDate); + newStart = setHours(newStart, Math.floor(clampedMinutes / 60)); + newStart = setMinutes(newStart, clampedMinutes % 60); + newStart.setSeconds(0, 0); + + const newEnd = addMinutes(newStart, duration); + + // Update event + eventsStore.updateEvent(draggedEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + + cleanup(); + } + + // ============================================================================ + // Resize Handlers + // ============================================================================ + function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + isResizing = true; + resizeEvent = event; + resizeEdge = edge; + + const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + resizeOriginalStart = start; + resizeOriginalEnd = end; + + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const duration = differenceInMinutes(end, start); + resizePreviewTop = minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); + } + + function handleResizeMove(e: PointerEvent) { + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return; + + const mouseMinutes = getMinutesFromY(e.clientY); + const snappedMinutes = snapToGrid(mouseMinutes); + + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + if (resizeEdge === 'top') { + const newStartMinutes = Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES); + const clampedStart = Math.max(firstVisibleHour * 60, newStartMinutes); + resizePreviewTop = minutesToPercent(clampedStart); + resizePreviewHeight = ((origEndMinutes - clampedStart) / (totalVisibleHours * 60)) * 100; + } else { + const newEndMinutes = Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES); + const clampedEnd = Math.min(24 * 60, newEndMinutes); + resizePreviewHeight = ((clampedEnd - origStartMinutes) / (totalVisibleHours * 60)) * 100; + } + } + + function handleResizeEnd(e: PointerEvent) { + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) { + cleanup(); + return; + } + + const mouseMinutes = getMinutesFromY(e.clientY); + const snappedMinutes = snapToGrid(mouseMinutes); + + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + let newStart = new Date(resizeOriginalStart); + let newEnd = new Date(resizeOriginalEnd); + + if (resizeEdge === 'top') { + const newStartMinutes = Math.max(0, Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES)); + newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60)); + newStart = setMinutes(newStart, newStartMinutes % 60); + newStart.setSeconds(0, 0); + } else { + const newEndMinutes = Math.min(24 * 60, Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES)); + newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60)); + newEnd = setMinutes(newEnd, newEndMinutes % 60); + newEnd.setSeconds(0, 0); + } + + eventsStore.updateEvent(resizeEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + + cleanup(); + } + + function cleanup() { + isDragging = false; + draggedEvent = null; + isResizing = false; + resizeEvent = null; + resizeOriginalStart = null; + resizeOriginalEnd = null; + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + } + + // ============================================================================ + // Event Styling + // ============================================================================ function getEventStyle(event: any) { const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; @@ -43,19 +267,29 @@ const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); - const top = (startMinutes / (24 * 60)) * 100; - const height = Math.max((duration / (24 * 60)) * 100, 2); + // Use percentage-based positioning for consistency with other views + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); // minimum ~20px at 60px/hour const color = calendarsStore.getColor(event.calendarId); return `top: ${top}%; height: ${height}%; background-color: ${color};`; } - function handleEventClick(event: any) { + function handleEventClick(event: any, e: MouseEvent) { + // Don't navigate if dragging or resizing + if (isDragging || isResizing) { + e.preventDefault(); + e.stopPropagation(); + return; + } goto(`/event/${event.id}`); } function handleSlotClick(hour: number) { + // Don't create event if dragging or resizing + if (isDragging || isResizing) return; + const startTime = new Date(viewStore.currentDate); startTime.setHours(hour, 0, 0, 0); goto(`/event/new?start=${startTime.toISOString()}`); @@ -74,7 +308,7 @@ @@ -93,7 +327,11 @@ {/each}
-
+
{#each hours as hour} + + +
startResize(event, 'bottom', e)} + role="slider" + aria-label="Endzeit ändern" + tabindex="-1" + >
+
{/each} @@ -132,7 +395,7 @@ .day-view { display: flex; flex-direction: column; - height: 100%; + } .all-day-section { @@ -166,7 +429,7 @@ .time-grid { flex: 1; display: flex; - overflow-y: auto; + } .time-column { @@ -195,6 +458,8 @@ flex: 1; position: relative; border-left: 1px solid hsl(var(--color-border)); + /* Fixed height for percentage positioning to work */ + height: calc(24 * var(--hour-height)); } .day-column.today { @@ -208,11 +473,66 @@ color: white; border: none; text-align: left; - cursor: pointer; + cursor: grab; z-index: 1; display: flex; flex-direction: column; gap: 2px; + padding: 4px 8px; + border-radius: var(--radius-sm); + overflow: hidden; + touch-action: none; + user-select: none; + transition: box-shadow 150ms ease, opacity 150ms ease; + } + + .event-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + .event-card.dragging { + cursor: grabbing; + opacity: 0.9; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + z-index: 100; + } + + .event-card.resizing { + cursor: ns-resize; + opacity: 0.9; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + z-index: 100; + } + + /* Resize Handles */ + .resize-handle { + position: absolute; + left: 0; + right: 0; + height: 8px; + cursor: ns-resize; + opacity: 0; + transition: opacity 150ms ease; + z-index: 10; + } + + .resize-handle.top { + top: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + } + + .resize-handle.bottom { + bottom: 0; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + } + + .event-card:hover .resize-handle { + opacity: 1; + background: rgba(255, 255, 255, 0.3); + } + + .resize-handle:hover { + background: rgba(255, 255, 255, 0.5) !important; } .event-time { @@ -229,4 +549,39 @@ font-size: 0.75rem; opacity: 0.8; } + + /* Time indicator */ + .time-indicator { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: hsl(var(--color-error)); + z-index: 50; + } + + .time-indicator::before { + content: ''; + position: absolute; + left: -4px; + top: -4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: hsl(var(--color-error)); + } + + /* Hour slots */ + .hour-slot { + height: var(--hour-height); + width: 100%; + border: none; + background: transparent; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + cursor: pointer; + } + + .hour-slot:hover { + background: hsl(var(--color-muted) / 0.2); + } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 55f344168..5c9449541 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -15,6 +15,13 @@ isToday, isSameDay, isWeekend, + setYear, + setMonth, + setDate, + getHours, + getMinutes, + differenceInMinutes, + addMinutes, } from 'date-fns'; import { de } from 'date-fns/locale'; @@ -53,16 +60,127 @@ return result; }); + // ============================================================================ + // Drag & Drop State + // ============================================================================ + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragTargetDay = $state(null); + let monthViewRef = $state(null); + + // Store for day cell refs + let dayCellRefs = $state>(new Map()); + + function setDayCellRef(day: Date, el: HTMLElement | null) { + const key = format(day, 'yyyy-MM-dd'); + if (el) { + dayCellRefs.set(key, el); + } else { + dayCellRefs.delete(key); + } + } + + // Svelte action for binding day cell refs + function bindDayCellRef(node: HTMLElement, day: Date) { + setDayCellRef(day, node); + return { + destroy() { + setDayCellRef(day, null); + } + }; + } + + // ============================================================================ + // Helper Functions + // ============================================================================ + 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; + } + + // ============================================================================ + // Drag Handlers + // ============================================================================ + function startDrag(event: any, 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; + + const targetDay = getDayFromPoint(e.clientX, e.clientY); + dragTargetDay = targetDay; + } + + function handleDragEnd(e: PointerEvent) { + if (!isDragging || !draggedEvent) { + cleanup(); + return; + } + + const targetDay = getDayFromPoint(e.clientX, e.clientY); + + if (targetDay) { + const start = typeof draggedEvent.startTime === 'string' ? new Date(draggedEvent.startTime) : draggedEvent.startTime; + const end = typeof draggedEvent.endTime === 'string' ? new Date(draggedEvent.endTime) : draggedEvent.endTime; + const duration = differenceInMinutes(end, start); + + // Keep the same time, change the date + let newStart = new Date(targetDay); + newStart.setHours(getHours(start), getMinutes(start), 0, 0); + + const newEnd = addMinutes(newStart, duration); + + 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); + } + + // ============================================================================ + // Event Handlers + // ============================================================================ function getEventsForDay(day: Date) { return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown } function handleDayClick(day: Date) { + // Don't navigate if dragging + if (isDragging) return; viewStore.setDate(day); viewStore.setViewType('day'); } function handleEventClick(event: any, e: MouseEvent) { + // Don't navigate if dragging + if (isDragging) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.stopPropagation(); goto(`/event/${event.id}`); } @@ -74,7 +192,7 @@ } -
+
{#each weekDays as day} @@ -87,11 +205,14 @@ {#each weeks as week}
{#each week as day} + {@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
handleDayClick(day)} onkeydown={(e) => e.key === 'Enter' && handleDayClick(day)} role="button" @@ -103,16 +224,21 @@
{#each getEventsForDay(day) as event} - +
{/each} {#if eventsStore.getEventsForDay(day).length > 3} @@ -135,7 +261,7 @@ .month-view { display: flex; flex-direction: column; - height: 100%; + } .weekday-headers { @@ -229,10 +355,24 @@ color: white; border-radius: var(--radius-sm); border: none; - cursor: pointer; + 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 { @@ -258,4 +398,11 @@ .more-events:hover { color: hsl(var(--color-primary)); } + + /* Drop target highlighting */ + .day-cell.drop-target { + background-color: hsl(var(--color-primary) / 0.2) !important; + outline: 2px dashed hsl(var(--color-primary)); + outline-offset: -2px; + } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte index bf0f1f284..d7fd43ce1 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -8,11 +8,20 @@ format, eachDayOfInterval, isToday, + isSameDay, parseISO, differenceInMinutes, isWeekend, + addMinutes, + setHours, + setMinutes, } from 'date-fns'; - import { de } from 'date-fns/locale'; + import { de, enUS, fr, es, it } from 'date-fns/locale'; + import { locale } from 'svelte-i18n'; + + // Constants + const HOUR_HEIGHT = 60; // px - should match CSS --hour-height + const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals // Props interface Props { @@ -20,6 +29,10 @@ } let { dayCount }: Props = $props(); + // Get date-fns locale based on current app locale + const dateLocales = { de, en: enUS, fr, es, it }; + let currentDateLocale = $derived(dateLocales[$locale?.substring(0, 2) as keyof typeof dateLocales] || de); + // Generate days based on view range, optionally filtering weekends let allDays = $derived( eachDayOfInterval({ @@ -32,14 +45,27 @@ settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays ); - // Generate hours (0-23) - let hours = Array.from({ length: 24 }, (_, i) => i); + // Generate hours (0-23 or 7-23 depending on setting) + let allHours = Array.from({ length: 24 }, (_, i) => i); + let hours = $derived( + settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours + ); + + // Calculate visible hours range for positioning + let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0); + let totalVisibleHours = $derived(24 - firstVisibleHour); + + // Helper to convert minutes to percentage position (accounting for hidden hours) + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } // Current time indicator position let now = $state(new Date()); let currentTimePosition = $derived.by(() => { const minutes = now.getHours() * 60 + now.getMinutes(); - return (minutes / (24 * 60)) * 100; + return minutesToPercent(minutes); }); // Update current time every minute @@ -57,6 +83,26 @@ return 'very-compact'; }); + // ========== Drag & Drop State ========== + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragOffsetMinutes = $state(0); + let dragTargetDay = $state(null); + let dragPreviewTop = $state(0); + let dragPreviewHeight = $state(0); + + // ========== Resize State ========== + let isResizing = $state(false); + let resizeEvent = $state(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeOriginalStart = $state(null); + let resizeOriginalEnd = $state(null); + let resizePreviewTop = $state(0); + let resizePreviewHeight = $state(0); + + // Reference to the days container for position calculations + let daysContainerEl: HTMLDivElement; + function getEventsForDay(day: Date) { return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); } @@ -72,23 +118,238 @@ const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); - const top = (startMinutes / (24 * 60)) * 100; - const height = Math.max((duration / (24 * 60)) * 100, 2); // Min 2% height + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); // Min 2% height const color = calendarsStore.getColor(event.calendarId); return `top: ${top}%; height: ${height}%; background-color: ${color};`; } - function handleEventClick(event: any) { + function formatEventTime(date: Date | string): string { + const d = typeof date === 'string' ? parseISO(date) : date; + return settingsStore.formatTime(d); + } + + function handleEventClick(event: any, e: MouseEvent) { + // Don't navigate if we just finished dragging or resizing + if (isDragging || isResizing) { + e.preventDefault(); + e.stopPropagation(); + return; + } goto(`/event/${event.id}`); } function handleSlotClick(day: Date, hour: number) { + // Don't create new event if dragging + if (isDragging || isResizing) return; + const startTime = new Date(day); startTime.setHours(hour, 0, 0, 0); goto(`/event/new?start=${startTime.toISOString()}`); } + + // ========== Drag & Drop Functions ========== + + function getDayFromX(clientX: number): Date | null { + if (!daysContainerEl) return null; + + const rect = daysContainerEl.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; + } + + function getMinutesFromY(clientY: number): number { + if (!daysContainerEl) return 0; + + const rect = daysContainerEl.getBoundingClientRect(); + const scrollTop = daysContainerEl.parentElement?.scrollTop || 0; + const relativeY = clientY - rect.top + scrollTop; + // Account for hidden early hours + const visibleMinutes = (relativeY / (totalVisibleHours * HOUR_HEIGHT)) * totalVisibleHours * 60; + const totalMinutes = visibleMinutes + firstVisibleHour * 60; + + // Snap to 15-minute intervals + return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + } + + function startDrag(event: any, e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + isDragging = true; + draggedEvent = event; + + const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const duration = differenceInMinutes(end, start); + + // Calculate initial preview position + const startMinutes = start.getHours() * 60 + start.getMinutes(); + dragPreviewTop = minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + dragTargetDay = start; + + // Calculate offset from event start to click position + const clickMinutes = getMinutesFromY(e.clientY); + dragOffsetMinutes = clickMinutes - startMinutes; + + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); + } + + function handleDragMove(e: PointerEvent) { + if (!isDragging || !draggedEvent) return; + + // Calculate new position + const newDay = getDayFromX(e.clientX); + const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; + + // Clamp to valid range (firstVisibleHour to 23:45) + const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(24 * 60 - 15, newMinutes)); + + // Update preview + dragPreviewTop = minutesToPercent(clampedMinutes); + if (newDay) { + dragTargetDay = newDay; + } + } + + async function handleDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isDragging || !draggedEvent || !dragTargetDay) { + isDragging = false; + draggedEvent = null; + return; + } + + const start = typeof draggedEvent.startTime === 'string' ? parseISO(draggedEvent.startTime) : draggedEvent.startTime; + const end = typeof draggedEvent.endTime === 'string' ? parseISO(draggedEvent.endTime) : draggedEvent.endTime; + const duration = differenceInMinutes(end, start); + + // Calculate new start time + const newMinutes = getMinutesFromY(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); + + // Update event via store + await eventsStore.updateEvent(draggedEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + + // Reset state + isDragging = false; + draggedEvent = null; + dragTargetDay = null; + } + + // ========== Resize Functions ========== + + function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + isResizing = true; + resizeEvent = event; + resizeEdge = edge; + + const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + resizeOriginalStart = start; + resizeOriginalEnd = end; + + // Set initial preview + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const duration = differenceInMinutes(end, start); + resizePreviewTop = minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); + } + + function handleResizeMove(e: PointerEvent) { + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return; + + const currentMinutes = getMinutesFromY(e.clientY); + const originalStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + if (resizeEdge === 'bottom') { + // Resize from bottom - change end time + const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes)); + const newDuration = newEndMinutes - originalStartMinutes; + resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100; + } else { + // Resize from top - change start time + const newStartMinutes = Math.max(firstVisibleHour * 60, Math.min(originalEndMinutes - 15, currentMinutes)); + const newDuration = originalEndMinutes - newStartMinutes; + resizePreviewTop = minutesToPercent(newStartMinutes); + resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100; + } + } + + async function handleResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) { + isResizing = false; + resizeEvent = null; + return; + } + + const currentMinutes = getMinutesFromY(e.clientY); + 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(24 * 60, currentMinutes)); + 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(0, Math.min(originalEndMinutes - 15, currentMinutes)); + const newHours = Math.floor(newStartMinutes / 60); + const newMins = newStartMinutes % 60; + newStart = setHours(new Date(resizeOriginalStart), newHours); + newStart = setMinutes(newStart, newMins); + } + + // Update event via store + await eventsStore.updateEvent(resizeEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + + // Reset state + isResizing = false; + resizeEvent = null; + resizeOriginalStart = null; + resizeOriginalEnd = null; + }
@@ -101,7 +362,7 @@
-
+
{#each days as day}
{#each hours as hour} {/each} - {#each getEventsForDay(day) as event} - + + +
startResize(event, 'bottom', e)} + role="slider" + aria-label="Endzeit ändern" + tabindex="-1" + >
+
{/each} + + {#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some(e => e.id === draggedEvent.id)} +
+ {#if columnClass !== 'very-compact'} + {formatEventTime(draggedEvent.startTime)} + {/if} + {draggedEvent.title} +
+ {/if} + {#if isToday(day)}
@@ -176,8 +480,8 @@ .multi-day-view { display: flex; flex-direction: column; - height: 100%; - min-height: 0; + + } .all-day-row { @@ -285,7 +589,7 @@ .time-grid { flex: 1; display: flex; - overflow-y: auto; + } .time-column { @@ -346,11 +650,36 @@ color: white; border: none; text-align: left; - cursor: pointer; + cursor: grab; z-index: 1; padding: 2px 4px; border-radius: var(--radius-sm); overflow: hidden; + transition: box-shadow 0.15s ease, opacity 0.15s ease; + touch-action: none; + user-select: none; + } + + .event-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } + + .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.9; + z-index: 100; + } + + .event-card.drag-ghost { + opacity: 0.6; + pointer-events: none; + border: 2px dashed white; } .compact .event-card, @@ -360,9 +689,47 @@ padding: 1px 2px; } + /* Resize handles */ + .resize-handle { + position: absolute; + left: 0; + right: 0; + height: 8px; + cursor: ns-resize; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 2; + } + + .resize-handle.top { + top: 0; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + } + + .resize-handle.bottom { + bottom: 0; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + } + + .event-card:hover .resize-handle { + opacity: 1; + background: rgba(255, 255, 255, 0.3); + } + + .resize-handle:hover { + background: rgba(255, 255, 255, 0.5) !important; + } + + /* Compact resize handles */ + .compact .resize-handle, + .very-compact .resize-handle { + height: 6px; + } + .event-time { font-size: 0.65rem; opacity: 0.9; + display: block; } .compact .event-time { @@ -391,7 +758,7 @@ left: 0; right: 0; height: 2px; - background: hsl(var(--color-destructive)); + background: hsl(var(--color-error)); z-index: 2; } @@ -403,6 +770,6 @@ width: 10px; height: 10px; border-radius: 50%; - background: hsl(var(--color-destructive)); + background: hsl(var(--color-error)); } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 95581e780..c34d9750a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -46,14 +46,27 @@ getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn }) ); - // Generate hours (0-23) - let hours = Array.from({ length: 24 }, (_, i) => i); + // Generate hours (0-23 or 7-23 depending on setting) + let allHours = Array.from({ length: 24 }, (_, i) => i); + let hours = $derived( + settingsStore.hideEarlyHours ? allHours.filter((h) => h >= 7) : allHours + ); + + // Calculate visible hours range for positioning + let firstVisibleHour = $derived(settingsStore.hideEarlyHours ? 7 : 0); + let totalVisibleHours = $derived(24 - firstVisibleHour); + + // Helper to convert minutes to percentage position (accounting for hidden hours) + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } // Current time indicator position let now = $state(new Date()); let currentTimePosition = $derived.by(() => { const minutes = now.getHours() * 60 + now.getMinutes(); - return (minutes / (24 * 60)) * 100; + return minutesToPercent(minutes); }); // Update current time every minute @@ -99,8 +112,8 @@ const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); - const top = (startMinutes / (24 * 60)) * 100; - const height = Math.max((duration / (24 * 60)) * 100, 2); // Min 2% height + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); // Min 2% height const color = calendarsStore.getColor(event.calendarId); @@ -153,7 +166,9 @@ const rect = daysContainerEl.getBoundingClientRect(); const scrollTop = daysContainerEl.parentElement?.scrollTop || 0; const relativeY = clientY - rect.top + scrollTop; - const totalMinutes = (relativeY / (24 * HOUR_HEIGHT)) * 24 * 60; + // Account for hidden early hours + const visibleMinutes = (relativeY / (totalVisibleHours * HOUR_HEIGHT)) * totalVisibleHours * 60; + const totalMinutes = visibleMinutes + firstVisibleHour * 60; // Snap to 15-minute intervals return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; @@ -172,8 +187,8 @@ // Calculate initial preview position const startMinutes = start.getHours() * 60 + start.getMinutes(); - dragPreviewTop = (startMinutes / (24 * 60)) * 100; - dragPreviewHeight = (duration / (24 * 60)) * 100; + dragPreviewTop = minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; dragTargetDay = start; // Calculate offset from event start to click position @@ -191,11 +206,11 @@ const newDay = getDayFromX(e.clientX); const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; - // Clamp to valid range (0-23:45) - const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes)); + // Clamp to valid range (firstVisibleHour to 23:45) + const clampedMinutes = Math.max(firstVisibleHour * 60, Math.min(24 * 60 - 15, newMinutes)); // Update preview - dragPreviewTop = (clampedMinutes / (24 * 60)) * 100; + dragPreviewTop = minutesToPercent(clampedMinutes); if (newDay) { dragTargetDay = newDay; } @@ -258,8 +273,8 @@ // Set initial preview const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); - resizePreviewTop = (startMinutes / (24 * 60)) * 100; - resizePreviewHeight = (duration / (24 * 60)) * 100; + resizePreviewTop = minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; document.addEventListener('pointermove', handleResizeMove); document.addEventListener('pointerup', handleResizeEnd); @@ -276,13 +291,13 @@ // Resize from bottom - change end time const newEndMinutes = Math.max(originalStartMinutes + 15, Math.min(24 * 60, currentMinutes)); const newDuration = newEndMinutes - originalStartMinutes; - resizePreviewHeight = (newDuration / (24 * 60)) * 100; + resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100; } else { // Resize from top - change start time - const newStartMinutes = Math.max(0, Math.min(originalEndMinutes - 15, currentMinutes)); + const newStartMinutes = Math.max(firstVisibleHour * 60, Math.min(originalEndMinutes - 15, currentMinutes)); const newDuration = originalEndMinutes - newStartMinutes; - resizePreviewTop = (newStartMinutes / (24 * 60)) * 100; - resizePreviewHeight = (newDuration / (24 * 60)) * 100; + resizePreviewTop = minutesToPercent(newStartMinutes); + resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100; } } @@ -464,8 +479,8 @@ .week-view { display: flex; flex-direction: column; - height: 100%; - min-height: 0; + + } .week-number-indicator { @@ -553,8 +568,8 @@ .time-grid { flex: 1; display: flex; - overflow-y: auto; - min-height: 0; + + } .time-column { diff --git a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts index 7f6c46397..9dd20307a 100644 --- a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts @@ -17,6 +17,9 @@ export interface CalendarAppSettings { showOnlyWeekdays: boolean; showWeekNumbers: boolean; timeFormat: TimeFormat; + filterHoursEnabled: boolean; // Filter visible hours + dayStartHour: number; // First visible hour (0-23) + dayEndHour: number; // Last visible hour (0-23) // UI settings sidebarCollapsed: boolean; @@ -32,6 +35,7 @@ const DEFAULT_SETTINGS: CalendarAppSettings = { showOnlyWeekdays: false, showWeekNumbers: false, timeFormat: '24h', + hideEarlyHours: false, sidebarCollapsed: false, defaultEventDuration: 60, defaultReminder: 15, @@ -90,6 +94,9 @@ export const settingsStore = { get timeFormat() { return settings.timeFormat; }, + get hideEarlyHours() { + return settings.hideEarlyHours; + }, get defaultEventDuration() { return settings.defaultEventDuration; }, diff --git a/apps/calendar/apps/web/src/routes/+layout.svelte b/apps/calendar/apps/web/src/routes/+layout.svelte index 80293930c..1b31673f1 100644 --- a/apps/calendar/apps/web/src/routes/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { viewStore } from '$lib/stores/view.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { settingsStore } from '$lib/stores/settings.svelte'; import { THEME_DEFINITIONS } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, @@ -222,7 +223,7 @@ class:sidebar-mode={isSidebarMode && !isCollapsed} class:floating-mode={!isSidebarMode && !isCollapsed} > -
+
{@render children()}
@@ -237,9 +238,6 @@ } .main-content { - flex: 1; - display: flex; - flex-direction: column; transition: all 300ms ease; } @@ -252,8 +250,6 @@ } .content-wrapper { - flex: 1; - min-height: 0; max-width: 100%; margin-left: auto; margin-right: auto; @@ -271,4 +267,14 @@ padding: 2rem; } } + + /* Full width when calendar sidebar is collapsed */ + .content-wrapper.calendar-expanded { + width: 100%; + max-width: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + } diff --git a/apps/calendar/apps/web/src/routes/+page.svelte b/apps/calendar/apps/web/src/routes/+page.svelte index 024069140..9de367a86 100644 --- a/apps/calendar/apps/web/src/routes/+page.svelte +++ b/apps/calendar/apps/web/src/routes/+page.svelte @@ -106,7 +106,7 @@ {/if} -
+
@@ -133,8 +133,7 @@ .calendar-layout { display: flex; gap: 1.5rem; - flex: 1; - min-height: 0; + width: 100%; position: relative; } @@ -153,8 +152,13 @@ width: 0; opacity: 0; overflow: hidden; - margin-right: -1.5rem; pointer-events: none; + padding: 0; + margin: 0; + } + + .calendar-layout:has(.calendar-sidebar.collapsed) { + gap: 0; } .sidebar-collapse-btn { @@ -243,17 +247,19 @@ display: flex; flex-direction: column; min-width: 0; - min-height: 0; background: hsl(var(--color-surface)); border-radius: var(--radius-lg); border: 1px solid hsl(var(--color-border)); - overflow: hidden; + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + + .calendar-main.expanded { + border-radius: 0; + border: none; } .calendar-content { flex: 1; - min-height: 0; - overflow: hidden; } @media (max-width: 1024px) {