feat(calendar): add larger resize handles, live time preview, and drag-to-create

- Increase resize handle height from 8px to 20px with visual grip indicator
- Show live time preview during resize operations
- Add drag-to-create functionality: click and hold on empty cell to drag and create events with custom duration
- Fix zitare TypeScript errors (SearchResultItem -> QuickInputItem, createUserSettingsStore API)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 23:03:23 +01:00
parent d3b0f9f38d
commit 23a1c5e539
9 changed files with 987 additions and 159 deletions

View file

@ -30,7 +30,7 @@
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
@ -164,6 +164,15 @@
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// ============================================================================
// Drag-to-Create State
// ============================================================================
let isCreating = $state(false);
let createStartMinutes = $state(0);
let createEndMinutes = $state(0);
let createPreviewTop = $state(0);
let createPreviewHeight = $state(0);
// ============================================================================
// Task Drag & Drop State
// ============================================================================
@ -395,10 +404,16 @@
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
// Creating cleanup
isCreating = false;
createStartMinutes = 0;
createEndMinutes = 0;
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
// Task cleanup
isTaskDragging = false;
draggedTask = null;
@ -630,7 +645,10 @@
// Keyboard Handling
// ============================================================================
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
if (
e.key === 'Escape' &&
(isDragging || isResizing || isTaskDragging || isTaskResizing || isCreating)
) {
e.preventDefault();
cleanup();
}
@ -663,6 +681,40 @@
return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`;
}
/**
* Calculate live time display during resize
*/
function getResizePreviewTime(): string {
if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return '';
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
// Calculate from preview position
const previewStartMinutes =
(resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60;
const previewEndMinutes =
previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60;
let startMinutes: number;
let endMinutes: number;
if (resizeEdge === 'top') {
startMinutes = Math.round(previewStartMinutes);
endMinutes = origEndMinutes;
} else {
startMinutes = origStartMinutes;
endMinutes = Math.round(previewEndMinutes);
}
const startHours = Math.floor(startMinutes / 60);
const startMins = startMinutes % 60;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
/**
* Get style for a scheduled task (time-blocking)
*/
@ -709,18 +761,100 @@
}
}
function handleSlotClick(hour: number, e: MouseEvent) {
// ============================================================================
// Drag-to-Create Handlers
// ============================================================================
function startCreate(e: PointerEvent) {
// Don't create event if dragging or resizing
if (isDragging || isResizing) return;
if (isDragging || isResizing || isTaskDragging || isTaskResizing) return;
const startTime = new Date(effectiveDate);
startTime.setHours(hour, 0, 0, 0);
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
} else {
goto(`/event/new?start=${startTime.toISOString()}`);
// Don't start creating if clicking on an event, task, or other interactive element
const target = e.target as HTMLElement;
if (
target.closest(
'.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle'
)
) {
return;
}
e.preventDefault();
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = snapToGrid(minutes);
isCreating = true;
hasMoved = false;
createStartMinutes = snappedMinutes;
createEndMinutes = snappedMinutes + SNAP_MINUTES; // Start with 15 min duration
updateCreatePreview();
document.addEventListener('pointermove', handleCreateMove);
document.addEventListener('pointerup', handleCreateEnd);
}
function handleCreateMove(e: PointerEvent) {
if (!isCreating) return;
hasMoved = true;
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = snapToGrid(minutes);
// Allow dragging both up and down from start point
if (snappedMinutes >= createStartMinutes) {
createEndMinutes = Math.max(snappedMinutes, createStartMinutes + SNAP_MINUTES);
} else {
// Dragging upward - swap start and end
createEndMinutes = createStartMinutes + SNAP_MINUTES;
createStartMinutes = snappedMinutes;
}
// Clamp to visible hours
createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes);
createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes);
updateCreatePreview();
}
function updateCreatePreview() {
createPreviewTop = minutesToPercent(createStartMinutes);
const duration = createEndMinutes - createStartMinutes;
createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
}
function handleCreateEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
if (!isCreating) return;
// Calculate final times
const startTime = new Date(effectiveDate);
startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0);
const endTime = new Date(effectiveDate);
endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0);
// Reset state
isCreating = false;
// Open quick create with the calculated times
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime);
} else {
goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`);
}
hasMoved = false;
}
function getCreatePreviewTime(): string {
const startHours = Math.floor(createStartMinutes / 60);
const startMins = createStartMinutes % 60;
const endHours = Math.floor(createEndMinutes / 60);
const endMins = createEndMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
@ -788,17 +922,15 @@
class="day-column"
class:today={isToday(effectiveDate)}
class:drop-target={isSidebarDropTarget}
class:creating={isCreating}
bind:this={dayColumnRef}
onpointerdown={startCreate}
ondragover={handleSidebarDragOver}
ondragleave={handleSidebarDragLeave}
ondrop={handleSidebarDrop}
>
{#each hours as hour}
<button
class="hour-slot"
onclick={(e) => handleSlotClick(hour, e)}
aria-label={`${hour}:00 Uhr`}
></button>
<div class="hour-slot" role="button" tabindex="-1" aria-label={`${hour}:00 Uhr`}></div>
{/each}
<!-- Block-style all-day events -->
@ -830,7 +962,7 @@
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTime(event)}
formattedTime={isBeingResized ? getResizePreviewTime() : formatEventTime(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
@ -859,6 +991,19 @@
{/each}
{/if}
<!-- Create preview (drag-to-create) -->
{#if isCreating}
<div
class="create-preview"
style="top: {createPreviewTop}%; height: {createPreviewHeight}%; background-color: {calendarsStore.getColor(
calendarsStore.defaultCalendar?.id || ''
)};"
>
<span class="event-time">{getCreatePreviewTime()}</span>
<span class="event-title">(Neuer Termin)</span>
</div>
{/if}
<!-- Overflow indicators for events outside visible time range -->
{#if overflowEvents.before.length > 0}
<div class="overflow-indicator top" title="{overflowEvents.before.length} Termin(e) früher">
@ -1084,6 +1229,39 @@
background: hsl(var(--color-muted) / 0.2);
}
/* Create preview (drag-to-create) */
.create-preview {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-align: left;
z-index: 50;
overflow: hidden;
color: white;
opacity: 0.85;
border: 2px dashed rgba(255, 255, 255, 0.5);
pointer-events: none;
}
.create-preview .event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.create-preview .event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.8;
}
.day-column.creating {
cursor: ns-resize;
}
/* Overflow indicators for events outside visible time range */
.overflow-indicator {
position: absolute;

View file

@ -227,25 +227,42 @@
position: absolute;
left: 0;
right: 0;
height: 8px;
height: 20px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '';
width: 32px;
height: 4px;
background: rgba(255, 255, 255, 0.5);
border-radius: 2px;
}
.resize-handle.top {
top: 0;
top: -6px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
bottom: -6px;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
.event-card:hover .resize-handle,
.event-card.resizing .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.event-card:hover .resize-handle::after,
.event-card.resizing .resize-handle::after {
background: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -38,7 +38,7 @@
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
}

View file

@ -46,7 +46,7 @@
dayCount: number;
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
@ -131,6 +131,14 @@
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// Drag-to-Create State
let isCreating = $state(false);
let createTargetDay = $state<Date | null>(null);
let createStartMinutes = $state(0);
let createEndMinutes = $state(0);
let createPreviewTop = $state(0);
let createPreviewHeight = $state(0);
// Task Drag & Drop State
let isTaskDragging = $state(false);
let draggedTask = $state<Task | null>(null);
@ -277,6 +285,42 @@
return settingsStore.formatTime(toDate(date));
}
/**
* Calculate live time display during resize
*/
function getResizePreviewTime(event: CalendarEvent): string {
if (!resizeEvent || resizeEvent.id !== event.id || !resizeOriginalStart || !resizeOriginalEnd) {
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
}
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
// Calculate from preview position
const previewStartMinutes =
(resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60;
const previewEndMinutes =
previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60;
let startMinutes: number;
let endMinutes: number;
if (resizeEdge === 'top') {
startMinutes = Math.round(previewStartMinutes);
endMinutes = origEndMinutes;
} else {
startMinutes = origStartMinutes;
endMinutes = Math.round(previewEndMinutes);
}
const startHours = Math.floor(startMinutes / 60);
const startMins = startMinutes % 60;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if we just finished dragging or resizing, or if we moved
if (isDragging || isResizing || hasMoved) {
@ -294,18 +338,112 @@
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
// Don't create new event if dragging
if (isDragging || isResizing) return;
// ========== Drag-to-Create Handlers ==========
function startCreate(e: PointerEvent) {
// Don't create event if dragging or resizing
if (isDragging || isResizing || isTaskDragging || isTaskResizing) return;
const startTime = new Date(day);
startTime.setHours(hour, 0, 0, 0);
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
} else {
goto(`/event/new?start=${startTime.toISOString()}`);
// Don't start creating if clicking on an event, task, or other interactive element
const target = e.target as HTMLElement;
if (
target.closest(
'.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle'
)
) {
return;
}
e.preventDefault();
const day = getDayFromX(e.clientX);
if (!day) return;
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
isCreating = true;
hasMoved = false;
createTargetDay = day;
createStartMinutes = snappedMinutes;
createEndMinutes = snappedMinutes + MINUTES_PER_SLOT;
updateCreatePreview();
document.addEventListener('pointermove', handleCreateMove);
document.addEventListener('pointerup', handleCreateEnd);
}
function handleCreateMove(e: PointerEvent) {
if (!isCreating) return;
hasMoved = true;
// Update target day
const day = getDayFromX(e.clientX);
if (day) {
createTargetDay = day;
}
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
// Allow dragging both up and down from start point
if (snappedMinutes >= createStartMinutes) {
createEndMinutes = Math.max(snappedMinutes, createStartMinutes + MINUTES_PER_SLOT);
} else {
createEndMinutes = createStartMinutes + MINUTES_PER_SLOT;
createStartMinutes = snappedMinutes;
}
// Clamp to visible hours
createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes);
createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes);
updateCreatePreview();
}
function updateCreatePreview() {
createPreviewTop = minutesToPercent(createStartMinutes);
const duration = createEndMinutes - createStartMinutes;
createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
}
function handleCreateEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
if (!isCreating || !createTargetDay) {
isCreating = false;
return;
}
// Calculate final times
const startTime = new Date(createTargetDay);
startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0);
const endTime = new Date(createTargetDay);
endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0);
// Reset state
isCreating = false;
createTargetDay = null;
// Open quick create with the calculated times
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime);
} else {
goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`);
}
hasMoved = false;
}
function getCreatePreviewTime(): string {
const startHours = Math.floor(createStartMinutes / 60);
const startMins = createStartMinutes % 60;
const endHours = Math.floor(createEndMinutes / 60);
const endMins = createEndMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
@ -816,7 +954,10 @@
// ========== Keyboard Handling ==========
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
if (
e.key === 'Escape' &&
(isDragging || isResizing || isTaskDragging || isTaskResizing || isCreating)
) {
e.preventDefault();
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
@ -826,6 +967,8 @@
document.removeEventListener('pointerup', handleTaskDragEnd);
document.removeEventListener('pointermove', handleTaskResizeMove);
document.removeEventListener('pointerup', handleTaskResizeEnd);
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
@ -839,6 +982,8 @@
taskDragTargetDay = null;
isTaskResizing = false;
resizeTask = null;
isCreating = false;
createTargetDay = null;
hasMoved = false;
}
}
@ -913,16 +1058,19 @@
class="day-column"
class:today={isToday(day)}
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
class:creating={isCreating && createTargetDay && isSameDay(day, createTargetDay)}
onpointerdown={startCreate}
ondragover={(e) => handleSidebarDragOver(e, day)}
ondragleave={handleSidebarDragLeave}
ondrop={(e) => handleSidebarDrop(e, day)}
>
{#each hours as hour}
<button
<div
class="hour-slot"
onclick={(e) => handleSlotClick(day, hour, e)}
role="button"
tabindex="-1"
aria-label={`${format(day, 'EEEE', { locale: currentDateLocale })} ${settingsStore.formatHour(hour)}`}
></button>
></div>
{/each}
<!-- Block-style all-day events -->
@ -983,7 +1131,9 @@
{#if columnClass !== 'very-compact'}
<span class="event-time">
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
{isBeingResized
? getResizePreviewTime(event)
: `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`}
</span>
{/if}
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
@ -1051,6 +1201,21 @@
</div>
{/if}
<!-- Create preview (drag-to-create) -->
{#if isCreating && createTargetDay && isSameDay(day, createTargetDay)}
<div
class="create-preview"
style="top: {createPreviewTop}%; height: {createPreviewHeight}%; background-color: {calendarsStore.getColor(
calendarsStore.defaultCalendar?.id || ''
)};"
>
{#if columnClass !== 'very-compact'}
<span class="event-time">{getCreatePreviewTime()}</span>
{/if}
<span class="event-title">(Neuer Termin)</span>
</div>
{/if}
<!-- Overflow indicators for events outside visible time range -->
{#if true}
{@const overflow = getOverflowEventsForDay(day)}
@ -1335,6 +1500,56 @@
background: hsl(var(--color-muted) / 0.5);
}
/* Create preview (drag-to-create) */
.create-preview {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-align: left;
z-index: 50;
overflow: hidden;
color: white;
opacity: 0.85;
border: 2px dashed rgba(255, 255, 255, 0.5);
pointer-events: none;
}
.create-preview .event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.create-preview .event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.8;
}
.day-column.creating {
cursor: ns-resize;
}
.compact .create-preview,
.very-compact .create-preview {
left: 1px;
right: 1px;
padding: 1px 2px;
}
.compact .create-preview .event-time,
.very-compact .create-preview .event-time {
font-size: 0.5rem;
}
.compact .create-preview .event-title,
.very-compact .create-preview .event-title {
font-size: 0.55rem;
}
.event-card {
position: absolute;
left: 2px;
@ -1442,36 +1657,55 @@
position: absolute;
left: 0;
right: 0;
height: 8px;
height: 20px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '';
width: 32px;
height: 4px;
background: rgba(255, 255, 255, 0.5);
border-radius: 2px;
}
.resize-handle.top {
top: 0;
top: -6px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
bottom: -6px;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
.event-card:hover .resize-handle,
.event-card.resizing .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
.event-card:hover .resize-handle::after,
.event-card.resizing .resize-handle::after {
background: rgba(255, 255, 255, 0.7);
}
/* Compact resize handles */
.compact .resize-handle,
.very-compact .resize-handle {
height: 6px;
height: 14px;
}
.compact .resize-handle::after,
.very-compact .resize-handle::after {
width: 20px;
height: 3px;
}
.event-time {
@ -1612,7 +1846,12 @@
}
.ultra-compact .resize-handle {
height: 4px;
height: 12px;
}
.ultra-compact .resize-handle::after {
width: 16px;
height: 2px;
}
.ultra-compact .overflow-line {

View file

@ -14,7 +14,7 @@
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
disableSwipe?: boolean;
}

View file

@ -45,7 +45,7 @@
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
@ -131,6 +131,14 @@
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// Drag-to-Create State
let isCreating = $state(false);
let createTargetDay = $state<Date | null>(null);
let createStartMinutes = $state(0);
let createEndMinutes = $state(0);
let createPreviewTop = $state(0);
let createPreviewHeight = $state(0);
// Task Drag & Drop State
let isTaskDragging = $state(false);
let draggedTask = $state<Task | null>(null);
@ -258,6 +266,40 @@
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
}
/**
* Calculate live time display during resize
*/
function getResizePreviewTime(): string {
if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return '';
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
// Calculate from preview position
const previewStartMinutes =
(resizePreviewTop / 100) * totalVisibleHours * 60 + firstVisibleHour * 60;
const previewEndMinutes =
previewStartMinutes + (resizePreviewHeight / 100) * totalVisibleHours * 60;
let startMinutes: number;
let endMinutes: number;
if (resizeEdge === 'top') {
startMinutes = Math.round(previewStartMinutes);
endMinutes = origEndMinutes;
} else {
startMinutes = origStartMinutes;
endMinutes = Math.round(previewEndMinutes);
}
const startHours = Math.floor(startMinutes / 60);
const startMins = startMinutes % 60;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
/**
* Get style for a scheduled task (time-blocking)
*/
@ -311,18 +353,112 @@
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
// Don't create new event if dragging
if (isDragging || isResizing) return;
// ========== Drag-to-Create Handlers ==========
function startCreate(e: PointerEvent) {
// Don't create event if dragging or resizing
if (isDragging || isResizing || isTaskDragging || isTaskResizing) return;
const startTime = new Date(day);
startTime.setHours(hour, 0, 0, 0);
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
} else {
goto(`/event/new?start=${startTime.toISOString()}`);
// Don't start creating if clicking on an event, task, or other interactive element
const target = e.target as HTMLElement;
if (
target.closest(
'.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle'
)
) {
return;
}
e.preventDefault();
const day = getDayFromX(e.clientX);
if (!day) return;
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
isCreating = true;
hasMoved = false;
createTargetDay = day;
createStartMinutes = snappedMinutes;
createEndMinutes = snappedMinutes + MINUTES_PER_SLOT;
updateCreatePreview();
document.addEventListener('pointermove', handleCreateMove);
document.addEventListener('pointerup', handleCreateEnd);
}
function handleCreateMove(e: PointerEvent) {
if (!isCreating) return;
hasMoved = true;
// Update target day
const day = getDayFromX(e.clientX);
if (day) {
createTargetDay = day;
}
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
// Allow dragging both up and down from start point
if (snappedMinutes >= createStartMinutes) {
createEndMinutes = Math.max(snappedMinutes, createStartMinutes + MINUTES_PER_SLOT);
} else {
createEndMinutes = createStartMinutes + MINUTES_PER_SLOT;
createStartMinutes = snappedMinutes;
}
// Clamp to visible hours
createStartMinutes = Math.max(firstVisibleHour * 60, createStartMinutes);
createEndMinutes = Math.min(lastVisibleHour * 60, createEndMinutes);
updateCreatePreview();
}
function updateCreatePreview() {
createPreviewTop = minutesToPercent(createStartMinutes);
const duration = createEndMinutes - createStartMinutes;
createPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
}
function handleCreateEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
if (!isCreating || !createTargetDay) {
isCreating = false;
return;
}
// Calculate final times
const startTime = new Date(createTargetDay);
startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0);
const endTime = new Date(createTargetDay);
endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0);
// Reset state
isCreating = false;
createTargetDay = null;
// Open quick create with the calculated times
if (onQuickCreate) {
onQuickCreate(startTime, { x: e.clientX, y: e.clientY }, endTime);
} else {
goto(`/event/new?start=${startTime.toISOString()}&end=${endTime.toISOString()}`);
}
hasMoved = false;
}
function getCreatePreviewTime(): string {
const startHours = Math.floor(createStartMinutes / 60);
const startMins = createStartMinutes % 60;
const endHours = Math.floor(createEndMinutes / 60);
const endMins = createEndMinutes % 60;
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
@ -888,6 +1024,15 @@
resizeTask = null;
hasMoved = false;
}
// Cancel creating
if (isCreating) {
e.preventDefault();
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
isCreating = false;
createTargetDay = null;
hasMoved = false;
}
}
}
@ -978,16 +1123,19 @@
class="day-column"
class:today={isToday(day)}
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
class:creating={isCreating && createTargetDay && isSameDay(day, createTargetDay)}
onpointerdown={startCreate}
ondragover={(e) => handleSidebarDragOver(e, day)}
ondragleave={handleSidebarDragLeave}
ondrop={(e) => handleSidebarDrop(e, day)}
>
{#each hours as hour}
<button
<div
class="hour-slot"
onclick={(e) => handleSlotClick(day, hour, e)}
role="button"
tabindex="-1"
aria-label={`${format(day, 'EEEE', { locale: currentDateLocale })} ${settingsStore.formatHour(hour)}`}
></button>
></div>
{/each}
<!-- Block-style all-day events -->
@ -1022,7 +1170,7 @@
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTimeRange(event)}
formattedTime={isBeingResized ? getResizePreviewTime() : formatEventTimeRange(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
@ -1076,6 +1224,19 @@
/>
{/if}
<!-- Create preview (drag-to-create) -->
{#if isCreating && createTargetDay && isSameDay(day, createTargetDay)}
<div
class="create-preview"
style="top: {createPreviewTop}%; height: {createPreviewHeight}%; background-color: {calendarsStore.getColor(
calendarsStore.defaultCalendar?.id || ''
)};"
>
<span class="event-time">{getCreatePreviewTime()}</span>
<span class="event-title">(Neuer Termin)</span>
</div>
{/if}
<!-- Overflow indicators for events outside visible time range -->
{#if true}
{@const overflow = getOverflowEventsForDay(day)}
@ -1353,6 +1514,39 @@
background: hsl(var(--color-muted) / 0.3);
}
/* Create preview (drag-to-create) */
.create-preview {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-align: left;
z-index: 50;
overflow: hidden;
color: white;
opacity: 0.85;
border: 2px dashed rgba(255, 255, 255, 0.5);
pointer-events: none;
}
.create-preview .event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.create-preview .event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.8;
}
.day-column.creating {
cursor: ns-resize;
}
.time-indicator {
position: absolute;
left: 0;

View file

@ -22,7 +22,7 @@
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
}

View file

@ -23,7 +23,7 @@
// Generate a unique key for the overlay to force remount
let overlayKey = $state(0);
function handleQuickCreate(date: Date, position: { x: number; y: number }) {
function handleQuickCreate(date: Date, position: { x: number; y: number }, endDate?: Date) {
// Close any existing overlay first
editingEvent = null;
@ -31,7 +31,8 @@
// Create draft event immediately so it appears in the grid
const defaultCalendar = calendarsStore.defaultCalendar;
const endTime = addMinutes(date, settingsStore.defaultEventDuration);
// Use provided endDate or calculate from default duration
const endTime = endDate ?? addMinutes(date, settingsStore.defaultEventDuration);
eventsStore.createDraftEvent({
calendarId: defaultCalendar?.id || '',

View file

@ -3,32 +3,52 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale, _ } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem, QuickInputItem } from '@manacore/shared-ui';
// Extend QuickInputItem for zitare-specific search results with href navigation
interface ZitareSearchItem extends QuickInputItem {
href?: string;
}
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { listsStore } from '$lib/stores/lists.svelte';
import { zitareSettings } from '$lib/stores/settings.svelte';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
createUserSettingsStore,
filterHiddenNavItems,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { QUOTES, type Quote } from '@zitare/content';
// App switcher items
const appItems = getPillAppItems('zitare');
let { children } = $props();
// User settings for nav visibility
const userSettings = createUserSettingsStore({
appId: 'zitare',
authUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001',
getAccessToken: () => authStore.getAccessToken(),
});
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let { children } = $props();
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Check if on home page for toolbar visibility
let isHomePage = $derived($page.url.pathname === '/');
// Bottom offset for QuickInputBar
let inputBarBottomOffset = $derived(zitareSettings.pillNavCollapsed ? '16px' : '70px');
// Visible themes in PillNav
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS]);
@ -43,7 +63,7 @@
})),
{
id: 'all-themes',
label: 'Alle Themes',
label: $_('nav.allThemes'),
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
@ -66,32 +86,25 @@
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
let userEmail = $derived(authStore.user?.email || $_('nav.menu'));
// Navigation items for Zitare
const navItems: PillNavItem[] = [
{ href: '/', label: 'Heute', icon: 'sun' },
{ href: '/categories', label: 'Kategorien', icon: 'grid' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/lists', label: 'Listen', icon: 'list' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Base navigation items for Zitare
let baseNavItems = $derived<PillNavItem[]>([
{ href: '/', label: $_('nav.today'), icon: 'sun' },
{ href: '/categories', label: $_('nav.categories'), icon: 'grid' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/lists', label: $_('nav.lists'), icon: 'list' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{ href: '/feedback', label: $_('nav.feedback'), icon: 'chat' },
]);
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zitare-nav-sidebar', String(isSidebar));
}
}
// Filter hidden nav items
let navItems = $derived(
filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems || {})
);
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('zitare-nav-collapsed', String(collapsed));
}
}
// Navigation routes for keyboard shortcuts
const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings', '/feedback'];
function handleToggleTheme() {
theme.toggleMode();
@ -107,67 +120,202 @@
goto('/login');
}
onMount(async () => {
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('zitare-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
// QuickInputBar search handler
async function handleSearch(query: string): Promise<ZitareSearchItem[]> {
if (!query.trim()) return [];
const results = quotesStore.search(query);
return results.slice(0, 10).map((quote: Quote) => ({
id: quote.id,
title: quotesStore.getText(quote).substring(0, 60) + '...',
subtitle: quote.author,
icon: 'quote' as const,
href: `/quote/${quote.id}`,
}));
}
// QuickInputBar select handler
function handleSelect(item: ZitareSearchItem) {
if (item.href) {
goto(item.href);
}
}
// QuickInputBar create preview
function handleParseCreate(query: string) {
if (!query.trim()) return null;
return {
title: `"${query}" ${$_('search.createList')}`,
subtitle: $_('search.createListDescription'),
};
}
// QuickInputBar create handler
async function handleCreate(query: string) {
if (!query.trim() || !authStore.isAuthenticated) return;
// Create a new list with the query as name
await listsStore.createList(query, '');
goto('/lists');
}
// Keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Don't interfere with text inputs
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('zitare-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
// Ctrl+1, Ctrl+2, etc. for navigation
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
goto(route);
}
}
// Load favorites if authenticated
// F key - Toggle Immersive Mode (no modifiers)
if (
(event.key === 'f' || event.key === 'F') &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
zitareSettings.toggleImmersiveMode();
}
// N key - New random quote (on home page)
if (
isHomePage &&
(event.key === 'n' || event.key === 'N') &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
quotesStore.loadRandomQuote();
}
}
// Toggle PillNav visibility (called by FAB)
function handlePillNavToggle() {
zitareSettings.togglePillNav();
}
onMount(() => {
// Initialize settings
zitareSettings.initialize();
// Load user settings and favorites if authenticated
if (authStore.isAuthenticated) {
await favoritesStore.load();
userSettings.load();
favoritesStore.load();
listsStore.loadLists();
}
// Add keyboard listener
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
});
</script>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Zitare"
homeRoute="/"
desktopPosition="bottom"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
{#if !zitareSettings.immersiveModeEnabled}
<!-- PillNav (shown/hidden via FAB) -->
{#if !zitareSettings.pillNavCollapsed}
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Zitare"
homeRoute="/"
desktopPosition="bottom"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
{/if}
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onCreate={handleCreate}
onParseCreate={handleParseCreate}
placeholder={$_('search.placeholder')}
emptyText={$_('search.noResults')}
searchingText={$_('search.searching')}
createText={$_('search.create')}
appIcon="quote"
bottomOffset={inputBarBottomOffset}
hasFabRight={true}
/>
<!-- FAB to toggle PillNav visibility -->
<button
class="pillnav-fab"
onclick={handlePillNavToggle}
title={zitareSettings.pillNavCollapsed ? $_('nav.showNav') : $_('nav.hideNav')}
>
{#if zitareSettings.pillNavCollapsed}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
{/if}
<!-- Immersive Mode Toggle (always visible) -->
<ImmersiveModeToggle
isImmersive={zitareSettings.immersiveModeEnabled}
onToggle={() => zitareSettings.toggleImmersiveMode()}
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
<!-- Main content -->
<main class="main-content bg-background" class:immersive={zitareSettings.immersiveModeEnabled}>
<div class="content-wrapper" class:immersive={zitareSettings.immersiveModeEnabled}>
{@render children()}
</div>
</main>
@ -178,40 +326,91 @@
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
.main-content {
flex: 1;
transition: all 300ms ease;
position: relative;
z-index: 0;
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 180px;
.main-content.immersive {
padding: 0 !important;
}
.content-wrapper {
max-width: 100%;
max-width: 900px;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
.content-wrapper.immersive {
max-width: 100%;
padding: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 640px) {
.content-wrapper {
.content-wrapper:not(.immersive) {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
.content-wrapper:not(.immersive) {
padding: 2rem;
}
}
@media (max-width: 768px) {
.main-content:not(.immersive) {
padding-bottom: calc(160px + env(safe-area-inset-bottom));
}
}
/* FAB to toggle PillNav */
.pillnav-fab {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
right: 1rem;
width: 54px;
height: 54px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
transition: all 0.2s ease;
}
.pillnav-fab:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.pillnav-fab:active {
transform: scale(0.95);
}
:global(.dark) .pillnav-fab {
background: rgba(30, 30, 30, 0.9);
border-color: rgba(255, 255, 255, 0.1);
}
.fab-icon {
width: 24px;
height: 24px;
color: var(--color-foreground);
}
</style>