mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
d3b0f9f38d
commit
23a1c5e539
9 changed files with 987 additions and 159 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue