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