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:
Till-JS 2025-12-02 23:42:03 +01:00
parent 9d8c1849ee
commit 0f2aae631d
9 changed files with 1013 additions and 92 deletions

View file

@ -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;
}
}

View file

@ -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));

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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;
},

View file

@ -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>

View file

@ -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) {