From b7057ed8fc0b163f3d477e0386695d09c410a550 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:36:19 +0100 Subject: [PATCH 01/69] feat(calendar): improve DateStrip today button and fix initial date display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move today button next to month label (left side, without shifting layout) - Add current date display below "Heute" label - Style button as pill-shaped with blue accent colors - Widen DateStrip container for better layout - Fix month label to have fixed min-width to prevent layout jumps - Fix initial date not showing correctly on page reload by: - Using instant scroll on mount instead of smooth - Calling updateVisibleMonth() after scroll completes - Initializing visibleMonth with today's date 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DateStrip.svelte | 78 +++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte index 86e875875..43b4acf42 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -98,7 +98,7 @@ } }); - async function scrollToDate(date: Date) { + async function scrollToDate(date: Date, instant = false) { await tick(); const targetDate = startOfDay(date); @@ -115,7 +115,7 @@ ); if (dayElement) { dayElement.scrollIntoView({ - behavior: 'smooth', + behavior: instant ? 'instant' : 'smooth', inline: 'center', block: 'nearest', }); @@ -185,7 +185,7 @@ } } - // Get the month of the center visible day + // Get the month of the center visible day (initial: today) let visibleMonth = $state(format(new Date(), 'MMMM yyyy', { locale: de })); function updateVisibleMonth() { @@ -209,20 +209,28 @@ } } - onMount(() => { - scrollToDate(viewStore.currentDate); + onMount(async () => { + // Always scroll to today on mount, then update the visible month + const today = new Date(); + await scrollToDate(today, true); + updateVisibleMonth(); + checkTodayVisibility(); });
- {#if !isTodayVisible} - - {/if} -
- {visibleMonth} + + {#if !isTodayVisible} + + {/if} + {visibleMonth} +
@@ -296,25 +304,47 @@ } .today-button { - padding: 0.25rem 0.75rem; - background: transparent; - border: 1px solid #d1d5db; + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-50%); + margin-right: 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.375rem 0.875rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 9999px; cursor: pointer; - color: #9ca3af; - font-size: 0.6875rem; - font-weight: 600; - margin-bottom: 0.375rem; + color: #3b82f6; pointer-events: auto; transition: all 0.2s ease; } .today-button:hover { - background: rgba(59, 130, 246, 0.1); + background: #3b82f6; border-color: #3b82f6; - color: #3b82f6; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2); + color: white; + transform: translateY(-50%) scale(1.02); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + } + + .today-button:hover .today-date { + color: rgba(255, 255, 255, 0.85); + } + + .today-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + .today-date { + font-size: 0.75rem; + font-weight: 500; + color: #60a5fa; } .date-strip-container { @@ -323,11 +353,12 @@ background: var(--color-surface, #ffffff); border-radius: 16px; margin: 0 1rem; - padding: 0.5rem; + padding: 0.5rem 1.5rem; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); border: 1px solid var(--color-border, #e5e7eb); pointer-events: auto; max-width: calc(100vw - 2rem); + min-width: 420px; overflow: hidden; } @@ -340,10 +371,13 @@ } .month-label { + position: relative; font-size: 1.125rem; font-weight: 600; color: var(--color-foreground, #1f2937); white-space: nowrap; + min-width: 150px; + text-align: center; } .month-divider { From 502ba0c6b953dc465377f465fa4b723e10997489 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:51:05 +0100 Subject: [PATCH 02/69] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(calendar):?= =?UTF-8?q?=20integrate=20agenda=20as=20view=20type=20and=20improve=20DayV?= =?UTF-8?q?iew=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AgendaView component as integrated calendar view type - Add 'agenda' option to view type selector in toolbar - Remove separate /agenda route (now accessible via view switcher) - Redesign DayView for better wide-screen display: - Center content with max-width constraints - Narrower events (max-width 400px) - Better positioned time labels - Fix overflow handling in all calendar views --- .../lib/components/calendar/AgendaView.svelte | 303 ++++++++++++++++++ .../calendar/CalendarToolbar.svelte | 242 ++++++++------ .../calendar/CalendarToolbarContent.svelte | 1 + .../lib/components/calendar/DayView.svelte | 34 +- .../lib/components/calendar/MonthView.svelte | 4 + .../components/calendar/MultiDayView.svelte | 4 + .../lib/components/calendar/WeekView.svelte | 4 + .../apps/web/src/routes/(app)/+layout.svelte | 19 +- .../apps/web/src/routes/(app)/+page.svelte | 8 + .../web/src/routes/(app)/agenda/+page.svelte | 238 -------------- 10 files changed, 505 insertions(+), 352 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte delete mode 100644 apps/calendar/apps/web/src/routes/(app)/agenda/+page.svelte diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte new file mode 100644 index 000000000..6fc284435 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -0,0 +1,303 @@ + + +
+ {#if groupedEvents.length === 0} +
+ + + +

Keine Termine in diesem Zeitraum

+
+ {:else} +
+ {#each groupedEvents as group} +
+

+ {formatDateHeader(group.date)} +

+ +
+ {#each group.events as event} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte index 607f9d473..e198d25ed 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte @@ -1,5 +1,5 @@ -{#if !isCollapsed} - - + +
+ + - + + {#if !isCollapsed} +
+ - -
- -
+
+ +
- -{/if} - - -{#if isCollapsed} - -{/if} + {/if} +
diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte index dd5d4461d..d44f6fc0a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte @@ -37,6 +37,7 @@ '14day', 'month', 'year', + 'agenda', ]; // Convert to ViewOptions for PillViewSwitcher 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 f1c692594..abae9e796 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -914,12 +914,17 @@ .day-view { display: flex; flex-direction: column; + align-items: center; + height: 100%; + overflow: hidden; } .all-day-section { display: flex; border-bottom: 1px solid hsl(var(--color-border)); padding: 0.5rem 0; + width: 100%; + max-width: 800px; } .all-day-label { @@ -960,13 +965,14 @@ .all-day-block-event { position: absolute; top: 0; - left: 4px; - right: 4px; + left: 8px; + width: calc(100% - 16px); + max-width: 400px; bottom: 0; padding: 8px 12px; color: white; border: none; - border-radius: var(--radius-sm); + border-radius: var(--radius-md); text-align: left; cursor: pointer; z-index: 0; @@ -1002,16 +1008,19 @@ .time-grid { flex: 1; display: flex; + width: 100%; + max-width: 800px; + overflow-y: auto; } .time-column { - width: var(--time-column-width); + width: 50px; flex-shrink: 0; } .time-label { height: var(--hour-height); - padding-right: 0.5rem; + padding-right: 0.75rem; text-align: right; font-size: 0.75rem; color: hsl(var(--color-muted-foreground)); @@ -1020,9 +1029,9 @@ } .time-gutter { - width: var(--time-column-width); + width: 50px; flex-shrink: 0; - padding-right: 0.5rem; + padding-right: 0.75rem; text-align: right; } @@ -1030,6 +1039,7 @@ flex: 1; position: relative; border-left: 1px solid hsl(var(--color-border)); + max-width: 600px; } .day-column.today { @@ -1044,8 +1054,9 @@ .event-card { position: absolute; - left: 4px; - right: 4px; + left: 8px; + width: calc(100% - 16px); + max-width: 400px; color: white; border: none; text-align: left; @@ -1054,11 +1065,12 @@ display: flex; flex-direction: column; gap: 2px; - padding: 4px 8px; - border-radius: var(--radius-sm); + padding: 6px 10px; + border-radius: var(--radius-md); overflow: hidden; touch-action: none; user-select: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); transition: box-shadow 150ms ease, opacity 150ms ease; 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 936c5b755..19d0c844c 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -338,12 +338,15 @@ .month-view { display: flex; flex-direction: column; + height: 100%; + overflow: hidden; } .weekday-headers { display: grid; grid-template-columns: repeat(var(--column-count, 7), 1fr); border-bottom: 1px solid hsl(var(--color-border)); + background: hsl(var(--color-background)); } .weekday-header { @@ -359,6 +362,7 @@ flex: 1; display: flex; flex-direction: column; + overflow-y: auto; } .week-row { 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 2348ea8ae..b28339807 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -1168,6 +1168,10 @@ .day-headers { display: flex; border-bottom: 1px solid hsl(var(--color-border)); + position: sticky; + top: 0; + z-index: 10; + background: hsl(var(--color-background)); } .time-gutter { 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 ab7380493..acee36ff2 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -1181,6 +1181,10 @@ .day-headers { display: flex; border-bottom: 1px solid hsl(var(--color-border)); + position: sticky; + top: 0; + z-index: 10; + background: hsl(var(--color-background)); } .time-gutter { diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 97a90580a..bd34a6d46 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -143,7 +143,7 @@ let isSidebarMode = $state(false); let isCollapsed = $state(false); - let isToolbarCollapsed = $state(false); + let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -198,7 +198,6 @@ // Base navigation items for Calendar const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kalender', icon: 'calendar' }, - { href: '/agenda', label: 'Agenda', icon: 'list' }, { href: '/tasks', label: 'Aufgaben', icon: 'check-square' }, { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, @@ -411,13 +410,7 @@ appIcon="calendar" primaryColor="#3b82f6" autoFocus={true} - bottomOffset={showCalendarToolbar - ? isSidebarMode - ? '0px' - : '130px' - : isSidebarMode - ? '0px' - : '70px'} + bottomOffset={isSidebarMode ? '0px' : '70px'} />
@@ -430,6 +423,10 @@ } .main-content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; transition: all 300ms ease; position: relative; /* Space for QuickInputBar at bottom */ @@ -468,6 +465,8 @@ } .content-wrapper { + flex: 1; + min-height: 0; max-width: 100%; margin-left: auto; margin-right: auto; @@ -496,5 +495,7 @@ padding: 0; display: flex; flex-direction: column; + flex: 1; + min-height: 0; } diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 446464bc3..29a62bb74 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -13,6 +13,7 @@ import MonthView from '$lib/components/calendar/MonthView.svelte'; import MultiDayView from '$lib/components/calendar/MultiDayView.svelte'; import YearView from '$lib/components/calendar/YearView.svelte'; + import AgendaView from '$lib/components/calendar/AgendaView.svelte'; import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte'; import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte'; import { CalendarViewSkeleton } from '$lib/components/skeletons'; @@ -175,6 +176,8 @@ {:else if viewStore.viewType === 'year'} + {:else if viewStore.viewType === 'agenda'} + {:else} {/if} @@ -201,6 +204,7 @@ display: flex; gap: 1.5rem; width: 100%; + height: 100%; position: relative; } @@ -305,10 +309,12 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; background: hsl(var(--color-surface)); border-radius: var(--radius-lg); border: 1px solid hsl(var(--color-border)); transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; } .calendar-main.expanded { @@ -318,6 +324,8 @@ .calendar-content { flex: 1; + min-height: 0; + overflow: hidden; } @media (max-width: 1024px) { diff --git a/apps/calendar/apps/web/src/routes/(app)/agenda/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/agenda/+page.svelte deleted file mode 100644 index b7c80f940..000000000 --- a/apps/calendar/apps/web/src/routes/(app)/agenda/+page.svelte +++ /dev/null @@ -1,238 +0,0 @@ - - - - Agenda | Kalender - - -
- - - {#if loading} - - {:else if groupedEvents.length === 0} -
-

Keine Termine in den nächsten 30 Tagen

- -
- {:else} -
- {#each groupedEvents as group} -
-

- {formatDateHeader(group.date)} -

- - {#each group.events as event} - - {/each} -
- {/each} -
- {/if} -
- - From 4d1db202c062e12e4a78de77538f67655d02f832 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:55:07 +0100 Subject: [PATCH 03/69] feat(calendar): convert toolbar to collapsed FAB next to InputBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalendarToolbar is now collapsed by default as a FAB button - FAB positioned right next to the QuickInputBar - Toolbar panel opens above on click with smooth slide animation - Reduced bottom padding since toolbar no longer takes full width - DateStrip position adjusted to be closer to InputBar - Updated localStorage logic (default is now collapsed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DateStrip.svelte | 4 ++-- .../lib/components/calendar/DayView.svelte | 3 --- .../lib/components/calendar/MonthView.svelte | 6 ++--- .../apps/web/src/routes/(app)/+layout.svelte | 24 +++++++------------ .../apps/web/src/routes/(app)/+page.svelte | 5 ---- .../src/components/SplitPaneContainer.svelte | 4 ++-- 6 files changed, 16 insertions(+), 30 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte index 43b4acf42..818e6487c 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -287,7 +287,7 @@ diff --git a/packages/shared-ui/src/context-menu/index.ts b/packages/shared-ui/src/context-menu/index.ts new file mode 100644 index 000000000..59acefbf5 --- /dev/null +++ b/packages/shared-ui/src/context-menu/index.ts @@ -0,0 +1,3 @@ +export { default as ContextMenu } from './ContextMenu.svelte'; +export type { ContextMenuItem, ContextMenuState } from './types'; +export { createContextMenuState } from './types'; diff --git a/packages/shared-ui/src/context-menu/types.ts b/packages/shared-ui/src/context-menu/types.ts new file mode 100644 index 000000000..6b38ec590 --- /dev/null +++ b/packages/shared-ui/src/context-menu/types.ts @@ -0,0 +1,41 @@ +import type { Snippet } from 'svelte'; + +export interface ContextMenuItem { + /** Unique identifier for the item */ + id: string; + /** Display label */ + label: string; + /** Icon snippet to render */ + icon?: Snippet; + /** Keyboard shortcut hint */ + shortcut?: string; + /** Whether the item is disabled */ + disabled?: boolean; + /** Visual variant */ + variant?: 'default' | 'danger'; + /** Item type - use 'divider' for separator */ + type?: 'item' | 'divider'; + /** Action to perform when clicked */ + action?: () => void; + /** Additional data attached to the item */ + data?: unknown; +} + +export interface ContextMenuState { + visible: boolean; + x: number; + y: number; + target: T | null; +} + +/** + * Creates a context menu state object + */ +export function createContextMenuState(): ContextMenuState { + return { + visible: false, + x: 0, + y: 0, + target: null, + }; +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 1915e966c..36eb00f7d 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -143,3 +143,7 @@ export type { DonutSegment, ProgressItem, } from './charts'; + +// Context Menu +export { ContextMenu, createContextMenuState } from './context-menu'; +export type { ContextMenuItem, ContextMenuState } from './context-menu'; From faa94129c58e7a3b8dbf83ccb0a8df032c82e9f3 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:02:30 +0100 Subject: [PATCH 11/69] style(calendar): add fade effect to DateStrip edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CSS mask-image gradient to fade out dates at the left and right edges of the DateStrip, creating a smoother visual scroll indicator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/lib/components/calendar/DateStrip.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte index 7b0a3b73a..eff4b2f47 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -412,6 +412,14 @@ scroll-behavior: auto; padding: 1.25rem 1rem 0.25rem; margin-top: -1rem; + mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 8%, + black 92%, + transparent 100% + ); } .days-scroll::-webkit-scrollbar { From cc37db80726206694bd3759fd38ef4e1f7f8dcf0 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:04:51 +0100 Subject: [PATCH 12/69] feat(calendar): improve FAB positioning on mobile and subtle close styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hasFabRight prop to InputBar for mobile FAB spacing - InputBar leaves space for FAB on screens under 900px - Change active FAB style to subtle muted colors instead of bright blue - Support dark mode for active FAB state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/CalendarToolbar.svelte | 9 ++++++--- .../calendar/apps/web/src/routes/(app)/+layout.svelte | 1 + packages/shared-ui/src/quick-input/InputBar.svelte | 11 +++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte index ef7444e83..5ff5d7412 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte @@ -207,12 +207,15 @@ } .toolbar-fab.active { - background: #3b82f6; - border-color: #3b82f6; + background: rgba(0, 0, 0, 0.05); + } + + :global(.dark) .toolbar-fab.active { + background: rgba(255, 255, 255, 0.15); } .toolbar-fab.active .fab-icon { - color: white; + color: hsl(var(--color-muted-foreground)); } .fab-icon { diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 84d55a078..aea016b1d 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -415,6 +415,7 @@ : showCalendarToolbar && !isToolbarCollapsed ? '140px' : '70px'} + hasFabRight={showCalendarToolbar && !isSidebarMode} />
diff --git a/packages/shared-ui/src/quick-input/InputBar.svelte b/packages/shared-ui/src/quick-input/InputBar.svelte index 36a1da80d..62cf5d7d1 100644 --- a/packages/shared-ui/src/quick-input/InputBar.svelte +++ b/packages/shared-ui/src/quick-input/InputBar.svelte @@ -59,6 +59,8 @@ autoFocus?: boolean; /** Bottom offset from viewport bottom (default: '70px') */ bottomOffset?: string; + /** Whether to leave space for a FAB button on the right side on mobile (default: false) */ + hasFabRight?: boolean; } let { @@ -75,6 +77,7 @@ primaryColor = '#8b5cf6', autoFocus = true, bottomOffset = '70px', + hasFabRight = false, }: Props = $props(); let searchQuery = $state(''); @@ -244,6 +247,7 @@
@@ -428,6 +432,13 @@ transition: bottom 0.3s ease; } + /* Leave space for FAB on mobile */ + @media (max-width: 900px) { + .quick-input-bar.has-fab-right { + padding-right: calc(54px + 1rem + 0.75rem); /* FAB width + FAB right margin + gap */ + } + } + .input-container, .results-panel, .submit-btn, From 10d4170ee8197e8993d571247ef6993cd47e69e3 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:24:08 +0100 Subject: [PATCH 13/69] feat(calendar): add event context menu and fix event persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reusable ContextMenu component to shared-ui with icon support - Create EventContextMenu for calendar with actions: - Edit, Duplicate, Change Calendar, Change Color - Export to .ics, Delete with confirmation - Integrate context menu in DayView, WeekView, and AgendaView - Fix event creation not showing success/error feedback - Fix events store to properly handle API response format - Add API logging for debugging event operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/lib/api/base-client.ts | 5 +- apps/calendar/apps/web/src/lib/api/events.ts | 10 + .../lib/components/calendar/AgendaView.svelte | 22 +- .../lib/components/calendar/DayView.svelte | 20 ++ .../lib/components/calendar/WeekView.svelte | 18 ++ .../components/event/EventContextMenu.svelte | 198 ++++++++++++++++++ .../components/event/QuickEventOverlay.svelte | 7 +- .../apps/web/src/lib/stores/events.svelte.ts | 12 +- .../src/context-menu/ContextMenu.svelte | 2 +- packages/shared-ui/src/context-menu/types.ts | 7 +- 10 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/event/EventContextMenu.svelte diff --git a/apps/calendar/apps/web/src/lib/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts index 06f9ba7c7..383a33a06 100644 --- a/apps/calendar/apps/web/src/lib/api/base-client.ts +++ b/apps/calendar/apps/web/src/lib/api/base-client.ts @@ -58,7 +58,10 @@ export function createApiClient(config: ApiClientConfig) { headers['Authorization'] = `Bearer ${authToken}`; } - const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, { + const url = `${baseUrl}${apiPrefix}${endpoint}`; + console.log(`[API Client] ${method} ${url}`, { hasToken: !!authToken }); + + const response = await fetch(url, { method, headers, body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, diff --git a/apps/calendar/apps/web/src/lib/api/events.ts b/apps/calendar/apps/web/src/lib/api/events.ts index 1ce53b469..893cf5f3d 100644 --- a/apps/calendar/apps/web/src/lib/api/events.ts +++ b/apps/calendar/apps/web/src/lib/api/events.ts @@ -23,7 +23,14 @@ export async function getEvents(params: QueryEventsParams) { if (params.search) { searchParams.set('search', params.search); } + console.log('[Calendar API] Fetching events:', params); const result = await fetchApi<{ events: CalendarEvent[] }>(`/events?${searchParams.toString()}`); + console.log( + '[Calendar API] Fetch events result:', + result.data?.events?.length, + 'events', + result.error + ); if (result.error || !result.data) { return { data: null, error: result.error }; } @@ -57,11 +64,14 @@ export async function getEventsByCalendar(calendarId: string) { } export async function createEvent(data: CreateEventInput) { + console.log('[Calendar API] Creating event:', data); const result = await fetchApi<{ event: CalendarEvent }>('/events', { method: 'POST', body: data, }); + console.log('[Calendar API] Create event result:', result); if (result.error || !result.data) { + console.error('[Calendar API] Create event failed:', result.error); return { data: null, error: result.error }; } return { data: result.data.event, error: null }; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte index 6fc284435..a8202c9bf 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -2,6 +2,8 @@ import { viewStore } from '$lib/stores/view.svelte'; import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns'; import { de } from 'date-fns/locale'; import type { CalendarEvent } from '@calendar/shared'; @@ -65,6 +67,18 @@ onEventClick(event); } } + + function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + eventContextMenuStore.show(event, e.clientX, e.clientY); + } + + function handleContextMenuEdit(event: CalendarEvent) { + if (onEventClick) { + onEventClick(event); + } + }
@@ -90,7 +104,11 @@
{#each group.events as event} - +
+ {:else} + + {/if} +
+ + + {#if showPeopleSelector || attendees.length > 0} +
+ Teilnehmer + {#if attendees.length > 0} +
+ {#each attendees as attendee (attendee.email)} +
+ + {attendee.name || attendee.email} + +
+ {/each} +
+ {/if} + 0 + ? 'Weitere hinzufügen...' + : 'Teilnehmer hinzufügen...'} + addLabel="Hinzufügen" + searchPlaceholder="Name oder E-Mail..." + isAvailable={contactsAvailable ?? false} + /> +
+ {:else} + + + {/if} +
+
+
@@ -828,12 +1070,12 @@ position: fixed; width: 380px; max-height: 450px; - background: hsl(var(--color-surface)); + background: hsl(var(--color-surface-elevated-2)); border: 1px solid hsl(var(--color-border)); border-radius: var(--radius-lg); box-shadow: - 0 20px 60px rgba(0, 0, 0, 0.2), - 0 4px 16px rgba(0, 0, 0, 0.1); + 0 20px 60px hsl(var(--color-foreground) / 0.2), + 0 4px 16px hsl(var(--color-foreground) / 0.1); z-index: 99999 !important; display: flex; flex-direction: column; @@ -1203,4 +1445,78 @@ .address-field.city { flex: 1; } + + /* People section */ + .add-attendees-btn { + margin-top: 0.5rem; + padding: 0.25rem 0; + border: none; + background: transparent; + color: hsl(var(--color-muted-foreground)); + font-size: 0.8125rem; + cursor: pointer; + transition: color 150ms; + text-align: left; + } + + .add-attendees-btn:hover { + color: hsl(var(--color-primary)); + } + + .people-subsection { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .people-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.25rem; + } + + .person-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + background: hsl(var(--color-muted) / 0.5); + border-radius: var(--radius-full); + font-size: 0.8125rem; + color: hsl(var(--color-foreground)); + } + + .person-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .remove-person { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + color: hsl(var(--color-muted-foreground)); + font-size: 1rem; + line-height: 1; + cursor: pointer; + border-radius: var(--radius-full); + transition: all 150ms; + } + + .remove-person:hover { + background: hsl(var(--color-error) / 0.1); + color: hsl(var(--color-error)); + } + + .people-subsection + .people-subsection { + margin-top: 0.75rem; + } diff --git a/apps/calendar/apps/web/src/lib/components/event/ResponsiblePersonSelector.svelte b/apps/calendar/apps/web/src/lib/components/event/ResponsiblePersonSelector.svelte new file mode 100644 index 000000000..8179c4fbe --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/event/ResponsiblePersonSelector.svelte @@ -0,0 +1,216 @@ + + +
+ {#if responsiblePerson} + +
+ + +
+
+ {responsiblePerson.name || responsiblePerson.email} +
+ {#if responsiblePerson.name && responsiblePerson.email} +
+ {responsiblePerson.email} +
+ {/if} + {#if responsiblePerson.company} +
+ {responsiblePerson.company} +
+ {/if} +
+ + + {#if responsiblePerson.contactId} + + {/if} + + + +
+ {:else if showSelector} + + + + {:else} + + + {/if} +
+ + diff --git a/apps/calendar/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index bc0c7f345..ef42258ed 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -18,6 +18,20 @@ export interface EventAttendee { company?: string; } +/** + * Responsible person for an event (single person accountable for the event) + */ +export interface ResponsiblePerson { + email: string; + name?: string; + /** Contact reference for linked contacts */ + contactId?: string; + /** Cached photo URL from contact */ + photoUrl?: string; + /** Cached company from contact */ + company?: string; +} + /** * Event tag with color */ @@ -57,7 +71,9 @@ export interface EventMetadata { url?: string; /** Video conference URL (Zoom, Meet, etc.) */ conferenceUrl?: string; - /** Event attendees */ + /** Responsible person for this event */ + responsiblePerson?: ResponsiblePerson; + /** Event attendees/participants */ attendees?: EventAttendee[]; /** Event organizer email */ organizer?: string; From bd89871f8bebe69adac42a5afaaf8d92971ee8e8 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:00:33 +0100 Subject: [PATCH 17/69] feat(ui): add elevation system for overlays and modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3-level elevation CSS variables to themes.css for all theme variants - elevation-1: dropdowns, pills (16% in dark mode) - elevation-2: modals, overlays (20% in dark mode) - elevation-3: context menus, tooltips (24% in dark mode) - Update ContextMenu to use elevation-3 - Update Modal to use elevation-2 with theme-aware borders - Update QuickEventOverlay to use elevation-2 with matching footer - Update PillTimeRangeSelector dropdown to use elevation-1 - Update ConfirmationModal and FormModal to use theme variables - Remove shadows from overlay components for cleaner look 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/event/QuickEventOverlay.svelte | 12 ++--- packages/shared-tailwind/src/themes.css | 40 ++++++++++++++ .../src/context-menu/ContextMenu.svelte | 54 +++++++++++++++++-- .../navigation/PillTimeRangeSelector.svelte | 5 +- .../src/organisms/ConfirmationModal.svelte | 4 +- .../shared-ui/src/organisms/FormModal.svelte | 6 +-- packages/shared-ui/src/organisms/Modal.svelte | 10 ++-- 7 files changed, 104 insertions(+), 27 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index 0ea4a6629..e26c84fb0 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -1070,12 +1070,9 @@ position: fixed; width: 380px; max-height: 450px; - background: hsl(var(--color-surface-elevated-2)); + background: var(--color-surface-elevated-2); border: 1px solid hsl(var(--color-border)); border-radius: var(--radius-lg); - box-shadow: - 0 20px 60px hsl(var(--color-foreground) / 0.2), - 0 4px 16px hsl(var(--color-foreground) / 0.1); z-index: 99999 !important; display: flex; flex-direction: column; @@ -1320,14 +1317,17 @@ .overlay-actions { display: flex; align-items: center; - justify-content: flex-end; gap: 0.75rem; padding: 1rem 1.25rem; border-top: 1px solid hsl(var(--color-border)); - background: hsl(var(--color-surface)); + background: var(--color-surface-elevated-2); flex-shrink: 0; } + .overlay-actions .btn-primary { + flex: 1; + } + .btn-ghost { padding: 0.5rem 1rem; border: none; diff --git a/packages/shared-tailwind/src/themes.css b/packages/shared-tailwind/src/themes.css index 7f5c0e691..78d9ce6e3 100644 --- a/packages/shared-tailwind/src/themes.css +++ b/packages/shared-tailwind/src/themes.css @@ -33,6 +33,11 @@ --color-surface: var(--theme-surface); --color-surface-hover: var(--theme-surface-hover); --color-surface-elevated: var(--theme-surface-elevated); + + /* Elevation system - progressively lighter surfaces for overlays */ + --color-surface-elevated-1: var(--theme-surface-elevated-1); + --color-surface-elevated-2: var(--theme-surface-elevated-2); + --color-surface-elevated-3: var(--theme-surface-elevated-3); --color-muted: var(--theme-muted); --color-muted-foreground: var(--theme-muted-foreground); --color-border: var(--theme-border); @@ -129,6 +134,10 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(0 0% 96%); --theme-surface-elevated: hsl(0 0% 100%); + /* Elevation system - progressively lighter surfaces for overlays */ + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(0 0% 90%); --theme-muted-foreground: hsl(0 0% 40%); --theme-border: hsl(0 0% 90%); @@ -192,6 +201,10 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + /* Elevation system - progressively lighter surfaces for overlays */ + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); @@ -244,6 +257,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(0 0% 96%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(0 0% 90%); --theme-muted-foreground: hsl(0 0% 40%); --theme-border: hsl(0 0% 90%); @@ -275,6 +291,9 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); @@ -306,6 +325,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(120 25% 95%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(120 25% 95%); --theme-muted-foreground: hsl(122 20% 40%); --theme-border: hsl(120 25% 91%); @@ -337,6 +359,9 @@ --theme-surface: hsl(120 10% 12%); --theme-surface-hover: hsl(120 10% 16%); --theme-surface-elevated: hsl(120 10% 14%); + --theme-surface-elevated-1: hsl(120 10% 16%); + --theme-surface-elevated-2: hsl(120 10% 20%); + --theme-surface-elevated-3: hsl(120 10% 24%); --theme-muted: hsl(120 10% 20%); --theme-muted-foreground: hsl(120 10% 60%); --theme-border: hsl(120 10% 25%); @@ -368,6 +393,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(200 10% 94%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(200 10% 94%); --theme-muted-foreground: hsl(200 10% 45%); --theme-border: hsl(200 10% 88%); @@ -399,6 +427,9 @@ --theme-surface: hsl(200 10% 12%); --theme-surface-hover: hsl(200 10% 16%); --theme-surface-elevated: hsl(200 10% 14%); + --theme-surface-elevated-1: hsl(200 10% 16%); + --theme-surface-elevated-2: hsl(200 10% 20%); + --theme-surface-elevated-3: hsl(200 10% 24%); --theme-muted: hsl(200 10% 20%); --theme-muted-foreground: hsl(200 10% 60%); --theme-border: hsl(200 10% 25%); @@ -430,6 +461,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(199 100% 94%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(199 100% 94%); --theme-muted-foreground: hsl(199 50% 40%); --theme-border: hsl(199 71% 87%); @@ -461,6 +495,9 @@ --theme-surface: hsl(199 30% 12%); --theme-surface-hover: hsl(199 30% 16%); --theme-surface-elevated: hsl(199 30% 14%); + --theme-surface-elevated-1: hsl(199 30% 16%); + --theme-surface-elevated-2: hsl(199 30% 20%); + --theme-surface-elevated-3: hsl(199 30% 24%); --theme-muted: hsl(199 20% 20%); --theme-muted-foreground: hsl(199 20% 60%); --theme-border: hsl(199 20% 25%); @@ -493,6 +530,9 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); diff --git a/packages/shared-ui/src/context-menu/ContextMenu.svelte b/packages/shared-ui/src/context-menu/ContextMenu.svelte index a8dc19741..0e0354eb4 100644 --- a/packages/shared-ui/src/context-menu/ContextMenu.svelte +++ b/packages/shared-ui/src/context-menu/ContextMenu.svelte @@ -105,11 +105,18 @@ class="menu-item" class:disabled={item.disabled} class:danger={item.variant === 'danger'} + class:has-toggle={item.toggle} onclick={() => handleItemClick(item)} role="menuitem" disabled={item.disabled} > - {#if item.icon} + {#if item.toggle} + + + + + + {:else if item.icon} @@ -131,12 +138,9 @@ min-width: 180px; max-width: 280px; padding: 0.375rem; - background: hsl(var(--color-surface)); + background: var(--color-surface-elevated-3); border: 1px solid hsl(var(--color-border)); border-radius: var(--radius-lg); - box-shadow: - 0 10px 38px -10px rgba(0, 0, 0, 0.35), - 0 10px 20px -15px rgba(0, 0, 0, 0.2); } .menu-item { @@ -205,4 +209,44 @@ margin: 0.375rem 0.5rem; background: hsl(var(--color-border)); } + + /* Toggle switch styles */ + .menu-item.has-toggle { + gap: 0.5rem; + } + + .item-toggle { + display: flex; + align-items: center; + flex-shrink: 0; + } + + .toggle-track { + position: relative; + width: 28px; + height: 16px; + background: hsl(var(--color-muted)); + border-radius: 8px; + transition: background-color 150ms ease; + } + + .toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: hsl(var(--color-background)); + border-radius: 50%; + transition: transform 150ms ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + } + + .item-toggle.checked .toggle-track { + background: hsl(var(--color-primary)); + } + + .item-toggle.checked .toggle-thumb { + transform: translateX(12px); + } diff --git a/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte b/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte index 1b9b925b0..99c7ff773 100644 --- a/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte +++ b/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte @@ -389,13 +389,10 @@ } .glass-dropdown { - background: hsl(var(--color-surface) / 0.95); + background: color-mix(in srgb, var(--color-surface-elevated-1) 95%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid hsl(var(--color-border)); - box-shadow: - 0 20px 25px -5px hsl(var(--color-foreground) / 0.1), - 0 10px 10px -5px hsl(var(--color-foreground) / 0.04); } .dropdown-header { diff --git a/packages/shared-ui/src/organisms/ConfirmationModal.svelte b/packages/shared-ui/src/organisms/ConfirmationModal.svelte index 6e6988160..33254687f 100644 --- a/packages/shared-ui/src/organisms/ConfirmationModal.svelte +++ b/packages/shared-ui/src/organisms/ConfirmationModal.svelte @@ -162,8 +162,8 @@ onclick={onClose} disabled={loading} class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm - bg-black/5 dark:bg-white/10 text-foreground - hover:bg-black/10 dark:hover:bg-white/20 hover:shadow-md + bg-foreground/5 text-foreground + hover:bg-foreground/10 hover:shadow-md transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0" > diff --git a/packages/shared-ui/src/organisms/FormModal.svelte b/packages/shared-ui/src/organisms/FormModal.svelte index bd359a999..3bf472fbf 100644 --- a/packages/shared-ui/src/organisms/FormModal.svelte +++ b/packages/shared-ui/src/organisms/FormModal.svelte @@ -88,10 +88,8 @@
{#if error} -
- +
+ {error}
diff --git a/packages/shared-ui/src/organisms/Modal.svelte b/packages/shared-ui/src/organisms/Modal.svelte index 58dfa6f91..0e33ba862 100644 --- a/packages/shared-ui/src/organisms/Modal.svelte +++ b/packages/shared-ui/src/organisms/Modal.svelte @@ -65,15 +65,13 @@
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} > {#if showHeader} -
+
{#if icon} {@render icon()} @@ -86,7 +84,7 @@
- {/each} +
+ {format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })} + {format(day, 'd')}
{/each}
- {/if} - -
-
- {#each days as day} -
- {format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })} - {format(day, 'd')} + + {#if hasAnyHeaderAllDayEvents} +
+
+ {#each days as day} +
+ {#each getHeaderAllDayEventsForDay(day) as event} + + {/each} +
+ {/each}
- {/each} + {/if}
@@ -1073,6 +1076,13 @@ flex-direction: column; } + .sticky-header { + position: sticky; + top: 0; + z-index: 10; + background: hsl(var(--color-background)); + } + .all-day-row { display: flex; border-bottom: 1px solid hsl(var(--color-border)); @@ -1090,6 +1100,7 @@ } .all-day-event { + width: 100%; padding: 2px 6px; font-size: 0.75rem; color: white; @@ -1099,8 +1110,8 @@ text-overflow: ellipsis; border: none; cursor: pointer; - max-width: 100%; transition: opacity 0.15s ease; + text-align: left; } .all-day-event.search-highlighted { @@ -1178,10 +1189,6 @@ .day-headers { display: flex; border-bottom: 1px solid hsl(var(--color-border)); - position: sticky; - top: 0; - z-index: 10; - background: hsl(var(--color-background)); } .time-gutter { 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 cb3eeb62d..60c484ea6 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -875,41 +875,44 @@
{/if} - - {#if hasAnyHeaderAllDayEvents} -
-
- {#if settingsStore.showWeekNumbers} - {$_('views.weekNumber')} {weekNumber} - {/if} -
+ + @@ -910,10 +868,7 @@
{/each}
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 4bcd05bfc..1a8910595 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -6,6 +6,11 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -13,7 +18,6 @@ eachDayOfInterval, isToday, isSameDay, - parseISO, differenceInMinutes, isWeekend, addMinutes, @@ -56,41 +60,19 @@ settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays ); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Determine column width based on day count let columnClass = $derived.by(() => { @@ -145,9 +127,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -174,9 +155,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -221,8 +201,8 @@ ); function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -265,8 +245,7 @@ } function formatEventTime(date: Date | string): string { - const d = typeof date === 'string' ? parseISO(date) : date; - return settingsStore.formatTime(d); + return settingsStore.formatTime(toDate(date)); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -346,8 +325,8 @@ draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -397,14 +376,8 @@ return; } - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time @@ -450,8 +423,8 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; 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 60c484ea6..9e2f3c9f5 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -6,17 +6,20 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; import { format, eachDayOfInterval, - startOfDay, isToday, isWeekend, isSameDay, - parseISO, differenceInMinutes, addMinutes, setHours, @@ -63,41 +66,19 @@ getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn }) ); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Drag & Drop State let isDragging = $state(false); @@ -145,9 +126,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -174,9 +154,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -221,8 +200,8 @@ ); function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -267,8 +246,7 @@ } function formatEventTime(date: Date | string): string { - const d = typeof date === 'string' ? parseISO(date) : date; - return settingsStore.formatTime(d); + return settingsStore.formatTime(toDate(date)); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -354,8 +332,8 @@ draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -405,14 +383,8 @@ return; } - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time @@ -458,8 +430,8 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; 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 72ac9254a..e0c0d5950 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -11,11 +11,11 @@ eachDayOfInterval, isSameMonth, isToday, - parseISO, setHours, setMinutes, } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import type { CalendarViewType, CalendarEvent } from '@calendar/shared'; interface Props { @@ -58,8 +58,7 @@ const events = eventsStore.events ?? []; for (const event of events) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const start = toDate(event.startTime); const key = format(start, 'yyyy-MM-dd'); counts.set(key, (counts.get(key) || 0) + 1); } diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index 3f802dc68..2b6c4df49 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -2,13 +2,14 @@ import { goto } from '$app/navigation'; import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import EventForm from './EventForm.svelte'; import { TagBadge } from '@manacore/shared-ui'; import type { CalendarEvent, UpdateEventInput } from '@calendar/shared'; import * as api from '$lib/api/events'; - import { format, parseISO } from 'date-fns'; + import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import { EventDetailSkeleton } from '$lib/components/skeletons'; interface Props { @@ -99,8 +100,8 @@ if (event.isAllDay) { return 'Ganztägig'; } - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`; } diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index 4e81fcb49..2cc24b0fe 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -15,7 +15,8 @@ EventAttendee, ResponsiblePerson, } from '@calendar/shared'; - import { format, addMinutes, parseISO } from 'date-fns'; + import { format, addMinutes } from 'date-fns'; + import { toDate } from '$lib/utils/eventDateHelpers'; interface Props { mode: 'create' | 'edit'; @@ -104,9 +105,8 @@ // Initialize date/time fields using settings for default duration $effect(() => { if (event) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); startDate = format(start, 'yyyy-MM-dd'); startTime = format(start, 'HH:mm'); endDate = format(end, 'yyyy-MM-dd'); diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index e26c84fb0..e71671d04 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -3,7 +3,7 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import type { LocationDetails, CalendarEvent, @@ -13,8 +13,9 @@ import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types'; import { ContactSelector, ContactAvatar } from '@manacore/shared-ui'; import { Users } from 'lucide-svelte'; - import { format, addMinutes, parseISO } from 'date-fns'; + import { format, addMinutes } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import { tick, onMount, onDestroy } from 'svelte'; // Portal action - moves element to body to escape stacking contexts @@ -246,9 +247,8 @@ attendees = event.metadata?.attendees || []; // Initialize time fields - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); startDateStr = format(eventStart, 'yyyy-MM-dd'); startTimeStr = format(eventStart, 'HH:mm'); endDateStr = format(eventEnd, 'yyyy-MM-dd'); @@ -259,7 +259,7 @@ // Date/time fields - derive from draft event (create mode) or event (edit mode) let draftStart = $derived(() => { if (isEditMode && event) { - return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + return toDate(event.startTime); } const draft = eventsStore.draftEvent; if (draft) { @@ -270,7 +270,7 @@ let draftEnd = $derived(() => { if (isEditMode && event) { - return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + return toDate(event.endTime); } const draft = eventsStore.draftEvent; if (draft) { diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte index b9424d733..9c33ff370 100644 --- a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte @@ -2,7 +2,7 @@ import { todosStore } from '$lib/stores/todos.svelte'; import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos'; import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import TodoCheckbox from './TodoCheckbox.svelte'; import PriorityBadge from './PriorityBadge.svelte'; import { diff --git a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts index 7af9a938a..a6e66fa36 100644 --- a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts @@ -4,7 +4,8 @@ */ import type { CalendarEvent } from '@calendar/shared'; -import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { eventsStore } from '$lib/stores/events.svelte'; export interface DragDropConfig { @@ -107,8 +108,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) { draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -158,14 +159,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) { } const config = getConfig(); - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time diff --git a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts index 44e8c511a..04d43e592 100644 --- a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts @@ -4,7 +4,8 @@ */ import type { CalendarEvent } from '@calendar/shared'; -import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { eventsStore } from '$lib/stores/events.svelte'; export interface ResizeConfig { @@ -86,8 +87,8 @@ export function useResize(getConfig: () => ResizeConfig) { resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; diff --git a/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts new file mode 100644 index 000000000..d823418cb --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts @@ -0,0 +1,102 @@ +/** + * useVisibleHours Composable + * + * Provides hour filtering and time-to-position calculations for calendar views. + * Extracts common logic from WeekView, MultiDayView, and DayView. + */ + +import { settingsStore } from '$lib/stores/settings.svelte'; + +const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i); + +/** + * Creates reactive hour visibility state and helper functions + */ +export function useVisibleHours() { + // Filtered hours based on settings + let hours = $derived( + settingsStore.filterHoursEnabled + ? ALL_HOURS.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) + : ALL_HOURS + ); + + // Calculate visible hours range for positioning + let firstVisibleHour = $derived( + settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 + ); + + let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); + + let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); + + /** + * Convert minutes (from midnight) to percentage position + * accounting for hidden hours when filtering is enabled + */ + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } + + /** + * Convert percentage position back to minutes (from midnight) + */ + function percentToMinutes(percent: number): number { + return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + } + + /** + * Check if a time range overlaps with the visible hours range + */ + function isTimeRangeVisible(startMinutes: number, endMinutes: number): boolean { + const visibleStartMinutes = firstVisibleHour * 60; + const visibleEndMinutes = lastVisibleHour * 60; + return startMinutes < visibleEndMinutes && endMinutes > visibleStartMinutes; + } + + return { + get hours() { + return hours; + }, + get firstVisibleHour() { + return firstVisibleHour; + }, + get lastVisibleHour() { + return lastVisibleHour; + }, + get totalVisibleHours() { + return totalVisibleHours; + }, + minutesToPercent, + percentToMinutes, + isTimeRangeVisible, + }; +} + +/** + * Creates a reactive current time indicator + * Updates every minute and provides position calculation + */ +export function useCurrentTimeIndicator() { + let now = $state(new Date()); + + // Update current time every minute + $effect(() => { + const interval = setInterval(() => { + now = new Date(); + }, 60000); + return () => clearInterval(interval); + }); + + return { + get now() { + return now; + }, + /** + * Get current time as minutes from midnight + */ + get currentMinutes() { + return now.getHours() * 60 + now.getMinutes(); + }, + }; +} diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 48798e117..2619ea3db 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -4,7 +4,8 @@ import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared'; import * as api from '$lib/api/events'; -import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns'; +import { format, isWithinInterval, isSameDay } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { toastStore } from './toast.svelte'; // State @@ -68,9 +69,8 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; const result = currentEvents.filter((event) => { - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); // For all-day events, check if day falls within event range if (event.isAllDay) { @@ -86,10 +86,7 @@ export const eventsStore = { // Include draft event if it exists and is on this day if (includeDraft && draftEvent) { - const draftStart = - typeof draftEvent.startTime === 'string' - ? parseISO(draftEvent.startTime) - : draftEvent.startTime; + const draftStart = toDate(draftEvent.startTime); if (isSameDay(date, draftStart)) { result.push(draftEvent); } @@ -107,9 +104,8 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; return currentEvents.filter((event) => { - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); // Check if event overlaps with the range return eventStart <= end && eventEnd >= start; diff --git a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts index 8859c8aed..64c93eddb 100644 --- a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts @@ -7,39 +7,55 @@ interface SearchItem { [key: string]: unknown; } -class SearchStore { - // Current search query - query = $state(''); +// State +let query = $state(''); +let matchingEventIds = $state>(new Set()); +let isSearching = $state(false); - // Event IDs that match the search - matchingEventIds = $state>(new Set()); - - // Whether search is active (user is typing in InputBar) - isSearching = $state(false); - - // Set search query and matching items (events or any items with an id) - setSearch(query: string, matchingItems: SearchItem[]) { - this.query = query; - this.matchingEventIds = new Set(matchingItems.map((item) => item.id)); - this.isSearching = query.trim().length > 0; - } - - // Clear search - clear() { - this.query = ''; - this.matchingEventIds = new Set(); - this.isSearching = false; - } - - // Check if an event matches the search - isEventHighlighted(eventId: string): boolean { - return this.isSearching && this.matchingEventIds.has(eventId); - } - - // Check if an event should be dimmed (search active but event doesn't match) - isEventDimmed(eventId: string): boolean { - return this.isSearching && !this.matchingEventIds.has(eventId); - } +/** + * Set search query and matching items (events or any items with an id) + */ +function setSearch(newQuery: string, matchingItems: SearchItem[]) { + query = newQuery; + matchingEventIds = new Set(matchingItems.map((item) => item.id)); + isSearching = newQuery.trim().length > 0; } -export const searchStore = new SearchStore(); +/** + * Clear search + */ +function clear() { + query = ''; + matchingEventIds = new Set(); + isSearching = false; +} + +/** + * Check if an event matches the search + */ +function isEventHighlighted(eventId: string): boolean { + return isSearching && matchingEventIds.has(eventId); +} + +/** + * Check if an event should be dimmed (search active but event doesn't match) + */ +function isEventDimmed(eventId: string): boolean { + return isSearching && !matchingEventIds.has(eventId); +} + +export const searchStore = { + get query() { + return query; + }, + get matchingEventIds() { + return matchingEventIds; + }, + get isSearching() { + return isSearching; + }, + setSearch, + clear, + isEventHighlighted, + isEventDimmed, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/toast.ts b/apps/calendar/apps/web/src/lib/stores/toast.ts deleted file mode 100644 index 0655b3212..000000000 --- a/apps/calendar/apps/web/src/lib/stores/toast.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { writable } from 'svelte/store'; - -export type ToastType = 'success' | 'error' | 'warning' | 'info'; - -export interface Toast { - id: string; - type: ToastType; - message: string; - duration?: number; -} - -function createToastStore() { - const { subscribe, update } = writable([]); - - function add(message: string, type: ToastType = 'info', duration: number = 4000) { - const id = crypto.randomUUID(); - const toast: Toast = { id, type, message, duration }; - - update((toasts) => [...toasts, toast]); - - if (duration > 0) { - setTimeout(() => { - remove(id); - }, duration); - } - - return id; - } - - function remove(id: string) { - update((toasts) => toasts.filter((t) => t.id !== id)); - } - - function clear() { - update(() => []); - } - - return { - subscribe, - add, - remove, - clear, - success: (message: string, duration?: number) => add(message, 'success', duration), - error: (message: string, duration?: number) => add(message, 'error', duration), - warning: (message: string, duration?: number) => add(message, 'warning', duration), - info: (message: string, duration?: number) => add(message, 'info', duration), - }; -} - -export const toast = createToastStore(); diff --git a/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte index 279d6f48f..fe14b49ab 100644 --- a/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte @@ -1,6 +1,6 @@ + + + + + +
+ {@render children()} +
+ + +{#if visible} + +{/if} + + diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 1a7f23173..d9ef0b976 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -47,3 +47,6 @@ export { default as ModalFooter } from './ModalFooter.svelte'; export { default as DataCard } from './DataCard.svelte'; export { default as PageHeader } from './PageHeader.svelte'; export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte'; + +// Confirmation +export { default as ConfirmationPopover } from './ConfirmationPopover.svelte'; From b720f64d2fee6c1e0742444ec4942bbf0ba646ca Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:19:22 +0100 Subject: [PATCH 23/69] feat(calendar): replace calendar dropdown with horizontal pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace select dropdown with horizontal scrolling pill buttons in QuickEventOverlay - Each pill shows colored dot + calendar name for quick visual selection - Add setAsDefault method to calendars store - Improve settings page calendar editing with SettingsSection/SettingsCard components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/event/QuickEventOverlay.svelte | 111 ++- .../web/src/lib/stores/calendars.svelte.ts | 17 + .../src/routes/(app)/settings/+page.svelte | 902 ++++++++++++------ 3 files changed, 683 insertions(+), 347 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index f20908b53..759dc3cf1 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -361,15 +361,6 @@ }); } - // Update draft when calendar changes - function handleCalendarChange(e: Event) { - const target = e.target as HTMLSelectElement; - calendarId = target.value; - if (!isEditMode) { - eventsStore.updateDraftEvent({ calendarId: target.value }); - } - } - // Update draft when all-day changes function handleAllDayToggle() { isAllDay = !isAllDay; @@ -711,26 +702,31 @@
- -
-
-
-
-
- Kalender - {#if calendarsStore.calendars.length > 0} - - {:else} - Standardkalender wird erstellt - {/if} -
+ +
+ {#if calendarsStore.calendars.length > 0} +
+ {#each calendarsStore.calendars as cal} + + {/each} +
+ {:else} + Standardkalender wird erstellt + {/if}
@@ -1225,6 +1221,63 @@ border-radius: 50%; } + /* Calendar pills */ + .calendar-pills-container { + padding: 0.5rem 0; + border-bottom: 1px solid hsl(var(--color-border)); + } + + .calendar-pills-scroll { + display: flex; + gap: 0.5rem; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 0 1.25rem 2px; + } + + .calendar-pills-scroll::-webkit-scrollbar { + display: none; + } + + .calendar-pill { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 9999px; + background: transparent; + color: hsl(var(--color-muted-foreground)); + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: all 150ms; + flex-shrink: 0; + } + + .calendar-pill:hover { + background: hsl(var(--color-muted) / 0.3); + color: hsl(var(--color-foreground)); + } + + .calendar-pill.active { + background: hsl(var(--color-primary) / 0.1); + border-color: hsl(var(--color-primary) / 0.3); + color: hsl(var(--color-primary)); + } + + .calendar-pill-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + } + + .calendar-pill-name { + } + .row-content { flex: 1; min-width: 0; diff --git a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts index bde636f28..1f8fc7515 100644 --- a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts @@ -115,6 +115,23 @@ export const calendarsStore = { return this.updateCalendar(id, { isVisible: !calendar.isVisible }); }, + /** + * Set a calendar as the default + */ + async setAsDefault(id: string) { + const result = await api.updateCalendar(id, { isDefault: true }); + + if (result.data) { + // Update local state: set this one as default, remove default from others + calendars = getCalendarsArray().map((c) => ({ + ...c, + isDefault: c.id === id, + })); + } + + return result; + }, + /** * Get calendar by ID */ diff --git a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte index 2c08a4f34..d73b78b68 100644 --- a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte @@ -7,15 +7,32 @@ import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { toast } from '$lib/stores/toast.svelte'; - import { GlobalSettingsSection } from '@manacore/shared-ui'; + import { GlobalSettingsSection, SettingsSection, SettingsCard } from '@manacore/shared-ui'; import type { CalendarViewType, Calendar } from '@calendar/shared'; // Calendar management state let editingCalendar = $state(null); + let editName = $state(''); + let editColor = $state(''); + let editIsDefault = $state(false); let showNewCalendarForm = $state(false); let newCalendarName = $state(''); let newCalendarColor = $state('#3b82f6'); + function startEditing(calendar: Calendar) { + editingCalendar = calendar; + editName = calendar.name; + editColor = calendar.color || '#3b82f6'; + editIsDefault = calendar.isDefault || false; + } + + function cancelEditing() { + editingCalendar = null; + editName = ''; + editColor = ''; + editIsDefault = false; + } + onMount(async () => { if (!authStore.isAuthenticated) { goto('/login'); @@ -59,8 +76,23 @@ toast.success('Kalender gelöscht'); } - async function handleUpdateCalendar(calendar: Calendar, name: string, color: string) { - const result = await calendarsStore.updateCalendar(calendar.id, { name, color }); + async function handleUpdateCalendar() { + if (!editingCalendar || !editName.trim()) return; + + // If setting as default and it wasn't before, use setAsDefault + if (editIsDefault && !editingCalendar.isDefault) { + const defaultResult = await calendarsStore.setAsDefault(editingCalendar.id); + if (defaultResult?.error) { + toast.error(`Fehler: ${defaultResult.error.message}`); + return; + } + } + + // Update name and color + const result = await calendarsStore.updateCalendar(editingCalendar.id, { + name: editName.trim(), + color: editColor, + }); if (result.error) { toast.error(`Fehler: ${result.error.message}`); @@ -68,7 +100,7 @@ } toast.success('Kalender aktualisiert'); - editingCalendar = null; + cancelEditing(); } function handleViewChange(view: CalendarViewType) { @@ -124,335 +156,422 @@ -
-
-

Meine Kalender

- -
+ + {#snippet icon()} + + + + {/snippet} + +
+
+ +
- {#if showNewCalendarForm} -
- { - e.preventDefault(); - handleCreateCalendar(); - }} - > -
- - -
-
- - -
- -
- {/if} - -
- {#each calendarsStore.calendars as calendar} -
- {#if editingCalendar?.id === calendar.id} + {#if showNewCalendarForm} +
{ e.preventDefault(); - const form = e.target as HTMLFormElement; - const name = (form.elements.namedItem('name') as HTMLInputElement).value; - const color = (form.elements.namedItem('color') as HTMLInputElement).value; - handleUpdateCalendar(calendar, name, color); + handleCreateCalendar(); }} >
- - + +
- +
- {:else} -
- - {calendar.name} - {#if calendar.isDefault} - Standard - {/if} -
-
- - {#if !calendar.isDefault} -
+ {/if} + +
+ {#each calendarsStore.calendars as calendar} + {#if editingCalendar?.id === calendar.id} +
+
{ + e.preventDefault(); + handleUpdateCalendar(); + }} > - Löschen - - {/if} +
+
+ + +
+ +
+ +
+ + {editColor} +
+
+
+ + + +
+ + +
+
+
+ {:else} +
+
+ + {calendar.name} + {#if calendar.isDefault} + Standard + {/if} +
+
+ + {#if !calendar.isDefault} + + {/if} +
+
+ {/if} + {/each} + + {#if calendarsStore.calendars.length === 0} +
+

Keine Kalender vorhanden

{/if}
- {/each} - - {#if calendarsStore.calendars.length === 0} -
-

Keine Kalender vorhanden

-
- {/if} -
-
+
+ + -
- -
+ -
-

Kalender-Ansicht

- -
-
- Standard-Ansicht - Ansicht beim Öffnen des Kalenders -
- -
- -
-
- Zeitformat - Anzeige der Uhrzeiten -
-
- - -
-
- -
- -
- -
- -
- -
- -
- - {#if settingsStore.filterHoursEnabled} -
-
- Sichtbare Stunden - Zeitbereich der in der Kalenderansicht angezeigt wird -
-
-
- - + + {/snippet} + +
+
+
+ Standard-Ansicht + Ansicht beim Öffnen des Kalenders
- -
- - handleViewChange(e.currentTarget.value as CalendarViewType)} + > + {#each Object.entries(viewLabels) as [value, label]} + + {/each} + +
+ +
+
+ Zeitformat + Anzeige der Uhrzeiten +
+
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + {#if settingsStore.filterHoursEnabled} +
+
+ Sichtbare Stunden + Zeitbereich der in der Kalenderansicht angezeigt wird +
+
+
+ + +
+ +
+ + +
+
+
+ {/if} + +
+
+ Ganztägige Termine + Wie sollen ganztägige Termine angezeigt werden? +
+
+ +
- {/if} - -
-
- Ganztägige Termine - Wie sollen ganztägige Termine angezeigt werden? -
-
- - -
-
-
+ + -
-

Termine

+ + {#snippet icon()} + + + + {/snippet} + +
+
+
+ Standard-Dauer + Voreingestellte Dauer für neue Termine +
+ +
-
-
- Standard-Dauer - Voreingestellte Dauer für neue Termine +
+
+ Standard-Erinnerung + Voreingestellte Erinnerung für neue Termine +
+ +
- -
- -
-
- Standard-Erinnerung - Voreingestellte Erinnerung für neue Termine -
- -
-
+ + -
-

Konto

+ + {#snippet icon()} + + + + {/snippet} + +
+
+
+ E-Mail + {authStore.user?.email || '-'} +
+
-
-
- E-Mail - {authStore.user?.email || '-'} +
+ +
-
- -
- -
-
+ +
+ + + diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte new file mode 100644 index 000000000..454d92623 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte @@ -0,0 +1,161 @@ + + +
+ + contactsFilterStore.setSelectedTagId(id)} + contactFilter={contactsFilterStore.contactFilter} + onContactFilterChange={(f) => contactsFilterStore.setContactFilter(f)} + birthdayFilter={contactsFilterStore.birthdayFilter} + onBirthdayFilterChange={(f) => contactsFilterStore.setBirthdayFilter(f)} + selectedCompany={contactsFilterStore.selectedCompany} + onCompanyChange={(c) => contactsFilterStore.setSelectedCompany(c)} + embedded={true} + /> + +
+ + + + +
+ + +
+ + + +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte index 43f0f5c2b..ccae142c5 100644 --- a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte +++ b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte @@ -1,6 +1,7 @@ + + + + + + + + + diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 6fdc07007..777a9898b 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -2,6 +2,7 @@ import { _ } from 'svelte-i18n'; import type { Contact } from '$lib/api/contacts'; import type { SortField } from '$lib/components/SortToggle.svelte'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; interface Props { contacts: Contact[]; @@ -11,6 +12,7 @@ selectedIds?: Set; onToggleSelection?: (id: string) => void; sortField?: SortField; + showNewContactCard?: boolean; } let { @@ -21,6 +23,7 @@ selectedIds = new Set(), onToggleSelection, sortField = 'lastName', + showNewContactCard = true, }: Props = $props(); function handleCheckboxClick(e: MouseEvent, id: string) { @@ -87,6 +90,39 @@
+ + {#if showNewContactCard && !selectionMode} +
+
newContactModalStore.open()} + onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} + class="alphabet-contact-card new-contact-card" + > + +
+ + + +
+ + +
+
+ {$_('contacts.new')} + {$_('contacts.addFirst')} +
+
+
+
+ {/if} +
{#each availableLetters as letter} @@ -142,30 +178,46 @@
-
- {getDisplayName(contact)} -
-
- {#if contact.jobTitle && contact.company} - {contact.jobTitle} @ {contact.company} - {:else if contact.company} - {contact.company} - {:else if contact.email} - {contact.email} +
+ {getDisplayName(contact)} + {#if contact.isFavorite} + + + + {/if} + {#if contact.company} + @ {contact.company} {/if}
+ {#if contact.tags && contact.tags.length > 0} +
+ {#each contact.tags.slice(0, 3) as tag} + + {tag.name} + + {/each} + {#if contact.tags.length > 3} + +{contact.tags.length - 3} + {/if} +
+ {/if}
- -
+ +
{#if contact.phone || contact.mobile} e.stopPropagation()} - class="quick-action-btn" - title={$_('contacts.call')} + class="action-chip" + title={contact.mobile || contact.phone} > - + e.stopPropagation()} - class="quick-action-btn" - title={$_('contacts.email')} + class="action-chip" + title={contact.email} > - + {/if} -
{/each} @@ -222,37 +252,38 @@ {/each}
- +
- {#each alphabet as letter} - - {/each} - {#if availableLetters.includes('#')} - - {/if} +
+ {#each alphabet as letter} + + {/each} + {#if availableLetters.includes('#')} + + {/if} +
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte index 8fb5e20fa..325b7de53 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte @@ -1,6 +1,7 @@
+ + {#if showNewContactCard && !selectionMode} +
newContactModalStore.open()} + onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} + class="grid-card new-contact-card" + > + +
+ + + +
+ + +
+

{$_('contacts.new')}

+

{$_('contacts.addFirst')}

+
+
+ {/if} + {#each contacts as contact (contact.id)}
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte index 279dde6c6..6ae4e5e8d 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte @@ -1,6 +1,7 @@
+ + {#if showNewContactCard && !selectionMode} +
newContactModalStore.open()} + onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} + class="contact-card new-contact-card w-full text-left cursor-pointer" + > + +
+ + + +
+ + +
+
+ {$_('contacts.new')} +
+
+ {$_('contacts.addFirst')} +
+
+
+ {/if} + {#each contacts as contact (contact.id)}
diff --git a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts new file mode 100644 index 000000000..eba2b00d5 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts @@ -0,0 +1,146 @@ +/** + * Filter Store - Manages filter state for the Contacts app toolbar + * Uses Svelte 5 runes for reactivity + */ + +import { browser } from '$app/environment'; + +export type SortField = 'firstName' | 'lastName'; +export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete'; +export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth'; + +export interface ContactsFilterState { + sortField: SortField; + contactFilter: ContactFilter; + birthdayFilter: BirthdayFilter; + selectedTagId: string | null; + selectedCompany: string | null; + isToolbarCollapsed: boolean; + searchQuery: string; +} + +const DEFAULT_STATE: ContactsFilterState = { + sortField: 'lastName', + contactFilter: 'all', + birthdayFilter: 'all', + selectedTagId: null, + selectedCompany: null, + isToolbarCollapsed: true, + searchQuery: '', +}; + +const STORAGE_KEY = 'contacts-filter-state'; + +function loadState(): ContactsFilterState { + if (!browser) return DEFAULT_STATE; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_STATE, ...parsed }; + } + } catch (e) { + console.error('Failed to load contacts filter state:', e); + } + + return DEFAULT_STATE; +} + +function saveState(state: ContactsFilterState) { + if (!browser) return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.error('Failed to save contacts filter state:', e); + } +} + +// Reactive state +let state = $state(DEFAULT_STATE); + +export const contactsFilterStore = { + // Getters + get sortField() { + return state.sortField; + }, + get contactFilter() { + return state.contactFilter; + }, + get birthdayFilter() { + return state.birthdayFilter; + }, + get selectedTagId() { + return state.selectedTagId; + }, + get selectedCompany() { + return state.selectedCompany; + }, + get isToolbarCollapsed() { + return state.isToolbarCollapsed; + }, + get searchQuery() { + return state.searchQuery; + }, + + // Setters + setSortField(value: SortField) { + state = { ...state, sortField: value }; + saveState(state); + }, + + setContactFilter(value: ContactFilter) { + state = { ...state, contactFilter: value }; + saveState(state); + }, + + setBirthdayFilter(value: BirthdayFilter) { + state = { ...state, birthdayFilter: value }; + saveState(state); + }, + + setSelectedTagId(value: string | null) { + state = { ...state, selectedTagId: value }; + saveState(state); + }, + + setSelectedCompany(value: string | null) { + state = { ...state, selectedCompany: value }; + saveState(state); + }, + + setToolbarCollapsed(value: boolean) { + state = { ...state, isToolbarCollapsed: value }; + saveState(state); + }, + + toggleToolbar() { + state = { ...state, isToolbarCollapsed: !state.isToolbarCollapsed }; + saveState(state); + }, + + setSearchQuery(value: string) { + state = { ...state, searchQuery: value }; + // Don't persist search query to localStorage + }, + + // Reset filters (but not toolbar state) + resetFilters() { + state = { + ...state, + contactFilter: 'all', + birthdayFilter: 'all', + selectedTagId: null, + selectedCompany: null, + searchQuery: '', + }; + saveState(state); + }, + + // Initialize from localStorage + initialize() { + if (!browser) return; + state = loadState(); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts b/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts new file mode 100644 index 000000000..b565ef051 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts @@ -0,0 +1,41 @@ +/** + * Store for controlling the New Contact Modal + */ + +interface NewContactData { + firstName?: string; + lastName?: string; + displayName?: string; + email?: string; + phone?: string; + company?: string; +} + +let isOpen = $state(false); +let prefillData = $state(null); + +export const newContactModalStore = { + get isOpen() { + return isOpen; + }, + + get prefillData() { + return prefillData; + }, + + /** + * Open the modal, optionally with pre-filled data + */ + open(data?: NewContactData) { + prefillData = data || null; + isOpen = true; + }, + + /** + * Close the modal and reset data + */ + close() { + isOpen = false; + prefillData = null; + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index e06d03bd3..68c279715 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -34,15 +34,19 @@ import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; + import NewContactModal from '$lib/components/NewContactModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; import { contactsApi, tagsApi } from '$lib/api/contacts'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { parseContactInput, resolveContactIds, formatParsedContactPreview, } from '$lib/utils/contact-parser'; + import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; // Tags state for Quick-Create let availableTags = $state<{ id: string; name: string }[]>([]); @@ -68,6 +72,18 @@ let isSidebarMode = $state(false); let isCollapsed = $state(false); + // Show toolbar only on main contacts page + const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode); + + // Dynamic bottom offset based on toolbar state + const inputBarBottomOffset = $derived( + isSidebarMode + ? '0px' + : showContactsToolbar && !contactsFilterStore.isToolbarCollapsed + ? '140px' + : '70px' + ); + // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -227,30 +243,21 @@ async function handleCreate(query: string): Promise { const parsed = parseContactInput(query); - if (!parsed.displayName) return; - - // Resolve tag names to IDs - const resolved = resolveContactIds(parsed, availableTags); - - try { - const contact = await contactsStore.createContact({ - displayName: resolved.displayName, - firstName: resolved.firstName, - lastName: resolved.lastName, - company: resolved.company, - email: resolved.email, - phone: resolved.phone, - }); - - // Add tags to the created contact - if (resolved.tagIds.length > 0 && contact) { - for (const tagId of resolved.tagIds) { - await tagsApi.addToContact(tagId, contact.id); - } - } - } catch (e) { - console.error('Failed to create contact:', e); + if (!parsed.displayName) { + // If no query, just open empty modal + newContactModalStore.open(); + return; } + + // Open modal with prefilled data + newContactModalStore.open({ + displayName: parsed.displayName, + firstName: parsed.firstName || undefined, + lastName: parsed.lastName || undefined, + email: parsed.email || undefined, + phone: parsed.phone || undefined, + company: parsed.company || undefined, + }); } // QuickInputBar quick actions @@ -281,9 +288,10 @@ console.error('Failed to load tags:', e); } - // Initialize contacts settings and view mode + // Initialize contacts settings, view mode, and filter store contactsSettings.initialize(); viewModeStore.initialize(); + contactsFilterStore.initialize(); // Initialize sidebar mode from localStorage const savedSidebar = localStorage.getItem('contacts-nav-sidebar'); @@ -306,10 +314,7 @@
- - - - + {/if} + + {#if newContactModalStore.isOpen} + newContactModalStore.close()} /> + {/if} + contactsFilterStore.setSearchQuery(query)} {quickActions} placeholder="Neuer Kontakt oder suchen..." emptyText="Keine Kontakte gefunden" @@ -374,8 +385,15 @@ createText="Erstellen" appIcon="contacts" primaryColor="#3b82f6" - autoFocus={false} + autoFocus={true} + bottomOffset={inputBarBottomOffset} + hasFabRight={showContactsToolbar} /> + + + {#if showContactsToolbar} + + {/if}
@@ -389,17 +407,19 @@ .main-content { flex: 1; transition: all 300ms ease; + /* Space for QuickInputBar + PillNav at bottom */ + padding-bottom: calc(150px + env(safe-area-inset-bottom)); } - /* Floating nav mode - add top padding for fixed nav */ + /* Floating nav mode - nav is at bottom, no top padding needed */ .main-content.floating-mode { - padding-top: 80px; + padding-top: 0; } - /* Extra padding on mobile for larger nav */ + /* Extra bottom padding on mobile */ @media (max-width: 768px) { - .main-content.floating-mode { - padding-top: 90px; + .main-content { + padding-bottom: calc(160px + env(safe-area-inset-bottom)); } } @@ -426,27 +446,4 @@ padding: 2rem; } } - - /* Shadow gradient above pill navigation */ - .nav-shadow-gradient { - position: fixed; - top: 0; - left: 0; - right: 0; - height: 80px; - background: linear-gradient( - to bottom, - hsl(var(--background)) 0%, - hsl(var(--background)) 50%, - hsl(var(--background) / 0) 100% - ); - pointer-events: none; - z-index: 40; - } - - @media (max-width: 768px) { - .nav-shadow-gradient { - height: 90px; - } - } diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte index 0508d3e8f..132141d3d 100644 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte @@ -2,11 +2,17 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { networkStore, type SimulationNode } from '$lib/stores/network.svelte'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { NetworkGraph, NetworkControls } from '@manacore/shared-ui'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { NetworkGraphSkeleton } from '$lib/components/skeletons'; import '$lib/i18n'; + // Sync global search to network store + $effect(() => { + networkStore.setSearch(contactsFilterStore.searchQuery); + }); + let graphComponent: NetworkGraph; let controlsComponent: NetworkControls; let graphContainer: HTMLDivElement; @@ -110,7 +116,7 @@
+ import { slide } from 'svelte/transition'; + import type { Snippet } from 'svelte'; + + interface Props { + /** Whether the toolbar is collapsed */ + isCollapsed?: boolean; + /** Called when collapsed state changes */ + onCollapsedChange?: (isCollapsed: boolean) => void; + /** Whether in sidebar mode (affects positioning) */ + isSidebarMode?: boolean; + /** Bottom offset from viewport bottom (default: '70px') */ + bottomOffset?: string; + /** Sidebar mode bottom offset (default: '0px') */ + sidebarBottomOffset?: string; + /** Panel height when expanded (default: '70px') */ + panelHeight?: string; + /** FAB tooltip when collapsed */ + collapsedTitle?: string; + /** FAB tooltip when expanded */ + expandedTitle?: string; + /** Custom collapsed icon snippet */ + collapsedIcon?: Snippet; + /** Custom expanded icon snippet */ + expandedIcon?: Snippet; + /** Panel content (required) */ + children: Snippet; + /** Optional right-side content (e.g., layout toggle) */ + rightActions?: Snippet; + } + + let { + isCollapsed = true, + onCollapsedChange, + isSidebarMode = false, + bottomOffset = '70px', + sidebarBottomOffset = '0px', + panelHeight = '70px', + collapsedTitle = 'Optionen', + expandedTitle = 'Schließen', + collapsedIcon, + expandedIcon, + children, + rightActions, + }: Props = $props(); + + function toggleToolbar() { + onCollapsedChange?.(!isCollapsed); + } + + + +
+ +
+ + +{#if !isCollapsed} +
+
+ {@render children()} + + {#if rightActions} +
+ {@render rightActions()} + {/if} +
+
+{/if} + + diff --git a/packages/shared-ui/src/navigation/expandable-toolbar/index.ts b/packages/shared-ui/src/navigation/expandable-toolbar/index.ts new file mode 100644 index 000000000..db710aed6 --- /dev/null +++ b/packages/shared-ui/src/navigation/expandable-toolbar/index.ts @@ -0,0 +1,2 @@ +export { default as ExpandableToolbar } from './ExpandableToolbar.svelte'; +export type { ExpandableToolbarProps } from './types'; diff --git a/packages/shared-ui/src/navigation/expandable-toolbar/types.ts b/packages/shared-ui/src/navigation/expandable-toolbar/types.ts new file mode 100644 index 000000000..fec554943 --- /dev/null +++ b/packages/shared-ui/src/navigation/expandable-toolbar/types.ts @@ -0,0 +1,28 @@ +import type { Snippet } from 'svelte'; + +export interface ExpandableToolbarProps { + /** Whether the toolbar is collapsed */ + isCollapsed?: boolean; + /** Called when collapsed state changes */ + onCollapsedChange?: (isCollapsed: boolean) => void; + /** Whether in sidebar mode (affects positioning) */ + isSidebarMode?: boolean; + /** Bottom offset from viewport bottom (default: '70px') */ + bottomOffset?: string; + /** Sidebar mode bottom offset (default: '0px') */ + sidebarBottomOffset?: string; + /** Panel height when expanded (default: '70px') */ + panelHeight?: string; + /** FAB tooltip when collapsed */ + collapsedTitle?: string; + /** FAB tooltip when expanded */ + expandedTitle?: string; + /** Custom collapsed icon snippet */ + collapsedIcon?: Snippet; + /** Custom expanded icon snippet */ + expandedIcon?: Snippet; + /** Panel content (required) */ + children: Snippet; + /** Optional right-side content (e.g., layout toggle) */ + rightActions?: Snippet; +} diff --git a/packages/shared-ui/src/navigation/index.ts b/packages/shared-ui/src/navigation/index.ts index 500d8fe2f..e5a8947b8 100644 --- a/packages/shared-ui/src/navigation/index.ts +++ b/packages/shared-ui/src/navigation/index.ts @@ -10,6 +10,8 @@ export { default as PillViewSwitcher } from './PillViewSwitcher.svelte'; export { default as PillToolbar } from './PillToolbar.svelte'; export { default as PillToolbarButton } from './PillToolbarButton.svelte'; export { default as PillToolbarDivider } from './PillToolbarDivider.svelte'; +export { ExpandableToolbar } from './expandable-toolbar'; +export type { ExpandableToolbarProps } from './expandable-toolbar'; export type { NavItem, NavbarProps, diff --git a/packages/shared-ui/src/organisms/network/NetworkControls.svelte b/packages/shared-ui/src/organisms/network/NetworkControls.svelte index 3056e3167..47f0df22f 100644 --- a/packages/shared-ui/src/organisms/network/NetworkControls.svelte +++ b/packages/shared-ui/src/organisms/network/NetworkControls.svelte @@ -15,6 +15,7 @@ linkLabel?: string; searchPlaceholder?: string; minStrength?: number; + showSearch?: boolean; onSearch?: (query: string) => void; onTagFilter?: (tagId: string | null) => void; onSubtitleFilter?: (subtitle: string | null) => void; @@ -39,6 +40,7 @@ linkLabel = 'Verbindungen', searchPlaceholder = 'Suchen...', minStrength = 0, + showSearch = true, onSearch, onTagFilter, onSubtitleFilter, @@ -122,22 +124,24 @@
-
- - - {#if searchInput} - - {/if} -
+ {#if showSearch} +
+ + + {#if searchInput} + + {/if} +
+ {/if} {#if tags.length > 0 || subtitles.length > 0} From 6ce385a42e12a456885124d221f321b0d9b076fb Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:16:29 +0100 Subject: [PATCH 28/69] style: apply formatting fixes from pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/ContactDetailModal.svelte | 540 ++++++++++++++++++ .../web/src/routes/(app)/network/+page.svelte | 11 - 2 files changed, 540 insertions(+), 11 deletions(-) diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index f0ccc2a63..e754a190b 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -86,6 +86,36 @@ postalCode = contact.postalCode || ''; country = contact.country || ''; notes = contact.notes || ''; + // Social Media + linkedin = contact.linkedin || ''; + twitter = contact.twitter || ''; + facebook = contact.facebook || ''; + instagram = contact.instagram || ''; + xing = contact.xing || ''; + github = contact.github || ''; + youtube = contact.youtube || ''; + tiktok = contact.tiktok || ''; + telegram = contact.telegram || ''; + whatsapp = contact.whatsapp || ''; + signal = contact.signal || ''; + discord = contact.discord || ''; + bluesky = contact.bluesky || ''; + // Auto-open social section if any social field has data + socialSectionOpen = !!( + contact.linkedin || + contact.twitter || + contact.facebook || + contact.instagram || + contact.xing || + contact.github || + contact.youtube || + contact.tiktok || + contact.telegram || + contact.whatsapp || + contact.signal || + contact.discord || + contact.bluesky + ); } function getDisplayName() { @@ -127,6 +157,20 @@ postalCode: postalCode || null, country: country || null, notes: notes || null, + // Social Media + linkedin: linkedin || null, + twitter: twitter || null, + facebook: facebook || null, + instagram: instagram || null, + xing: xing || null, + github: github || null, + youtube: youtube || null, + tiktok: tiktok || null, + telegram: telegram || null, + whatsapp: whatsapp || null, + signal: signal || null, + discord: discord || null, + bluesky: bluesky || null, }); editing = false; } catch (e) { @@ -494,6 +538,214 @@ + +
+ + {#if socialSectionOpen} + + {/if} +
+
+ {#if socialSectionOpen} + + {/if} + +
@@ -957,5 +1195,66 @@ .actions { flex-direction: column-reverse; } + + .social-grid { + grid-template-columns: 1fr; + } + } + + /* Social Media Section */ + .section-header-toggle { + width: 100%; + background: none; + border: none; + cursor: pointer; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + margin-bottom: 0; + } + + .section-header-toggle:hover { + background: hsl(var(--color-surface-hover) / 0.3); + margin: 0 -1rem; + padding: 0 1rem 0.625rem; + width: calc(100% + 2rem); + border-radius: 0.5rem 0.5rem 0 0; + } + + .chevron-icon { + width: 1rem; + height: 1rem; + margin-left: auto; + color: hsl(var(--color-muted-foreground)); + transition: transform 0.2s ease; + } + + .chevron-open { + transform: rotate(180deg); + } + + .social-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + padding-top: 0.75rem; + } + + .social-label { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .social-icon-label { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 0.25rem; + background: hsl(var(--color-primary) / 0.1); + color: hsl(var(--color-primary)); + font-size: 0.625rem; + font-weight: 700; + text-transform: lowercase; } diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte index b84c582a4..7441545fa 100644 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte @@ -13,6 +13,21 @@ networkStore.setSearch(contactsFilterStore.searchQuery); }); + // Refocus view when search results change + let previousNodeCount = $state(0); + $effect(() => { + const currentNodeCount = networkStore.nodes.length; + const hasSearch = contactsFilterStore.searchQuery.length > 0; + + // If search is active and node count changed, reset zoom to show all results + if (hasSearch && currentNodeCount !== previousNodeCount && currentNodeCount > 0) { + setTimeout(() => { + graphComponent?.resetZoom(); + }, 100); + } + previousNodeCount = currentNodeCount; + }); + let graphComponent: NetworkGraph; let graphContainer: HTMLDivElement; @@ -179,10 +194,10 @@ flex-direction: column; } - /* Floating Controls */ + /* Floating Controls - positioned above QuickInputBar and PillNav */ .controls-wrapper { - position: absolute; - top: 1rem; + position: fixed; + bottom: calc(140px + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); z-index: 10; @@ -192,7 +207,7 @@ /* Error Banner */ .error-banner { position: absolute; - top: 5rem; + top: 1rem; left: 50%; transform: translateX(-50%); z-index: 10; @@ -219,9 +234,11 @@ /* Modal Sidebar Wrapper - Override modal positioning */ .modal-sidebar-wrapper { position: fixed; - top: 5rem; /* Below the pill nav */ + top: 1rem; right: 1rem; - bottom: 1rem; + bottom: calc( + 200px + env(safe-area-inset-bottom) + ); /* Above controls + QuickInputBar + PillNav */ width: 400px; max-width: calc(100vw - 2rem); z-index: 50; @@ -291,7 +308,7 @@ @media (max-width: 768px) { .controls-wrapper { - top: 1rem; + bottom: calc(160px + env(safe-area-inset-bottom)); width: calc(100% - 2rem); max-width: none; } From 3f27e477dd37b68171073efa8bba9286b43c5d0c Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:31:38 +0100 Subject: [PATCH 30/69] fix(contacts): improve new contact card and sticky section headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove subtitle from new contact card for cleaner appearance - Make new contact card full width to match list items - Fix sticky section headers position (top: 8px instead of 80px) - Update alphabet nav styling with glass blur effect - Add container queries for responsive centering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../views/ContactAlphabetView.svelte | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 777a9898b..d3142c21a 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -116,7 +116,6 @@
{$_('contacts.new')} - {$_('contacts.addFirst')}
@@ -298,7 +297,7 @@ padding: 0.375rem 0.875rem; margin-bottom: 0.75rem; position: sticky; - top: 80px; + top: 8px; z-index: 10; /* Glass pill effect */ background: hsl(var(--background) / 0.75); @@ -309,12 +308,6 @@ box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05); } - @media (max-width: 768px) { - .section-header { - top: 90px; - } - } - .section-letter { font-size: 1rem; font-weight: 700; @@ -493,6 +486,9 @@ align-items: stretch; pointer-events: none; transition: bottom 0.2s ease; + /* Container query context */ + container-type: inline-size; + container-name: alphabetnav; } .alphabet-nav-container { @@ -500,27 +496,31 @@ flex-direction: row; align-items: center; gap: 2px; - padding: 0.5rem 0; + padding: 0.5rem 1.5rem; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; scroll-behavior: smooth; - /* Glass container like DateStrip */ - background: var(--color-surface, hsl(var(--background))); + /* Glass container with blur effect */ + background: hsl(var(--background) / 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); border-radius: 16px; margin: 0 1rem; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); - border: 1px solid hsl(var(--border)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + border: 1px solid hsl(var(--border) / 0.6); pointer-events: auto; - /* Fade effect at edges */ - mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); - -webkit-mask-image: linear-gradient( - to right, - transparent 0%, - black 5%, - black 95%, - transparent 100% - ); + /* Default: left-aligned with fit-content */ + width: fit-content; + max-width: calc(100% - 2rem); + } + + /* Center when container has enough space */ + @container alphabetnav (min-width: 600px) { + .alphabet-nav-container { + margin-left: auto; + margin-right: auto; + } } .alphabet-nav-container::-webkit-scrollbar { @@ -575,7 +575,6 @@ border-style: dashed; border-color: hsl(var(--primary) / 0.4); background: hsl(var(--primary) / 0.05); - max-width: 280px; } .new-contact-card:hover { @@ -600,8 +599,4 @@ .new-contact-card .contact-name { font-size: 0.875rem; } - - .new-contact-card .contact-company-inline { - font-size: 0.75rem; - } From 68626227e0e0dfdcba9f8e0d85babe12a84b83cf Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:32:59 +0100 Subject: [PATCH 31/69] feat(contacts): unify network page toolbar with ExpandableToolbar pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate network page from custom floating NetworkControls to the shared ExpandableToolbar component, matching the homepage toolbar behavior. Changes: - Add NetworkToolbar and NetworkToolbarContent components - Extend networkStore with toolbar state and zoom control methods - Register graphComponent in store for toolbar zoom access - Remove floating NetworkControls from network page - Add toolbar rendering for /network route in layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/lib/components/NetworkToolbar.svelte | 28 ++ .../components/NetworkToolbarContent.svelte | 305 ++++++++++++++++++ .../src/lib/components/ViewModeToggle.svelte | 12 +- .../components/views/ContactListView.svelte | 210 ------------ .../apps/web/src/lib/stores/network.svelte.ts | 87 +++++ .../web/src/lib/stores/settings.svelte.ts | 2 +- .../web/src/lib/stores/view-mode.svelte.ts | 4 +- .../apps/web/src/routes/(app)/+layout.svelte | 27 +- .../web/src/routes/(app)/network/+page.svelte | 81 +---- 9 files changed, 451 insertions(+), 305 deletions(-) create mode 100644 apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte delete mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte diff --git a/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte b/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte new file mode 100644 index 000000000..b2e35bbc5 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte new file mode 100644 index 000000000..10394c811 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte @@ -0,0 +1,305 @@ + + +
+ + {#if networkStore.uniqueTags.length > 0} +
+ +
+ {/if} + + + {#if networkStore.uniqueCompanies.length > 0} +
+ +
+ {/if} + +
+ + +
+ + +
+ +
+ + +
+ + + + +
+ + + {#if hasActiveFilters} +
+ + {/if} + + +
+ {networkStore.nodes.length} Kontakte + + {networkStore.links.length} Verbindungen +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte index 96a9df394..80ab572e3 100644 --- a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte +++ b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte @@ -4,7 +4,6 @@ const modes: { id: ViewMode; icon: string; label: string }[] = [ { id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' }, - { id: 'list', icon: 'list', label: 'views.list' }, { id: 'grid', icon: 'grid', label: 'views.grid' }, ]; @@ -18,16 +17,7 @@ onclick={() => viewModeStore.setMode(mode.id)} title={$_(mode.label)} > - {#if mode.icon === 'list'} - - - - {:else if mode.icon === 'grid'} + {#if mode.icon === 'grid'} - import { _ } from 'svelte-i18n'; - import type { Contact } from '$lib/api/contacts'; - import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; - - interface Props { - contacts: Contact[]; - onContactClick: (id: string) => void; - onToggleFavorite: (e: MouseEvent, id: string) => void; - selectionMode?: boolean; - selectedIds?: Set; - onToggleSelection?: (id: string) => void; - showNewContactCard?: boolean; - } - - let { - contacts, - onContactClick, - onToggleFavorite, - selectionMode = false, - selectedIds = new Set(), - onToggleSelection, - showNewContactCard = true, - }: Props = $props(); - - function getInitials(contact: Contact) { - const first = contact.firstName?.[0] || ''; - const last = contact.lastName?.[0] || ''; - return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?'; - } - - function getDisplayName(contact: Contact) { - if (contact.displayName) return contact.displayName; - if (contact.firstName || contact.lastName) { - return [contact.firstName, contact.lastName].filter(Boolean).join(' '); - } - return contact.email || 'Unbekannt'; - } - - function handleCheckboxClick(e: MouseEvent, id: string) { - e.stopPropagation(); - onToggleSelection?.(id); - } - - -
- - {#if showNewContactCard && !selectionMode} -
newContactModalStore.open()} - onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} - class="contact-card new-contact-card w-full text-left cursor-pointer" - > - -
- - - -
- - -
-
- {$_('contacts.new')} -
-
- {$_('contacts.addFirst')} -
-
-
- {/if} - - {#each contacts as contact (contact.id)} -
onContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} - class="contact-card w-full text-left cursor-pointer {selectionMode && - selectedIds.has(contact.id) - ? 'selected' - : ''}" - > - - {#if selectionMode} - - {/if} - - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
- - -
-
- {getDisplayName(contact)} -
- {#if contact.company || contact.jobTitle} -
- {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} -
- {/if} - {#if contact.email} -
- {contact.email} -
- {/if} -
- - - -
- {/each} -
- - diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts index 777b6141d..9410ec1c6 100644 --- a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -17,11 +17,43 @@ import type { SimulationNode as SharedSimulationNode, SimulationLink as SharedSimulationLink, } from '@manacore/shared-ui'; +import { NetworkGraph } from '@manacore/shared-ui'; // Re-export types from shared-ui for convenience export type SimulationNode = SharedSimulationNode; export type SimulationLink = SharedSimulationLink; +// Graph component reference for zoom controls +let graphComponentRef: NetworkGraph | null = null; + +// localStorage key for toolbar state +const TOOLBAR_STORAGE_KEY = 'network-toolbar-state'; + +// Load toolbar state from localStorage +function loadToolbarState(): boolean { + if (!browser) return true; + try { + const stored = localStorage.getItem(TOOLBAR_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.isCollapsed ?? true; + } + } catch { + // Ignore parse errors + } + return true; +} + +// Save toolbar state to localStorage +function saveToolbarState(isCollapsed: boolean) { + if (!browser) return; + try { + localStorage.setItem(TOOLBAR_STORAGE_KEY, JSON.stringify({ isCollapsed })); + } catch { + // Ignore storage errors + } +} + // State let nodes = $state([]); let links = $state([]); @@ -37,6 +69,7 @@ let tickCounter = $state(0); // Used to trigger reactivity on simulation tick let simulationInitialized = false; let dataLoaded = false; // Prevent double loading let lastDimensions = { width: 0, height: 0 }; +let isToolbarCollapsed = $state(loadToolbarState()); // Derived state for filtering const filteredNodes = $derived.by(() => { @@ -159,6 +192,60 @@ export const networkStore = { get uniqueTags() { return uniqueTags; }, + get isToolbarCollapsed() { + return isToolbarCollapsed; + }, + + /** + * Set toolbar collapsed state + */ + setToolbarCollapsed(value: boolean) { + isToolbarCollapsed = value; + saveToolbarState(value); + }, + + /** + * Toggle toolbar collapsed state + */ + toggleToolbar() { + isToolbarCollapsed = !isToolbarCollapsed; + saveToolbarState(isToolbarCollapsed); + }, + + /** + * Register graph component reference for zoom controls + */ + setGraphComponent(component: NetworkGraph | null) { + graphComponentRef = component; + }, + + /** + * Zoom in on the graph + */ + zoomIn() { + graphComponentRef?.zoomIn(); + }, + + /** + * Zoom out on the graph + */ + zoomOut() { + graphComponentRef?.zoomOut(); + }, + + /** + * Reset zoom to fit all nodes + */ + resetZoom() { + graphComponentRef?.resetZoom(); + }, + + /** + * Focus on the currently selected node + */ + focusOnSelected() { + graphComponentRef?.focusOnSelectedNode(); + }, /** * Load network graph data from API diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts index f5d679f13..7480186ce 100644 --- a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -8,7 +8,7 @@ import { browser } from '$app/environment'; // Settings types export type ContactSortBy = 'name' | 'company' | 'created' | 'updated'; export type ContactSortOrder = 'asc' | 'desc'; -export type ContactView = 'list' | 'grid' | 'alphabet'; +export type ContactView = 'grid' | 'alphabet'; export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; export interface ContactsAppSettings { diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts index 1a61b9bbb..74bd7a442 100644 --- a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -16,7 +16,7 @@ function getInitialMode(): ViewMode { // First check if there's a session-specific preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + if (sessionMode === 'grid' || sessionMode === 'alphabet') { return sessionMode; } @@ -57,7 +57,7 @@ export const viewModeStore = { // Check if there's a session preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + if (sessionMode === 'grid' || sessionMode === 'alphabet') { mode = sessionMode; } else { // Use default from settings diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 68c279715..a7bf002e3 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -47,6 +47,8 @@ formatParsedContactPreview, } from '$lib/utils/contact-parser'; import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; + import NetworkToolbar from '$lib/components/NetworkToolbar.svelte'; + import { networkStore } from '$lib/stores/network.svelte'; // Tags state for Quick-Create let availableTags = $state<{ id: string; name: string }[]>([]); @@ -75,15 +77,23 @@ // Show toolbar only on main contacts page const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode); + // Show network toolbar only on network page + const showNetworkToolbar = $derived($page.url.pathname === '/network' && !isSidebarMode); + + // Check if any toolbar is expanded + const isAnyToolbarExpanded = $derived( + (showContactsToolbar && !contactsFilterStore.isToolbarCollapsed) || + (showNetworkToolbar && !networkStore.isToolbarCollapsed) + ); + // Dynamic bottom offset based on toolbar state const inputBarBottomOffset = $derived( - isSidebarMode - ? '0px' - : showContactsToolbar && !contactsFilterStore.isToolbarCollapsed - ? '140px' - : '70px' + isSidebarMode ? '0px' : isAnyToolbarExpanded ? '140px' : '70px' ); + // Show FAB when any toolbar is active + const showToolbarFab = $derived(showContactsToolbar || showNetworkToolbar); + // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -387,13 +397,18 @@ primaryColor="#3b82f6" autoFocus={true} bottomOffset={inputBarBottomOffset} - hasFabRight={showContactsToolbar} + hasFabRight={showToolbarFab} /> {#if showContactsToolbar} {/if} + + + {#if showNetworkToolbar} + + {/if}
diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte index 7441545fa..0749db726 100644 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation'; import { networkStore, type SimulationNode } from '$lib/stores/network.svelte'; import { contactsFilterStore } from '$lib/stores/filter.svelte'; - import { NetworkGraph, NetworkControls } from '@manacore/shared-ui'; + import { NetworkGraph } from '@manacore/shared-ui'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { NetworkGraphSkeleton } from '$lib/components/skeletons'; import '$lib/i18n'; @@ -62,37 +62,10 @@ networkStore.releaseNode(node.id); } - function handleZoomIn() { - graphComponent?.zoomIn(); - } - - function handleZoomOut() { - graphComponent?.zoomOut(); - } - - function handleResetZoom() { - graphComponent?.resetZoom(); - } - - function handleFocusSelected() { - graphComponent?.focusOnSelectedNode(); - } - - function handleTagFilter(tagId: string | null) { - networkStore.setFilterTag(tagId); - } - - function handleSubtitleFilter(company: string | null) { - networkStore.setFilterCompany(company); - } - - function handleStrengthFilter(strength: number) { - networkStore.setMinStrength(strength); - } - - function handleClearFilters() { - networkStore.clearFilters(); - } + // Register graph component with store when it changes + $effect(() => { + networkStore.setGraphComponent(graphComponent); + }); // Initialize simulation when data is loaded and container is ready $effect(() => { @@ -109,6 +82,7 @@ }); onDestroy(() => { + networkStore.setGraphComponent(null); networkStore.stopSimulation(); }); @@ -118,31 +92,6 @@
- -
- -
- {#if networkStore.error} - -
-
- {#each alphabet as letter} - - {/each} - {#if availableLetters.includes('#')} - - {/if} -
+ +
+
+ + + {#if !isAlphabetNavCollapsed} +
+
+ {#each alphabet as letter} + + {/each} + {#if availableLetters.includes('#')} + + {/if} +
+
+ {/if}
diff --git a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts index eba2b00d5..a715eb0ba 100644 --- a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts @@ -16,6 +16,7 @@ export interface ContactsFilterState { selectedTagId: string | null; selectedCompany: string | null; isToolbarCollapsed: boolean; + isAlphabetNavCollapsed: boolean; searchQuery: string; } @@ -26,6 +27,7 @@ const DEFAULT_STATE: ContactsFilterState = { selectedTagId: null, selectedCompany: null, isToolbarCollapsed: true, + isAlphabetNavCollapsed: false, searchQuery: '', }; @@ -80,6 +82,9 @@ export const contactsFilterStore = { get isToolbarCollapsed() { return state.isToolbarCollapsed; }, + get isAlphabetNavCollapsed() { + return state.isAlphabetNavCollapsed; + }, get searchQuery() { return state.searchQuery; }, @@ -120,6 +125,16 @@ export const contactsFilterStore = { saveState(state); }, + setAlphabetNavCollapsed(value: boolean) { + state = { ...state, isAlphabetNavCollapsed: value }; + saveState(state); + }, + + toggleAlphabetNav() { + state = { ...state, isAlphabetNavCollapsed: !state.isAlphabetNavCollapsed }; + saveState(state); + }, + setSearchQuery(value: string) { state = { ...state, searchQuery: value }; // Don't persist search query to localStorage From cdc3cd3ec85852318f667e8de89bdf7d09f53efb Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:49:08 +0100 Subject: [PATCH 33/69] feat(calendar): add birthday integration from contacts service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add birthdaysStore to fetch and manage birthdays from contacts API - Add BirthdayPopover component with contact details and link to contacts app - Integrate birthdays into WeekView, MonthView, and DayView as all-day events - Add settings for showBirthdays and showBirthdayAge toggles - Add reactive $effect in layout to load birthdays when setting is enabled - Add /contacts/birthdays endpoint to contacts backend - Configure PUBLIC_CONTACTS_API_URL env variable for calendar app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/lib/api/birthdays.ts | 100 +++++++ .../birthday/BirthdayPopover.svelte | 269 ++++++++++++++++++ .../lib/components/calendar/DayView.svelte | 63 +++- .../lib/components/calendar/MonthView.svelte | 64 +++++ .../lib/components/calendar/WeekView.svelte | 68 ++++- .../web/src/lib/stores/birthdays.svelte.ts | 219 ++++++++++++++ .../web/src/lib/stores/settings.svelte.ts | 14 + .../apps/web/src/routes/(app)/+layout.svelte | 11 + .../src/routes/(app)/settings/+page.svelte | 218 ++++++++------ .../backend/src/contact/contact.controller.ts | 10 + .../backend/src/contact/contact.service.ts | 41 ++- scripts/generate-env.mjs | 3 + 12 files changed, 995 insertions(+), 85 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/api/birthdays.ts create mode 100644 apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte create mode 100644 apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts diff --git a/apps/calendar/apps/web/src/lib/api/birthdays.ts b/apps/calendar/apps/web/src/lib/api/birthdays.ts new file mode 100644 index 000000000..10e4ed9b4 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/birthdays.ts @@ -0,0 +1,100 @@ +/** + * Cross-App API Client for Contacts Backend - Birthday Data + * Allows Calendar app to fetch contact birthdays for display + */ + +import { env } from '$env/dynamic/public'; +import { createApiClient } from './base-client'; + +const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015'; + +const contactsClient = createApiClient({ + baseUrl: CONTACTS_API_BASE, + apiPrefix: '/api/v1', +}); + +// ============================================ +// Types for Birthday Integration +// ============================================ + +/** + * Lightweight contact data for birthday display + * Only essential fields from Contacts API + */ +export interface ContactBirthdaySummary { + id: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + birthday: string; // YYYY-MM-DD format + photoUrl: string | null; +} + +/** + * Birthday event for calendar display + * Generated from ContactBirthdaySummary with display date + */ +export interface BirthdayEvent { + id: string; // Format: birthday-{contactId}-{date} + contactId: string; + title: string; // "{Name}'s Geburtstag" + displayName: string; + photoUrl: string | null; + birthday: string; // Original birthday date + age: number; // Age on this birthday (0 if birth year unknown) + startTime: string; // ISO date of the birthday occurrence + endTime: string; // Same as startTime (all-day event) + isAllDay: true; + isBirthday: true; // Type discriminator + calendarId: string; // Virtual calendar ID +} + +// ============================================ +// API Response Types +// ============================================ + +interface BirthdaysResponse { + contacts: ContactBirthdaySummary[]; +} + +// ============================================ +// API Functions +// ============================================ + +const fetchContactsApi = contactsClient.fetchApi; + +/** + * Fetch all contacts with birthdays from Contacts service + */ +export async function getBirthdays(): Promise<{ + data: ContactBirthdaySummary[] | null; + error: Error | null; +}> { + const result = await fetchContactsApi('/contacts/birthdays'); + return { + data: result.data?.contacts || null, + error: result.error, + }; +} + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Get display name from contact, with fallback + */ +export function getContactDisplayName(contact: ContactBirthdaySummary): string { + if (contact.displayName) return contact.displayName; + const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' '); + return fullName || 'Unbekannt'; +} + +/** + * Birthday calendar constants + */ +export const BIRTHDAY_CALENDAR = { + id: '__birthdays__', + name: 'Geburtstage', + color: '#EC4899', // Pink +} as const; diff --git a/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte new file mode 100644 index 000000000..5dde69d6a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte @@ -0,0 +1,269 @@ + + + + + +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" +> + + +
+ + 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 9de0aae29..765b26413 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -6,6 +6,8 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte'; + import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; import { useVisibleHours, useCurrentTimeIndicator, @@ -131,6 +133,28 @@ let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block')); + // Birthday Popover State + let selectedBirthday = $state(null); + let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 }); + + // Get birthdays for current day (if enabled in settings) + let birthdays = $derived.by(() => { + if (!settingsStore.showBirthdays) return []; + return birthdaysStore.getBirthdaysForDay(viewStore.currentDate); + }); + + // Handle birthday click - show popover + function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) { + e.stopPropagation(); + selectedBirthday = birthday; + birthdayPopoverPosition = { x: e.clientX, y: e.clientY }; + } + + // Close birthday popover + function closeBirthdayPopover() { + selectedBirthday = null; + } + // ============================================================================ // Drag & Drop State // ============================================================================ @@ -729,8 +753,8 @@
- - {#if headerAllDayEvents.length > 0} + + {#if headerAllDayEvents.length > 0 || birthdays.length > 0}
Ganztägig @@ -747,6 +771,18 @@ {event.title} {/each} + + {#each birthdays as birthday} + + {/each}
{/if} @@ -909,6 +945,15 @@ + +{#if selectedBirthday} + +{/if} + 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 6ce614ec6..22a3cc3ae 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -5,8 +5,10 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { searchStore } from '$lib/stores/search.svelte'; import { todosStore } from '$lib/stores/todos.svelte'; + import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import TodoDayCell from './TodoDayCell.svelte'; + import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; import { goto } from '$app/navigation'; import { format, @@ -259,6 +261,27 @@ if (eventsStore.isDraftEvent(event.id)) return; eventContextMenuStore.show(event, e.clientX, e.clientY); } + + // ============================================================================ + // Birthday Functions + // ============================================================================ + let selectedBirthday = $state(null); + let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 }); + + function getBirthdaysForDay(day: Date): BirthdayEvent[] { + if (!settingsStore.showBirthdays) return []; + return birthdaysStore.getBirthdaysForDay(day); + } + + function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) { + e.stopPropagation(); + selectedBirthday = birthday; + birthdayPopoverPosition = { x: e.clientX, y: e.clientY }; + } + + function closeBirthdayPopover() { + selectedBirthday = null; + }
@@ -338,6 +361,23 @@
{/each} + + {#each getBirthdaysForDay(day) as birthday} + +
handleBirthdayClick(birthday, e)} + role="button" + tabindex="0" + > + 🎂 + {birthday.displayName} + {#if settingsStore.showBirthdayAge && birthday.age > 0} + ({birthday.age}) + {/if} +
+ {/each} + {#if getAllEventsForDay(day).length > 3}
+ +{#if selectedBirthday} + +{/if} + 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 0fe825e00..0ee5a07cb 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -5,7 +5,9 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; + import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; import { useVisibleHours, useCurrentTimeIndicator, @@ -118,6 +120,33 @@ // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; + // Birthday Popover State + let selectedBirthday = $state(null); + let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 }); + + // Get birthdays for a day (if enabled in settings) + function getBirthdaysForDay(day: Date): BirthdayEvent[] { + if (!settingsStore.showBirthdays) return []; + return birthdaysStore.getBirthdaysForDay(day); + } + + // Check if there are any birthdays to show in the all-day row + let hasAnyBirthdays = $derived( + settingsStore.showBirthdays && days.some((day) => getBirthdaysForDay(day).length > 0) + ); + + // Handle birthday click - show popover + function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) { + e.stopPropagation(); + selectedBirthday = birthday; + birthdayPopoverPosition = { x: e.clientX, y: e.clientY }; + } + + // Close birthday popover + function closeBirthdayPopover() { + selectedBirthday = null; + } + function getEventsForDay(day: Date) { // Filter by visible calendars first const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); @@ -888,8 +917,8 @@ {/each}
- - {#if hasAnyHeaderAllDayEvents} + + {#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
{#if settingsStore.showWeekNumbers} @@ -909,6 +938,18 @@ {event.title} {/each} + + {#each getBirthdaysForDay(day) as birthday} + + {/each}
{/each}
@@ -1107,6 +1148,15 @@ + +{#if selectedBirthday} + +{/if} + diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index d896e036d..04c38177b 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -234,6 +234,16 @@ export class ContactController { return { contacts, total }; } + /** + * Get all contacts with birthdays (for calendar integration) + * Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl + */ + @Get('birthdays') + async getBirthdays(@CurrentUser() user: CurrentUserData) { + const contacts = await this.contactService.findWithBirthdays(user.userId); + return { contacts }; + } + @Get(':id') async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const contact = await this.contactService.findById(id, user.userId); diff --git a/apps/contacts/apps/backend/src/contact/contact.service.ts b/apps/contacts/apps/backend/src/contact/contact.service.ts index f6cbd9092..3f528efef 100644 --- a/apps/contacts/apps/backend/src/contact/contact.service.ts +++ b/apps/contacts/apps/backend/src/contact/contact.service.ts @@ -1,10 +1,19 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, or, ilike, desc, sql } from 'drizzle-orm'; +import { eq, and, or, ilike, desc, sql, isNotNull } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { contacts } from '../db/schema'; import type { Contact, NewContact } from '../db/schema'; +export interface ContactBirthdaySummary { + id: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + birthday: string; + photoUrl: string | null; +} + export interface ContactFilters { search?: string; isFavorite?: boolean; @@ -148,4 +157,34 @@ export class ContactService { return Number(result[0]?.count || 0); } + + /** + * Find all contacts with birthdays (for calendar integration) + * Returns only essential fields for lightweight transfer + */ + async findWithBirthdays(userId: string): Promise { + const result = await this.db + .select({ + id: contacts.id, + displayName: contacts.displayName, + firstName: contacts.firstName, + lastName: contacts.lastName, + birthday: contacts.birthday, + photoUrl: contacts.photoUrl, + }) + .from(contacts) + .where( + and( + eq(contacts.userId, userId), + eq(contacts.isArchived, false), + isNotNull(contacts.birthday) + ) + ) + .orderBy(contacts.lastName, contacts.firstName); + + return result.map((c) => ({ + ...c, + birthday: c.birthday || '', + })); + } } diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 6f66d295b..1b6d93249 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -428,6 +428,9 @@ const APP_CONFIGS = [ PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, PUBLIC_TODO_BACKEND_URL: (env) => env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`, + // Cross-app integration: Contacts service for birthdays + PUBLIC_CONTACTS_API_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`, + PUBLIC_CONTACTS_WEB_URL: () => 'http://localhost:5184', }, }, From c4fe9ea192c8d1ec4ae77197815632d4b8b93fd4 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:07:52 +0100 Subject: [PATCH 34/69] refactor(calendar): extract shared constants and event filtering utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of calendar refactoring: - Create calendarConstants.ts with HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES, etc. - Create eventFiltering.ts with reusable filter functions: - getVisibleTimedEvents, getVisibleAllDayEvents, getVisibleOverflowEvents - filterByVisibleCalendars, filterByHourRange, getEventMinutes - Update WeekView, DayView, MultiDayView, MonthView to use new utilities - Remove ~200 lines of duplicated filtering logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DayView.svelte | 101 ++++------- .../lib/components/calendar/MonthView.svelte | 20 ++- .../components/calendar/MultiDayView.svelte | 97 ++++------ .../lib/components/calendar/WeekView.svelte | 97 ++++------ .../web/src/lib/utils/calendarConstants.ts | 60 +++++++ .../apps/web/src/lib/utils/eventFiltering.ts | 165 ++++++++++++++++++ 6 files changed, 335 insertions(+), 205 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/utils/calendarConstants.ts create mode 100644 apps/calendar/apps/web/src/lib/utils/eventFiltering.ts 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 765b26413..e8dc910f3 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -13,6 +13,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; @@ -29,9 +36,9 @@ let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); - // Constants - const HOUR_HEIGHT = 60; // pixels per hour - const SNAP_MINUTES = 15; // snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const SNAP_MINUTES = SNAP_INTERVAL_MINUTES; // Use composables for hour filtering and time indicator const visibleHours = useVisibleHours(); @@ -48,75 +55,37 @@ let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Get timed events, filtering out those outside visible range when hour filter is enabled - let timedEvents = $derived.by(() => { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; - }); + let timedEvents = $derived( + getVisibleTimedEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ) + ); // Get events that are completely outside the visible time range - let overflowEvents = $derived.by(() => { + let overflowEvents = $derived.by((): OverflowEvents => { if (!settingsStore.filterHoursEnabled) { - return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] }; + return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); }); - let allDayEvents = $derived.by(() => { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); - }); + let allDayEvents = $derived( + getVisibleAllDayEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars + ) + ); // Get display mode for an event (per-event override takes precedence over global setting) function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' { 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 22a3cc3ae..5b205e283 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -30,6 +30,7 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; import { _ } from 'svelte-i18n'; + import { filterByVisibleCalendars } from '$lib/utils/eventFiltering'; import type { CalendarEvent } from '@calendar/shared'; @@ -194,17 +195,18 @@ // ============================================================================ // Event Handlers // ============================================================================ - function getEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => visibleCalendarIds.has(e.calendarId)) - .slice(0, 3); // Max 3 events shown + function getEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ).slice(0, 3); // Max 3 events shown } - function getAllEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore.getEventsForDay(day).filter((e) => visibleCalendarIds.has(e.calendarId)); + function getAllEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } function handleDayClick(day: Date, e: MouseEvent) { 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 ee0a54037..c033a8a0a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -11,6 +11,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -27,9 +34,9 @@ import { de, enUS, fr, es, it } from 'date-fns/locale'; import { locale } from 'svelte-i18n'; - // Constants - const HOUR_HEIGHT = 60; // px - should match CSS --hour-height - const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES; import type { CalendarEvent } from '@calendar/shared'; @@ -119,75 +126,35 @@ // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; - function getEventsForDay(day: Date) { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - // If hour filtering is enabled, only show events that overlap with visible range - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; + function getEventsForDay(day: Date): CalendarEvent[] { + return getVisibleTimedEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ); } - // Get events that are completely outside the visible time range - function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + function getOverflowEventsForDay(day: Date): OverflowEvents { if (!settingsStore.filterHoursEnabled) { return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); } - function getAllDayEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); + function getAllDayEventsForDay(day: Date): CalendarEvent[] { + return getVisibleAllDayEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } // Get display mode for an event (per-event override takes precedence over global setting) 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 0ee5a07cb..b25c9765a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -13,6 +13,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; @@ -41,9 +48,9 @@ let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); - // Constants - const HOUR_HEIGHT = 60; // px - should match CSS --hour-height - const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES; // Get date-fns locale based on current app locale const dateLocales = { de, en: enUS, fr, es, it }; @@ -147,75 +154,35 @@ selectedBirthday = null; } - function getEventsForDay(day: Date) { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - // If hour filtering is enabled, only show events that overlap with visible range - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; + function getEventsForDay(day: Date): CalendarEvent[] { + return getVisibleTimedEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ); } - // Get events that are completely outside the visible time range - function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + function getOverflowEventsForDay(day: Date): OverflowEvents { if (!settingsStore.filterHoursEnabled) { return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); } - function getAllDayEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); + function getAllDayEventsForDay(day: Date): CalendarEvent[] { + return getVisibleAllDayEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } // Get display mode for an event (per-event override takes precedence over global setting) diff --git a/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts b/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts new file mode 100644 index 000000000..7d91e2dfa --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts @@ -0,0 +1,60 @@ +/** + * Shared calendar constants + * Single source of truth for magic numbers used across calendar views + */ + +/** + * Height of one hour in pixels (should match CSS --hour-height variable) + */ +export const HOUR_HEIGHT_PX = 60; + +/** + * Snap interval for drag/drop and resize operations in minutes + */ +export const SNAP_INTERVAL_MINUTES = 15; + +/** + * Default event duration in minutes when creating quick events + */ +export const DEFAULT_EVENT_DURATION_MINUTES = 60; + +/** + * Minimum event height as percentage of visible hours + */ +export const MIN_EVENT_HEIGHT_PERCENT = 1.5; + +/** + * Maximum number of event dots to show in month view cells + */ +export const MAX_EVENT_DOTS = 5; + +/** + * Days buffer for infinite scroll in date strip + */ +export const DATE_STRIP_BUFFER_DAYS = 60; + +/** + * Default visible hours range + */ +export const DEFAULT_DAY_START_HOUR = 0; +export const DEFAULT_DAY_END_HOUR = 24; + +/** + * Week starts on (0 = Sunday, 1 = Monday) + */ +export const DEFAULT_WEEK_STARTS_ON = 1; + +/** + * All constants as a single object for convenient destructuring + */ +export const CALENDAR_CONSTANTS = { + HOUR_HEIGHT_PX, + SNAP_INTERVAL_MINUTES, + DEFAULT_EVENT_DURATION_MINUTES, + MIN_EVENT_HEIGHT_PERCENT, + MAX_EVENT_DOTS, + DATE_STRIP_BUFFER_DAYS, + DEFAULT_DAY_START_HOUR, + DEFAULT_DAY_END_HOUR, + DEFAULT_WEEK_STARTS_ON, +} as const; diff --git a/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts new file mode 100644 index 000000000..5b0e4dcad --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts @@ -0,0 +1,165 @@ +/** + * Event Filtering Utilities + * Reusable functions for filtering calendar events by visibility, time range, etc. + */ + +import type { CalendarEvent } from '@calendar/shared'; +import type { Calendar } from '@calendar/shared'; +import { toDate } from './eventDateHelpers'; + +/** + * Create a Set of visible calendar IDs for efficient lookup + */ +export function getVisibleCalendarIds(visibleCalendars: Calendar[]): Set { + return new Set(visibleCalendars.map((c) => c.id)); +} + +/** + * Filter events to only include those from visible calendars + */ +export function filterByVisibleCalendars( + events: CalendarEvent[], + visibleCalendars: Calendar[] +): CalendarEvent[] { + const visibleIds = getVisibleCalendarIds(visibleCalendars); + return events.filter((e) => visibleIds.has(e.calendarId)); +} + +/** + * Filter events to only include timed (non-all-day) events + */ +export function filterTimedEvents(events: CalendarEvent[]): CalendarEvent[] { + return events.filter((e) => !e.isAllDay); +} + +/** + * Filter events to only include all-day events + */ +export function filterAllDayEvents(events: CalendarEvent[]): CalendarEvent[] { + return events.filter((e) => e.isAllDay); +} + +/** + * Get event time in minutes from midnight + */ +export function getEventMinutes(event: CalendarEvent): { start: number; end: number } { + const start = toDate(event.startTime); + const end = toDate(event.endTime); + return { + start: start.getHours() * 60 + start.getMinutes(), + end: end.getHours() * 60 + end.getMinutes(), + }; +} + +/** + * Check if an event overlaps with a given time range (in minutes from midnight) + */ +export function eventOverlapsTimeRange( + event: CalendarEvent, + startMinutes: number, + endMinutes: number +): boolean { + const { start: eventStart, end: eventEnd } = getEventMinutes(event); + return eventStart < endMinutes && eventEnd > startMinutes; +} + +/** + * Filter timed events that overlap with a visible hour range + */ +export function filterByHourRange( + events: CalendarEvent[], + dayStartHour: number, + dayEndHour: number +): CalendarEvent[] { + const startMinutes = dayStartHour * 60; + const endMinutes = dayEndHour * 60; + return events.filter((event) => eventOverlapsTimeRange(event, startMinutes, endMinutes)); +} + +/** + * Result type for overflow events + */ +export interface OverflowEvents { + before: CalendarEvent[]; + after: CalendarEvent[]; +} + +/** + * Get events that are outside the visible hour range + * Returns events that end before the visible range starts (before) + * and events that start after the visible range ends (after) + */ +export function getOverflowEvents( + events: CalendarEvent[], + dayStartHour: number, + dayEndHour: number +): OverflowEvents { + const startMinutes = dayStartHour * 60; + const endMinutes = dayEndHour * 60; + + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + for (const event of events) { + const { start: eventStart, end: eventEnd } = getEventMinutes(event); + + if (eventEnd <= startMinutes) { + before.push(event); + } else if (eventStart >= endMinutes) { + after.push(event); + } + } + + return { before, after }; +} + +/** + * Combined filter: Get visible timed events for a day with optional hour filtering + */ +export function getVisibleTimedEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[], + options?: { + filterHoursEnabled?: boolean; + dayStartHour?: number; + dayEndHour?: number; + } +): CalendarEvent[] { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + filtered = filterTimedEvents(filtered); + + if ( + options?.filterHoursEnabled && + options.dayStartHour !== undefined && + options.dayEndHour !== undefined + ) { + filtered = filterByHourRange(filtered, options.dayStartHour, options.dayEndHour); + } + + return filtered; +} + +/** + * Combined filter: Get visible all-day events for a day + */ +export function getVisibleAllDayEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[] +): CalendarEvent[] { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + return filterAllDayEvents(filtered); +} + +/** + * Combined filter: Get overflow events for visible calendars + */ +export function getVisibleOverflowEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[], + dayStartHour: number, + dayEndHour: number +): OverflowEvents { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + filtered = filterTimedEvents(filtered); + return getOverflowEvents(filtered, dayStartHour, dayEndHour); +} From 026c1654e3a9acacd79f876249e4b684f814432e Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:13:22 +0100 Subject: [PATCH 35/69] fix(contacts): resolve Svelte 5 hydration error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move type exports from .svelte files to separate .types.ts files (FilterDropdown, CommandBar) to prevent SSR hydration issues - Replace direct NetworkGraph component import in network store with TypeScript interface to avoid SSR component instantiation - Add missing shared packages to vite.config.ts ssr.noExternal and optimizeDeps.exclude (splitscreen, i18n, profile-ui, etc.) The hydration error "Cannot read properties of undefined (reading 'call')" was caused by Svelte 5's stricter handling of component imports in .svelte.ts files and type exports from .svelte files during SSR. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/lib/stores/network.svelte.ts | 13 +- apps/contacts/apps/web/vite.config.ts | 14 + .../src/command-bar/CommandBar.svelte | 24 +- .../src/command-bar/CommandBar.types.ts | 22 + packages/shared-ui/src/command-bar/index.ts | 2 +- .../src/molecules/FilterDropdown.svelte | 716 ++++++++++++++++++ .../src/molecules/FilterDropdown.types.ts | 8 + packages/shared-ui/src/molecules/index.ts | 2 + 8 files changed, 774 insertions(+), 27 deletions(-) create mode 100644 packages/shared-ui/src/command-bar/CommandBar.types.ts create mode 100644 packages/shared-ui/src/molecules/FilterDropdown.svelte create mode 100644 packages/shared-ui/src/molecules/FilterDropdown.types.ts diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts index 9410ec1c6..f0d2f8ea2 100644 --- a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -17,14 +17,21 @@ import type { SimulationNode as SharedSimulationNode, SimulationLink as SharedSimulationLink, } from '@manacore/shared-ui'; -import { NetworkGraph } from '@manacore/shared-ui'; // Re-export types from shared-ui for convenience export type SimulationNode = SharedSimulationNode; export type SimulationLink = SharedSimulationLink; +// Interface for NetworkGraph component zoom methods +interface NetworkGraphZoomMethods { + zoomIn(): void; + zoomOut(): void; + resetZoom(): void; + focusOnSelectedNode(): void; +} + // Graph component reference for zoom controls -let graphComponentRef: NetworkGraph | null = null; +let graphComponentRef: NetworkGraphZoomMethods | null = null; // localStorage key for toolbar state const TOOLBAR_STORAGE_KEY = 'network-toolbar-state'; @@ -215,7 +222,7 @@ export const networkStore = { /** * Register graph component reference for zoom controls */ - setGraphComponent(component: NetworkGraph | null) { + setGraphComponent(component: NetworkGraphZoomMethods | null) { graphComponentRef = component; }, diff --git a/apps/contacts/apps/web/vite.config.ts b/apps/contacts/apps/web/vite.config.ts index 2c048d22b..12b3a0503 100644 --- a/apps/contacts/apps/web/vite.config.ts +++ b/apps/contacts/apps/web/vite.config.ts @@ -23,6 +23,13 @@ export default defineConfig({ '@manacore/shared-branding', '@manacore/shared-subscription-ui', '@manacore/shared-utils', + '@manacore/shared-splitscreen', + '@manacore/shared-i18n', + '@manacore/shared-profile-ui', + '@manacore/shared-tags', + '@manacore/shared-help-types', + '@manacore/shared-help-content', + '@manacore/shared-help-ui', ], }, optimizeDeps: { @@ -40,6 +47,13 @@ export default defineConfig({ '@manacore/shared-branding', '@manacore/shared-subscription-ui', '@manacore/shared-utils', + '@manacore/shared-splitscreen', + '@manacore/shared-i18n', + '@manacore/shared-profile-ui', + '@manacore/shared-tags', + '@manacore/shared-help-types', + '@manacore/shared-help-content', + '@manacore/shared-help-ui', ], }, }); diff --git a/packages/shared-ui/src/command-bar/CommandBar.svelte b/packages/shared-ui/src/command-bar/CommandBar.svelte index 1130c62a1..fcf39c812 100644 --- a/packages/shared-ui/src/command-bar/CommandBar.svelte +++ b/packages/shared-ui/src/command-bar/CommandBar.svelte @@ -1,5 +1,6 @@ + +
+ + + + {#if isOpen} + + + + +
+ + {#if showSearch} +
+ + + + + {#if searchQuery} + + {/if} +
+ {/if} + + +
+ {#if filteredOptions.length === 0} +
Keine Ergebnisse
+ {:else} + {#each [...groupedOptions] as [groupName, groupOptions], groupIndex} + {#if groupName} +
{groupName}
+ {/if} + {#each groupOptions as option, optionIndex} + {@const flatIndex = selectableOptions.indexOf(option)} + + {/each} + {/each} + {/if} +
+ + + {#if multiSelect && Array.isArray(value) && value.length > 0} + + {/if} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/molecules/FilterDropdown.types.ts b/packages/shared-ui/src/molecules/FilterDropdown.types.ts new file mode 100644 index 000000000..39e45266c --- /dev/null +++ b/packages/shared-ui/src/molecules/FilterDropdown.types.ts @@ -0,0 +1,8 @@ +export interface FilterDropdownOption { + value: string; + label: string; + icon?: string; + disabled?: boolean; + divider?: boolean; + group?: string; +} diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index d9ef0b976..25d193845 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -3,7 +3,9 @@ export { default as Input } from './Input.svelte'; export { default as Select } from './Select.svelte'; export { default as Textarea } from './Textarea.svelte'; export { default as Checkbox } from './Checkbox.svelte'; +export { default as FilterDropdown } from './FilterDropdown.svelte'; export type { SelectOption } from './Select.types'; +export type { FilterDropdownOption } from './FilterDropdown.types'; // Stats components export { GlassCard, StatRow } from './stats'; From 5bf275d9d058a987f8d1f2e77f661f39078548ce Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:13:58 +0100 Subject: [PATCH 36/69] refactor(calendar): add comprehensive drag/drop composables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create reusable composables for calendar event and task drag/drop: - useEventDragDrop: Event drag and resize with document-level listeners - useTaskDragDrop: Task drag and resize (updated to match new API) - useSidebarDrop: Sidebar task drop handling - useCalendarKeyboard: Keyboard shortcut handling (Escape to cancel) These composables extract ~600 lines of duplicated logic from WeekView, DayView, and MultiDayView. Integration into views will be done in a follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/lib/composables/index.ts | 22 +- .../composables/useCalendarKeyboard.svelte.ts | 41 ++ .../composables/useEventDragDrop.svelte.ts | 427 ++++++++++++++++++ .../lib/composables/useSidebarDrop.svelte.ts | 131 ++++++ .../lib/composables/useTaskDragDrop.svelte.ts | 359 ++++++++------- 5 files changed, 807 insertions(+), 173 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts diff --git a/apps/calendar/apps/web/src/lib/composables/index.ts b/apps/calendar/apps/web/src/lib/composables/index.ts index 553f49eac..35ee3f82c 100644 --- a/apps/calendar/apps/web/src/lib/composables/index.ts +++ b/apps/calendar/apps/web/src/lib/composables/index.ts @@ -3,6 +3,26 @@ * Reusable logic extracted from components */ +// Visible hours and time indicator +export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte'; + +// Event drag/drop and resize (comprehensive composable) +export { + useEventDragDrop, + type EventDragDropConfig, + type EventDragState, + type EventResizeState, +} from './useEventDragDrop.svelte'; + +// Task drag/drop and resize +export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte'; + +// Sidebar task drop handling +export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte'; + +// Keyboard handling +export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte'; + +// Legacy exports (kept for backwards compatibility, may be removed later) export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte'; export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte'; -export { useTaskDragDrop } from './useTaskDragDrop.svelte'; diff --git a/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts new file mode 100644 index 000000000..3c229551d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useCalendarKeyboard.svelte.ts @@ -0,0 +1,41 @@ +/** + * Calendar Keyboard Handling Composable + * Handles keyboard shortcuts for calendar views (e.g., Escape to cancel drag/resize) + */ + +export interface CancellableOperation { + /** Check if operation is active */ + isActive: () => boolean; + /** Cancel the operation */ + cancel: () => void; +} + +/** + * Creates a keyboard handler that cancels operations on Escape key + * Automatically sets up and cleans up the event listener via $effect + * + * @param operations - Array of operations that can be cancelled (e.g., drag/drop, resize) + */ +export function useCalendarKeyboard(operations: CancellableOperation[]) { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + // Check if any operation is active + const activeOperation = operations.find((op) => op.isActive()); + if (activeOperation) { + e.preventDefault(); + activeOperation.cancel(); + } + } + } + + // Setup listener - call this in $effect + function setup() { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + + return { + setup, + handleKeyDown, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts new file mode 100644 index 000000000..345e0b4f7 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.svelte.ts @@ -0,0 +1,427 @@ +/** + * Event Drag & Drop + Resize Composable + * Extracts duplicated drag/resize logic from WeekView, DayView, MultiDayView + */ + +import type { CalendarEvent } from '@calendar/shared'; +import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; +import { eventsStore } from '$lib/stores/events.svelte'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + +export interface EventDragDropConfig { + /** Reference to the container element for position calculations */ + containerEl: HTMLElement | null; + /** Array of visible days (for multi-day views) or single day (for day view) */ + days: Date[]; + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Last visible hour (for filtered hours mode) */ + lastVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Height of one hour in pixels */ + hourHeight: number; + /** Minutes per snap interval (default: 15) */ + snapMinutes?: number; + /** Function to convert minutes to percentage position */ + minutesToPercent: (minutes: number) => number; +} + +export interface EventDragState { + isDragging: boolean; + draggedEvent: CalendarEvent | null; + dragTargetDay: Date | null; + dragPreviewTop: number; + dragPreviewHeight: number; + hasMoved: boolean; +} + +export interface EventResizeState { + isResizing: boolean; + resizeEvent: CalendarEvent | null; + resizeEdge: 'top' | 'bottom'; + resizePreviewTop: number; + resizePreviewHeight: number; +} + +export function useEventDragDrop(getConfig: () => EventDragDropConfig) { + // ========== Drag State ========== + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragOffsetMinutes = $state(0); + let dragTargetDay = $state(null); + let dragPreviewTop = $state(0); + let dragPreviewHeight = $state(0); + + // ========== Resize State ========== + let isResizing = $state(false); + let resizeEvent = $state(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeOriginalStart = $state(null); + let resizeOriginalEnd = $state(null); + let resizePreviewTop = $state(0); + let resizePreviewHeight = $state(0); + let resizeOffsetMinutes = $state(0); + + // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) + let hasMoved = $state(false); + + // ========== Helper Functions ========== + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function snapToGrid(minutes: number): number { + const snap = getSnapMinutes(); + return Math.round(minutes / snap) * snap; + } + + /** + * Get day from X coordinate (for multi-day views) + */ + function getDayFromX(clientX: number): Date | null { + const config = getConfig(); + if (!config.containerEl) return null; + + const rect = config.containerEl.getBoundingClientRect(); + const relativeX = clientX - rect.left; + const dayWidth = rect.width / config.days.length; + const dayIndex = Math.floor(relativeX / dayWidth); + + if (dayIndex >= 0 && dayIndex < config.days.length) { + return config.days[dayIndex]; + } + return null; + } + + /** + * Get minutes from Y coordinate + */ + function getMinutesFromY(clientY: number): number { + const config = getConfig(); + if (!config.containerEl) return 0; + + const rect = config.containerEl.getBoundingClientRect(); + const scrollTop = config.containerEl.parentElement?.scrollTop || 0; + const relativeY = clientY - rect.top + scrollTop; + + // Account for hidden early hours + const visibleMinutes = + (relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60; + const totalMinutes = visibleMinutes + config.firstVisibleHour * 60; + + // Snap to interval + return snapToGrid(totalMinutes); + } + + // ========== Drag Functions ========== + + function startDrag(event: CalendarEvent, e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const config = getConfig(); + + isDragging = true; + draggedEvent = event; + hasMoved = false; + + const start = toDate(event.startTime); + const end = toDate(event.endTime); + const duration = differenceInMinutes(end, start); + + // Calculate initial preview position + const startMinutes = start.getHours() * 60 + start.getMinutes(); + dragPreviewTop = config.minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + dragTargetDay = start; + + // Calculate offset from event start to click position + const clickMinutes = getMinutesFromY(e.clientY); + dragOffsetMinutes = clickMinutes - startMinutes; + + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); + } + + function handleDragMove(e: PointerEvent) { + if (!isDragging || !draggedEvent) return; + + const config = getConfig(); + hasMoved = true; + + // Calculate new position + const newDay = getDayFromX(e.clientX); + const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; + + // Clamp to valid range + const clampedMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(config.lastVisibleHour * 60 - 15, newMinutes) + ); + + // Update preview + dragPreviewTop = config.minutesToPercent(clampedMinutes); + if (newDay) { + dragTargetDay = newDay; + } + } + + async function handleDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) { + cleanupDrag(); + return; + } + + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); + const duration = differenceInMinutes(end, start); + + // Calculate new start time + const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; + const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes)); + const newHours = Math.floor(clampedMinutes / 60); + const newMins = clampedMinutes % 60; + + let newStart = new Date(dragTargetDay); + newStart = setHours(newStart, newHours); + newStart = setMinutes(newStart, newMins); + + const newEnd = addMinutes(newStart, duration); + + // Update event via store + if (eventsStore.isDraftEvent(draggedEvent.id)) { + eventsStore.updateDraftEvent({ + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } else { + await eventsStore.updateEvent(draggedEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } + + cleanupDrag(); + } + + function cleanupDrag() { + isDragging = false; + draggedEvent = null; + dragTargetDay = null; + hasMoved = false; + } + + // ========== Resize Functions ========== + + function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const config = getConfig(); + + isResizing = true; + resizeEvent = event; + resizeEdge = edge; + hasMoved = false; + + const start = toDate(event.startTime); + const end = toDate(event.endTime); + + resizeOriginalStart = start; + resizeOriginalEnd = end; + + // Set initial preview + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + const duration = differenceInMinutes(end, start); + resizePreviewTop = config.minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + + // Calculate offset between snapped click position and actual event boundary + const clickMinutes = getMinutesFromY(e.clientY); + if (edge === 'top') { + resizeOffsetMinutes = clickMinutes - startMinutes; + } else { + resizeOffsetMinutes = clickMinutes - endMinutes; + } + + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); + } + + function handleResizeMove(e: PointerEvent) { + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return; + + const config = getConfig(); + hasMoved = true; + + const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping when drag starts + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + const originalStartMinutes = + resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + if (resizeEdge === 'bottom') { + // Resize from bottom - change end time + const newEndMinutes = Math.max( + originalStartMinutes + 15, + Math.min(config.lastVisibleHour * 60, adjustedMinutes) + ); + const newDuration = newEndMinutes - originalStartMinutes; + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } else { + // Resize from top - change start time + const newStartMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(originalEndMinutes - 15, adjustedMinutes) + ); + const newDuration = originalEndMinutes - newStartMinutes; + resizePreviewTop = config.minutesToPercent(newStartMinutes); + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } + } + + async function handleResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) { + cleanupResize(); + return; + } + + const config = getConfig(); + const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + const originalStartMinutes = + resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + let newStart = resizeOriginalStart; + let newEnd = resizeOriginalEnd; + + if (resizeEdge === 'bottom') { + const newEndMinutes = Math.max( + originalStartMinutes + 15, + Math.min(config.lastVisibleHour * 60, adjustedMinutes) + ); + const newHours = Math.floor(newEndMinutes / 60); + const newMins = newEndMinutes % 60; + newEnd = setHours(new Date(resizeOriginalEnd), newHours); + newEnd = setMinutes(newEnd, newMins); + } else { + const newStartMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(originalEndMinutes - 15, adjustedMinutes) + ); + const newHours = Math.floor(newStartMinutes / 60); + const newMins = newStartMinutes % 60; + newStart = setHours(new Date(resizeOriginalStart), newHours); + newStart = setMinutes(newStart, newMins); + } + + // Update event via store + if (eventsStore.isDraftEvent(resizeEvent.id)) { + eventsStore.updateDraftEvent({ + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } else { + await eventsStore.updateEvent(resizeEvent.id, { + startTime: newStart.toISOString(), + endTime: newEnd.toISOString(), + }); + } + + cleanupResize(); + } + + function cleanupResize() { + isResizing = false; + resizeEvent = null; + resizeOriginalStart = null; + resizeOriginalEnd = null; + resizeOffsetMinutes = 0; + hasMoved = false; + } + + // ========== Combined Cleanup ========== + + function cleanup() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + cleanupDrag(); + cleanupResize(); + } + + /** + * Cancel any active drag/resize operation (e.g., on Escape key) + */ + function cancel() { + if (isDragging || isResizing) { + cleanup(); + } + } + + return { + // Drag state (reactive getters) + get isDragging() { + return isDragging; + }, + get draggedEvent() { + return draggedEvent; + }, + get dragTargetDay() { + return dragTargetDay; + }, + get dragPreviewTop() { + return dragPreviewTop; + }, + get dragPreviewHeight() { + return dragPreviewHeight; + }, + + // Resize state (reactive getters) + get isResizing() { + return isResizing; + }, + get resizeEvent() { + return resizeEvent; + }, + get resizeEdge() { + return resizeEdge; + }, + get resizePreviewTop() { + return resizePreviewTop; + }, + get resizePreviewHeight() { + return resizePreviewHeight; + }, + + // Shared state + get hasMoved() { + return hasMoved; + }, + + // Reset hasMoved after click handling + resetHasMoved() { + hasMoved = false; + }, + + // Methods + startDrag, + startResize, + cancel, + cleanup, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts new file mode 100644 index 000000000..b03aa49bc --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useSidebarDrop.svelte.ts @@ -0,0 +1,131 @@ +/** + * Sidebar Task Drop Composable + * Handles dropping tasks from sidebar into calendar day columns + */ + +import { todosStore } from '$lib/stores/todos.svelte'; +import { format } from 'date-fns'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + +export interface SidebarDropConfig { + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Minutes per snap interval (default: 15) */ + snapMinutes?: number; +} + +export function useSidebarDrop(getConfig: () => SidebarDropConfig) { + // Track active drop target (for visual feedback) + let dropTarget = $state<{ day: Date; y: number } | null>(null); + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function formatTime(hours: number, minutes: number): string { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + /** + * Handle dragover event on a day column + */ + function handleDragOver(e: DragEvent, day: Date) { + e.preventDefault(); + if (!e.dataTransfer) return; + + // Check if this is a sidebar task drag + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + + e.dataTransfer.dropEffect = 'move'; + dropTarget = { day, y: e.clientY }; + } + + /** + * Handle dragleave event + */ + function handleDragLeave(e: DragEvent) { + // Only clear if leaving the column entirely + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + dropTarget = null; + } + } + + /** + * Handle drop event on a day column + */ + async function handleDrop(e: DragEvent, day: Date) { + e.preventDefault(); + dropTarget = null; + + if (!e.dataTransfer) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + const config = getConfig(); + + // Calculate drop time from Y position + const dayColumn = (e.target as HTMLElement).closest('.day-column'); + if (!dayColumn) return; + + const rect = dayColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snapMinutes = getSnapMinutes(); + const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; + const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = formatTime(hours, minutes); + + // Calculate end time + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = formatTime(endHours, endMins); + + // Update the task with scheduled time + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(day, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } + } + + /** + * Clear drop target (use when component unmounts or for manual cleanup) + */ + function clearDropTarget() { + dropTarget = null; + } + + return { + // State (reactive getter) + get dropTarget() { + return dropTarget; + }, + + // Methods + handleDragOver, + handleDragLeave, + handleDrop, + clearDropTarget, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts index f090319ec..bef9100b6 100644 --- a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.svelte.ts @@ -1,306 +1,321 @@ /** - * Composable for Task Drag & Drop in Calendar Views - * Handles dragging tasks to reschedule and resizing to change duration + * Task Drag & Drop + Resize Composable + * Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView + * + * Uses document-level event listeners for smooth drag operations across the entire screen. */ -import type { Task, UpdateTaskInput } from '$lib/api/todos'; +import type { Task } from '$lib/stores/todos.svelte'; import { todosStore } from '$lib/stores/todos.svelte'; -import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { format } from 'date-fns'; +import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; -const SNAP_MINUTES = 15; - -interface UseTaskDragDropOptions { - /** Minimum snap interval in minutes */ +export interface TaskDragDropConfig { + /** Reference to the container element for position calculations */ + containerEl: HTMLElement | null; + /** Array of visible days (for multi-day views) or single day (for day view) */ + days: Date[]; + /** First visible hour (for filtered hours mode) */ + firstVisibleHour: number; + /** Total visible hours */ + totalVisibleHours: number; + /** Minutes per snap interval (default: 15) */ snapMinutes?: number; - /** Callback when task is updated */ - onTaskUpdate?: (task: Task) => void; } -export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) { - const snapMinutes = options.snapMinutes ?? SNAP_MINUTES; - - // Drag state - let isDragging = $state(false); +export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) { + // ========== Drag State ========== + let isTaskDragging = $state(false); let draggedTask = $state(null); - let dragStartY = $state(0); - let dragTargetDay = $state(null); - let dragPreviewTop = $state(0); - let dragPreviewHeight = $state(0); + let taskDragTargetDay = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); - // Resize state - let isResizing = $state(false); + // ========== Resize State ========== + let isTaskResizing = $state(false); let resizeTask = $state(null); - let resizeEdge = $state<'top' | 'bottom'>('bottom'); - let resizeStartY = $state(0); - let resizePreviewTop = $state(0); - let resizePreviewHeight = $state(0); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); - // Track if we actually moved + // Track if we actually moved during drag/resize let hasMoved = $state(false); - /** - * Start dragging a task - */ - function startDrag( - task: Task, - e: PointerEvent, - gridElement: HTMLElement, - firstVisibleHour: number, - totalVisibleHours: number - ) { + // ========== Helper Functions ========== + + function getSnapMinutes(): number { + return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES; + } + + function formatTime(hours: number, minutes: number): string { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + // ========== Drag Functions ========== + + function startDrag(task: Task, e: PointerEvent) { e.preventDefault(); - isDragging = true; + + const config = getConfig(); + isTaskDragging = true; draggedTask = task; - dragStartY = e.clientY; hasMoved = false; - // Calculate initial position + // Initialize preview position from task's current time if (task.scheduledStartTime) { const [h, m] = task.scheduledStartTime.split(':').map(Number); - const startMinutes = h * 60 + m - firstVisibleHour * 60; - dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + const startMinutes = h * 60 + m - config.firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100; } - // Calculate height from duration const duration = task.estimatedDuration || 30; - dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; - // Capture pointer - (e.target as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener('pointermove', handleDragMove); + document.addEventListener('pointerup', handleDragEnd); } - /** - * Handle drag move - */ - function onDragMove( - e: PointerEvent, - gridElement: HTMLElement, - day: Date, - firstVisibleHour: number, - totalVisibleHours: number - ) { - if (!isDragging || !draggedTask) return; + function handleDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask) return; + const config = getConfig(); hasMoved = true; - dragTargetDay = day; - const rect = gridElement.getBoundingClientRect(); + // Find which day column we're over + if (config.containerEl) { + const dayColumns = config.containerEl.querySelectorAll('.day-column'); + for (let i = 0; i < dayColumns.length; i++) { + const col = dayColumns[i]; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right) { + taskDragTargetDay = config.days[i]; + break; + } + } + } + + // Calculate vertical position + const targetColumn = config.containerEl?.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); const relativeY = e.clientY - rect.top; - const percentY = (relativeY / rect.height) * 100; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); // Snap to intervals - const minutesPerPercent = (totalVisibleHours * 60) / 100; - const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60; + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snapMinutes = getSnapMinutes(); const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - - dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100; + taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; } - /** - * End drag and update task - */ - async function endDrag(firstVisibleHour: number, totalVisibleHours: number) { - if (!isDragging || !draggedTask || !hasMoved) { - isDragging = false; - draggedTask = null; - dragTargetDay = null; + async function handleDragEnd() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + cleanupDrag(); return; } - // Calculate new time from position - const minutesFromMidnight = - (dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; - const hours = Math.floor(minutesFromMidnight / 60); - const minutes = Math.round(minutesFromMidnight % 60); + const config = getConfig(); - const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + // Calculate new time from position + const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60); + const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = formatTime(hours, minutes); // Calculate end time based on duration const duration = draggedTask.estimatedDuration || 30; - const endMinutes = minutesFromMidnight + duration; - const endHours = Math.floor(endMinutes / 60); - const endMins = Math.round(endMinutes % 60); - const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = formatTime(endHours, endMins); - const updateData: UpdateTaskInput = { - scheduledDate: dragTargetDay - ? format(dragTargetDay, 'yyyy-MM-dd') - : draggedTask.scheduledDate, + await todosStore.updateTodo(draggedTask.id, { + scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined, scheduledStartTime: newStartTime, scheduledEndTime: newEndTime, - }; + }); - const result = await todosStore.updateTodo(draggedTask.id, updateData); - if (result.data) { - options.onTaskUpdate?.(result.data); - } + cleanupDrag(); + } - isDragging = false; + function cleanupDrag() { + isTaskDragging = false; draggedTask = null; - dragTargetDay = null; + taskDragTargetDay = null; hasMoved = false; } - /** - * Start resizing a task - */ - function startResize( - task: Task, - edge: 'top' | 'bottom', - e: PointerEvent, - firstVisibleHour: number, - totalVisibleHours: number - ) { + // ========== Resize Functions ========== + + function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { e.preventDefault(); e.stopPropagation(); - isResizing = true; + + const config = getConfig(); + isTaskResizing = true; resizeTask = task; - resizeEdge = edge; - resizeStartY = e.clientY; + taskResizeEdge = edge; hasMoved = false; // Initialize preview position if (task.scheduledStartTime) { const [h, m] = task.scheduledStartTime.split(':').map(Number); - const startMinutes = h * 60 + m - firstVisibleHour * 60; - resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + const startMinutes = h * 60 + m - config.firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100; } const duration = task.estimatedDuration || 30; - resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; - (e.target as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener('pointermove', handleResizeMove); + document.addEventListener('pointerup', handleResizeEnd); } - /** - * Handle resize move - */ - function onResizeMove( - e: PointerEvent, - gridElement: HTMLElement, - firstVisibleHour: number, - totalVisibleHours: number - ) { - if (!isResizing || !resizeTask) return; + function handleResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask) return; + const config = getConfig(); hasMoved = true; - const rect = gridElement.getBoundingClientRect(); + const targetColumn = config.containerEl?.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); const relativeY = e.clientY - rect.top; const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); - const minutesPerPercent = (totalVisibleHours * 60) / 100; + const minutesPerPercent = (config.totalVisibleHours * 60) / 100; + const snapMinutes = getSnapMinutes(); - if (resizeEdge === 'top') { + if (taskResizeEdge === 'top') { // Adjust start time, keep end fixed - const originalEndPercent = resizePreviewTop + resizePreviewHeight; + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; const rawMinutes = percentY * minutesPerPercent; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; - resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop); + taskResizePreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); } else { // Adjust end time, keep start fixed const rawMinutes = percentY * minutesPerPercent; const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes; - const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; - resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop); + const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); } } - /** - * End resize and update task - */ - async function endResize(firstVisibleHour: number, totalVisibleHours: number) { - if (!isResizing || !resizeTask || !hasMoved) { - isResizing = false; - resizeTask = null; + async function handleResizeEnd() { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + cleanupResize(); return; } + const config = getConfig(); + // Calculate new times from position const startMinutes = - (resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + (taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60; const endMinutes = - ((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) + - firstVisibleHour * 60; + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) + + config.firstVisibleHour * 60; const startHours = Math.floor(startMinutes / 60); const startMins = Math.round(startMinutes % 60); const endHours = Math.floor(endMinutes / 60); const endMins = Math.round(endMinutes % 60); - const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; - const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newStartTime = formatTime(startHours, startMins); + const newEndTime = formatTime(endHours, endMins); const newDuration = Math.round(endMinutes - startMinutes); - const updateData: UpdateTaskInput = { + await todosStore.updateTodo(resizeTask.id, { scheduledStartTime: newStartTime, scheduledEndTime: newEndTime, estimatedDuration: newDuration, - }; + }); - const result = await todosStore.updateTodo(resizeTask.id, updateData); - if (result.data) { - options.onTaskUpdate?.(result.data); - } + cleanupResize(); + } - isResizing = false; + function cleanupResize() { + isTaskResizing = false; resizeTask = null; hasMoved = false; } + // ========== Combined Cleanup ========== + + function cleanup() { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + cleanupDrag(); + cleanupResize(); + } + /** - * Cancel any ongoing drag/resize + * Cancel any active drag/resize operation */ function cancel() { - isDragging = false; - isResizing = false; - draggedTask = null; - resizeTask = null; - dragTargetDay = null; - hasMoved = false; + if (isTaskDragging || isTaskResizing) { + cleanup(); + } } return { - // State getters - get isDragging() { - return isDragging; + // Drag state (reactive getters) + get isTaskDragging() { + return isTaskDragging; }, get draggedTask() { return draggedTask; }, - get dragTargetDay() { - return dragTargetDay; + get taskDragTargetDay() { + return taskDragTargetDay; }, - get dragPreviewTop() { - return dragPreviewTop; + get taskDragPreviewTop() { + return taskDragPreviewTop; }, - get dragPreviewHeight() { - return dragPreviewHeight; + get taskDragPreviewHeight() { + return taskDragPreviewHeight; }, - get isResizing() { - return isResizing; + + // Resize state (reactive getters) + get isTaskResizing() { + return isTaskResizing; }, get resizeTask() { return resizeTask; }, - get resizePreviewTop() { - return resizePreviewTop; + get taskResizeEdge() { + return taskResizeEdge; }, - get resizePreviewHeight() { - return resizePreviewHeight; + get taskResizePreviewTop() { + return taskResizePreviewTop; }, + get taskResizePreviewHeight() { + return taskResizePreviewHeight; + }, + + // Shared state get hasMoved() { return hasMoved; }, // Methods startDrag, - onDragMove, - endDrag, startResize, - onResizeMove, - endResize, cancel, + cleanup, }; } From e0ef15276d0ae24f7c667ba566a47bcf6e949a12 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:17:22 +0100 Subject: [PATCH 37/69] feat(calendar): add minimize button to DateStrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a transparent minimize button at the left edge of the DateStrip that allows quick collapsing to FAB without needing the context menu. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DateStrip.svelte | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte index 13e9783bd..6797fa0a1 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -26,6 +26,10 @@ contextMenu?.show(e.clientX, e.clientY); } + function handleMinimize() { + settingsStore.set('dateStripCollapsed', true); + } + interface Props { isSidebarMode?: boolean; isToolbarExpanded?: boolean; @@ -248,6 +252,13 @@ class:toolbar-expanded={isToolbarExpanded} class:compact={settingsStore.dateStripCompact} > + + +
@@ -714,4 +725,47 @@ .date-strip-wrapper.compact .today-date { font-size: 0.625rem; } + + /* Minimize button */ + .minimize-btn { + position: absolute; + left: 0.5rem; + bottom: 34%; + transform: translateY(50%); + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; + color: hsl(var(--color-muted-foreground)); + pointer-events: auto; + transition: all 0.15s ease; + z-index: 10; + } + + .minimize-btn:hover { + background: hsl(var(--color-muted) / 0.8); + color: hsl(var(--color-foreground)); + } + + .minimize-btn:active { + transform: translateY(50%) scale(0.95); + } + + @media (max-width: 640px) { + .minimize-btn { + left: 0.25rem; + width: 32px; + height: 32px; + } + + .minimize-btn svg { + width: 18px; + height: 18px; + } + } From 668957a30be1faf806358f808b40212f4551509e Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:18:50 +0100 Subject: [PATCH 38/69] refactor(calendar): extract birthday popover logic into composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create useBirthdayPopover composable to eliminate duplicated birthday popover state and handlers across DayView, WeekView, and MonthView. Changes: - Add useBirthdayPopover.svelte.ts composable - Update DayView to use the composable - Update WeekView to use the composable - Update MonthView to use the composable - Export from composables/index.ts This removes ~40 lines of duplicated code across the three views. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/DayView.svelte | 34 ++++--------- .../lib/components/calendar/MonthView.svelte | 24 +++------ .../lib/components/calendar/WeekView.svelte | 32 +++--------- .../apps/web/src/lib/composables/index.ts | 3 ++ .../composables/useBirthdayPopover.svelte.ts | 49 +++++++++++++++++++ 5 files changed, 76 insertions(+), 66 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/composables/useBirthdayPopover.svelte.ts 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 e8dc910f3..5a501906a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -6,12 +6,9 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; - import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte'; + import { birthdaysStore } from '$lib/stores/birthdays.svelte'; import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; - import { - useVisibleHours, - useCurrentTimeIndicator, - } from '$lib/composables/useVisibleHours.svelte'; + import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables'; import { toDate } from '$lib/utils/eventDateHelpers'; import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; import { @@ -102,9 +99,8 @@ let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block')); - // Birthday Popover State - let selectedBirthday = $state(null); - let birthdayPopoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 }); + // Birthday Popover (using composable) + const birthdayPopover = useBirthdayPopover(); // Get birthdays for current day (if enabled in settings) let birthdays = $derived.by(() => { @@ -112,18 +108,6 @@ return birthdaysStore.getBirthdaysForDay(viewStore.currentDate); }); - // Handle birthday click - show popover - function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) { - e.stopPropagation(); - selectedBirthday = birthday; - birthdayPopoverPosition = { x: e.clientX, y: e.clientY }; - } - - // Close birthday popover - function closeBirthdayPopover() { - selectedBirthday = null; - } - // ============================================================================ // Drag & Drop State // ============================================================================ @@ -744,7 +728,7 @@ {#each birthdays as birthday} + {/each} +
+ + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewModePillContextMenu.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePillContextMenu.svelte new file mode 100644 index 000000000..2068219d8 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePillContextMenu.svelte @@ -0,0 +1,104 @@ + + + diff --git a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts index 5fb620daf..96860581c 100644 --- a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts @@ -34,6 +34,7 @@ export interface CalendarAppSettings { dateStripShowMonthDividers: boolean; // Show vertical dividers between months dateStripCompact: boolean; // Use compact/smaller DateStrip dateStripShowWeekNumbers: boolean; // Show week numbers at start of week + dateStripCollapsed: boolean; // Whether DateStrip is minimized to FAB // Birthday settings (cross-app integration with Contacts) showBirthdays: boolean; // Show contact birthdays in calendar @@ -42,6 +43,9 @@ export interface CalendarAppSettings { // UI settings sidebarCollapsed: boolean; + // Quick View Pill settings + quickViewPillViews: CalendarViewType[]; // Views shown in quick switcher + // Event defaults defaultEventDuration: number; // in minutes defaultReminder: number; // in minutes before event @@ -65,11 +69,15 @@ const DEFAULT_SETTINGS: CalendarAppSettings = { dateStripShowMonthDividers: true, dateStripCompact: false, dateStripShowWeekNumbers: false, + dateStripCollapsed: false, // Birthday defaults showBirthdays: true, showBirthdayAge: true, // UI defaults sidebarCollapsed: false, + // Quick View Pill defaults + quickViewPillViews: ['week', 'month', 'agenda'], + // Event defaults defaultEventDuration: 60, defaultReminder: 15, }; @@ -189,6 +197,9 @@ export const settingsStore = { get dateStripShowWeekNumbers() { return settings.dateStripShowWeekNumbers; }, + get dateStripCollapsed() { + return settings.dateStripCollapsed; + }, // Birthday settings get showBirthdays() { return settings.showBirthdays; @@ -205,6 +216,9 @@ export const settingsStore = { get sidebarCollapsed() { return settings.sidebarCollapsed; }, + get quickViewPillViews() { + return settings.quickViewPillViews; + }, get cloudSyncEnabled() { return cloudSyncEnabled; }, From cc28cc739e85ae404224147f3240471481a5acf2 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:29:58 +0100 Subject: [PATCH 41/69] feat(calendar): add birthday calendar and collapsible date strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add virtual birthday calendar to calendars store - Create DateStripFab component for collapsed date strip mode - Add dateStripCollapsed setting support in layout - Adjust InputBar positioning for FAB visibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/calendar/DateStripFab.svelte | 113 ++++++++++++++++++ .../web/src/lib/stores/calendars.svelte.ts | 49 ++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 36 +++++- 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte new file mode 100644 index 000000000..94eedf059 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte @@ -0,0 +1,113 @@ + + +
+ + +
+ + + + diff --git a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts index 1f8fc7515..bf9489a31 100644 --- a/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/calendars.svelte.ts @@ -4,18 +4,42 @@ import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared'; import * as api from '$lib/api/calendars'; +import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays'; +import { settingsStore } from './settings.svelte'; // State let calendars = $state([]); let loading = $state(false); let error = $state(null); +// Virtual birthday calendar (created dynamically based on settings) +const birthdayCalendar: Calendar = { + id: BIRTHDAY_CALENDAR.id, + userId: '', + name: BIRTHDAY_CALENDAR.name, + color: BIRTHDAY_CALENDAR.color, + isDefault: false, + isVisible: true, // Visibility controlled by settingsStore.showBirthdays + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + // Helper to safely get calendars array (Svelte 5 runes safety) function getCalendarsArray(): Calendar[] { const arr = calendars ?? []; return Array.isArray(arr) ? arr : []; } +// Derived: all calendars including virtual birthday calendar +const allCalendars = $derived.by(() => { + const userCalendars = getCalendarsArray(); + // Add virtual birthday calendar if birthdays are enabled in settings + if (settingsStore.showBirthdays) { + return [...userCalendars, { ...birthdayCalendar, isVisible: true }]; + } + return userCalendars; +}); + // Derived: visible calendars const visibleCalendars = $derived(getCalendarsArray().filter((c) => c.isVisible)); @@ -30,6 +54,9 @@ export const calendarsStore = { get calendars() { return calendars; }, + get allCalendars() { + return allCalendars; + }, get visibleCalendars() { return visibleCalendars; }, @@ -42,6 +69,9 @@ export const calendarsStore = { get error() { return error; }, + get birthdayCalendarId() { + return BIRTHDAY_CALENDAR.id; + }, /** * Fetch all calendars @@ -143,7 +173,26 @@ export const calendarsStore = { * Get calendar color by ID (with fallback) */ getColor(id: string) { + // Handle virtual birthday calendar + if (id === BIRTHDAY_CALENDAR.id) { + return BIRTHDAY_CALENDAR.color; + } const calendar = getCalendarsArray().find((c) => c.id === id); return calendar?.color || '#3b82f6'; }, + + /** + * Toggle birthday calendar visibility + * (This updates the settings store, not the calendar itself) + */ + toggleBirthdaysVisibility() { + settingsStore.set('showBirthdays', !settingsStore.showBirthdays); + }, + + /** + * Check if a calendar ID is the virtual birthday calendar + */ + isBirthdayCalendar(id: string) { + return id === BIRTHDAY_CALENDAR.id; + }, }; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index fcc0688c6..52066a92b 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -52,6 +52,7 @@ import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte'; import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte'; import DateStrip from '$lib/components/calendar/DateStrip.svelte'; + import DateStripFab from '$lib/components/calendar/DateStripFab.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; @@ -387,7 +388,11 @@ {#if showCalendarToolbar} - + {#if settingsStore.dateStripCollapsed} + + {:else} + + {/if} {/if} @@ -433,6 +438,7 @@ ? '140px' : '70px'} hasFabRight={showCalendarToolbar && !isSidebarMode} + hasFabLeft={showCalendarToolbar && !isSidebarMode && settingsStore.dateStripCollapsed} />
@@ -517,4 +523,32 @@ flex: 1; min-height: 0; } + + /* Adjust InputBar when FABs are visible (toolbar FAB on right, DateStripFab on left) */ + /* For a centered InputBar with max-width 450px, left edge is at 50% - 225px */ + /* DateStripFab is positioned at: 50% - 225px - 8px gap - 54px fab width */ + /* Note: In sidebar mode, InputBar uses default 700px max-width */ + :global(.quick-input-bar.has-fab-right .input-container), + :global(.quick-input-bar.has-fab-left .input-container) { + max-width: 450px; + } + + /* On smaller screens (<900px), FABs move to fixed positions (left: 1rem, right: 1rem) */ + @media (max-width: 900px) { + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: calc(100% - 140px); /* 54px FAB + padding */ + margin-left: auto; + margin-right: 0; + } + :global(.quick-input-bar.has-fab-left .input-container) { + max-width: calc(100% - 140px); /* 54px FAB + padding */ + margin-left: 0; + margin-right: auto; + } + :global(.quick-input-bar.has-fab-right.has-fab-left .input-container) { + max-width: calc(100% - 200px); /* Both FABs */ + margin-left: auto; + margin-right: auto; + } + } From 48edd85591b92e5988b964dec72ed5d0a04e046b Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:30:24 +0100 Subject: [PATCH 42/69] refactor(calendar): improve agenda and event components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace native select with FilterDropdown in AgendaFilters - Add view options to DateStripContextMenu - Improve EventForm layout and styling - Enhance QuickEventOverlay with better time handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/agenda/AgendaFilters.svelte | 38 ++++--------- .../calendar/DateStripContextMenu.svelte | 13 ++++- .../src/lib/components/event/EventForm.svelte | 53 +++++++++++-------- .../components/event/QuickEventOverlay.svelte | 29 +++++++--- 4 files changed, 76 insertions(+), 57 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte index 15500781d..b1efe167e 100644 --- a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte @@ -1,5 +1,6 @@ @@ -53,15 +54,13 @@
- + onChange={(v) => onRangeChange?.(v as '7' | '30' | 'all')} + placeholder="Zeitraum" + embedded={true} + />
@@ -122,21 +121,6 @@ color: hsl(var(--color-muted-foreground)); } - .range-selector select { - padding: 0.375rem 0.75rem; - border-radius: var(--radius-md); - border: 1px solid hsl(var(--color-border)); - background: hsl(var(--color-surface)); - color: hsl(var(--color-foreground)); - font-size: 0.8125rem; - cursor: pointer; - } - - .range-selector select:focus { - outline: none; - border-color: hsl(var(--color-primary)); - } - @media (max-width: 480px) { .agenda-filters { flex-direction: column; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte index 158aa9116..83b25db47 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte @@ -1,6 +1,6 @@ - -
- -
- {#each availableLetters as letter} -
- -
- {letter} - {groupedContacts[letter].length} -
- - -
- {#each groupedContacts[letter] as contact (contact.id)} -
onContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} - class="alphabet-card" - > - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
- - -
-

{getDisplayName(contact)}

-
- {#if contact.jobTitle && contact.company} - {contact.jobTitle} @ {contact.company} - {:else if contact.company} - {contact.company} - {:else if contact.email} - {contact.email} - {/if} -
- {#if contact.phone || contact.mobile || contact.email} -
- {#if contact.phone || contact.mobile} - - - - - {contact.mobile || contact.phone} - - {/if} - {#if contact.email} - - - - - {contact.email} - - {/if} -
- {/if} -
- - -
- {#if contact.phone || contact.mobile} - e.stopPropagation()} - class="action-btn action-call" - title="Anrufen" - > - - - - - {/if} - {#if contact.email} - e.stopPropagation()} - class="action-btn action-email" - title="E-Mail senden" - > - - - - - {/if} - -
-
- {/each} -
-
- {/each} -
- - -
- {#each alphabet as letter} - - {/each} - {#if availableLetters.includes('#')} - - {/if} -
-
- - diff --git a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte deleted file mode 100644 index de131246c..000000000 --- a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte +++ /dev/null @@ -1,363 +0,0 @@ - - -
- {#each contacts as contact (contact.id)} -
onContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} - class="favorite-card" - > - -
- - - - - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
- - -
-

{getDisplayName(contact)}

- {#if contact.jobTitle} -

{contact.jobTitle}

- {/if} - {#if contact.company} -

{contact.company}

- {/if} - - -
- {#if contact.email} -
- - - - {contact.email} -
- {/if} - {#if contact.phone || contact.mobile} -
- - - - {contact.mobile || contact.phone} -
- {/if} - {#if contact.birthday} -
- - - - {new Date(contact.birthday).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'long', - })} -
- {/if} -
-
- - - -
- {/each} -
- - diff --git a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte deleted file mode 100644 index 2377f9f93..000000000 --- a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte +++ /dev/null @@ -1,324 +0,0 @@ - - -
- {#each contacts as contact (contact.id)} -
onContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} - class="favorite-row" - > - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
- - -
-
-

{getDisplayName(contact)}

- {#if contact.jobTitle || contact.company} -

- {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} -

- {/if} -
- - -
- {#if contact.email} -
- - - - {contact.email} -
- {/if} - {#if contact.phone || contact.mobile} -
- - - - {contact.mobile || contact.phone} -
- {/if} - {#if contact.birthday} -
- - - - {new Date(contact.birthday).toLocaleDateString('de-DE', { - day: 'numeric', - month: 'short', - })} -
- {/if} -
-
- - -
- {#if contact.phone || contact.mobile} - e.stopPropagation()} - class="action-btn action-call" - title="Anrufen" - > - - - - - {/if} - {#if contact.email} - e.stopPropagation()} - class="action-btn action-email" - title="E-Mail senden" - > - - - - - {/if} - -
-
- {/each} -
- - diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte deleted file mode 100644 index 5d5d3214b..000000000 --- a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
- -
- -
- - -
- -
- -
- - -
- -
- - -
- - -
-
-
- - diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte deleted file mode 100644 index 452e4c079..000000000 --- a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- {#each Array(count) as _, i} - - {/each} -
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte deleted file mode 100644 index 528c0bed3..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte +++ /dev/null @@ -1,767 +0,0 @@ - - - - Favoriten - Contacts - - -
- -
-
-
- - - -
-
-

Favoriten

-

- {#if contacts.length === 0} - Markiere Kontakte als Favoriten für schnellen Zugriff - {:else} - {contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''} für schnellen Zugriff - {/if} -

-
-
- - - {#if contacts.length > 0} -
-
-
- - - -
-
- {contacts.length} - Favoriten -
-
-
-
- - - -
-
- {contacts.filter((c) => c.email).length} - Mit E-Mail -
-
-
-
- - - -
-
- {contacts.filter((c) => c.phone || c.mobile).length} - Mit Telefon -
-
-
- {/if} -
- - -
- -
- - - - - {#if searchQuery} - - {/if} -
- - -
- - - -
-
- - {#if error} - - {/if} - - {#if loading} - - {#if viewMode === 'cards'} - - {:else} - - {/if} - {:else if contacts.length === 0} -
-
- - - -
-

Keine Favoriten

-

- Markiere Kontakte als Favoriten, um sie hier schnell wiederzufinden. Klicke einfach auf das - Herz-Symbol bei einem Kontakt. -

- - - - - Zu allen Kontakten - -
- {:else if filteredContacts.length === 0} -
- -

Keine Ergebnisse

-

Keine Favoriten gefunden für "{searchQuery}"

- -
- {:else} - -
- {#if viewMode === 'cards'} - - {:else if viewMode === 'list'} - - {:else} - - {/if} -
- - - - {/if} -
- - From c7a9e88d1339ebe8685a747bf38bd8058eed440b Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:31:12 +0100 Subject: [PATCH 44/69] refactor(contacts): remove network and new contact pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove NetworkToolbar and NetworkToolbarContent components - Remove standalone network visualization page - Remove dedicated new contact page Network view integrated into main contacts; new contact via modal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/lib/components/NetworkToolbar.svelte | 28 - .../components/NetworkToolbarContent.svelte | 305 ------- .../routes/(app)/contacts/new/+page.svelte | 747 ------------------ .../web/src/routes/(app)/network/+page.svelte | 247 ------ 4 files changed, 1327 deletions(-) delete mode 100644 apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte delete mode 100644 apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte delete mode 100644 apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte delete mode 100644 apps/contacts/apps/web/src/routes/(app)/network/+page.svelte diff --git a/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte b/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte deleted file mode 100644 index b2e35bbc5..000000000 --- a/apps/contacts/apps/web/src/lib/components/NetworkToolbar.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte deleted file mode 100644 index 10394c811..000000000 --- a/apps/contacts/apps/web/src/lib/components/NetworkToolbarContent.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - -
- - {#if networkStore.uniqueTags.length > 0} -
- -
- {/if} - - - {#if networkStore.uniqueCompanies.length > 0} -
- -
- {/if} - -
- - -
- - -
- -
- - -
- - - - -
- - - {#if hasActiveFilters} -
- - {/if} - - -
- {networkStore.nodes.length} Kontakte - - {networkStore.links.length} Verbindungen -
-
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte deleted file mode 100644 index 4f5900295..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte +++ /dev/null @@ -1,747 +0,0 @@ - - - - Neuer Kontakt - Contacts - - -
- -
- - - - - -

Neuer Kontakt

-
-
- - -
-
-
- {initials()} -
- -
-

{displayName()}

- {#if company || jobTitle} -

{[jobTitle, company].filter(Boolean).join(' bei ')}

- {/if} -
- - {#if error} - - {/if} - -
{ - e.preventDefault(); - handleSubmit(); - }} - class="form" - > - -
-
-
- - - -
-

Name

-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - - -
-

Kontakt

-
-
- -
- - - - -
-
-
-
- -
- - - - -
-
-
- -
- - - - -
-
-
-
- - -
-
-
- - - -
-

Arbeit

-
-
- - -
-
- - -
-
- - -
-
-
- - - - -
-

Adresse

-
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - - -
-

Notizen

-
- -
- - -
- Abbrechen - -
-
-
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte deleted file mode 100644 index 0749db726..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ /dev/null @@ -1,247 +0,0 @@ - - - - Netzwerk - Contacts - - -
- - {#if networkStore.error} - - {/if} - - -
- {#if networkStore.loading} - - {:else} - - {/if} -
- - - {#if networkStore.selectedNodeId} - - {/if} -
- - From fc3129aaa5235b53f75526a94bb79806d77bc0ad Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:31:36 +0100 Subject: [PATCH 45/69] refactor(contacts): major component and API refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components: - Simplify ContactDetailModal and NewContactModal - Enhance ContactsToolbarContent with expanded functionality - Add AlphabetNavContextMenu for alphabet navigation - Add SocialMediaLinks component for displaying social links - Add SocialMediaFields form component - Add ContactNetworkView as integrated network visualization - Improve skeleton components with shared utilities API & Config: - Add centralized API client module - Refactor contacts API with better error handling - Add social-media configuration module - Update batch and config modules Stores: - Simplify filter store - Update settings and user-settings stores - Clean up view-mode store - Minor auth store updates Routes: - Update layout with simplified navigation - Minor updates to settings, statistics pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/apps/web/package.json | 5 +- apps/contacts/apps/web/src/lib/api/batch.ts | 28 +- apps/contacts/apps/web/src/lib/api/client.ts | 71 +++ apps/contacts/apps/web/src/lib/api/config.ts | 10 +- .../contacts/apps/web/src/lib/api/contacts.ts | 138 +++-- .../components/AlphabetNavContextMenu.svelte | 86 +++ .../lib/components/ContactDetailModal.svelte | 530 +----------------- .../web/src/lib/components/ContactList.svelte | 50 +- .../components/ContactsToolbarContent.svelte | 433 +++++++++++--- .../src/lib/components/NewContactModal.svelte | 286 +--------- .../web/src/lib/components/SearchModal.svelte | 23 +- .../lib/components/SocialMediaLinks.svelte | 151 +++++ .../components/forms/SocialMediaFields.svelte | 369 ++++++++++++ .../components/network/NetworkGraph.svelte | 6 +- .../skeletons/ContactGridSkeleton.svelte | 9 +- .../skeletons/ContactListSkeleton.svelte | 9 +- .../skeletons/DuplicateListSkeleton.svelte | 12 +- .../skeletons/TagGridSkeleton.svelte | 9 +- .../web/src/lib/components/skeletons/index.ts | 7 +- .../web/src/lib/components/skeletons/utils.ts | 19 + .../views/ContactAlphabetView.svelte | 89 ++- .../components/views/ContactGridView.svelte | 4 +- .../views/ContactNetworkView.svelte | 257 +++++++++ .../apps/web/src/lib/config/social-media.ts | 168 ++++++ .../apps/web/src/lib/services/feedback.ts | 3 +- .../apps/web/src/lib/stores/auth.svelte.ts | 5 +- .../apps/web/src/lib/stores/filter.svelte.ts | 67 +-- .../web/src/lib/stores/settings.svelte.ts | 32 +- .../src/lib/stores/user-settings.svelte.ts | 3 +- .../web/src/lib/stores/view-mode.svelte.ts | 11 +- .../apps/web/src/routes/(app)/+layout.svelte | 59 +- .../src/routes/(app)/settings/+page.svelte | 3 +- .../src/routes/(app)/statistics/+page.svelte | 23 +- .../(auth)/forgot-password/+page.svelte | 4 +- 34 files changed, 1808 insertions(+), 1171 deletions(-) create mode 100644 apps/contacts/apps/web/src/lib/api/client.ts create mode 100644 apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/SocialMediaLinks.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/skeletons/utils.ts create mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactNetworkView.svelte create mode 100644 apps/contacts/apps/web/src/lib/config/social-media.ts diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index f274af7a9..735a53d38 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -31,8 +31,6 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", - "@manacore/shared-splitscreen": "workspace:*", - "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -43,7 +41,9 @@ "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", @@ -52,6 +52,7 @@ "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", + "date-fns": "^4.1.0", "lucide-svelte": "^0.556.0", "svelte-i18n": "^4.0.1" }, diff --git a/apps/contacts/apps/web/src/lib/api/batch.ts b/apps/contacts/apps/web/src/lib/api/batch.ts index 5307425ee..2fcf3b956 100644 --- a/apps/contacts/apps/web/src/lib/api/batch.ts +++ b/apps/contacts/apps/web/src/lib/api/batch.ts @@ -1,30 +1,4 @@ -import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; - -async function fetchWithAuth(url: string, options: RequestInit = {}) { - const token = await authStore.getAccessToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); -} +import { fetchWithAuth } from './client'; export interface BatchResult { success: number; diff --git a/apps/contacts/apps/web/src/lib/api/client.ts b/apps/contacts/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..750ed1668 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/client.ts @@ -0,0 +1,71 @@ +/** + * Centralized API client with authentication + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; + +/** + * Make an authenticated API request + * @param url API endpoint (will be prefixed with API_BASE) + * @param options Fetch options + * @returns Parsed JSON response + */ +export async function fetchWithAuth( + url: string, + options: RequestInit = {} +): Promise { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +/** + * Make an authenticated API request without JSON content type + * Used for file uploads (FormData) + */ +export async function fetchWithAuthFormData( + url: string, + options: RequestInit = {} +): Promise { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} diff --git a/apps/contacts/apps/web/src/lib/api/config.ts b/apps/contacts/apps/web/src/lib/api/config.ts index 21c4a43ec..6316da671 100644 --- a/apps/contacts/apps/web/src/lib/api/config.ts +++ b/apps/contacts/apps/web/src/lib/api/config.ts @@ -1,7 +1,13 @@ -import { PUBLIC_BACKEND_URL } from '$env/static/public'; +import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; /** * API Configuration - * Uses environment variable PUBLIC_BACKEND_URL with fallback for development + * Uses environment variables with fallbacks for development */ export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`; + +/** + * Mana Core Auth URL + * Central authentication service URL + */ +export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index ee86bca7c..9ff6ef786 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -1,33 +1,9 @@ import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; +import { MANA_AUTH_URL } from './config'; +import { fetchWithAuth, fetchWithAuthFormData } from './client'; import { createTagsClient, type Tag } from '@manacore/shared-tags'; -async function fetchWithAuth(url: string, options: RequestInit = {}) { - const token = await authStore.getAccessToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); -} - export interface Contact { id: string; userId: string; @@ -63,6 +39,8 @@ export interface Contact { signal?: string | null; discord?: string | null; bluesky?: string | null; + // Tags (populated by API) + tags?: Array<{ id: string; name: string; color: string | null }>; isFavorite: boolean; isArchived: boolean; organizationId?: string | null; @@ -104,9 +82,19 @@ export interface ContactFilters { offset?: number; } +// API Response types +interface ContactResponse { + contact: Contact; +} + +interface ContactListResponse { + contacts: Contact[]; + total: number; +} + // Contacts API export const contactsApi = { - async list(filters: ContactFilters = {}) { + async list(filters: ContactFilters = {}): Promise { const params = new URLSearchParams(); if (filters.search) params.set('search', filters.search); if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); @@ -116,16 +104,16 @@ export const contactsApi = { if (filters.offset) params.set('offset', String(filters.offset)); const query = params.toString(); - return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); + return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); }, async get(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}`); + const response = await fetchWithAuth(`/contacts/${id}`); return response.contact; }, async create(data: Partial): Promise { - const response = await fetchWithAuth('/contacts', { + const response = await fetchWithAuth('/contacts', { method: 'POST', body: JSON.stringify(data), }); @@ -133,7 +121,7 @@ export const contactsApi = { }, async update(id: string, data: Partial): Promise { - const response = await fetchWithAuth(`/contacts/${id}`, { + const response = await fetchWithAuth(`/contacts/${id}`, { method: 'PATCH', body: JSON.stringify(data), }); @@ -147,14 +135,14 @@ export const contactsApi = { }, async toggleFavorite(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/favorite`, { + const response = await fetchWithAuth(`/contacts/${id}/favorite`, { method: 'POST', }); return response.contact; }, async toggleArchive(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/archive`, { + const response = await fetchWithAuth(`/contacts/${id}/archive`, { method: 'POST', }); return response.contact; @@ -164,16 +152,6 @@ export const contactsApi = { // Tags API - Uses central Tags API from mana-core-auth // Contact-tag associations still use the Contacts backend -// Get auth URL dynamically at runtime -function getAuthUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) - .__PUBLIC_MANA_CORE_AUTH_URL__; - return injectedUrl || 'http://localhost:3001'; - } - return 'http://localhost:3001'; -} - // Lazy-initialized tags client let _tagsClient: ReturnType | null = null; @@ -181,7 +159,7 @@ function getTagsClient() { if (!browser) return null; if (!_tagsClient) { _tagsClient = createTagsClient({ - authUrl: getAuthUrl(), + authUrl: MANA_AUTH_URL, getToken: async () => { const token = await authStore.getAccessToken(); return token || ''; @@ -226,19 +204,19 @@ export const tagsApi = { // Contact-tag associations still use Contacts backend async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'POST', }); }, async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'DELETE', }); }, async getForContact(contactId: string): Promise<{ tagIds: string[] }> { - return fetchWithAuth(`/tags/contact/${contactId}`); + return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`); }, // Create default tags via central Tags API @@ -250,44 +228,68 @@ export const tagsApi = { }, }; +// Notes API Response types +interface NotesListResponse { + notes: ContactNote[]; +} + +interface NoteResponse { + note: ContactNote; +} + // Notes API export const notesApi = { - async list(contactId: string) { - return fetchWithAuth(`/contacts/${contactId}/notes`); + async list(contactId: string): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`); }, - async create(contactId: string, data: { content: string; isPinned?: boolean }) { - return fetchWithAuth(`/contacts/${contactId}/notes`, { + async create( + contactId: string, + data: { content: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`, { method: 'POST', body: JSON.stringify(data), }); }, - async update(noteId: string, data: { content?: string; isPinned?: boolean }) { - return fetchWithAuth(`/notes/${noteId}`, { + async update( + noteId: string, + data: { content?: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/notes/${noteId}`, { method: 'PATCH', body: JSON.stringify(data), }); }, - async delete(noteId: string) { - return fetchWithAuth(`/notes/${noteId}`, { + async delete(noteId: string): Promise { + await fetchWithAuth(`/notes/${noteId}`, { method: 'DELETE', }); }, - async togglePin(noteId: string) { - return fetchWithAuth(`/notes/${noteId}/pin`, { + async togglePin(noteId: string): Promise { + return fetchWithAuth(`/notes/${noteId}/pin`, { method: 'POST', }); }, }; +// Activities API Response types +interface ActivitiesListResponse { + activities: ContactActivity[]; +} + +interface ActivityResponse { + activity: ContactActivity; +} + // Activities API export const activitiesApi = { - async list(contactId: string, limit?: number) { + async list(contactId: string, limit?: number): Promise { const params = limit ? `?limit=${limit}` : ''; - return fetchWithAuth(`/contacts/${contactId}/activities${params}`); + return fetchWithAuth(`/contacts/${contactId}/activities${params}`); }, async create( @@ -297,8 +299,8 @@ export const activitiesApi = { description?: string; metadata?: Record; } - ) { - return fetchWithAuth(`/contacts/${contactId}/activities`, { + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/activities`, { method: 'POST', body: JSON.stringify(data), }); @@ -308,25 +310,13 @@ export const activitiesApi = { // Photo API export const photoApi = { async upload(contactId: string, file: File): Promise<{ photoUrl: string }> { - const token = await authStore.getAccessToken(); - const formData = new FormData(); formData.append('photo', file); - const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, { + return fetchWithAuthFormData<{ photoUrl: string }>(`/contacts/${contactId}/photo`, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, body: formData, }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Upload failed' })); - throw new Error(error.message || 'Upload failed'); - } - - return response.json(); }, async delete(contactId: string): Promise { diff --git a/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte new file mode 100644 index 000000000..3c3e9f4d9 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte @@ -0,0 +1,86 @@ + + + diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index e754a190b..386e2f0b2 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -5,6 +5,8 @@ import ContactNotes from './ContactNotes.svelte'; import ContactTasks from './ContactTasks.svelte'; import { ContactDetailSkeleton } from '$lib/components/skeletons'; + import SocialMediaFields from './forms/SocialMediaFields.svelte'; + import SocialMediaLinks from './SocialMediaLinks.svelte'; interface Props { contactId: string; @@ -50,7 +52,6 @@ let signal = $state(''); let discord = $state(''); let bluesky = $state(''); - let socialSectionOpen = $state(false); const initials = $derived(() => { if (!contact) return '?'; @@ -100,22 +101,6 @@ signal = contact.signal || ''; discord = contact.discord || ''; bluesky = contact.bluesky || ''; - // Auto-open social section if any social field has data - socialSectionOpen = !!( - contact.linkedin || - contact.twitter || - contact.facebook || - contact.instagram || - contact.xing || - contact.github || - contact.youtube || - contact.tiktok || - contact.telegram || - contact.whatsapp || - contact.signal || - contact.discord || - contact.bluesky - ); } function getDisplayName() { @@ -538,213 +523,22 @@ - -
- - {#if socialSectionOpen} - - {/if} -
+ +
@@ -1108,176 +902,7 @@ {/if} - {#if contact.linkedin || contact.twitter || contact.facebook || contact.instagram || contact.xing || contact.github || contact.youtube || contact.tiktok || contact.telegram || contact.whatsapp || contact.signal || contact.discord || contact.bluesky} -
-
-
- - - -
-

Social Media

-
- -
- {/if} + @@ -1981,122 +1606,5 @@ .quick-actions { gap: 1rem; } - - .social-grid { - grid-template-columns: 1fr; - } - } - - /* Social Media Section */ - .section-header-toggle { - width: 100%; - background: none; - border: none; - cursor: pointer; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - margin-bottom: 0; - } - - .section-header-toggle:hover { - background: hsl(var(--color-surface-hover) / 0.3); - margin: 0 -1rem; - padding: 0 1rem 0.625rem; - width: calc(100% + 2rem); - border-radius: 0.5rem 0.5rem 0 0; - } - - .chevron-icon { - width: 1rem; - height: 1rem; - margin-left: auto; - color: hsl(var(--color-muted-foreground)); - transition: transform 0.2s ease; - } - - .chevron-open { - transform: rotate(180deg); - } - - .social-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; - padding-top: 0.75rem; - } - - .social-label { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .social-icon-label { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border-radius: 0.25rem; - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - text-transform: lowercase; - } - - /* Social Links in View Mode */ - .social-links-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; - } - - .social-link { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.625rem; - background: hsl(var(--color-muted) / 0.5); - border-radius: 0.5rem; - text-decoration: none; - color: inherit; - transition: all 0.2s ease; - } - - .social-link:hover:not(.social-link-static) { - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - } - - .social-link-static { - cursor: default; - } - - .social-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - border-radius: 0.375rem; - background: hsl(var(--color-primary) / 0.15); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - flex-shrink: 0; - } - - .social-link-text { - font-size: 0.8125rem; - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - @media (max-width: 480px) { - .social-links-grid { - grid-template-columns: 1fr; - } } diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte index 63ff53273..48a20a6e8 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactList.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -5,12 +5,17 @@ import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { goto } from '$app/navigation'; - import ContactListView from '$lib/components/views/ContactListView.svelte'; import ContactGridView from '$lib/components/views/ContactGridView.svelte'; import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte'; - import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons'; + import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte'; + import { + ContactListSkeleton, + ContactGridSkeleton, + NetworkGraphSkeleton, + } from '$lib/components/skeletons'; import { batchApi } from '$lib/api/batch'; import { toasts } from '$lib/stores/toast'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; // Infinite scroll let intersectionObserver: IntersectionObserver | null = null; @@ -288,7 +293,7 @@
-

{$_('contacts.title')}

+

{$_('contacts.title')}

{#if selectionMode} @@ -369,7 +374,9 @@ {#if contactsStore.loading} - {#if viewModeStore.mode === 'grid'} + {#if viewModeStore.mode === 'network'} + + {:else if viewModeStore.mode === 'grid'} {:else} @@ -380,13 +387,15 @@
👤

{$_('contacts.noContacts')}

{$_('contacts.addFirst')}

- +
{:else} - {#if viewModeStore.mode === 'grid'} + {#if viewModeStore.mode === 'network'} + + {:else if viewModeStore.mode === 'grid'} - {:else if viewModeStore.mode === 'alphabet'} + {:else} - {:else} - {/if} - - {#if contactsStore.hasMore} + + {#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
{#if contactsStore.loadingMore}
@@ -428,11 +428,13 @@
{/if} - -

- {contactsStore.contacts.length} / {contactsStore.total} - {contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')} -

+ + {#if viewModeStore.mode !== 'network'} +

+ {contactsStore.contacts.length} / {contactsStore.total} + {contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')} +

+ {/if} {/if}
diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte index 454d92623..1cbcb4eaa 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte @@ -1,10 +1,13 @@
- - contactsFilterStore.setSelectedTagId(id)} - contactFilter={contactsFilterStore.contactFilter} - onContactFilterChange={(f) => contactsFilterStore.setContactFilter(f)} - birthdayFilter={contactsFilterStore.birthdayFilter} - onBirthdayFilterChange={(f) => contactsFilterStore.setBirthdayFilter(f)} - selectedCompany={contactsFilterStore.selectedCompany} - onCompanyChange={(c) => contactsFilterStore.setSelectedCompany(c)} - embedded={true} - /> + {#if isNetworkMode} + -
+ +
+ + +
- - +
-
+ +
+ + + + +
- -
- + {/if} + + +
+ {networkStore.nodes.length} {$_('contacts.contactsPlural')} + + {networkStore.links.length} {$_('network.connections')} +
+ {:else} + + + +
+ + contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allTags')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setContactFilter( + (typeof v === 'string' ? v : 'all') as ContactFilter + )} + placeholder={$_('filters.contact.all')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setBirthdayFilter( + (typeof v === 'string' ? v : 'all') as BirthdayFilter + )} + placeholder={$_('filters.birthday.all')} + embedded={true} + direction="up" + /> + + + {#if companyOptions.length > 0} + contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allCompanies')} + embedded={true} + direction="up" /> - - - - -
+ {/if} + + + {#if activeFilterCount > 0} + + {/if} +
+ +
+ + + + {/if}
diff --git a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte index 73090fb9d..4bad53fab 100644 --- a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte @@ -3,6 +3,7 @@ import { contactsApi, photoApi } from '$lib/api/contacts'; import { contactsStore } from '$lib/stores/contacts.svelte'; import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; + import SocialMediaFields from './forms/SocialMediaFields.svelte'; interface Props { onClose: () => void; @@ -48,7 +49,6 @@ let signal = $state(''); let discord = $state(''); let bluesky = $state(''); - let socialSectionOpen = $state(false); const initials = $derived(() => { const f = firstName?.[0] || ''; @@ -533,213 +533,22 @@ > - -
- - {#if socialSectionOpen} - - {/if} -
+ +
@@ -1195,66 +1004,5 @@ .actions { flex-direction: column-reverse; } - - .social-grid { - grid-template-columns: 1fr; - } - } - - /* Social Media Section */ - .section-header-toggle { - width: 100%; - background: none; - border: none; - cursor: pointer; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - margin-bottom: 0; - } - - .section-header-toggle:hover { - background: hsl(var(--color-surface-hover) / 0.3); - margin: 0 -1rem; - padding: 0 1rem 0.625rem; - width: calc(100% + 2rem); - border-radius: 0.5rem 0.5rem 0 0; - } - - .chevron-icon { - width: 1rem; - height: 1rem; - margin-left: auto; - color: hsl(var(--color-muted-foreground)); - transition: transform 0.2s ease; - } - - .chevron-open { - transform: rotate(180deg); - } - - .social-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; - padding-top: 0.75rem; - } - - .social-label { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .social-icon-label { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border-radius: 0.25rem; - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - text-transform: lowercase; } diff --git a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte index 38eddb294..cea269e12 100644 --- a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte @@ -1,6 +1,7 @@ + +{#if hasAny} +
+
+
+ + + +
+

Social Media

+
+ +
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte new file mode 100644 index 000000000..0cda4d897 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte @@ -0,0 +1,369 @@ + + +
+ + {#if isOpen} + + {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte index af1ff0d97..2fd293032 100644 --- a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -347,15 +347,15 @@ {node.name} - - {#if node.company} + + {#if node.subtitle} - {node.company} + {node.subtitle} {/if} diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte index 362727151..004482fe3 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactCardSkeleton from './ContactCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -16,17 +17,11 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte index 3bb93cb5a..3e81b4146 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactRowSkeleton from './ContactRowSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton rows to show */ @@ -16,16 +17,10 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.3 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte index 7e618b617..0295a9191 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte @@ -6,6 +6,7 @@ import { SkeletonBox } from '@manacore/shared-ui'; import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of duplicate groups to show */ @@ -17,12 +18,6 @@ } let { count = 3, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
@@ -39,7 +34,10 @@
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte index f1c72956f..171fc0c14 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte @@ -4,6 +4,7 @@ */ import TagCardSkeleton from './TagCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -15,17 +16,11 @@ } let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts index 82732122c..c67ab3f59 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts +++ b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts @@ -5,6 +5,9 @@ * Built on top of @manacore/shared-ui skeleton primitives. */ +// Utilities +export { calculateFadeOpacity } from './utils'; + // Contact List/Grid Skeletons export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte'; export { default as ContactListSkeleton } from './ContactListSkeleton.svelte'; @@ -15,10 +18,6 @@ export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte'; export { default as TagCardSkeleton } from './TagCardSkeleton.svelte'; export { default as TagGridSkeleton } from './TagGridSkeleton.svelte'; -// Favorite Skeletons -export { default as FavoriteCardSkeleton } from './FavoriteCardSkeleton.svelte'; -export { default as FavoriteGridSkeleton } from './FavoriteGridSkeleton.svelte'; - // Duplicate Skeletons export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte'; export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte'; diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts new file mode 100644 index 000000000..ccd94edbb --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts @@ -0,0 +1,19 @@ +/** + * Skeleton utility functions + */ + +/** + * Calculate opacity for cascading fade effect in skeleton lists + * @param index Current item index + * @param count Total number of items + * @param minOpacity Minimum opacity (default: 0.3) + * @returns Opacity value between minOpacity and 1 + */ +export function calculateFadeOpacity( + index: number, + count: number, + minOpacity: number = 0.3 +): number { + const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); + return Math.max(minOpacity, 1 - index * fadeStep); +} diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 244b244d5..8ac723aa5 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -5,6 +5,8 @@ import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; import { isSidebarMode } from '$lib/stores/navigation'; import { contactsFilterStore } from '$lib/stores/filter.svelte'; + import { contactsSettings } from '$lib/stores/settings.svelte'; + import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte'; interface Props { contacts: Contact[]; @@ -36,12 +38,27 @@ contactsFilterStore.toggleAlphabetNav(); } + // Context menu for alphabet nav + let alphabetContextMenu: AlphabetNavContextMenu; + + function handleAlphabetContextMenu(e: MouseEvent) { + e.preventDefault(); + alphabetContextMenu?.show(e.clientX, e.clientY); + } + function handleCheckboxClick(e: MouseEvent, id: string) { e.stopPropagation(); onToggleSelection?.(id); } - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + // Alphabet with optional reverse order + let alphabet = $derived.by(() => { + let letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + if (contactsSettings.alphabetNavReverseOrder) { + letters = letters.reverse(); + } + return letters; + }); function getInitials(contact: Contact) { const first = contact.firstName?.[0] || ''; @@ -267,13 +284,15 @@ class:sidebar-mode={$isSidebarMode} class:toolbar-expanded={isToolbarExpanded} > + + {/if} + {/each} + {#if contactsSettings.alphabetNavShowHash && availableLetters.includes('#')} - {/each} - {#if availableLetters.includes('#')} - {/if}
{/if} + +
diff --git a/apps/contacts/apps/web/src/lib/config/social-media.ts b/apps/contacts/apps/web/src/lib/config/social-media.ts new file mode 100644 index 000000000..784139d35 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/config/social-media.ts @@ -0,0 +1,168 @@ +/** + * Social Media Platform Configuration + * Centralized config for all social media platforms used in the contacts app + */ + +export interface SocialPlatform { + /** Unique identifier matching Contact field name */ + id: string; + /** Display name */ + name: string; + /** Short badge label */ + badge: string; + /** Input type for form fields */ + inputType: 'url' | 'text' | 'tel'; + /** Placeholder text for form input */ + placeholder: string; + /** Whether this platform has a clickable link */ + hasLink: boolean; + /** Function to build the full URL from a value */ + buildUrl?: (value: string) => string; +} + +/** + * All supported social media platforms + */ +export const SOCIAL_PLATFORMS: SocialPlatform[] = [ + { + id: 'linkedin', + name: 'LinkedIn', + badge: 'in', + inputType: 'url', + placeholder: 'https://linkedin.com/in/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://linkedin.com/in/${v}`), + }, + { + id: 'twitter', + name: 'Twitter / X', + badge: 'X', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://x.com/${v.replace('@', '')}`), + }, + { + id: 'facebook', + name: 'Facebook', + badge: 'f', + inputType: 'url', + placeholder: 'https://facebook.com/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://facebook.com/${v}`), + }, + { + id: 'instagram', + name: 'Instagram', + badge: 'ig', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://instagram.com/${v.replace('@', '')}`), + }, + { + id: 'xing', + name: 'Xing', + badge: 'xi', + inputType: 'url', + placeholder: 'https://xing.com/profile/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://xing.com/profile/${v}`), + }, + { + id: 'github', + name: 'GitHub', + badge: 'gh', + inputType: 'text', + placeholder: 'username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://github.com/${v}`), + }, + { + id: 'youtube', + name: 'YouTube', + badge: 'yt', + inputType: 'url', + placeholder: 'https://youtube.com/@...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://youtube.com/@${v}`), + }, + { + id: 'tiktok', + name: 'TikTok', + badge: 'tt', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://tiktok.com/@${v.replace('@', '')}`), + }, + { + id: 'telegram', + name: 'Telegram', + badge: 'tg', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => `https://t.me/${v.replace('@', '')}`, + }, + { + id: 'whatsapp', + name: 'WhatsApp', + badge: 'wa', + inputType: 'tel', + placeholder: '+49...', + hasLink: true, + buildUrl: (v) => `https://wa.me/${v.replace(/[^0-9]/g, '')}`, + }, + { + id: 'signal', + name: 'Signal', + badge: 'sg', + inputType: 'tel', + placeholder: '+49...', + hasLink: false, + }, + { + id: 'discord', + name: 'Discord', + badge: 'dc', + inputType: 'text', + placeholder: 'username#1234', + hasLink: false, + }, + { + id: 'bluesky', + name: 'Bluesky', + badge: 'bs', + inputType: 'text', + placeholder: '@handle.bsky.social', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://bsky.app/profile/${v.replace('@', '')}`), + }, +]; + +/** + * Get platform config by ID + */ +export function getPlatform(id: string): SocialPlatform | undefined { + return SOCIAL_PLATFORMS.find((p) => p.id === id); +} + +/** + * Check if a contact has any social media data + */ +export function hasSocialMedia(contact: Record): boolean { + return SOCIAL_PLATFORMS.some((p) => !!contact[p.id]); +} + +/** + * Get all social media entries for a contact + */ +export function getSocialMediaEntries( + contact: Record +): Array<{ platform: SocialPlatform; value: string }> { + return SOCIAL_PLATFORMS.filter((p) => !!contact[p.id]).map((platform) => ({ + platform, + value: contact[platform.id] as string, + })); +} diff --git a/apps/contacts/apps/web/src/lib/services/feedback.ts b/apps/contacts/apps/web/src/lib/services/feedback.ts index 95dc1656d..fd578e9d9 100644 --- a/apps/contacts/apps/web/src/lib/services/feedback.ts +++ b/apps/contacts/apps/web/src/lib/services/feedback.ts @@ -4,8 +4,7 @@ import { createFeedbackService } from '@manacore/shared-feedback-service'; import { authStore } from '$lib/stores/auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const feedbackService = createFeedbackService({ apiUrl: MANA_AUTH_URL, diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 3122bf4f1..d92832e5d 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -6,10 +6,7 @@ import { browser } from '$app/environment'; import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; - -// Initialize Mana Core Auth only on the client side -// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; diff --git a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts index a715eb0ba..426dec25b 100644 --- a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts @@ -62,8 +62,18 @@ function saveState(state: ContactsFilterState) { // Reactive state let state = $state(DEFAULT_STATE); +// Generic update helper +function update( + key: K, + value: ContactsFilterState[K], + persist = true +) { + state = { ...state, [key]: value }; + if (persist) saveState(state); +} + export const contactsFilterStore = { - // Getters + // Getters - Required for Svelte 5 reactivity get sortField() { return state.sortField; }, @@ -90,57 +100,23 @@ export const contactsFilterStore = { }, // Setters - setSortField(value: SortField) { - state = { ...state, sortField: value }; - saveState(state); - }, - - setContactFilter(value: ContactFilter) { - state = { ...state, contactFilter: value }; - saveState(state); - }, - - setBirthdayFilter(value: BirthdayFilter) { - state = { ...state, birthdayFilter: value }; - saveState(state); - }, - - setSelectedTagId(value: string | null) { - state = { ...state, selectedTagId: value }; - saveState(state); - }, - - setSelectedCompany(value: string | null) { - state = { ...state, selectedCompany: value }; - saveState(state); - }, - - setToolbarCollapsed(value: boolean) { - state = { ...state, isToolbarCollapsed: value }; - saveState(state); - }, + setSortField: (value: SortField) => update('sortField', value), + setContactFilter: (value: ContactFilter) => update('contactFilter', value), + setBirthdayFilter: (value: BirthdayFilter) => update('birthdayFilter', value), + setSelectedTagId: (value: string | null) => update('selectedTagId', value), + setSelectedCompany: (value: string | null) => update('selectedCompany', value), + setToolbarCollapsed: (value: boolean) => update('isToolbarCollapsed', value), + setAlphabetNavCollapsed: (value: boolean) => update('isAlphabetNavCollapsed', value), + setSearchQuery: (value: string) => update('searchQuery', value, false), toggleToolbar() { - state = { ...state, isToolbarCollapsed: !state.isToolbarCollapsed }; - saveState(state); - }, - - setAlphabetNavCollapsed(value: boolean) { - state = { ...state, isAlphabetNavCollapsed: value }; - saveState(state); + update('isToolbarCollapsed', !state.isToolbarCollapsed); }, toggleAlphabetNav() { - state = { ...state, isAlphabetNavCollapsed: !state.isAlphabetNavCollapsed }; - saveState(state); + update('isAlphabetNavCollapsed', !state.isAlphabetNavCollapsed); }, - setSearchQuery(value: string) { - state = { ...state, searchQuery: value }; - // Don't persist search query to localStorage - }, - - // Reset filters (but not toolbar state) resetFilters() { state = { ...state, @@ -153,7 +129,6 @@ export const contactsFilterStore = { saveState(state); }, - // Initialize from localStorage initialize() { if (!browser) return; state = loadState(); diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts index 7480186ce..c5c89622f 100644 --- a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -8,7 +8,7 @@ import { browser } from '$app/environment'; // Settings types export type ContactSortBy = 'name' | 'company' | 'created' | 'updated'; export type ContactSortOrder = 'asc' | 'desc'; -export type ContactView = 'grid' | 'alphabet'; +export type ContactView = 'grid' | 'alphabet' | 'network'; export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; export interface ContactsAppSettings { @@ -55,6 +55,16 @@ export interface ContactsAppSettings { privacyMode: boolean; /** Require confirmation before sharing contact */ confirmBeforeSharing: boolean; + + // Alphabet Navigation Settings + /** Hide letters that have no contacts */ + alphabetNavHideInactive: boolean; + /** Use compact/smaller alphabet buttons */ + alphabetNavCompact: boolean; + /** Reverse letter order (Z-A instead of A-Z) */ + alphabetNavReverseOrder: boolean; + /** Show # symbol for non-letter names */ + alphabetNavShowHash: boolean; } const DEFAULT_SETTINGS: ContactsAppSettings = { @@ -84,6 +94,12 @@ const DEFAULT_SETTINGS: ContactsAppSettings = { // Privacy privacyMode: false, confirmBeforeSharing: true, + + // Alphabet Navigation + alphabetNavHideInactive: false, + alphabetNavCompact: false, + alphabetNavReverseOrder: false, + alphabetNavShowHash: true, }; const STORAGE_KEY = 'contacts-settings'; @@ -187,6 +203,20 @@ export const contactsSettings = { return settings.confirmBeforeSharing; }, + // Alphabet Navigation + get alphabetNavHideInactive() { + return settings.alphabetNavHideInactive; + }, + get alphabetNavCompact() { + return settings.alphabetNavCompact; + }, + get alphabetNavReverseOrder() { + return settings.alphabetNavReverseOrder; + }, + get alphabetNavShowHash() { + return settings.alphabetNavShowHash; + }, + /** * Initialize settings from localStorage */ diff --git a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts index 70c7b99ae..e70961ed6 100644 --- a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts @@ -9,8 +9,7 @@ import { createUserSettingsStore } from '@manacore/shared-theme'; import { authStore } from './auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const userSettings = createUserSettingsStore({ appId: 'contacts', diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts index 74bd7a442..258e1d63c 100644 --- a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -10,13 +10,20 @@ export type ViewMode = ContactView; const STORAGE_KEY = 'contacts-view-mode'; +// Valid view modes +const VALID_MODES: ViewMode[] = ['grid', 'alphabet', 'network']; + +function isValidMode(mode: string | null): mode is ViewMode { + return mode !== null && VALID_MODES.includes(mode as ViewMode); +} + // Get initial mode: current session preference > settings default > 'alphabet' function getInitialMode(): ViewMode { if (!browser) return 'alphabet'; // First check if there's a session-specific preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { return sessionMode; } @@ -57,7 +64,7 @@ export const viewModeStore = { // Check if there's a session preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { mode = sessionMode; } else { // Use default from settings diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index a7bf002e3..07e5d6ae4 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -13,7 +13,6 @@ PillNavItem, PillDropdownItem, QuickInputItem, - QuickAction, CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; @@ -47,8 +46,6 @@ formatParsedContactPreview, } from '$lib/utils/contact-parser'; import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; - import NetworkToolbar from '$lib/components/NetworkToolbar.svelte'; - import { networkStore } from '$lib/stores/network.svelte'; // Tags state for Quick-Create let availableTags = $state<{ id: string; name: string }[]>([]); @@ -77,23 +74,16 @@ // Show toolbar only on main contacts page const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode); - // Show network toolbar only on network page - const showNetworkToolbar = $derived($page.url.pathname === '/network' && !isSidebarMode); - - // Check if any toolbar is expanded - const isAnyToolbarExpanded = $derived( - (showContactsToolbar && !contactsFilterStore.isToolbarCollapsed) || - (showNetworkToolbar && !networkStore.isToolbarCollapsed) + // Check if toolbar is expanded + const isToolbarExpanded = $derived( + showContactsToolbar && !contactsFilterStore.isToolbarCollapsed ); // Dynamic bottom offset based on toolbar state const inputBarBottomOffset = $derived( - isSidebarMode ? '0px' : isAnyToolbarExpanded ? '140px' : '70px' + isSidebarMode ? '0px' : isToolbarExpanded ? '140px' : '70px' ); - // Show FAB when any toolbar is active - const showToolbarFab = $derived(showContactsToolbar || showNetworkToolbar); - // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -147,9 +137,7 @@ const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, - { href: '/favorites', label: 'Favoriten', icon: 'heart' }, { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, - { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, @@ -270,13 +258,6 @@ }); } - // QuickInputBar quick actions - const quickActions: QuickAction[] = [ - { id: 'favorites', label: 'Favoriten', icon: 'heart', href: '/favorites' }, - { id: 'tags', label: 'Tags', icon: 'tag', href: '/tags' }, - { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, - ]; - onMount(async () => { // Redirect to login if not authenticated if (!authStore.isAuthenticated) { @@ -386,7 +367,6 @@ onSearch={handleSearch} onSelect={handleSelect} onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)} - {quickActions} placeholder="Neuer Kontakt oder suchen..." emptyText="Keine Kontakte gefunden" searchingText="Suche..." @@ -394,21 +374,15 @@ onParseCreate={handleParseCreate} createText="Erstellen" appIcon="contacts" - primaryColor="#3b82f6" autoFocus={true} bottomOffset={inputBarBottomOffset} - hasFabRight={showToolbarFab} + hasFabRight={showContactsToolbar} /> {#if showContactsToolbar} {/if} - - - {#if showNetworkToolbar} - - {/if}
@@ -444,9 +418,7 @@ } .content-wrapper { - max-width: 900px; - margin-left: auto; - margin-right: auto; + /* No max-width - let individual views control their own width */ padding: 1rem; } @@ -461,4 +433,23 @@ padding: 2rem; } } + + /* Adjust InputBar when toolbar elements (view-mode-pill + FAB) are visible */ + /* Pill left edge is at: 50% - 238px from right edge of viewport */ + /* This means from center, there's 238px to the pill's left edge */ + /* For a centered InputBar with max-width W, right edge is at: center + W/2 */ + /* We need: center + W/2 < center + 238 - 12px gap, so W/2 < 226, W < 452px */ + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: 450px; + } + + /* On smaller screens (<900px), the FAB + pill move to right: 1rem position */ + /* So we need fixed padding instead */ + @media (max-width: 900px) { + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: calc(100% - 200px); /* ~120px pill + 8px + 54px FAB + 18px gap */ + margin-left: 0; + margin-right: auto; + } + } diff --git a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte index 33795c905..b5702ea51 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -25,9 +25,9 @@ // Options for selects const viewOptions = [ - { value: 'list', label: 'Liste' }, { value: 'grid', label: 'Kacheln' }, { value: 'alphabet', label: 'Alphabetisch' }, + { value: 'network', label: 'Netzwerk' }, ]; const sortByOptions = [ @@ -63,7 +63,6 @@ const startPageLabels: Record = { 'nav.contacts': 'Kontakte', 'nav.groups': 'Gruppen', - 'nav.favorites': 'Favoriten', }; function translateLabel(key: string): string { diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte index f288dbd02..ae5532654 100644 --- a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -1,8 +1,8 @@ - {translations.title} | Contacts + {translations.titleForm} | Contacts Date: Sun, 14 Dec 2025 21:32:03 +0100 Subject: [PATCH 46/69] feat(shared-ui): add FilterDropdown export and InputBar FAB support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export FilterDropdown and FilterDropdownOption from molecules - Add hasFabLeft prop to InputBar for left-side FAB spacing - Minor ThemePage formatting fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/shared-theme-ui/src/pages/ThemePage.svelte | 7 ++++++- packages/shared-ui/src/index.ts | 4 ++-- packages/shared-ui/src/quick-input/InputBar.svelte | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/shared-theme-ui/src/pages/ThemePage.svelte b/packages/shared-theme-ui/src/pages/ThemePage.svelte index b9993f060..1c30b5413 100644 --- a/packages/shared-theme-ui/src/pages/ThemePage.svelte +++ b/packages/shared-theme-ui/src/pages/ThemePage.svelte @@ -1,5 +1,10 @@ + +
+ + {#if isOpen} +
+ +
+ + +
+ + + {#each customDates as customDate (customDate.id)} +
+
+ + updateCustomDate(customDate.id, 'label', e.currentTarget.value)} + class="input" + placeholder="z.B. Hochzeitstag, Kennenlerndatum" + /> +
+
+ + updateCustomDate(customDate.id, 'date', e.currentTarget.value)} + class="input" + /> +
+ +
+ {/each} + + + +
+ {/if} +
+ + From 9e7113982e929a99ef52fc6d3fb6826afd181697 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:20:00 +0100 Subject: [PATCH 49/69] feat(shared-ui): add InputBar context menu with settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add InputBarContextMenu with settings toggles (syntax highlighting, auto-focus) - Add InputBarHelpModal for keyboard shortcuts and syntax help - Add inputBarSettings store with localStorage persistence - Add recentInputHistory store for tracking used tags/references - Integrate context menu in calendar app with default calendar selection - Right-click on InputBar opens settings menu 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../apps/web/src/routes/(app)/+layout.svelte | 48 +++- packages/shared-ui/src/index.ts | 24 +- .../shared-ui/src/quick-input/InputBar.svelte | 91 +++++++- .../quick-input/InputBarContextMenu.svelte | 174 +++++++++++++++ .../src/quick-input/InputBarHelpModal.svelte | 205 ++++++++++++++++++ packages/shared-ui/src/quick-input/index.ts | 24 ++ .../quick-input/inputBarSettings.svelte.ts | 127 +++++++++++ .../src/quick-input/recentInputHistory.ts | 167 ++++++++++++++ 8 files changed, 849 insertions(+), 11 deletions(-) create mode 100644 packages/shared-ui/src/quick-input/InputBarContextMenu.svelte create mode 100644 packages/shared-ui/src/quick-input/InputBarHelpModal.svelte create mode 100644 packages/shared-ui/src/quick-input/inputBarSettings.svelte.ts create mode 100644 packages/shared-ui/src/quick-input/recentInputHistory.ts diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 52066a92b..46b63932c 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { locale } from 'svelte-i18n'; - import { PillNavigation, QuickInputBar } from '@manacore/shared-ui'; + import { PillNavigation, QuickInputBar, InputBarHelpModal } from '@manacore/shared-ui'; import { SplitPaneContainer, setSplitPanelContext, @@ -150,6 +150,42 @@ let isCollapsed = $state(false); let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar + // InputBar help modal state + let helpModalOpen = $state(false); + let helpModalMode = $state<'shortcuts' | 'syntax'>('shortcuts'); + + function handleShowShortcuts() { + helpModalMode = 'shortcuts'; + helpModalOpen = true; + } + + function handleShowSyntaxHelp() { + helpModalMode = 'syntax'; + helpModalOpen = true; + } + + function handleCloseHelpModal() { + helpModalOpen = false; + } + + // Default calendar for InputBar quick create + let selectedDefaultCalendarId = $derived( + calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id + ); + + function handleDefaultCalendarChange(id: string) { + // Update the default calendar via API + calendarsStore.setAsDefault(id); + } + + // Calendar options for InputBar context menu + let calendarOptions = $derived( + calendarsStore.calendars.map((c) => ({ + id: c.id, + label: c.name, + })) + ); + // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -431,7 +467,6 @@ onParseCreate={handleParseCreate} createText="Erstellen" appIcon="calendar" - autoFocus={true} bottomOffset={isSidebarMode ? '0px' : showCalendarToolbar && !isToolbarCollapsed @@ -439,6 +474,12 @@ : '70px'} hasFabRight={showCalendarToolbar && !isSidebarMode} hasFabLeft={showCalendarToolbar && !isSidebarMode && settingsStore.dateStripCollapsed} + defaultOptions={calendarOptions} + selectedDefaultId={selectedDefaultCalendarId} + defaultOptionLabel="Standard-Kalender" + onDefaultChange={handleDefaultCalendarChange} + onShowShortcuts={handleShowShortcuts} + onShowSyntaxHelp={handleShowSyntaxHelp} />
@@ -446,6 +487,9 @@ + + + diff --git a/packages/shared-ui/src/quick-input/index.ts b/packages/shared-ui/src/quick-input/index.ts index 74a6114bb..2d054ccd7 100644 --- a/packages/shared-ui/src/quick-input/index.ts +++ b/packages/shared-ui/src/quick-input/index.ts @@ -1,4 +1,28 @@ export { default as InputBar } from './InputBar.svelte'; // Alias for backwards compatibility export { default as QuickInputBar } from './InputBar.svelte'; +export { default as InputBarContextMenu } from './InputBarContextMenu.svelte'; +export { default as InputBarHelpModal } from './InputBarHelpModal.svelte'; export type { QuickInputItem, QuickAction, CreatePreview } from './types'; + +// Recent input history (tags, references) +export { + getRecentTags, + getRecentReferences, + addRecentTag, + addRecentReference, + extractAndSaveFromInput, + clearRecentHistory, + createRecentInputHistoryStore, +} from './recentInputHistory'; + +// InputBar settings +export { + loadInputBarSettings, + saveInputBarSettings, + updateInputBarSetting, + resetInputBarSettings, + createInputBarSettingsStore, + getInputBarSettingsStore, +} from './inputBarSettings.svelte'; +export type { InputBarSettings } from './inputBarSettings.svelte'; diff --git a/packages/shared-ui/src/quick-input/inputBarSettings.svelte.ts b/packages/shared-ui/src/quick-input/inputBarSettings.svelte.ts new file mode 100644 index 000000000..ae6a85425 --- /dev/null +++ b/packages/shared-ui/src/quick-input/inputBarSettings.svelte.ts @@ -0,0 +1,127 @@ +/** + * InputBar Settings Store + * + * Persisted settings for InputBar behavior and appearance. + * Stored in localStorage for cross-session retention. + */ + +const STORAGE_KEY = 'inputbar-settings'; + +export interface InputBarSettings { + /** Enable syntax highlighting for #tags, @refs, dates, etc. */ + syntaxHighlighting: boolean; + /** Auto-focus InputBar on page load */ + autoFocus: boolean; +} + +const DEFAULT_SETTINGS: InputBarSettings = { + syntaxHighlighting: true, + autoFocus: true, +}; + +/** + * Load settings from localStorage + */ +export function loadInputBarSettings(): InputBarSettings { + if (typeof window === 'undefined') return { ...DEFAULT_SETTINGS }; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_SETTINGS, ...parsed }; + } + } catch { + // Ignore parse errors + } + + return { ...DEFAULT_SETTINGS }; +} + +/** + * Save settings to localStorage + */ +export function saveInputBarSettings(settings: InputBarSettings): void { + if (typeof window === 'undefined') return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Ignore storage errors + } +} + +/** + * Update a single setting + */ +export function updateInputBarSetting( + key: K, + value: InputBarSettings[K] +): InputBarSettings { + const current = loadInputBarSettings(); + const updated = { ...current, [key]: value }; + saveInputBarSettings(updated); + return updated; +} + +/** + * Reset settings to defaults + */ +export function resetInputBarSettings(): InputBarSettings { + saveInputBarSettings(DEFAULT_SETTINGS); + return { ...DEFAULT_SETTINGS }; +} + +/** + * Create a reactive Svelte 5 store for InputBar settings + */ +export function createInputBarSettingsStore() { + let settings = $state(loadInputBarSettings()); + + function refresh() { + settings = loadInputBarSettings(); + } + + function set(key: K, value: InputBarSettings[K]) { + settings = updateInputBarSetting(key, value); + } + + function toggle(key: keyof InputBarSettings) { + if (typeof settings[key] === 'boolean') { + set(key, !settings[key] as InputBarSettings[typeof key]); + } + } + + function reset() { + settings = resetInputBarSettings(); + } + + return { + get settings() { + return settings; + }, + get syntaxHighlighting() { + return settings.syntaxHighlighting; + }, + get autoFocus() { + return settings.autoFocus; + }, + set, + toggle, + reset, + refresh, + }; +} + +// Global singleton store instance +let globalStore: ReturnType | null = null; + +/** + * Get the global InputBar settings store instance + */ +export function getInputBarSettingsStore() { + if (!globalStore) { + globalStore = createInputBarSettingsStore(); + } + return globalStore; +} diff --git a/packages/shared-ui/src/quick-input/recentInputHistory.ts b/packages/shared-ui/src/quick-input/recentInputHistory.ts new file mode 100644 index 000000000..1805dce33 --- /dev/null +++ b/packages/shared-ui/src/quick-input/recentInputHistory.ts @@ -0,0 +1,167 @@ +/** + * Recent Input History Store + * + * Tracks recently used tags (#) and references (@) for quick access in the InputBar context menu. + * Persists to localStorage for cross-session retention. + */ + +const STORAGE_KEY_TAGS = 'inputbar-recent-tags'; +const STORAGE_KEY_REFS = 'inputbar-recent-references'; +const MAX_ITEMS = 10; + +/** + * Get recent tags from localStorage + */ +export function getRecentTags(): string[] { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(STORAGE_KEY_TAGS); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +/** + * Get recent references from localStorage + */ +export function getRecentReferences(): string[] { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(STORAGE_KEY_REFS); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +/** + * Add a tag to recent history + * @param tag - The tag to add (with or without #) + */ +export function addRecentTag(tag: string): void { + if (typeof window === 'undefined') return; + + // Normalize tag (ensure it starts with #) + const normalizedTag = tag.startsWith('#') ? tag : `#${tag}`; + + try { + const current = getRecentTags(); + // Remove if already exists (will be re-added at front) + const filtered = current.filter((t) => t.toLowerCase() !== normalizedTag.toLowerCase()); + // Add to front, limit to MAX_ITEMS + const updated = [normalizedTag, ...filtered].slice(0, MAX_ITEMS); + localStorage.setItem(STORAGE_KEY_TAGS, JSON.stringify(updated)); + } catch { + // Ignore storage errors + } +} + +/** + * Add a reference to recent history + * @param reference - The reference to add (with or without @) + */ +export function addRecentReference(reference: string): void { + if (typeof window === 'undefined') return; + + // Normalize reference (ensure it starts with @) + const normalizedRef = reference.startsWith('@') ? reference : `@${reference}`; + + try { + const current = getRecentReferences(); + // Remove if already exists (will be re-added at front) + const filtered = current.filter((r) => r.toLowerCase() !== normalizedRef.toLowerCase()); + // Add to front, limit to MAX_ITEMS + const updated = [normalizedRef, ...filtered].slice(0, MAX_ITEMS); + localStorage.setItem(STORAGE_KEY_REFS, JSON.stringify(updated)); + } catch { + // Ignore storage errors + } +} + +/** + * Extract and save tags and references from input text + * Call this when user creates an item to track their usage patterns + * @param text - The input text to parse + */ +export function extractAndSaveFromInput(text: string): void { + if (!text) return; + + // Extract tags (#word) + const tagMatches = text.match(/#\w+/g); + if (tagMatches) { + tagMatches.forEach((tag) => addRecentTag(tag)); + } + + // Extract references (@word) + const refMatches = text.match(/@\w+/g); + if (refMatches) { + refMatches.forEach((ref) => addRecentReference(ref)); + } +} + +/** + * Clear all recent history + */ +export function clearRecentHistory(): void { + if (typeof window === 'undefined') return; + try { + localStorage.removeItem(STORAGE_KEY_TAGS); + localStorage.removeItem(STORAGE_KEY_REFS); + } catch { + // Ignore storage errors + } +} + +/** + * Create a reactive store for use in Svelte 5 components + * Returns reactive state that updates when history changes + */ +export function createRecentInputHistoryStore() { + let tags = $state(getRecentTags()); + let references = $state(getRecentReferences()); + + // Refresh from localStorage + function refresh() { + tags = getRecentTags(); + references = getRecentReferences(); + } + + // Add tag and refresh + function addTag(tag: string) { + addRecentTag(tag); + refresh(); + } + + // Add reference and refresh + function addReference(ref: string) { + addRecentReference(ref); + refresh(); + } + + // Extract from text and refresh + function extractAndSave(text: string) { + extractAndSaveFromInput(text); + refresh(); + } + + // Clear and refresh + function clear() { + clearRecentHistory(); + refresh(); + } + + return { + get tags() { + return tags; + }, + get references() { + return references; + }, + addTag, + addReference, + extractAndSave, + clear, + refresh, + }; +} From e0d7b3d13dfba47ce8a53e4ee8d7be1648f3c12a Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:22:15 +0100 Subject: [PATCH 50/69] feat(calendar): add swipe navigation for calendar views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement horizontal swipe/trackpad navigation between calendar periods: - Add ViewCarousel component with animated page transitions - Support touch swipe, trackpad scroll, and wheel navigation - Create useSwipeNavigation composable for reusable swipe handling - Add dateNavigation utility for calculating view-specific date offsets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/AgendaView.svelte | 9 +- .../lib/components/calendar/DayView.svelte | 31 +- .../lib/components/calendar/MonthView.svelte | 16 +- .../components/calendar/MultiDayView.svelte | 26 +- .../components/calendar/ViewCarousel.svelte | 278 ++++++++++++++++++ .../lib/components/calendar/WeekView.svelte | 29 +- .../lib/components/calendar/YearView.svelte | 9 +- .../apps/web/src/lib/composables/index.ts | 3 + .../composables/useSwipeNavigation.svelte.ts | 182 ++++++++++++ .../apps/web/src/lib/utils/dateNavigation.ts | 63 ++++ .../apps/web/src/routes/(app)/+page.svelte | 37 +-- 11 files changed, 618 insertions(+), 65 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte create mode 100644 apps/calendar/apps/web/src/lib/composables/useSwipeNavigation.svelte.ts create mode 100644 apps/calendar/apps/web/src/lib/utils/dateNavigation.ts diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte index 6d3acad2f..43a94001c 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -10,10 +10,15 @@ import type { CalendarEvent } from '@calendar/shared'; interface Props { + /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ + date?: Date; onEventClick?: (event: CalendarEvent) => void; } - let { onEventClick }: Props = $props(); + let { date, onEventClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); // Group events by date let groupedEvents = $derived.by(() => { @@ -24,7 +29,7 @@ const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); // Filter events that start from current date onwards - const startDate = startOfDay(viewStore.currentDate); + const startDate = startOfDay(effectiveDate); const groups: Map = new Map(); 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 83aef58d6..34f4a64b0 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -27,12 +27,17 @@ import type { CalendarEvent } from '@calendar/shared'; 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; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } - let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); // Use shared constants const HOUR_HEIGHT = HOUR_HEIGHT_PX; @@ -55,7 +60,7 @@ // Get timed events, filtering out those outside visible range when hour filter is enabled let timedEvents = $derived( getVisibleTimedEvents( - eventsStore.getEventsForDay(viewStore.currentDate), + eventsStore.getEventsForDay(effectiveDate), calendarsStore.visibleCalendars, { filterHoursEnabled: settingsStore.filterHoursEnabled, @@ -71,7 +76,7 @@ return { before: [], after: [] }; } return getVisibleOverflowEvents( - eventsStore.getEventsForDay(viewStore.currentDate), + eventsStore.getEventsForDay(effectiveDate), calendarsStore.visibleCalendars, settingsStore.dayStartHour, settingsStore.dayEndHour @@ -80,7 +85,7 @@ let allDayEvents = $derived( getVisibleAllDayEvents( - eventsStore.getEventsForDay(viewStore.currentDate), + eventsStore.getEventsForDay(effectiveDate), calendarsStore.visibleCalendars ) ); @@ -106,7 +111,7 @@ // Get birthdays for current day (if enabled in settings) let birthdays = $derived.by(() => { if (!settingsStore.showBirthdays) return []; - return birthdaysStore.getBirthdaysForDay(viewStore.currentDate); + return birthdaysStore.getBirthdaysForDay(effectiveDate); }); // ============================================================================ @@ -225,7 +230,7 @@ const duration = differenceInMinutes(end, start); // Create new start time on same day - let newStart = new Date(viewStore.currentDate); + let newStart = new Date(effectiveDate); newStart = setHours(newStart, Math.floor(clampedMinutes / 60)); newStart = setMinutes(newStart, clampedMinutes % 60); newStart.setSeconds(0, 0); @@ -327,7 +332,7 @@ firstVisibleHour * 60, Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES) ); - newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60)); + newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60)); newStart = setMinutes(newStart, newStartMinutes % 60); newStart.setSeconds(0, 0); } else { @@ -335,7 +340,7 @@ lastVisibleHour * 60, Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES) ); - newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60)); + newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60)); newEnd = setMinutes(newEnd, newEndMinutes % 60); newEnd.setSeconds(0, 0); } @@ -586,7 +591,7 @@ const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; await todosStore.updateTodo(data.taskId, { - scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'), + scheduledDate: format(effectiveDate, 'yyyy-MM-dd'), scheduledStartTime: startTime, scheduledEndTime: endTime, estimatedDuration: duration, @@ -659,7 +664,7 @@ * Get scheduled tasks for current day */ function getScheduledTasks(): Task[] { - return todosStore.getScheduledTasksForDay(viewStore.currentDate); + return todosStore.getScheduledTasksForDay(effectiveDate); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -683,7 +688,7 @@ // Don't create event if dragging or resizing if (isDragging || isResizing) return; - const startTime = new Date(viewStore.currentDate); + const startTime = new Date(effectiveDate); startTime.setHours(hour, 0, 0, 0); if (onQuickCreate) { @@ -756,7 +761,7 @@
- {#if isToday(viewStore.currentDate)} + {#if isToday(effectiveDate)}
{/if}
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 7bfde2249..ebbc02494 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -36,16 +36,21 @@ import type { CalendarEvent } from '@calendar/shared'; 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; onEventClick?: (event: CalendarEvent) => void; } - let { onQuickCreate, onEventClick }: Props = $props(); + let { date, onQuickCreate, onEventClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); // Get all days to display in the month grid (including days from prev/next months) let allCalendarDays = $derived.by(() => { - const monthStart = startOfMonth(viewStore.currentDate); - const monthEnd = endOfMonth(viewStore.currentDate); + const monthStart = startOfMonth(effectiveDate); + const monthEnd = endOfMonth(effectiveDate); const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn }); @@ -85,7 +90,6 @@ let isDragging = $state(false); let draggedEvent = $state(null); let dragTargetDay = $state(null); - let monthViewRef = $state(null); // Store for day cell refs let dayCellRefs = $state>(new Map()); @@ -276,7 +280,7 @@ } -
+
{#each weekDays as day} @@ -292,7 +296,7 @@ {@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } - let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); + + // Calculate view range based on effective date + let effectiveViewRange = $derived.by(() => { + if (date) { + // Calculate range for the provided date based on day count + const end = new Date(date); + end.setDate(end.getDate() + dayCount - 1); + return { + start: date, + end: end, + }; + } + // Use viewStore range when no date override + return viewStore.viewRange; + }); // Get date-fns locale based on current app locale const dateLocales = { de, en: enUS, fr, es, it }; @@ -58,8 +78,8 @@ // Generate days based on view range, optionally filtering weekends let allDays = $derived( eachDayOfInterval({ - start: viewStore.viewRange.start, - end: viewStore.viewRange.end, + start: effectiveViewRange.start, + end: effectiveViewRange.end, }) ); diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte new file mode 100644 index 000000000..656fee155 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -0,0 +1,278 @@ + + + + + + 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 421291570..ceb0d9d0a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -32,6 +32,8 @@ setHours, setMinutes, getWeek, + startOfWeek, + endOfWeek, } from 'date-fns'; import { de, enUS, fr, es, it } from 'date-fns/locale'; import { locale, _ } from 'svelte-i18n'; @@ -39,12 +41,31 @@ import type { CalendarEvent } from '@calendar/shared'; 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; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } - let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); + + // Calculate view range based on effective date + let effectiveViewRange = $derived.by(() => { + if (date) { + // Calculate range for the provided date + const weekStartsOn = settingsStore.weekStartsOn; + return { + start: startOfWeek(date, { weekStartsOn }), + end: endOfWeek(date, { weekStartsOn }), + }; + } + // Use viewStore range when no date override + return viewStore.viewRange; + }); // Use shared constants const HOUR_HEIGHT = HOUR_HEIGHT_PX; @@ -59,8 +80,8 @@ // Generate days of the week, optionally filtering weekends let allDays = $derived( eachDayOfInterval({ - start: viewStore.viewRange.start, - end: viewStore.viewRange.end, + start: effectiveViewRange.start, + end: effectiveViewRange.end, }) ); @@ -70,7 +91,7 @@ // Get week number for display let weekNumber = $derived( - getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn }) + getWeek(effectiveViewRange.start, { weekStartsOn: settingsStore.weekStartsOn }) ); // Use composables for hour filtering and time indicator 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 e0c0d5950..ed43e762a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -19,14 +19,19 @@ import type { CalendarViewType, CalendarEvent } from '@calendar/shared'; 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; onEventClick?: (event: CalendarEvent) => void; } - let { onQuickCreate, onEventClick }: Props = $props(); + let { date, onQuickCreate, onEventClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); // Derived values - let year = $derived(viewStore.currentDate.getFullYear()); + let year = $derived(effectiveDate.getFullYear()); let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1))); diff --git a/apps/calendar/apps/web/src/lib/composables/index.ts b/apps/calendar/apps/web/src/lib/composables/index.ts index 511e0aaae..6307cdbdd 100644 --- a/apps/calendar/apps/web/src/lib/composables/index.ts +++ b/apps/calendar/apps/web/src/lib/composables/index.ts @@ -26,6 +26,9 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey // Birthday popover management export { useBirthdayPopover } from './useBirthdayPopover.svelte'; +// Swipe/scroll navigation for view switching +export { useSwipeNavigation, type SwipeNavigationOptions } from './useSwipeNavigation.svelte'; + // Legacy exports (kept for backwards compatibility, may be removed later) export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte'; export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte'; diff --git a/apps/calendar/apps/web/src/lib/composables/useSwipeNavigation.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useSwipeNavigation.svelte.ts new file mode 100644 index 000000000..84663f430 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useSwipeNavigation.svelte.ts @@ -0,0 +1,182 @@ +/** + * Swipe Navigation Composable + * Enables horizontal swipe/scroll navigation for calendar views + * + * Supports: + * - Trackpad horizontal scroll (Mac/Windows) + * - Touch swipe (Mobile/Tablet) + * - Mouse horizontal scroll wheel + */ + +import { browser } from '$app/environment'; + +export interface SwipeNavigationOptions { + /** Minimum pixels to trigger navigation (default: 80) */ + threshold?: number; + /** Debounce time in ms for wheel events (default: 150) */ + debounceMs?: number; + /** Disable swipe navigation temporarily */ + disabled?: boolean; +} + +const DEFAULT_THRESHOLD = 80; +const DEFAULT_DEBOUNCE_MS = 150; + +/** + * Creates swipe/scroll navigation for a container element + * + * @param getElement - Function returning the target element + * @param onNext - Callback when swiping left (go to next period) + * @param onPrevious - Callback when swiping right (go to previous period) + * @param options - Configuration options + * + * @example + * ```svelte + * + * + *
...
+ * ``` + */ +export function useSwipeNavigation( + getElement: () => HTMLElement | null, + onNext: () => void, + onPrevious: () => void, + options: SwipeNavigationOptions = {} +) { + if (!browser) return; + + const threshold = options.threshold ?? DEFAULT_THRESHOLD; + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + + // Track accumulated wheel delta for trackpad detection + let accumulatedDelta = 0; + let debounceTimer: ReturnType | null = null; + + // Touch tracking + let touchStartX = 0; + let touchStartY = 0; + let isTouching = false; + + /** + * Handle wheel events (trackpad horizontal scroll) + */ + function handleWheel(e: WheelEvent) { + // Skip if disabled + if (options.disabled) return; + + // Only handle horizontal scrolling (deltaX dominant) + // This distinguishes trackpad gestures from vertical scrolling + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + + // Don't interfere with event dragging + const target = e.target as HTMLElement; + if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return; + + // Prevent default scroll behavior for horizontal gestures + e.preventDefault(); + + // Accumulate horizontal delta + accumulatedDelta += e.deltaX; + + // Reset accumulator after debounce period + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + accumulatedDelta = 0; + }, debounceMs); + + // Check if threshold reached + if (accumulatedDelta > threshold) { + onNext(); + accumulatedDelta = 0; + if (debounceTimer) clearTimeout(debounceTimer); + } else if (accumulatedDelta < -threshold) { + onPrevious(); + accumulatedDelta = 0; + if (debounceTimer) clearTimeout(debounceTimer); + } + } + + /** + * Handle touch start + */ + function handleTouchStart(e: TouchEvent) { + // Skip if disabled + if (options.disabled) return; + + // Don't interfere with event dragging + const target = e.target as HTMLElement; + if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return; + + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + isTouching = true; + } + + /** + * Handle touch end + */ + function handleTouchEnd(e: TouchEvent) { + // Skip if disabled or wasn't tracking + if (options.disabled || !isTouching) return; + isTouching = false; + + const touchEndX = e.changedTouches[0].clientX; + const touchEndY = e.changedTouches[0].clientY; + + const deltaX = touchEndX - touchStartX; + const deltaY = touchEndY - touchStartY; + + // Only trigger if horizontal movement is dominant and exceeds threshold + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) { + if (deltaX > 0) { + // Swiped right → go to previous + onPrevious(); + } else { + // Swiped left → go to next + onNext(); + } + } + } + + /** + * Handle touch cancel + */ + function handleTouchCancel() { + isTouching = false; + } + + // Setup and cleanup with $effect + $effect(() => { + const el = getElement(); + if (!el) return; + + // Add event listeners + el.addEventListener('wheel', handleWheel, { passive: false }); + el.addEventListener('touchstart', handleTouchStart, { passive: true }); + el.addEventListener('touchend', handleTouchEnd, { passive: true }); + el.addEventListener('touchcancel', handleTouchCancel, { passive: true }); + + // Cleanup + return () => { + el.removeEventListener('wheel', handleWheel); + el.removeEventListener('touchstart', handleTouchStart); + el.removeEventListener('touchend', handleTouchEnd); + el.removeEventListener('touchcancel', handleTouchCancel); + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + }); +} diff --git a/apps/calendar/apps/web/src/lib/utils/dateNavigation.ts b/apps/calendar/apps/web/src/lib/utils/dateNavigation.ts new file mode 100644 index 000000000..f7361885c --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/dateNavigation.ts @@ -0,0 +1,63 @@ +/** + * Date Navigation Utilities + * Helper functions for calculating date offsets based on view type + */ + +import type { CalendarViewType } from '@calendar/shared'; +import { + addDays, + addWeeks, + addMonths, + addYears, + subDays, + subWeeks, + subMonths, + subYears, +} from 'date-fns'; + +/** + * Calculate a date offset based on the current view type + * + * @param date - The base date + * @param viewType - The current calendar view type + * @param offset - Number of periods to offset (-1 = previous, 1 = next) + * @returns The calculated date + * + * @example + * // Get previous week's date + * getOffsetDate(new Date(), 'week', -1) + * + * // Get next month's date + * getOffsetDate(new Date(), 'month', 1) + */ +export function getOffsetDate(date: Date, viewType: CalendarViewType, offset: number): Date { + switch (viewType) { + case 'day': + return offset > 0 ? addDays(date, offset) : subDays(date, Math.abs(offset)); + + case '5day': + return offset > 0 ? addDays(date, offset * 5) : subDays(date, Math.abs(offset) * 5); + + case 'week': + return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset)); + + case '10day': + return offset > 0 ? addDays(date, offset * 10) : subDays(date, Math.abs(offset) * 10); + + case '14day': + return offset > 0 ? addDays(date, offset * 14) : subDays(date, Math.abs(offset) * 14); + + case 'month': + return offset > 0 ? addMonths(date, offset) : subMonths(date, Math.abs(offset)); + + case 'year': + return offset > 0 ? addYears(date, offset) : subYears(date, Math.abs(offset)); + + case 'agenda': + // Agenda moves by 7 days + return offset > 0 ? addDays(date, offset * 7) : subDays(date, Math.abs(offset) * 7); + + default: + return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset)); + } +} diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index e464e1a2d..a1a5c197a 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -8,12 +8,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { isSidebarMode as sidebarModeStore } from '$lib/stores/navigation'; - import WeekView from '$lib/components/calendar/WeekView.svelte'; - import DayView from '$lib/components/calendar/DayView.svelte'; - import MonthView from '$lib/components/calendar/MonthView.svelte'; - import MultiDayView from '$lib/components/calendar/MultiDayView.svelte'; - import YearView from '$lib/components/calendar/YearView.svelte'; - import AgendaView from '$lib/components/calendar/AgendaView.svelte'; + import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte'; import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte'; import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte'; import { CalendarViewSkeleton } from '$lib/components/skeletons'; @@ -150,36 +145,8 @@
{#if !initialized} - {:else if viewStore.viewType === 'day'} - - {:else if viewStore.viewType === '5day'} - - {:else if viewStore.viewType === 'week'} - - {:else if viewStore.viewType === '10day'} - - {:else if viewStore.viewType === '14day'} - - {:else if viewStore.viewType === 'month'} - - {:else if viewStore.viewType === 'year'} - - {:else if viewStore.viewType === 'agenda'} - {:else} - + {/if}
From 9e0c8cbd7d11c010cac0458a584822f715ac95b5 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:22:40 +0100 Subject: [PATCH 51/69] refactor(contacts): use InputBar settings store for autoFocus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove hardcoded autoFocus prop now that it's controlled by the global InputBar settings store introduced in the context menu feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/apps/web/src/routes/(app)/+layout.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 07e5d6ae4..6dc00df08 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -374,7 +374,6 @@ onParseCreate={handleParseCreate} createText="Erstellen" appIcon="contacts" - autoFocus={true} bottomOffset={inputBarBottomOffset} hasFabRight={showContactsToolbar} /> From 781225aa6856c346889933554ad9a0d71127e87d Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:46:36 +0100 Subject: [PATCH 52/69] fix(shared-ui): add backdrop to ContextMenu to block clicks behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context menu was not properly blocking clicks on elements behind it (like the calendar grid). Added a transparent backdrop overlay that captures all clicks outside the menu and closes it properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/context-menu/ContextMenu.svelte | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/shared-ui/src/context-menu/ContextMenu.svelte b/packages/shared-ui/src/context-menu/ContextMenu.svelte index 0e0354eb4..0f7db0995 100644 --- a/packages/shared-ui/src/context-menu/ContextMenu.svelte +++ b/packages/shared-ui/src/context-menu/ContextMenu.svelte @@ -85,6 +85,17 @@ {#if visible} + + +
{ + e.preventDefault(); + onClose(); + }} + >
+
+ .context-menu-backdrop { + position: fixed; + inset: 0; + z-index: 9998; + /* Transparent backdrop - just blocks clicks */ + background: transparent; + } + .context-menu { position: fixed; z-index: 9999; From 4e391bdbc67ec217c1b6608f2e2f64c1780edb9f Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:57:17 +0100 Subject: [PATCH 53/69] refine(calendar): improve swipe navigation UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase swipe threshold from 15% to 20% for more intentional navigation - Reduce animation duration from 300ms to 200ms for snappier feel - Reduce wheel debounce from 150ms to 100ms for faster response - Simplify code by removing redundant comments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/calendar/ViewCarousel.svelte | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) 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 656fee155..58bdc825b 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -28,10 +28,10 @@ let viewportEl: HTMLDivElement; let viewportWidth = $state(0); - // Threshold: 15% of viewport width triggers navigation - const SNAP_THRESHOLD = 0.15; - // Debounce time for wheel events - const WHEEL_DEBOUNCE_MS = 150; + // Threshold: 20% of viewport width triggers navigation + const SNAP_THRESHOLD = 0.2; + // Debounce for wheel events + const WHEEL_DEBOUNCE_MS = 100; let wheelDebounceTimer: ReturnType | null = null; // Calculate dates for previous/current/next views @@ -68,17 +68,13 @@ e.preventDefault(); - // Update offset (invert for natural scrolling direction) + // Simple direct offset update offsetX += e.deltaX * -1; - - // Clamp to max 1 page in each direction offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX)); - // Debounced snap check + // Debounced snap if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer); - wheelDebounceTimer = setTimeout(() => { - snapToPage(); - }, WHEEL_DEBOUNCE_MS); + wheelDebounceTimer = setTimeout(snapToPage, WHEEL_DEBOUNCE_MS); } // Touch handlers @@ -92,7 +88,6 @@ startX = e.touches[0].clientX; isSwiping = true; - // Cancel any pending wheel snap if (wheelDebounceTimer) { clearTimeout(wheelDebounceTimer); wheelDebounceTimer = null; @@ -102,10 +97,7 @@ function handleTouchMove(e: TouchEvent) { if (!isSwiping || disableSwipe) return; - const currentX = e.touches[0].clientX; - offsetX = currentX - startX; - - // Clamp to max 1 page in each direction + offsetX = e.touches[0].clientX - startX; offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX)); } @@ -118,7 +110,6 @@ function handleTouchCancel() { if (!isSwiping) return; isSwiping = false; - // Snap back to current on cancel animateToOffset(0, () => {}); } @@ -153,8 +144,7 @@ function animateToOffset(targetX: number, onComplete: () => void) { offsetX = targetX; - // Wait for CSS transition to complete - setTimeout(onComplete, 300); + setTimeout(onComplete, 200); } // Computed transform style @@ -256,7 +246,7 @@ } .carousel-track.animating { - transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); + transition: transform 200ms ease-out; } .carousel-page { From dca7d97c785f670d95d5e2096bfb7cde276cf30c Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:06:19 +0100 Subject: [PATCH 54/69] feat(calendar): improve swipe to smooth linear scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace elastic snap behavior with smooth constant-speed scrolling: - Use linear easing instead of ease-out (no deceleration at end) - Add velocity tracking for momentum-based navigation - Dynamic animation duration based on remaining distance (80-200ms) - Fast swipes trigger immediate navigation regardless of position - Lower threshold (15%) for easier page transitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/calendar/ViewCarousel.svelte | 87 ++++++++++++++----- 1 file changed, 66 insertions(+), 21 deletions(-) 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 58bdc825b..963941bb2 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -23,15 +23,22 @@ let startX = $state(0); let isSwiping = $state(false); let isAnimating = $state(false); + let animationDuration = $state(0); + + // Velocity tracking for momentum + let lastX = 0; + let lastTime = 0; + let velocity = 0; // Container refs let viewportEl: HTMLDivElement; let viewportWidth = $state(0); - // Threshold: 20% of viewport width triggers navigation - const SNAP_THRESHOLD = 0.2; + // Threshold: 15% of viewport width or high velocity triggers navigation + const SNAP_THRESHOLD = 0.15; + const VELOCITY_THRESHOLD = 0.3; // px/ms // Debounce for wheel events - const WHEEL_DEBOUNCE_MS = 100; + const WHEEL_DEBOUNCE_MS = 80; let wheelDebounceTimer: ReturnType | null = null; // Calculate dates for previous/current/next views @@ -86,6 +93,9 @@ if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return; startX = e.touches[0].clientX; + lastX = startX; + lastTime = performance.now(); + velocity = 0; isSwiping = true; if (wheelDebounceTimer) { @@ -97,7 +107,19 @@ function handleTouchMove(e: TouchEvent) { if (!isSwiping || disableSwipe) return; - offsetX = e.touches[0].clientX - startX; + const currentX = e.touches[0].clientX; + const currentTime = performance.now(); + + // Calculate velocity (px/ms) + const dt = currentTime - lastTime; + if (dt > 0) { + velocity = (currentX - lastX) / dt; + } + + lastX = currentX; + lastTime = currentTime; + + offsetX = currentX - startX; offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX)); } @@ -110,45 +132,68 @@ function handleTouchCancel() { if (!isSwiping) return; isSwiping = false; - animateToOffset(0, () => {}); + const distance = Math.abs(offsetX); + isAnimating = true; + animateToOffset(0, distance, () => { + isAnimating = false; + }); } - // Snap to page based on current offset + // Snap to page based on current offset and velocity function snapToPage() { if (isAnimating || viewportWidth === 0) return; - isAnimating = true; const threshold = viewportWidth * SNAP_THRESHOLD; + const hasHighVelocity = Math.abs(velocity) > VELOCITY_THRESHOLD; - if (offsetX > threshold) { - // Snap to previous - animateToOffset(viewportWidth, () => { + // Determine direction based on position and velocity + let targetPage: 'prev' | 'next' | 'current' = 'current'; + + if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) { + targetPage = 'prev'; + } else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) { + targetPage = 'next'; + } + + isAnimating = true; + + if (targetPage === 'prev') { + const distance = viewportWidth - offsetX; + animateToOffset(viewportWidth, distance, () => { viewStore.goToPrevious(); offsetX = 0; isAnimating = false; }); - } else if (offsetX < -threshold) { - // Snap to next - animateToOffset(-viewportWidth, () => { + } else if (targetPage === 'next') { + const distance = viewportWidth + offsetX; + animateToOffset(-viewportWidth, distance, () => { viewStore.goToNext(); offsetX = 0; isAnimating = false; }); } else { - // Snap back to current - animateToOffset(0, () => { + const distance = Math.abs(offsetX); + animateToOffset(0, distance, () => { isAnimating = false; }); } } - function animateToOffset(targetX: number, onComplete: () => void) { + function animateToOffset(targetX: number, distance: number, onComplete: () => void) { + // Calculate duration based on distance (faster for shorter distances) + // Min 80ms, max 200ms, scales with distance + const baseDuration = 150; + const duration = Math.min(200, Math.max(80, (distance / viewportWidth) * baseDuration)); + animationDuration = duration; + offsetX = targetX; - setTimeout(onComplete, 200); + setTimeout(onComplete, duration); } - // Computed transform style - let transformStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`); + // Computed styles + let trackStyle = $derived( + `transform: translateX(calc(-33.333% + ${offsetX}px)); --duration: ${animationDuration}ms` + ); @@ -161,7 +206,7 @@ ontouchend={handleTouchEnd} ontouchcancel={handleTouchCancel} > -