From 23a1c5e539eac60e63c3f4019b20b8c93d6243cc Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:03:23 +0100 Subject: [PATCH] feat(calendar): add larger resize handles, live time preview, and drag-to-create - Increase resize handle height from 8px to 20px with visual grip indicator - Show live time preview during resize operations - Add drag-to-create functionality: click and hold on empty cell to drag and create events with custom duration - Fix zitare TypeScript errors (SearchResultItem -> QuickInputItem, createUserSettingsStore API) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DayView.svelte | 212 +++++++++- .../lib/components/calendar/EventCard.svelte | 27 +- .../lib/components/calendar/MonthView.svelte | 2 +- .../components/calendar/MultiDayView.svelte | 289 +++++++++++-- .../components/calendar/ViewCarousel.svelte | 2 +- .../lib/components/calendar/WeekView.svelte | 224 +++++++++- .../lib/components/calendar/YearView.svelte | 2 +- .../apps/web/src/routes/(app)/+page.svelte | 5 +- .../apps/web/src/routes/(app)/+layout.svelte | 383 +++++++++++++----- 9 files changed, 987 insertions(+), 159 deletions(-) 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 cca7d88e8..1429230b0 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -30,7 +30,7 @@ interface Props { /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ date?: Date; - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } @@ -164,6 +164,15 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // ============================================================================ + // Drag-to-Create State + // ============================================================================ + let isCreating = $state(false); + let createStartMinutes = $state(0); + let createEndMinutes = $state(0); + let createPreviewTop = $state(0); + let createPreviewHeight = $state(0); + // ============================================================================ // Task Drag & Drop State // ============================================================================ @@ -395,10 +404,16 @@ resizeOriginalEnd = null; resizeOffsetMinutes = 0; hasMoved = false; + // Creating cleanup + isCreating = false; + createStartMinutes = 0; + createEndMinutes = 0; document.removeEventListener('pointermove', handleDragMove); document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('pointermove', handleResizeMove); document.removeEventListener('pointerup', handleResizeEnd); + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); // Task cleanup isTaskDragging = false; draggedTask = null; @@ -630,7 +645,10 @@ // Keyboard Handling // ============================================================================ function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) { + if ( + e.key === 'Escape' && + (isDragging || isResizing || isTaskDragging || isTaskResizing || isCreating) + ) { e.preventDefault(); cleanup(); } @@ -663,6 +681,40 @@ return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`; } + /** + * Calculate live time display during resize + */ + function getResizePreviewTime(): string { + if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return ''; + + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + // Calculate from preview position + const previewStartMinutes = + (resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60; + const previewEndMinutes = + previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60; + + let startMinutes: number; + let endMinutes: number; + + if (resizeEdge === 'top') { + startMinutes = Math.round(previewStartMinutes); + endMinutes = origEndMinutes; + } else { + startMinutes = origStartMinutes; + endMinutes = Math.round(previewEndMinutes); + } + + const startHours = Math.floor(startMinutes / 60); + const startMins = startMinutes % 60; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + } + /** * Get style for a scheduled task (time-blocking) */ @@ -709,18 +761,100 @@ } } - function handleSlotClick(hour: number, e: MouseEvent) { + // ============================================================================ + // Drag-to-Create Handlers + // ============================================================================ + function startCreate(e: PointerEvent) { // Don't create event if dragging or resizing - if (isDragging || isResizing) return; + if (isDragging || isResizing || isTaskDragging || isTaskResizing) return; - const startTime = new Date(effectiveDate); - startTime.setHours(hour, 0, 0, 0); - - if (onQuickCreate) { - onQuickCreate(startTime, { x: e.clientX, y: e.clientY }); - } else { - goto(`/event/new?start=${startTime.toISOString()}`); + // Don't start creating if clicking on an event, task, or other interactive element + const target = e.target as HTMLElement; + if ( + target.closest( + '.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle' + ) + ) { + return; } + + e.preventDefault(); + + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = snapToGrid(minutes); + + isCreating = true; + hasMoved = false; + createStartMinutes = snappedMinutes; + createEndMinutes = snappedMinutes + SNAP_MINUTES; // Start with 15 min duration + + updateCreatePreview(); + + document.addEventListener('pointermove', handleCreateMove); + document.addEventListener('pointerup', handleCreateEnd); + } + + function handleCreateMove(e: PointerEvent) { + if (!isCreating) return; + + hasMoved = true; + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = snapToGrid(minutes); + + // Allow dragging both up and down from start point + if (snappedMinutes >= createStartMinutes) { + createEndMinutes = Math.max(snappedMinutes, createStartMinutes + SNAP_MINUTES); + } else { + // Dragging upward - swap start and end + createEndMinutes = createStartMinutes + SNAP_MINUTES; + createStartMinutes = snappedMinutes; + } + + // Clamp to visible hours + createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes); + createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes); + + updateCreatePreview(); + } + + function updateCreatePreview() { + createPreviewTop = minutesToPercent(createStartMinutes); + const duration = createEndMinutes - createStartMinutes; + createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + } + + function handleCreateEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + + if (!isCreating) return; + + // Calculate final times + const startTime = new Date(effectiveDate); + startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0); + + const endTime = new Date(effectiveDate); + endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0); + + // Reset state + isCreating = false; + + // Open quick create with the calculated times + if (onQuickCreate) { + onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime); + } else { + goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`); + } + + hasMoved = false; + } + + function getCreatePreviewTime(): string { + const startHours = Math.floor(createStartMinutes / 60); + const startMins = createStartMinutes % 60; + const endHours = Math.floor(createEndMinutes / 60); + const endMins = createEndMinutes % 60; + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; } function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { @@ -788,17 +922,15 @@ class="day-column" class:today={isToday(effectiveDate)} class:drop-target={isSidebarDropTarget} + class:creating={isCreating} bind:this={dayColumnRef} + onpointerdown={startCreate} ondragover={handleSidebarDragOver} ondragleave={handleSidebarDragLeave} ondrop={handleSidebarDrop} > {#each hours as hour} - +
{/each} @@ -830,7 +962,7 @@ isResizing={isBeingResized} isSearchHighlighted={searchStore.isEventHighlighted(event.id)} isSearchDimmed={searchStore.isEventDimmed(event.id)} - formattedTime={formatEventTime(event)} + formattedTime={isBeingResized ? getResizePreviewTime() : formatEventTime(event)} onClick={handleEventClick} onPointerDown={startDrag} onContextMenu={handleEventContextMenu} @@ -859,6 +991,19 @@ {/each} {/if} + + {#if isCreating} +
+ {getCreatePreviewTime()} + (Neuer Termin) +
+ {/if} + {#if overflowEvents.before.length > 0}
@@ -1084,6 +1229,39 @@ background: hsl(var(--color-muted) / 0.2); } + /* Create preview (drag-to-create) */ + .create-preview { + position: absolute; + left: 2px; + right: 2px; + padding: 2px 4px; + border-radius: var(--radius-sm); + 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 .event-time { + display: block; + font-size: 0.6rem; + opacity: 0.9; + } + + .create-preview .event-title { + display: block; + font-size: 0.7rem; + font-weight: 500; + opacity: 0.8; + } + + .day-column.creating { + cursor: ns-resize; + } + /* Overflow indicators for events outside visible time range */ .overflow-indicator { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte b/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte index a106e4cea..dec881105 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte @@ -227,25 +227,42 @@ position: absolute; left: 0; right: 0; - height: 8px; + 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: 0; + top: -6px; border-radius: var(--radius-sm) var(--radius-sm) 0 0; } .resize-handle.bottom { - bottom: 0; + bottom: -6px; border-radius: 0 0 var(--radius-sm) var(--radius-sm); } - .event-card:hover .resize-handle { + .event-card:hover .resize-handle, + .event-card.resizing .resize-handle { opacity: 1; - background: rgba(255, 255, 255, 0.3); + 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); } 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 7afd97ed7..d51504fb4 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -38,7 +38,7 @@ interface Props { /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ date?: Date; - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; } 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 170586324..ee9f93713 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -46,7 +46,7 @@ dayCount: number; /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ date?: Date; - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } @@ -131,6 +131,14 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // Drag-to-Create State + let isCreating = $state(false); + let createTargetDay = $state(null); + let createStartMinutes = $state(0); + let createEndMinutes = $state(0); + let createPreviewTop = $state(0); + let createPreviewHeight = $state(0); + // Task Drag & Drop State let isTaskDragging = $state(false); let draggedTask = $state(null); @@ -277,6 +285,42 @@ return settingsStore.formatTime(toDate(date)); } + /** + * Calculate live time display during resize + */ + function getResizePreviewTime(event: CalendarEvent): string { + if (!resizeEvent || resizeEvent.id !== event.id || !resizeOriginalStart || !resizeOriginalEnd) { + return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`; + } + + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + // Calculate from preview position + const previewStartMinutes = + (resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60; + const previewEndMinutes = + previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60; + + let startMinutes: number; + let endMinutes: number; + + if (resizeEdge === 'top') { + startMinutes = Math.round(previewStartMinutes); + endMinutes = origEndMinutes; + } else { + startMinutes = origStartMinutes; + endMinutes = Math.round(previewEndMinutes); + } + + const startHours = Math.floor(startMinutes / 60); + const startMins = startMinutes % 60; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + } + function handleEventClick(event: CalendarEvent, e: MouseEvent) { // Don't navigate if we just finished dragging or resizing, or if we moved if (isDragging || isResizing || hasMoved) { @@ -294,18 +338,112 @@ } } - function handleSlotClick(day: Date, hour: number, e: MouseEvent) { - // Don't create new event if dragging - if (isDragging || isResizing) return; + // ========== Drag-to-Create Handlers ========== + function startCreate(e: PointerEvent) { + // Don't create event if dragging or resizing + if (isDragging || isResizing || isTaskDragging || isTaskResizing) return; - const startTime = new Date(day); - startTime.setHours(hour, 0, 0, 0); - - if (onQuickCreate) { - onQuickCreate(startTime, { x: e.clientX, y: e.clientY }); - } else { - goto(`/event/new?start=${startTime.toISOString()}`); + // Don't start creating if clicking on an event, task, or other interactive element + const target = e.target as HTMLElement; + if ( + target.closest( + '.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle' + ) + ) { + return; } + + e.preventDefault(); + + const day = getDayFromX(e.clientX); + if (!day) return; + + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + + isCreating = true; + hasMoved = false; + createTargetDay = day; + createStartMinutes = snappedMinutes; + createEndMinutes = snappedMinutes + MINUTES_PER_SLOT; + + updateCreatePreview(); + + document.addEventListener('pointermove', handleCreateMove); + document.addEventListener('pointerup', handleCreateEnd); + } + + function handleCreateMove(e: PointerEvent) { + if (!isCreating) return; + + hasMoved = true; + + // Update target day + const day = getDayFromX(e.clientX); + if (day) { + createTargetDay = day; + } + + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + + // Allow dragging both up and down from start point + if (snappedMinutes >= createStartMinutes) { + createEndMinutes = Math.max(snappedMinutes, createStartMinutes + MINUTES_PER_SLOT); + } else { + createEndMinutes = createStartMinutes + MINUTES_PER_SLOT; + createStartMinutes = snappedMinutes; + } + + // Clamp to visible hours + createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes); + createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes); + + updateCreatePreview(); + } + + function updateCreatePreview() { + createPreviewTop = minutesToPercent(createStartMinutes); + const duration = createEndMinutes - createStartMinutes; + createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + } + + function handleCreateEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + + if (!isCreating || !createTargetDay) { + isCreating = false; + return; + } + + // Calculate final times + 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); + + // Reset state + isCreating = false; + createTargetDay = null; + + // Open quick create with the calculated times + if (onQuickCreate) { + onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime); + } else { + goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`); + } + + hasMoved = false; + } + + function getCreatePreviewTime(): string { + const startHours = Math.floor(createStartMinutes / 60); + const startMins = createStartMinutes % 60; + const endHours = Math.floor(createEndMinutes / 60); + const endMins = createEndMinutes % 60; + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; } function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { @@ -816,7 +954,10 @@ // ========== Keyboard Handling ========== function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) { + if ( + e.key === 'Escape' && + (isDragging || isResizing || isTaskDragging || isTaskResizing || isCreating) + ) { e.preventDefault(); document.removeEventListener('pointermove', handleDragMove); document.removeEventListener('pointerup', handleDragEnd); @@ -826,6 +967,8 @@ document.removeEventListener('pointerup', handleTaskDragEnd); document.removeEventListener('pointermove', handleTaskResizeMove); document.removeEventListener('pointerup', handleTaskResizeEnd); + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); isDragging = false; draggedEvent = null; dragTargetDay = null; @@ -839,6 +982,8 @@ taskDragTargetDay = null; isTaskResizing = false; resizeTask = null; + isCreating = false; + createTargetDay = null; hasMoved = false; } } @@ -913,16 +1058,19 @@ class="day-column" class:today={isToday(day)} class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)} + class:creating={isCreating && createTargetDay && isSameDay(day, createTargetDay)} + onpointerdown={startCreate} ondragover={(e) => handleSidebarDragOver(e, day)} ondragleave={handleSidebarDragLeave} ondrop={(e) => handleSidebarDrop(e, day)} > {#each hours as hour} - + >
{/each} @@ -983,7 +1131,9 @@ {#if columnClass !== 'very-compact'} - {formatEventTime(event.startTime)} - {formatEventTime(event.endTime)} + {isBeingResized + ? getResizePreviewTime(event) + : `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`} {/if} {event.title || (isDraft ? '(Neuer Termin)' : '')} @@ -1051,6 +1201,21 @@ {/if} + + {#if isCreating && createTargetDay && isSameDay(day, createTargetDay)} +
+ {#if columnClass !== 'very-compact'} + {getCreatePreviewTime()} + {/if} + (Neuer Termin) +
+ {/if} + {#if true} {@const overflow = getOverflowEventsForDay(day)} @@ -1335,6 +1500,56 @@ background: hsl(var(--color-muted) / 0.5); } + /* Create preview (drag-to-create) */ + .create-preview { + position: absolute; + left: 2px; + right: 2px; + padding: 2px 4px; + border-radius: var(--radius-sm); + 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 .event-time { + display: block; + font-size: 0.6rem; + opacity: 0.9; + } + + .create-preview .event-title { + display: block; + font-size: 0.7rem; + font-weight: 500; + opacity: 0.8; + } + + .day-column.creating { + cursor: ns-resize; + } + + .compact .create-preview, + .very-compact .create-preview { + left: 1px; + right: 1px; + padding: 1px 2px; + } + + .compact .create-preview .event-time, + .very-compact .create-preview .event-time { + font-size: 0.5rem; + } + + .compact .create-preview .event-title, + .very-compact .create-preview .event-title { + font-size: 0.55rem; + } + .event-card { position: absolute; left: 2px; @@ -1442,36 +1657,55 @@ position: absolute; left: 0; right: 0; - height: 8px; + 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: 0; + top: -6px; border-radius: var(--radius-sm) var(--radius-sm) 0 0; } .resize-handle.bottom { - bottom: 0; + bottom: -6px; border-radius: 0 0 var(--radius-sm) var(--radius-sm); } - .event-card:hover .resize-handle { + .event-card:hover .resize-handle, + .event-card.resizing .resize-handle { opacity: 1; - background: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.15); } - .resize-handle:hover { - background: rgba(255, 255, 255, 0.5) !important; + .event-card:hover .resize-handle::after, + .event-card.resizing .resize-handle::after { + background: rgba(255, 255, 255, 0.7); } /* Compact resize handles */ .compact .resize-handle, .very-compact .resize-handle { - height: 6px; + height: 14px; + } + + .compact .resize-handle::after, + .very-compact .resize-handle::after { + width: 20px; + height: 3px; } .event-time { @@ -1612,7 +1846,12 @@ } .ultra-compact .resize-handle { - height: 4px; + height: 12px; + } + + .ultra-compact .resize-handle::after { + width: 16px; + height: 2px; } .ultra-compact .overflow-line { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte index 4658e609f..54dcdf42a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -14,7 +14,7 @@ import type { CalendarEvent } from '@calendar/shared'; interface Props { - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; disableSwipe?: boolean; } 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 ebdb88043..6c2767494 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -45,7 +45,7 @@ interface Props { /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ date?: Date; - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } @@ -131,6 +131,14 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // Drag-to-Create State + let isCreating = $state(false); + let createTargetDay = $state(null); + let createStartMinutes = $state(0); + let createEndMinutes = $state(0); + let createPreviewTop = $state(0); + let createPreviewHeight = $state(0); + // Task Drag & Drop State let isTaskDragging = $state(false); let draggedTask = $state(null); @@ -258,6 +266,40 @@ return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`; } + /** + * Calculate live time display during resize + */ + function getResizePreviewTime(): string { + if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return ''; + + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + // Calculate from preview position + const previewStartMinutes = + (resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60; + const previewEndMinutes = + previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60; + + let startMinutes: number; + let endMinutes: number; + + if (resizeEdge === 'top') { + startMinutes = Math.round(previewStartMinutes); + endMinutes = origEndMinutes; + } else { + startMinutes = origStartMinutes; + endMinutes = Math.round(previewEndMinutes); + } + + const startHours = Math.floor(startMinutes / 60); + const startMins = startMinutes % 60; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + } + /** * Get style for a scheduled task (time-blocking) */ @@ -311,18 +353,112 @@ } } - function handleSlotClick(day: Date, hour: number, e: MouseEvent) { - // Don't create new event if dragging - if (isDragging || isResizing) return; + // ========== Drag-to-Create Handlers ========== + function startCreate(e: PointerEvent) { + // Don't create event if dragging or resizing + if (isDragging || isResizing || isTaskDragging || isTaskResizing) return; - const startTime = new Date(day); - startTime.setHours(hour, 0, 0, 0); - - if (onQuickCreate) { - onQuickCreate(startTime, { x: e.clientX, y: e.clientY }); - } else { - goto(`/event/new?start=${startTime.toISOString()}`); + // Don't start creating if clicking on an event, task, or other interactive element + const target = e.target as HTMLElement; + if ( + target.closest( + '.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle' + ) + ) { + return; } + + e.preventDefault(); + + const day = getDayFromX(e.clientX); + if (!day) return; + + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + + isCreating = true; + hasMoved = false; + createTargetDay = day; + createStartMinutes = snappedMinutes; + createEndMinutes = snappedMinutes + MINUTES_PER_SLOT; + + updateCreatePreview(); + + document.addEventListener('pointermove', handleCreateMove); + document.addEventListener('pointerup', handleCreateEnd); + } + + function handleCreateMove(e: PointerEvent) { + if (!isCreating) return; + + hasMoved = true; + + // Update target day + const day = getDayFromX(e.clientX); + if (day) { + createTargetDay = day; + } + + const minutes = getMinutesFromY(e.clientY); + const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + + // Allow dragging both up and down from start point + if (snappedMinutes >= createStartMinutes) { + createEndMinutes = Math.max(snappedMinutes, createStartMinutes + MINUTES_PER_SLOT); + } else { + createEndMinutes = createStartMinutes + MINUTES_PER_SLOT; + createStartMinutes = snappedMinutes; + } + + // Clamp to visible hours + createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes); + createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes); + + updateCreatePreview(); + } + + function updateCreatePreview() { + createPreviewTop = minutesToPercent(createStartMinutes); + const duration = createEndMinutes - createStartMinutes; + createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + } + + function handleCreateEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + + if (!isCreating || !createTargetDay) { + isCreating = false; + return; + } + + // Calculate final times + 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); + + // Reset state + isCreating = false; + createTargetDay = null; + + // Open quick create with the calculated times + if (onQuickCreate) { + onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime); + } else { + goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`); + } + + hasMoved = false; + } + + function getCreatePreviewTime(): string { + const startHours = Math.floor(createStartMinutes / 60); + const startMins = createStartMinutes % 60; + const endHours = Math.floor(createEndMinutes / 60); + const endMins = createEndMinutes % 60; + return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; } function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { @@ -888,6 +1024,15 @@ resizeTask = null; hasMoved = false; } + // Cancel creating + if (isCreating) { + e.preventDefault(); + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + isCreating = false; + createTargetDay = null; + hasMoved = false; + } } } @@ -978,16 +1123,19 @@ class="day-column" class:today={isToday(day)} class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)} + class:creating={isCreating && createTargetDay && isSameDay(day, createTargetDay)} + onpointerdown={startCreate} ondragover={(e) => handleSidebarDragOver(e, day)} ondragleave={handleSidebarDragLeave} ondrop={(e) => handleSidebarDrop(e, day)} > {#each hours as hour} - + > {/each} @@ -1022,7 +1170,7 @@ isResizing={isBeingResized} isSearchHighlighted={searchStore.isEventHighlighted(event.id)} isSearchDimmed={searchStore.isEventDimmed(event.id)} - formattedTime={formatEventTimeRange(event)} + formattedTime={isBeingResized ? getResizePreviewTime() : formatEventTimeRange(event)} onClick={handleEventClick} onPointerDown={startDrag} onContextMenu={handleEventContextMenu} @@ -1076,6 +1224,19 @@ /> {/if} + + {#if isCreating && createTargetDay && isSameDay(day, createTargetDay)} +
+ {getCreatePreviewTime()} + (Neuer Termin) +
+ {/if} + {#if true} {@const overflow = getOverflowEventsForDay(day)} @@ -1353,6 +1514,39 @@ background: hsl(var(--color-muted) / 0.3); } + /* Create preview (drag-to-create) */ + .create-preview { + position: absolute; + left: 2px; + right: 2px; + padding: 2px 4px; + border-radius: var(--radius-sm); + 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 .event-time { + display: block; + font-size: 0.6rem; + opacity: 0.9; + } + + .create-preview .event-title { + display: block; + font-size: 0.7rem; + font-weight: 500; + opacity: 0.8; + } + + .day-column.creating { + cursor: ns-resize; + } + .time-indicator { position: absolute; left: 0; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte index ace2f26fd..d2d13b816 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -22,7 +22,7 @@ interface Props { /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ date?: Date; - onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void; onEventClick?: (event: CalendarEvent) => void; } diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 37ab2b46b..efd0099d4 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -23,7 +23,7 @@ // Generate a unique key for the overlay to force remount let overlayKey = $state(0); - function handleQuickCreate(date: Date, position: { x: number; y: number }) { + function handleQuickCreate(date: Date, position: { x: number; y: number }, endDate?: Date) { // Close any existing overlay first editingEvent = null; @@ -31,7 +31,8 @@ // Create draft event immediately so it appears in the grid const defaultCalendar = calendarsStore.defaultCalendar; - const endTime = addMinutes(date, settingsStore.defaultEventDuration); + // Use provided endDate or calculate from default duration + const endTime = endDate ?? addMinutes(date, settingsStore.defaultEventDuration); eventsStore.createDraftEvent({ calendarId: defaultCalendar?.id || '', diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index 5e08fcd1e..33a873ad9 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -3,32 +3,52 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { locale, _ } from 'svelte-i18n'; - import { PillNavigation } from '@manacore/shared-ui'; - import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; + import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui'; + import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui'; + + // Extend QuickInputItem for zitare-specific search results with href navigation + interface ZitareSearchItem extends QuickInputItem { + href?: string; + } import { theme } from '$lib/stores/theme.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { favoritesStore } from '$lib/stores/favorites.svelte'; + import { quotesStore } from '$lib/stores/quotes.svelte'; + import { listsStore } from '$lib/stores/lists.svelte'; + import { zitareSettings } from '$lib/stores/settings.svelte'; import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS, - EXTENDED_THEME_VARIANTS, + createUserSettingsStore, + filterHiddenNavItems, } from '@manacore/shared-theme'; import type { ThemeVariant } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; + import { QUOTES, type Quote } from '@zitare/content'; // App switcher items const appItems = getPillAppItems('zitare'); - let { children } = $props(); + // User settings for nav visibility + const userSettings = createUserSettingsStore({ + appId: 'zitare', + authUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001', + getAccessToken: () => authStore.getAccessToken(), + }); - let isSidebarMode = $state(false); - let isCollapsed = $state(false); + let { children } = $props(); // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Check if on home page for toolbar visibility + let isHomePage = $derived($page.url.pathname === '/'); + + // Bottom offset for QuickInputBar + let inputBarBottomOffset = $derived(zitareSettings.pillNavCollapsed ? '16px' : '70px'); + // Visible themes in PillNav let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS]); @@ -43,7 +63,7 @@ })), { id: 'all-themes', - label: 'Alle Themes', + label: $_('nav.allThemes'), icon: 'palette', onClick: () => goto('/themes'), active: false, @@ -66,32 +86,25 @@ let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); // User email for user dropdown - let userEmail = $derived(authStore.user?.email || 'Menü'); + let userEmail = $derived(authStore.user?.email || $_('nav.menu')); - // Navigation items for Zitare - const navItems: PillNavItem[] = [ - { href: '/', label: 'Heute', icon: 'sun' }, - { href: '/categories', label: 'Kategorien', icon: 'grid' }, - { href: '/favorites', label: 'Favoriten', icon: 'heart' }, - { href: '/lists', label: 'Listen', icon: 'list' }, - { href: '/search', label: 'Suche', icon: 'search' }, - { href: '/settings', label: 'Einstellungen', icon: 'settings' }, - { href: '/feedback', label: 'Feedback', icon: 'chat' }, - ]; + // Base navigation items for Zitare + let baseNavItems = $derived([ + { href: '/', label: $_('nav.today'), icon: 'sun' }, + { href: '/categories', label: $_('nav.categories'), icon: 'grid' }, + { href: '/favorites', label: $_('nav.favorites'), icon: 'heart' }, + { href: '/lists', label: $_('nav.lists'), icon: 'list' }, + { href: '/settings', label: $_('nav.settings'), icon: 'settings' }, + { href: '/feedback', label: $_('nav.feedback'), icon: 'chat' }, + ]); - function handleModeChange(isSidebar: boolean) { - isSidebarMode = isSidebar; - if (typeof localStorage !== 'undefined') { - localStorage.setItem('zitare-nav-sidebar', String(isSidebar)); - } - } + // Filter hidden nav items + let navItems = $derived( + filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems || {}) + ); - function handleCollapsedChange(collapsed: boolean) { - isCollapsed = collapsed; - if (typeof localStorage !== 'undefined') { - localStorage.setItem('zitare-nav-collapsed', String(collapsed)); - } - } + // Navigation routes for keyboard shortcuts + const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings', '/feedback']; function handleToggleTheme() { theme.toggleMode(); @@ -107,67 +120,202 @@ goto('/login'); } - onMount(async () => { - // Initialize sidebar mode from localStorage - const savedSidebar = localStorage.getItem('zitare-nav-sidebar'); - if (savedSidebar === 'true') { - isSidebarMode = true; + // QuickInputBar search handler + async function handleSearch(query: string): Promise { + if (!query.trim()) return []; + + const results = quotesStore.search(query); + return results.slice(0, 10).map((quote: Quote) => ({ + id: quote.id, + title: quotesStore.getText(quote).substring(0, 60) + '...', + subtitle: quote.author, + icon: 'quote' as const, + href: `/quote/${quote.id}`, + })); + } + + // QuickInputBar select handler + function handleSelect(item: ZitareSearchItem) { + if (item.href) { + goto(item.href); + } + } + + // QuickInputBar create preview + function handleParseCreate(query: string) { + if (!query.trim()) return null; + return { + title: `"${query}" ${$_('search.createList')}`, + subtitle: $_('search.createListDescription'), + }; + } + + // QuickInputBar create handler + async function handleCreate(query: string) { + if (!query.trim() || !authStore.isAuthenticated) return; + + // Create a new list with the query as name + await listsStore.createList(query, ''); + goto('/lists'); + } + + // Keyboard shortcuts + function handleKeydown(event: KeyboardEvent) { + const target = event.target as HTMLElement; + + // Don't interfere with text inputs + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; } - // Initialize collapsed state from localStorage - const savedCollapsed = localStorage.getItem('zitare-nav-collapsed'); - if (savedCollapsed === 'true') { - isCollapsed = true; + // Ctrl+1, Ctrl+2, etc. for navigation + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) { + const num = parseInt(event.key); + if (num >= 1 && num <= navRoutes.length) { + event.preventDefault(); + const route = navRoutes[num - 1]; + goto(route); + } } - // Load favorites if authenticated + // F key - Toggle Immersive Mode (no modifiers) + if ( + (event.key === 'f' || event.key === 'F') && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + zitareSettings.toggleImmersiveMode(); + } + + // N key - New random quote (on home page) + if ( + isHomePage && + (event.key === 'n' || event.key === 'N') && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + quotesStore.loadRandomQuote(); + } + } + + // Toggle PillNav visibility (called by FAB) + function handlePillNavToggle() { + zitareSettings.togglePillNav(); + } + + onMount(() => { + // Initialize settings + zitareSettings.initialize(); + + // Load user settings and favorites if authenticated if (authStore.isAuthenticated) { - await favoritesStore.load(); + userSettings.load(); + favoritesStore.load(); + listsStore.loadLists(); } + + // Add keyboard listener + window.addEventListener('keydown', handleKeydown); + + return () => { + window.removeEventListener('keydown', handleKeydown); + }; });
-