mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ feat(calendar): add drag & drop and page-level scrolling
- 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
This commit is contained in:
parent
9d8c1849ee
commit
0f2aae631d
9 changed files with 1013 additions and 92 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,15 +81,28 @@
|
|||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- Weekdays only toggle -->
|
||||
<button
|
||||
class="weekdays-toggle"
|
||||
class:active={settingsStore.showOnlyWeekdays}
|
||||
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
|
||||
title="Nur Wochentage anzeigen (Mo-Fr)"
|
||||
>
|
||||
Mo-Fr
|
||||
</button>
|
||||
<!-- Filter toggles -->
|
||||
<div class="filter-toggles">
|
||||
<!-- Weekdays only toggle -->
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={settingsStore.showOnlyWeekdays}
|
||||
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
|
||||
title="Nur Wochentage anzeigen (Mo-Fr)"
|
||||
>
|
||||
Mo-Fr
|
||||
</button>
|
||||
|
||||
<!-- Hide early hours toggle -->
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={settingsStore.hideEarlyHours}
|
||||
onclick={() => settingsStore.set('hideEarlyHours', !settingsStore.hideEarlyHours)}
|
||||
title="Frühe Stunden ausblenden (0-6 Uhr)"
|
||||
>
|
||||
7-24
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="view-selector">
|
||||
{#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));
|
||||
|
|
|
|||
|
|
@ -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<any>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
let dayColumnRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Resize State
|
||||
// ============================================================================
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<any>(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);
|
||||
|
||||
// ============================================================================
|
||||
// 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 @@
|
|||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => handleEventClick(event)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
|
|
@ -93,7 +327,11 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="day-column" class:today={isToday(viewStore.currentDate)}>
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(viewStore.currentDate)}
|
||||
bind:this={dayColumnRef}
|
||||
>
|
||||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
|
|
@ -104,11 +342,27 @@
|
|||
|
||||
<!-- Events -->
|
||||
{#each timedEvents as event}
|
||||
<button
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
<div
|
||||
class="event-card"
|
||||
style={getEventStyle(event)}
|
||||
onclick={() => handleEventClick(event)}
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
style={isBeingDragged ? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};` : isBeingResized ? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};` : getEventStyle(event)}
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="slider"
|
||||
aria-label="Startzeit ändern"
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
<span class="event-time">
|
||||
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
|
||||
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
|
||||
|
|
@ -117,7 +371,16 @@
|
|||
{#if event.location}
|
||||
<span class="event-location">{event.location}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={(e) => startResize(event, 'bottom', e)}
|
||||
role="slider"
|
||||
aria-label="Endzeit ändern"
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
|
|
@ -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);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<any>(null);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let monthViewRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Store for day cell refs
|
||||
let dayCellRefs = $state<Map<string, HTMLElement>>(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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="month-view" style="--column-count: {columnCount}">
|
||||
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
|
||||
<!-- Week day headers -->
|
||||
<div class="weekday-headers">
|
||||
{#each weekDays as day}
|
||||
|
|
@ -87,11 +205,14 @@
|
|||
{#each weeks as week}
|
||||
<div class="week-row">
|
||||
{#each week as day}
|
||||
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-cell"
|
||||
class:other-month={!isSameMonth(day, viewStore.currentDate)}
|
||||
class:today={isToday(day)}
|
||||
class:drop-target={isDropTarget}
|
||||
use:bindDayCellRef={day}
|
||||
onclick={() => handleDayClick(day)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day)}
|
||||
role="button"
|
||||
|
|
@ -103,16 +224,21 @@
|
|||
|
||||
<div class="day-events">
|
||||
{#each getEventsForDay(day) as event}
|
||||
<button
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
<div
|
||||
class="event-pill"
|
||||
class:dragging={isBeingDragged}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if !event.isAllDay}
|
||||
<span class="event-time">{format(typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime, 'HH:mm')}</span>
|
||||
{/if}
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<any>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
|
||||
// ========== Resize State ==========
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<any>(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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="multi-day-view" class:compact={columnClass === 'compact'} class:very-compact={columnClass === 'very-compact'}>
|
||||
|
|
@ -101,7 +362,7 @@
|
|||
<button
|
||||
class="all-day-event"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => handleEventClick(event)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
|
|
@ -134,34 +395,77 @@
|
|||
</div>
|
||||
|
||||
<!-- Day columns -->
|
||||
<div class="days-container">
|
||||
<div class="days-container" bind:this={daysContainerEl}>
|
||||
{#each days as day}
|
||||
<div class="day-column" class:today={isToday(day)}>
|
||||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
onclick={() => handleSlotClick(day, hour)}
|
||||
aria-label={`${format(day, 'EEEE', { locale: de })} ${hour}:00 Uhr`}
|
||||
aria-label={`${format(day, 'EEEE', { locale: currentDateLocale })} ${settingsStore.formatHour(hour)}`}
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Events -->
|
||||
{#each getEventsForDay(day) as event}
|
||||
<button
|
||||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
<div
|
||||
class="event-card"
|
||||
style={getEventStyle(event)}
|
||||
onclick={() => handleEventClick(event)}
|
||||
title={`${format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} - ${event.title}`}
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
style={isBeingDragged
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: isBeingResized
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: getEventStyle(event)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/event/${event.id}`)}
|
||||
title={`${formatEventTime(event.startTime)} - ${event.title}`}
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="slider"
|
||||
aria-label="Startzeit ändern"
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
{#if columnClass !== 'very-compact'}
|
||||
<span class="event-time">
|
||||
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')}
|
||||
{formatEventTime(event.startTime)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="event-title">{event.title}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={(e) => startResize(event, 'bottom', e)}
|
||||
role="slider"
|
||||
aria-label="Endzeit ändern"
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Drag preview ghost (for cross-day dragging) -->
|
||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some(e => e.id === draggedEvent.id)}
|
||||
<div
|
||||
class="event-card drag-ghost"
|
||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(draggedEvent.calendarId)};"
|
||||
>
|
||||
{#if columnClass !== 'very-compact'}
|
||||
<span class="event-time">{formatEventTime(draggedEvent.startTime)}</span>
|
||||
{/if}
|
||||
<span class="event-title">{draggedEvent.title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(day)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -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));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-wrapper" class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Main Calendar Area -->
|
||||
<div class="calendar-main">
|
||||
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
|
||||
<CalendarHeader />
|
||||
|
||||
<div class="calendar-content">
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue