From f2ac3e245ec6133a5af4bf776ecd4993ffad7e16 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:00:26 +0100 Subject: [PATCH] feat(splitscreen): add split-screen feature for multi-app side-by-side view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new @manacore/shared-splitscreen package enabling iFrame-based split-screen functionality across Calendar, Todo, and Contacts apps. Features: - SplitPaneContainer with CSS Grid layout - AppPanel with iFrame sandbox permissions and loading/error states - ResizeHandle with mouse, touch, and keyboard support (20-80% range) - PanelControls for swap and close actions - Svelte 5 runes-based store with Context API - URL persistence (?panel=todo&split=60) - localStorage persistence with versioning - Mobile auto-disable (<1024px breakpoint) Integration: - PillNavigation: added onOpenInPanel prop and Ctrl/Cmd+click support - PillDropdown: added split button per app item - Calendar, Todo, Contacts layouts wrapped with SplitPaneContainer Also fixes: - WeekView.svelte: fixed {@const} placement error - MultiDayView.svelte: fixed {@const} placement error πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/calendar/apps/web/package.json | 1 + .../components/calendar/MultiDayView.svelte | 152 ++++- .../lib/components/calendar/WeekView.svelte | 142 ++++- .../apps/web/src/routes/(app)/+layout.svelte | 206 ++++--- apps/contacts/apps/web/package.json | 1 + .../apps/web/src/routes/(app)/+layout.svelte | 155 ++--- apps/todo/apps/web/package.json | 1 + .../apps/web/src/routes/(app)/+layout.svelte | 137 +++-- docs/central-services/README.md | 2 + docs/central-services/SPLIT-SCREEN.md | 375 ++++++++++++ packages/shared-splitscreen/package.json | 39 ++ .../src/components/AppPanel.svelte | 155 +++++ .../src/components/PanelControls.svelte | 117 ++++ .../src/components/ResizeHandle.svelte | 197 ++++++ .../src/components/SplitPaneContainer.svelte | 112 ++++ packages/shared-splitscreen/src/index.ts | 46 ++ .../src/stores/split-panel.svelte.ts | 251 ++++++++ packages/shared-splitscreen/src/types.ts | 88 +++ .../shared-splitscreen/src/utils/index.ts | 13 + .../src/utils/local-storage.ts | 97 +++ .../shared-splitscreen/src/utils/url-state.ts | 65 ++ packages/shared-splitscreen/tsconfig.json | 18 + .../src/navigation/PillDropdown.svelte | 184 ++++-- .../src/navigation/PillNavigation.svelte | 175 +++++- packages/shared-ui/src/navigation/types.ts | 8 +- packages/shared-vite-config/src/index.ts | 1 + pnpm-lock.yaml | 563 +++++++++++------- 27 files changed, 2770 insertions(+), 531 deletions(-) create mode 100644 docs/central-services/SPLIT-SCREEN.md create mode 100644 packages/shared-splitscreen/package.json create mode 100644 packages/shared-splitscreen/src/components/AppPanel.svelte create mode 100644 packages/shared-splitscreen/src/components/PanelControls.svelte create mode 100644 packages/shared-splitscreen/src/components/ResizeHandle.svelte create mode 100644 packages/shared-splitscreen/src/components/SplitPaneContainer.svelte create mode 100644 packages/shared-splitscreen/src/index.ts create mode 100644 packages/shared-splitscreen/src/stores/split-panel.svelte.ts create mode 100644 packages/shared-splitscreen/src/types.ts create mode 100644 packages/shared-splitscreen/src/utils/index.ts create mode 100644 packages/shared-splitscreen/src/utils/local-storage.ts create mode 100644 packages/shared-splitscreen/src/utils/url-state.ts create mode 100644 packages/shared-splitscreen/tsconfig.json diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index f9f78a82f..bd75e50f6 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -32,6 +32,7 @@ "dependencies": { "@calendar/shared": "workspace:*", "@manacore/shared-auth": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", 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 24d10f077..74386f63d 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -136,7 +136,61 @@ let daysContainerEl: HTMLDivElement; function getEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + + // 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 = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : 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; + } + + // Get events that are completely outside the visible time range + function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + if (!settingsStore.filterHoursEnabled) { + return { before: [], after: [] }; + } + + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + const visibleStartMinutes = settingsStore.dayStartHour * 60; + 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 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 }; } function getAllDayEventsForDay(day: Date) { @@ -961,6 +1015,36 @@ {/if} + + {#if true} + {@const overflow = getOverflowEventsForDay(day)} + {#if overflow.before.length > 0} +
+ {#each overflow.before as event} +
+ {/each} +
+ {/if} + {#if overflow.after.length > 0} +
+ {#each overflow.after as event} +
+ {/each} +
+ {/if} + {/if} + {#if isToday(day)}
@@ -1290,27 +1374,6 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } - /* Task drag ghost */ - .task-drag-ghost { - position: absolute; - left: 2px; - right: 2px; - padding: 4px 6px; - background: hsl(var(--color-surface) / 0.8); - border: 2px dashed hsl(var(--color-primary)); - border-radius: var(--radius-sm); - opacity: 0.7; - pointer-events: none; - z-index: 50; - overflow: hidden; - } - - .task-drag-ghost .task-title { - font-size: 0.7rem; - font-weight: 500; - color: hsl(var(--color-foreground)); - } - /* Sidebar task drop target */ .day-column.drop-target { background: hsl(var(--color-primary) / 0.15); @@ -1408,4 +1471,49 @@ border-radius: 50%; background: hsl(var(--color-error)); } + + /* Overflow indicators for events outside visible time range */ + .overflow-indicator { + position: absolute; + left: 2px; + right: 2px; + display: flex; + flex-direction: column; + gap: 2px; + z-index: 5; + padding: 2px; + } + + .overflow-indicator.top { + top: 0; + } + + .overflow-indicator.bottom { + bottom: 0; + } + + .overflow-line { + height: 3px; + border-radius: 2px; + opacity: 0.7; + cursor: pointer; + transition: + opacity 0.15s ease, + height 0.15s ease; + } + + .overflow-line:hover { + opacity: 1; + height: 5px; + } + + .compact .overflow-line, + .very-compact .overflow-line { + height: 2px; + } + + .compact .overflow-line:hover, + .very-compact .overflow-line:hover { + height: 4px; + } 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 9d28ee0b0..ab7380493 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -135,7 +135,61 @@ let daysContainerEl: HTMLDivElement; function getEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + + // 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 = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : 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; + } + + // Get events that are completely outside the visible time range + function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + if (!settingsStore.filterHoursEnabled) { + return { before: [], after: [] }; + } + + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + const visibleStartMinutes = settingsStore.dayStartHour * 60; + 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 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 }; } function getAllDayEventsForDay(day: Date) { @@ -992,6 +1046,36 @@ {/if} + + {#if true} + {@const overflow = getOverflowEventsForDay(day)} + {#if overflow.before.length > 0} +
+ {#each overflow.before as event} +
+ {/each} +
+ {/if} + {#if overflow.after.length > 0} +
+ {#each overflow.after as event} +
+ {/each} +
+ {/if} + {/if} + {#if isToday(day)}
@@ -1272,27 +1356,6 @@ filter: grayscale(0.3); } - /* Task drag ghost */ - .task-drag-ghost { - position: absolute; - left: 2px; - right: 2px; - padding: 4px 6px; - background: hsl(var(--color-surface) / 0.8); - border: 2px dashed hsl(var(--color-primary)); - border-radius: var(--radius-sm); - opacity: 0.7; - pointer-events: none; - z-index: 50; - overflow: hidden; - } - - .task-drag-ghost .task-title { - font-size: 0.7rem; - font-weight: 500; - color: hsl(var(--color-foreground)); - } - .event-card.draft { outline: 2px solid hsl(var(--color-primary)); outline-offset: -1px; @@ -1374,4 +1437,39 @@ background: hsl(var(--color-error)); border-radius: 50%; } + + /* Overflow indicators for events outside visible time range */ + .overflow-indicator { + position: absolute; + left: 2px; + right: 2px; + display: flex; + flex-direction: column; + gap: 2px; + z-index: 5; + padding: 2px; + } + + .overflow-indicator.top { + top: 0; + } + + .overflow-indicator.bottom { + bottom: 0; + } + + .overflow-line { + height: 3px; + border-radius: 2px; + opacity: 0.7; + cursor: pointer; + transition: + opacity 0.15s ease, + height 0.15s ease; + } + + .overflow-line:hover { + opacity: 1; + height: 5px; + } diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 5e5738a0f..97a90580a 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -4,6 +4,11 @@ import { onMount } from 'svelte'; import { locale } from 'svelte-i18n'; import { PillNavigation, QuickInputBar } from '@manacore/shared-ui'; + import { + SplitPaneContainer, + setSplitPanelContext, + DEFAULT_APPS, + } from '@manacore/shared-splitscreen'; import type { PillNavItem, PillDropdownItem, @@ -28,6 +33,7 @@ import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, + isToolbarCollapsed as toolbarCollapsedStore, } from '$lib/stores/navigation'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; @@ -42,11 +48,20 @@ formatParsedEventPreview, } from '$lib/utils/event-parser'; import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte'; + import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte'; import DateStrip from '$lib/components/calendar/DateStrip.svelte'; // App switcher items const appItems = getPillAppItems('calendar'); + // Split-Panel Store fΓΌr Split-Screen Feature + const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS); + + // Handler fΓΌr Split-Screen Panel-Γ–ffnung + function handleOpenInPanel(appId: string, url: string) { + splitPanel.openPanel(appId); + } + let { children } = $props(); // InputBar search - search events @@ -128,6 +143,7 @@ let isSidebarMode = $state(false); let isCollapsed = $state(false); + let isToolbarCollapsed = $state(false); // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -234,6 +250,19 @@ } } + function handleToolbarModeChange(isSidebar: boolean) { + // Sync toolbar mode with nav mode + handleModeChange(isSidebar); + } + + function handleToolbarCollapsedChange(collapsed: boolean) { + isToolbarCollapsed = collapsed; + toolbarCollapsedStore.set(collapsed); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('calendar-toolbar-collapsed', String(collapsed)); + } + } + function handleToggleTheme() { theme.toggleMode(); } @@ -254,6 +283,9 @@ return; } + // Initialize split-panel from URL/localStorage + splitPanel.initialize(); + // Initialize view state viewStore.initialize(); @@ -281,86 +313,114 @@ isCollapsed = true; collapsedStore.set(true); } + + // Initialize toolbar collapsed state from localStorage + const savedToolbarCollapsed = localStorage.getItem('calendar-toolbar-collapsed'); + if (savedToolbarCollapsed === 'true') { + isToolbarCollapsed = true; + toolbarCollapsedStore.set(true); + } }); -
- - - - {#if showCalendarToolbar} - - {/if} - - - {#if showCalendarToolbar} - - {/if} - -
-
+
+ - {@render children()} -
-
+ {#snippet toolbarContent()} + {#if showCalendarToolbar} + + {/if} + {/snippet} +
- - -
+ + {#if showCalendarToolbar} + + {/if} + + + {#if showCalendarToolbar && !isSidebarMode} + + {/if} + +
+
+ {@render children()} +
+
+ + + + + diff --git a/packages/shared-splitscreen/src/components/PanelControls.svelte b/packages/shared-splitscreen/src/components/PanelControls.svelte new file mode 100644 index 000000000..0dfd2c012 --- /dev/null +++ b/packages/shared-splitscreen/src/components/PanelControls.svelte @@ -0,0 +1,117 @@ + + +
+ {panelName} + +
+ + + +
+
+ + diff --git a/packages/shared-splitscreen/src/components/ResizeHandle.svelte b/packages/shared-splitscreen/src/components/ResizeHandle.svelte new file mode 100644 index 000000000..bfec10b6f --- /dev/null +++ b/packages/shared-splitscreen/src/components/ResizeHandle.svelte @@ -0,0 +1,197 @@ + + + + + diff --git a/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte b/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte new file mode 100644 index 000000000..ebb34febf --- /dev/null +++ b/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte @@ -0,0 +1,112 @@ + + +
+
+ {@render children()} +
+ + {#if splitPanel.isActive && splitPanel.rightPanel} + + +
+ + splitPanel.swapPanels()} + onClose={() => splitPanel.closePanel()} + /> +
+ {/if} +
+ + diff --git a/packages/shared-splitscreen/src/index.ts b/packages/shared-splitscreen/src/index.ts new file mode 100644 index 000000000..0ca19baa7 --- /dev/null +++ b/packages/shared-splitscreen/src/index.ts @@ -0,0 +1,46 @@ +/** + * @manacore/shared-splitscreen + * + * Split-screen panel system for ManaCore apps. + * Enables displaying two apps side-by-side using iFrames. + */ + +// Types +export type { + PanelConfig, + SplitScreenState, + AppDefinition, + PanelEvent, + StorageConfig, + UrlState, +} from './types.js'; + +export { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from './types.js'; + +// Store +export { + createSplitPanelStore, + setSplitPanelContext, + getSplitPanelContext, + hasSplitPanelContext, + DEFAULT_APPS, + type SplitPanelStore, +} from './stores/split-panel.svelte.js'; + +// Utils +export { + parseUrlState, + updateUrlState, + clearUrlState, + getCurrentUrlState, + savePanelState, + loadPanelState, + clearPanelState, + createStorageConfig, +} from './utils/index.js'; + +// Components (will be added) +export { default as SplitPaneContainer } from './components/SplitPaneContainer.svelte'; +export { default as AppPanel } from './components/AppPanel.svelte'; +export { default as PanelControls } from './components/PanelControls.svelte'; +export { default as ResizeHandle } from './components/ResizeHandle.svelte'; diff --git a/packages/shared-splitscreen/src/stores/split-panel.svelte.ts b/packages/shared-splitscreen/src/stores/split-panel.svelte.ts new file mode 100644 index 000000000..9559f6a6e --- /dev/null +++ b/packages/shared-splitscreen/src/stores/split-panel.svelte.ts @@ -0,0 +1,251 @@ +/** + * Split-Panel Store + * Svelte 5 runes-based state management for split-screen panels. + */ + +import { getContext, setContext } from 'svelte'; +import type { PanelConfig, AppDefinition, StorageConfig } from '../types.js'; +import { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from '../types.js'; +import { savePanelState, loadPanelState, createStorageConfig } from '../utils/local-storage.js'; +import { updateUrlState, clearUrlState, getCurrentUrlState } from '../utils/url-state.js'; + +const SPLIT_PANEL_CONTEXT_KEY = Symbol('split-panel'); + +/** + * Available apps that can be opened in split-screen. + */ +export const DEFAULT_APPS: AppDefinition[] = [ + { + id: 'calendar', + name: 'Calendar', + baseUrl: 'http://localhost:5179', + icon: 'calendar', + color: '#3b82f6', + }, + { + id: 'todo', + name: 'Todo', + baseUrl: 'http://localhost:5188', + icon: 'check-square', + color: '#10b981', + }, + { + id: 'contacts', + name: 'Contacts', + baseUrl: 'http://localhost:5184', + icon: 'users', + color: '#8b5cf6', + }, + { + id: 'clock', + name: 'Clock', + baseUrl: 'http://localhost:5187', + icon: 'clock', + color: '#f59e0b', + }, +]; + +export interface SplitPanelStore { + // State + readonly isActive: boolean; + readonly rightPanel: PanelConfig | null; + readonly dividerPosition: number; + readonly isMobile: boolean; + + // Available apps (excluding current) + readonly availableApps: AppDefinition[]; + + // Actions + openPanel: (appId: string, path?: string) => void; + closePanel: () => void; + swapPanels: () => void; + setDividerPosition: (position: number) => void; + resetDividerPosition: () => void; + initialize: () => void; +} + +/** + * Create a split-panel store for an app. + */ +export function createSplitPanelStore( + currentAppId: string, + apps: AppDefinition[] = DEFAULT_APPS +): SplitPanelStore { + // Reactive state using Svelte 5 runes + let isActive = $state(false); + let rightPanel = $state(null); + let dividerPosition = $state(DIVIDER_CONSTRAINTS.DEFAULT); + let isMobile = $state(false); + + // Storage config for persistence + const storageConfig: StorageConfig = createStorageConfig(currentAppId); + + // Filter out current app from available apps + const availableApps = $derived(apps.filter((app) => app.id !== currentAppId)); + + /** + * Open an app in the right panel. + */ + function openPanel(appId: string, path = '/'): void { + if (isMobile) return; + + const app = apps.find((a) => a.id === appId); + if (!app || app.id === currentAppId) return; + + const url = `${app.baseUrl}${path}`; + + rightPanel = { + appId: app.id, + url, + name: app.name, + }; + isActive = true; + + // Persist to URL and localStorage + updateUrlState({ panel: appId, split: dividerPosition }); + savePanelState(storageConfig, { rightPanel, dividerPosition, isActive: true }); + } + + /** + * Close the split panel. + */ + function closePanel(): void { + rightPanel = null; + isActive = false; + + // Clear persistence + clearUrlState(); + savePanelState(storageConfig, { rightPanel: null, dividerPosition, isActive: false }); + } + + /** + * Swap left and right panels (navigate to the right panel app). + */ + function swapPanels(): void { + if (!rightPanel) return; + + // Navigate to the other app + const targetUrl = rightPanel.url; + window.location.href = targetUrl; + } + + /** + * Set the divider position. + */ + function setDividerPosition(position: number): void { + const clamped = Math.max(DIVIDER_CONSTRAINTS.MIN, Math.min(DIVIDER_CONSTRAINTS.MAX, position)); + dividerPosition = clamped; + + // Persist + if (isActive) { + updateUrlState({ panel: rightPanel?.appId, split: clamped }); + savePanelState(storageConfig, { rightPanel, dividerPosition: clamped, isActive }); + } + } + + /** + * Reset divider to default position. + */ + function resetDividerPosition(): void { + setDividerPosition(DIVIDER_CONSTRAINTS.DEFAULT); + } + + /** + * Initialize from URL and localStorage. + */ + function initialize(): void { + if (typeof window === 'undefined') return; + + // Check mobile + const checkMobile = () => { + isMobile = window.innerWidth < MOBILE_BREAKPOINT; + if (isMobile && isActive) { + closePanel(); + } + }; + + checkMobile(); + window.addEventListener('resize', checkMobile); + + // Load from URL first, then localStorage + const urlState = getCurrentUrlState(); + const storedState = loadPanelState(storageConfig); + + const panelAppId = urlState.panel || storedState?.rightPanel?.appId; + const savedPosition = urlState.split || storedState?.dividerPosition; + + if (panelAppId && !isMobile) { + const app = apps.find((a) => a.id === panelAppId); + if (app && app.id !== currentAppId) { + openPanel(panelAppId); + if (savedPosition) { + setDividerPosition(savedPosition); + } + } + } + } + + // Return the store interface with getters for reactive access + return { + get isActive() { + return isActive; + }, + get rightPanel() { + return rightPanel; + }, + get dividerPosition() { + return dividerPosition; + }, + get isMobile() { + return isMobile; + }, + get availableApps() { + return availableApps; + }, + openPanel, + closePanel, + swapPanels, + setDividerPosition, + resetDividerPosition, + initialize, + }; +} + +/** + * Set the split-panel store in Svelte context. + * Call this in your layout component. + */ +export function setSplitPanelContext( + currentAppId: string, + apps: AppDefinition[] = DEFAULT_APPS +): SplitPanelStore { + const store = createSplitPanelStore(currentAppId, apps); + setContext(SPLIT_PANEL_CONTEXT_KEY, store); + return store; +} + +/** + * Get the split-panel store from Svelte context. + * Call this in child components. + */ +export function getSplitPanelContext(): SplitPanelStore { + const store = getContext(SPLIT_PANEL_CONTEXT_KEY); + if (!store) { + throw new Error( + '[SplitScreen] No split-panel context found. Did you call setSplitPanelContext in a parent component?' + ); + } + return store; +} + +/** + * Check if split-panel context exists. + */ +export function hasSplitPanelContext(): boolean { + try { + getContext(SPLIT_PANEL_CONTEXT_KEY); + return true; + } catch { + return false; + } +} diff --git a/packages/shared-splitscreen/src/types.ts b/packages/shared-splitscreen/src/types.ts new file mode 100644 index 000000000..52c185ca8 --- /dev/null +++ b/packages/shared-splitscreen/src/types.ts @@ -0,0 +1,88 @@ +/** + * Split-Screen Types + * Type definitions for the split-screen panel system. + */ + +/** + * Configuration for a panel showing an app in an iFrame. + */ +export interface PanelConfig { + /** Unique identifier for the app (e.g., 'calendar', 'todo', 'contacts') */ + appId: string; + /** Full URL to load in the iFrame */ + url: string; + /** Display name for the app */ + name?: string; +} + +/** + * State of the split-screen system. + */ +export interface SplitScreenState { + /** Whether split-screen mode is active */ + isActive: boolean; + /** Configuration for the right panel (null when not in split mode) */ + rightPanel: PanelConfig | null; + /** Position of the divider as percentage (20-80) */ + dividerPosition: number; +} + +/** + * App registration for the split-screen system. + * Used to define which apps can be opened in panels. + */ +export interface AppDefinition { + /** Unique app identifier */ + id: string; + /** Display name */ + name: string; + /** Base URL for the app */ + baseUrl: string; + /** Icon name (Lucide icon) */ + icon?: string; + /** App theme color */ + color?: string; +} + +/** + * Event payload for panel operations. + */ +export interface PanelEvent { + type: 'open' | 'close' | 'swap' | 'resize'; + panel?: PanelConfig; + dividerPosition?: number; +} + +/** + * Storage key configuration. + */ +export interface StorageConfig { + /** Key prefix for localStorage */ + prefix: string; + /** Current app ID for scoped storage */ + currentAppId: string; +} + +/** + * URL state parameters for split-screen. + */ +export interface UrlState { + /** App ID for the right panel */ + panel?: string; + /** Divider position percentage */ + split?: number; +} + +/** + * Minimum and maximum constraints for divider position. + */ +export const DIVIDER_CONSTRAINTS = { + MIN: 20, + MAX: 80, + DEFAULT: 50, +} as const; + +/** + * Breakpoint for disabling split-screen on mobile. + */ +export const MOBILE_BREAKPOINT = 1024; diff --git a/packages/shared-splitscreen/src/utils/index.ts b/packages/shared-splitscreen/src/utils/index.ts new file mode 100644 index 000000000..da6659145 --- /dev/null +++ b/packages/shared-splitscreen/src/utils/index.ts @@ -0,0 +1,13 @@ +/** + * Split-Screen Utilities + * Re-export all utility functions. + */ + +export { parseUrlState, updateUrlState, clearUrlState, getCurrentUrlState } from './url-state.js'; + +export { + savePanelState, + loadPanelState, + clearPanelState, + createStorageConfig, +} from './local-storage.js'; diff --git a/packages/shared-splitscreen/src/utils/local-storage.ts b/packages/shared-splitscreen/src/utils/local-storage.ts new file mode 100644 index 000000000..fa3c8a3e3 --- /dev/null +++ b/packages/shared-splitscreen/src/utils/local-storage.ts @@ -0,0 +1,97 @@ +/** + * LocalStorage Utilities + * Handle persistent storage for split-screen preferences. + */ + +import type { SplitScreenState, StorageConfig } from '../types.js'; +import { DIVIDER_CONSTRAINTS } from '../types.js'; + +const STORAGE_VERSION = 1; + +interface StoredState { + version: number; + state: Partial; +} + +/** + * Generate storage key for an app. + */ +function getStorageKey(config: StorageConfig): string { + return `${config.prefix}-splitscreen-${config.currentAppId}`; +} + +/** + * Save split-screen state to localStorage. + */ +export function savePanelState(config: StorageConfig, state: Partial): void { + if (typeof window === 'undefined') return; + + try { + const stored: StoredState = { + version: STORAGE_VERSION, + state: { + dividerPosition: state.dividerPosition, + rightPanel: state.rightPanel, + }, + }; + localStorage.setItem(getStorageKey(config), JSON.stringify(stored)); + } catch (_error) { + // localStorage not available or quota exceeded + } +} + +/** + * Load split-screen state from localStorage. + */ +export function loadPanelState(config: StorageConfig): Partial | null { + if (typeof window === 'undefined') return null; + + try { + const raw = localStorage.getItem(getStorageKey(config)); + if (!raw) return null; + + const stored: StoredState = JSON.parse(raw); + + // Version check for future migrations + if (stored.version !== STORAGE_VERSION) { + clearPanelState(config); + return null; + } + + // Validate divider position + if (stored.state.dividerPosition !== undefined) { + stored.state.dividerPosition = Math.max( + DIVIDER_CONSTRAINTS.MIN, + Math.min(DIVIDER_CONSTRAINTS.MAX, stored.state.dividerPosition) + ); + } + + return stored.state; + } catch (_error) { + // localStorage not available or corrupted data + return null; + } +} + +/** + * Clear split-screen state from localStorage. + */ +export function clearPanelState(config: StorageConfig): void { + if (typeof window === 'undefined') return; + + try { + localStorage.removeItem(getStorageKey(config)); + } catch (_error) { + // localStorage not available + } +} + +/** + * Get default storage config with manacore prefix. + */ +export function createStorageConfig(currentAppId: string): StorageConfig { + return { + prefix: 'manacore', + currentAppId, + }; +} diff --git a/packages/shared-splitscreen/src/utils/url-state.ts b/packages/shared-splitscreen/src/utils/url-state.ts new file mode 100644 index 000000000..11fd47229 --- /dev/null +++ b/packages/shared-splitscreen/src/utils/url-state.ts @@ -0,0 +1,65 @@ +/** + * URL State Utilities + * Handle URL-based state persistence for split-screen. + */ + +import type { UrlState } from '../types.js'; + +/** + * Parse split-screen state from URL search params. + * Reads `?panel=todo&split=60` format. + */ +export function parseUrlState(searchParams: URLSearchParams): UrlState { + const panel = searchParams.get('panel') || undefined; + const splitStr = searchParams.get('split'); + const split = splitStr ? parseInt(splitStr, 10) : undefined; + + return { + panel, + split: split && !isNaN(split) ? split : undefined, + }; +} + +/** + * Update URL with split-screen state without page reload. + * Uses replaceState to avoid adding to browser history. + */ +export function updateUrlState(state: UrlState): void { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + + if (state.panel) { + url.searchParams.set('panel', state.panel); + } else { + url.searchParams.delete('panel'); + } + + if (state.split && state.split !== 50) { + url.searchParams.set('split', state.split.toString()); + } else { + url.searchParams.delete('split'); + } + + window.history.replaceState({}, '', url.toString()); +} + +/** + * Clear split-screen state from URL. + */ +export function clearUrlState(): void { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + url.searchParams.delete('panel'); + url.searchParams.delete('split'); + window.history.replaceState({}, '', url.toString()); +} + +/** + * Get current URL state. + */ +export function getCurrentUrlState(): UrlState { + if (typeof window === 'undefined') return {}; + return parseUrlState(new URLSearchParams(window.location.search)); +} diff --git a/packages/shared-splitscreen/tsconfig.json b/packages/shared-splitscreen/tsconfig.json new file mode 100644 index 000000000..07a6403c2 --- /dev/null +++ b/packages/shared-splitscreen/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["svelte"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-ui/src/navigation/PillDropdown.svelte b/packages/shared-ui/src/navigation/PillDropdown.svelte index 3bc390a96..d839470cb 100644 --- a/packages/shared-ui/src/navigation/PillDropdown.svelte +++ b/packages/shared-ui/src/navigation/PillDropdown.svelte @@ -72,13 +72,21 @@ openSubmenuId = openSubmenuId === itemId ? null : itemId; } - function handleItemClick(item: PillDropdownItem) { + function handleItemClick(item: PillDropdownItem, event: MouseEvent) { if (item.submenu && item.submenu.length > 0) { toggleSubmenu(item.id); return; } if (item.onClick) { - item.onClick(); + item.onClick(event); + } + close(); + } + + function handleSplitClick(item: PillDropdownItem, event: MouseEvent) { + event.stopPropagation(); + if (item.onSplitClick) { + item.onSplitClick(); } close(); } @@ -186,58 +194,79 @@ style="animation-delay: {(header ? i + 1 : i) * 15}ms" > {:else} - + {#if item.showSplitButton && item.onSplitClick} + {/if} - + {#if item.submenu && item.submenu.length > 0 && openSubmenuId === item.id}