mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(splitscreen): add split-screen feature for multi-app side-by-side view
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 <noreply@anthropic.com>
This commit is contained in:
parent
f51708d75a
commit
f2ac3e245e
27 changed files with 2770 additions and 531 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
{#if true}
|
||||
{@const overflow = getOverflowEventsForDay(day)}
|
||||
{#if overflow.before.length > 0}
|
||||
<div class="overflow-indicator top" title="{overflow.before.length} Termin(e) früher">
|
||||
{#each overflow.before as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if overflow.after.length > 0}
|
||||
<div
|
||||
class="overflow-indicator bottom"
|
||||
title="{overflow.after.length} Termin(e) später"
|
||||
>
|
||||
{#each overflow.after as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(day)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
{#if true}
|
||||
{@const overflow = getOverflowEventsForDay(day)}
|
||||
{#if overflow.before.length > 0}
|
||||
<div class="overflow-indicator top" title="{overflow.before.length} Termin(e) früher">
|
||||
{#each overflow.before as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if overflow.after.length > 0}
|
||||
<div
|
||||
class="overflow-indicator bottom"
|
||||
title="{overflow.after.length} Termin(e) später"
|
||||
>
|
||||
{#each overflow.after as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(day)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Kalender"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<!-- Date strip (only on main calendar page) -->
|
||||
{#if showCalendarToolbar}
|
||||
<DateStrip />
|
||||
{/if}
|
||||
|
||||
<!-- Calendar toolbar (only on main calendar page) -->
|
||||
{#if showCalendarToolbar}
|
||||
<CalendarToolbar />
|
||||
{/if}
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
class:has-toolbar={showCalendarToolbar}
|
||||
>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Kalender"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
{#snippet toolbarContent()}
|
||||
{#if showCalendarToolbar}
|
||||
<CalendarToolbarContent vertical={true} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<!-- Global Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<!-- Date strip (only on main calendar page) -->
|
||||
{#if showCalendarToolbar}
|
||||
<DateStrip {isSidebarMode} />
|
||||
{/if}
|
||||
|
||||
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
|
||||
{#if showCalendarToolbar && !isSidebarMode}
|
||||
<CalendarToolbar
|
||||
{isSidebarMode}
|
||||
isCollapsed={isToolbarCollapsed}
|
||||
onModeChange={handleToolbarModeChange}
|
||||
onCollapsedChange={handleToolbarCollapsedChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
class:has-toolbar={showCalendarToolbar}
|
||||
>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={true}
|
||||
bottomOffset={showCalendarToolbar
|
||||
? isSidebarMode
|
||||
? '0px'
|
||||
: '130px'
|
||||
: isSidebarMode
|
||||
? '0px'
|
||||
: '70px'}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -50,6 +55,14 @@
|
|||
// App switcher items
|
||||
const appItems = getPillAppItems('contacts');
|
||||
|
||||
// Split-Panel Store für Split-Screen Feature
|
||||
const splitPanel = setSplitPanelContext('contacts', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Screen Panel-Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
|
|
@ -254,6 +267,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
|
||||
|
|
@ -287,78 +303,81 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Shadow gradient above navigation -->
|
||||
<div class="nav-shadow-gradient"></div>
|
||||
<SplitPaneContainer>
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Shadow gradient above navigation -->
|
||||
<div class="nav-shadow-gradient"></div>
|
||||
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Contacts"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Contacts"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
/>
|
||||
|
||||
<!-- Main Content with dynamic padding based on nav mode -->
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<!-- Main Content with dynamic padding based on nav mode -->
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Contact Detail Modal -->
|
||||
{#if showContactModal && modalContactId}
|
||||
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
|
||||
{/if}
|
||||
<!-- Contact Detail Modal -->
|
||||
{#if showContactModal && modalContactId}
|
||||
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
|
||||
{/if}
|
||||
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neuer Kontakt oder suchen..."
|
||||
emptyText="Keine Kontakte gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="contacts"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neuer Kontakt oder suchen..."
|
||||
emptyText="Keine Kontakte gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="contacts"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -36,6 +41,14 @@
|
|||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
||||
// Split-Panel Store für Split-Screen Feature
|
||||
const splitPanel = setSplitPanelContext('todo', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Screen Panel-Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// QuickInputBar quick actions
|
||||
|
|
@ -246,6 +259,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Load data
|
||||
await Promise.all([
|
||||
projectsStore.fetchProjects(),
|
||||
|
|
@ -310,67 +326,70 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper" class:full-width={$page.url.pathname === '/kanban'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper" class:full-width={$page.url.pathname === '/kanban'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neue Aufgabe oder suchen..."
|
||||
emptyText="Keine Aufgaben gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="todo"
|
||||
primaryColor="#8b5cf6"
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neue Aufgabe oder suchen..."
|
||||
emptyText="Keine Aufgaben gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="todo"
|
||||
primaryColor="#8b5cf6"
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Dieses Verzeichnis dokumentiert zentrale Services, die von allen Manacore-Apps g
|
|||
| **Theming** | Theme-Varianten, Dark Mode, Accessibility, Custom Themes | [THEMING.md](./THEMING.md) |
|
||||
| **Help** | Zentrale Hilfeseite mit FAQ, Features, Shortcuts, Changelog | [HELP.md](./HELP.md) |
|
||||
| **Command Bar** | Globale Schnellsuche und Navigation (Cmd/Ctrl+K) | [COMMAND-BAR.md](./COMMAND-BAR.md) |
|
||||
| **Split-Screen** | Zwei Apps nebeneinander im Browser (iFrame-basiert) | [SPLIT-SCREEN.md](./SPLIT-SCREEN.md) |
|
||||
|
||||
## Architektur-Prinzipien
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ pnpm db:push
|
|||
| `@manacore/shared-help-content` | Content-Loader, Parser, Merger, Suche |
|
||||
| `@manacore/shared-help-ui` | Svelte UI-Komponenten für Hilfeseite |
|
||||
| `@manacore/shared-help-mobile` | React Native Komponenten für Hilfe |
|
||||
| `@manacore/shared-splitscreen` | Split-Screen Container, Store, Komponenten |
|
||||
|
||||
## Hinzufügen neuer zentraler Services
|
||||
|
||||
|
|
|
|||
375
docs/central-services/SPLIT-SCREEN.md
Normal file
375
docs/central-services/SPLIT-SCREEN.md
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
# Split-Screen Feature
|
||||
|
||||
Das Split-Screen Feature ermöglicht es, zwei ManaCore-Apps nebeneinander in einem Browser-Tab anzuzeigen. Die rechte App wird dabei in einem iFrame eingebettet.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Aspekt | Details |
|
||||
|--------|---------|
|
||||
| **Package** | `@manacore/shared-splitscreen` |
|
||||
| **Integrierte Apps** | Calendar, Todo, Contacts |
|
||||
| **Aktivierung** | Split-Button in App-Dropdown oder Ctrl/Cmd+Klick |
|
||||
| **Persistenz** | URL-Parameter + localStorage |
|
||||
| **Mobile** | Automatisch deaktiviert (<1024px) |
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SplitPaneContainer │
|
||||
├─────────────────────────┬───────┬───────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ Main Panel │ ║ ║ ║ │ Side Panel │
|
||||
│ (aktuelle App) │ ║ ║ ║ │ (iFrame mit App) │
|
||||
│ │ ║ ║ ║ │ │
|
||||
│ ┌─────────────────┐ │ Resize│ ┌─────────────────────────┐ │
|
||||
│ │ PillNavigation │ │ Handle│ │ AppPanel │ │
|
||||
│ └─────────────────┘ │ │ │ ┌─────────────────────┐│ │
|
||||
│ │ │ │ │ PanelControls ││ │
|
||||
│ ┌─────────────────┐ │ │ │ │ [Swap] [Close] ││ │
|
||||
│ │ Content │ │ │ │ └─────────────────────┘│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ ┌─────────────────────┐│ │
|
||||
│ │ │ │ │ │ │ ││ │
|
||||
│ │ │ │ │ │ │ <iframe> ││ │
|
||||
│ │ │ │ │ │ │ ││ │
|
||||
│ │ │ │ │ │ └─────────────────────┘│ │
|
||||
│ └─────────────────┘ │ │ └─────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└─────────────────────────┴───────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
## Package-Struktur
|
||||
|
||||
```
|
||||
packages/shared-splitscreen/
|
||||
├── src/
|
||||
│ ├── index.ts # Barrel exports
|
||||
│ ├── types.ts # TypeScript types
|
||||
│ ├── stores/
|
||||
│ │ └── split-panel.svelte.ts # Svelte 5 runes store mit Context API
|
||||
│ ├── components/
|
||||
│ │ ├── SplitPaneContainer.svelte # Haupt-Layout (CSS Grid)
|
||||
│ │ ├── AppPanel.svelte # iFrame-Container
|
||||
│ │ ├── PanelControls.svelte # Swap/Close Buttons
|
||||
│ │ └── ResizeHandle.svelte # Draggable Divider
|
||||
│ └── utils/
|
||||
│ ├── url-state.ts # URL-Persistenz (?panel=todo&split=60)
|
||||
│ ├── local-storage.ts # localStorage-Persistenz
|
||||
│ └── index.ts # Utils barrel
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Komponenten
|
||||
|
||||
### SplitPaneContainer
|
||||
|
||||
Haupt-Container mit CSS Grid Layout.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { SplitPaneContainer, setSplitPanelContext, DEFAULT_APPS } from '@manacore/shared-splitscreen';
|
||||
|
||||
// Context initialisieren
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
</script>
|
||||
|
||||
<SplitPaneContainer>
|
||||
<!-- Dein App-Content -->
|
||||
<slot />
|
||||
</SplitPaneContainer>
|
||||
```
|
||||
|
||||
**CSS Grid:**
|
||||
- Ohne Split: `grid-template-columns: 1fr`
|
||||
- Mit Split: `grid-template-columns: {dividerPos}% 6px 1fr`
|
||||
|
||||
### AppPanel
|
||||
|
||||
iFrame-Container mit Loading/Error States.
|
||||
|
||||
**iFrame Sandbox Permissions:**
|
||||
```typescript
|
||||
const sandboxPermissions = [
|
||||
'allow-same-origin', // Für localStorage/Cookie-Zugriff
|
||||
'allow-scripts', // JavaScript ausführen
|
||||
'allow-forms', // Formulare absenden
|
||||
'allow-popups', // Popups öffnen
|
||||
'allow-popups-to-escape-sandbox',
|
||||
'allow-storage-access-by-user-activation',
|
||||
];
|
||||
```
|
||||
|
||||
### ResizeHandle
|
||||
|
||||
Draggable Divider für Panel-Größenanpassung.
|
||||
|
||||
**Features:**
|
||||
- Maus- und Touch-Support
|
||||
- Tastatur-Navigation (Pfeiltasten)
|
||||
- Doppelklick = Reset auf 50%
|
||||
- Constraints: 20% - 80%
|
||||
|
||||
**Accessibility:**
|
||||
```svelte
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={position}
|
||||
aria-valuemin={20}
|
||||
aria-valuemax={80}
|
||||
tabindex="0"
|
||||
/>
|
||||
```
|
||||
|
||||
### PanelControls
|
||||
|
||||
Overlay mit Swap- und Close-Buttons.
|
||||
|
||||
- **Swap:** Navigiert zur App im rechten Panel (window.location.href)
|
||||
- **Close:** Schließt das Split-Panel
|
||||
|
||||
## Store API
|
||||
|
||||
### Initialisierung
|
||||
|
||||
```typescript
|
||||
import { setSplitPanelContext, DEFAULT_APPS } from '@manacore/shared-splitscreen';
|
||||
|
||||
// Im Layout-Component (z.B. +layout.svelte)
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
|
||||
// In onMount initialisieren (lädt URL/localStorage State)
|
||||
onMount(() => {
|
||||
splitPanel.initialize();
|
||||
});
|
||||
```
|
||||
|
||||
### Store Interface
|
||||
|
||||
```typescript
|
||||
interface SplitPanelStore {
|
||||
// State (readonly)
|
||||
readonly isActive: boolean; // Split-Modus aktiv?
|
||||
readonly rightPanel: PanelConfig | null; // Rechtes Panel
|
||||
readonly dividerPosition: number; // Position in % (20-80)
|
||||
readonly isMobile: boolean; // Mobile Breakpoint erreicht?
|
||||
readonly availableApps: AppDefinition[]; // Verfügbare Apps (ohne aktuelle)
|
||||
|
||||
// Actions
|
||||
openPanel: (appId: string, path?: string) => void;
|
||||
closePanel: () => void;
|
||||
swapPanels: () => void;
|
||||
setDividerPosition: (position: number) => void;
|
||||
resetDividerPosition: () => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Context-Zugriff
|
||||
|
||||
```typescript
|
||||
import { getSplitPanelContext } from '@manacore/shared-splitscreen';
|
||||
|
||||
// In Child-Components
|
||||
const splitPanel = getSplitPanelContext();
|
||||
```
|
||||
|
||||
## Verfügbare Apps
|
||||
|
||||
Standard-Konfiguration in `DEFAULT_APPS`:
|
||||
|
||||
| App | ID | Port | Farbe |
|
||||
|-----|----|------|-------|
|
||||
| Calendar | `calendar` | 5179 | #3b82f6 |
|
||||
| Todo | `todo` | 5188 | #10b981 |
|
||||
| Contacts | `contacts` | 5184 | #8b5cf6 |
|
||||
| Clock | `clock` | 5187 | #f59e0b |
|
||||
|
||||
## Persistenz
|
||||
|
||||
### URL-State
|
||||
|
||||
```
|
||||
https://calendar.app/?panel=todo&split=60
|
||||
```
|
||||
|
||||
- `panel`: App-ID des rechten Panels
|
||||
- `split`: Divider-Position in % (nur wenn ≠ 50)
|
||||
|
||||
### localStorage
|
||||
|
||||
```typescript
|
||||
// Key: manacore-splitscreen-{appId}
|
||||
{
|
||||
"version": 1,
|
||||
"state": {
|
||||
"dividerPosition": 60,
|
||||
"rightPanel": {
|
||||
"appId": "todo",
|
||||
"url": "http://localhost:5188/",
|
||||
"name": "Todo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorität:** URL > localStorage
|
||||
|
||||
## Integration in Apps
|
||||
|
||||
### 1. Dependency hinzufügen
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@manacore/shared-splitscreen": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Layout anpassen
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
DEFAULT_APPS,
|
||||
} from '@manacore/shared-splitscreen';
|
||||
|
||||
// Split-Panel Store initialisieren
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Panel Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
splitPanel.initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
{...props}
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
/>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
```
|
||||
|
||||
### 3. PillNavigation Props
|
||||
|
||||
Die `PillNavigation`-Komponente unterstützt automatisch:
|
||||
|
||||
- **Split-Button:** Erscheint bei jedem App-Item im Dropdown
|
||||
- **Modifier-Key Detection:** Ctrl/Cmd+Klick öffnet im Split-Panel
|
||||
|
||||
```typescript
|
||||
// Neues Prop für PillNavigation
|
||||
onOpenInPanel?: (appId: string, url: string) => void;
|
||||
```
|
||||
|
||||
## Mobile Verhalten
|
||||
|
||||
Split-Screen ist auf mobilen Geräten deaktiviert:
|
||||
|
||||
```typescript
|
||||
const MOBILE_BREAKPOINT = 1024; // px
|
||||
|
||||
// Automatische Prüfung bei Resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth < MOBILE_BREAKPOINT && isActive) {
|
||||
closePanel();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**CSS Fallback:**
|
||||
```css
|
||||
@media (max-width: 1023px) {
|
||||
.split-pane-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.side-panel,
|
||||
.resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
| Constraint | Wert |
|
||||
|------------|------|
|
||||
| Min. Divider Position | 20% |
|
||||
| Max. Divider Position | 80% |
|
||||
| Default Position | 50% |
|
||||
| Mobile Breakpoint | 1024px |
|
||||
| Resize Handle Breite | 6px |
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
1. **Keine Cross-App Kommunikation:** Apps im iFrame können nicht direkt kommunizieren
|
||||
2. **Separate Auth Sessions:** Jede App hat ihre eigene Session (funktioniert wegen shared localStorage)
|
||||
3. **Kein Drag & Drop zwischen Apps:** Feature wurde bewusst nicht implementiert
|
||||
4. **Performance:** iFrame lädt komplette App, kann bei langsamen Verbindungen spürbar sein
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
- [ ] postMessage API für Cross-App Events
|
||||
- [ ] Mehr als 2 Panels
|
||||
- [ ] Panel Templates / Presets
|
||||
- [ ] Keyboard Shortcuts (Cmd+\ zum Togglen)
|
||||
- [ ] Panel-Position speichern per App-Kombination
|
||||
|
||||
## Debugging
|
||||
|
||||
### DevTools
|
||||
|
||||
```javascript
|
||||
// State prüfen
|
||||
const store = getSplitPanelContext();
|
||||
console.log({
|
||||
isActive: store.isActive,
|
||||
rightPanel: store.rightPanel,
|
||||
dividerPosition: store.dividerPosition,
|
||||
isMobile: store.isMobile,
|
||||
});
|
||||
|
||||
// Manuell öffnen
|
||||
store.openPanel('todo');
|
||||
|
||||
// Manuell schließen
|
||||
store.closePanel();
|
||||
```
|
||||
|
||||
### URL testen
|
||||
|
||||
```
|
||||
# Todo im Split öffnen
|
||||
http://localhost:5179/?panel=todo
|
||||
|
||||
# Mit angepasster Position
|
||||
http://localhost:5179/?panel=contacts&split=70
|
||||
```
|
||||
|
||||
### localStorage löschen
|
||||
|
||||
```javascript
|
||||
localStorage.removeItem('manacore-splitscreen-calendar');
|
||||
localStorage.removeItem('manacore-splitscreen-todo');
|
||||
localStorage.removeItem('manacore-splitscreen-contacts');
|
||||
```
|
||||
39
packages/shared-splitscreen/package.json
Normal file
39
packages/shared-splitscreen/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@manacore/shared-splitscreen",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"svelte": "./src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"svelte": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./store": {
|
||||
"svelte": "./src/stores/split-panel.svelte.ts",
|
||||
"default": "./src/stores/split-panel.svelte.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"default": "./src/utils/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
155
packages/shared-splitscreen/src/components/AppPanel.svelte
Normal file
155
packages/shared-splitscreen/src/components/AppPanel.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppPanel Component
|
||||
* iFrame container for displaying an app in split-screen.
|
||||
*/
|
||||
|
||||
import type { PanelConfig } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
panel: PanelConfig;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { panel, class: className = '' }: Props = $props();
|
||||
|
||||
let isLoading = $state(true);
|
||||
let hasError = $state(false);
|
||||
|
||||
function handleLoad() {
|
||||
isLoading = false;
|
||||
hasError = false;
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
isLoading = false;
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// iFrame sandbox permissions
|
||||
const sandboxPermissions = [
|
||||
'allow-same-origin',
|
||||
'allow-scripts',
|
||||
'allow-forms',
|
||||
'allow-popups',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
'allow-storage-access-by-user-activation',
|
||||
].join(' ');
|
||||
</script>
|
||||
|
||||
<div class="app-panel {className}">
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading {panel.name || panel.appId}...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasError}
|
||||
<div class="error-state">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>Failed to load {panel.name || panel.appId}</span>
|
||||
<button
|
||||
onclick={() => {
|
||||
isLoading = true;
|
||||
hasError = false;
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<iframe
|
||||
src={panel.url}
|
||||
title={panel.name || panel.appId}
|
||||
sandbox={sandboxPermissions}
|
||||
class:hidden={hasError}
|
||||
onload={handleLoad}
|
||||
onerror={handleError}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg-secondary, #1a1a1a);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: var(--color-bg-primary, #0a0a0a);
|
||||
}
|
||||
|
||||
iframe.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.error-state button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
117
packages/shared-splitscreen/src/components/PanelControls.svelte
Normal file
117
packages/shared-splitscreen/src/components/PanelControls.svelte
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* PanelControls Component
|
||||
* Controls overlay for split panel (swap, close).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
panelName: string;
|
||||
onSwap: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { panelName, onSwap, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel-controls">
|
||||
<span class="panel-label">{panelName}</span>
|
||||
|
||||
<div class="control-buttons">
|
||||
<button class="control-btn" title="Swap panels" onclick={onSwap}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 3l4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="M8 21l-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn close" title="Close panel" onclick={onClose}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-panel:hover .panel-controls,
|
||||
.panel-controls:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
197
packages/shared-splitscreen/src/components/ResizeHandle.svelte
Normal file
197
packages/shared-splitscreen/src/components/ResizeHandle.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ResizeHandle Component
|
||||
* Draggable divider for resizing split panels.
|
||||
*/
|
||||
|
||||
import { DIVIDER_CONSTRAINTS } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
position: number;
|
||||
onResize: (position: number) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
let { position, onResize, onReset }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let containerRef: HTMLElement | null = null;
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef) return;
|
||||
|
||||
const container = containerRef.closest('.split-pane-container');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
|
||||
const clamped = Math.max(
|
||||
DIVIDER_CONSTRAINTS.MIN,
|
||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
||||
);
|
||||
|
||||
onResize(clamped);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef || !e.touches[0]) return;
|
||||
|
||||
const container = containerRef.closest('.split-pane-container');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newPosition = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
|
||||
|
||||
const clamped = Math.max(
|
||||
DIVIDER_CONSTRAINTS.MIN,
|
||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
||||
);
|
||||
|
||||
onResize(clamped);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
onReset();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const step = event.shiftKey ? 10 : 2;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
onResize(Math.max(DIVIDER_CONSTRAINTS.MIN, position - step));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
onResize(Math.min(DIVIDER_CONSTRAINTS.MAX, position + step));
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
onResize(DIVIDER_CONSTRAINTS.DEFAULT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="resize-handle"
|
||||
class:dragging={isDragging}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={position}
|
||||
aria-valuemin={DIVIDER_CONSTRAINTS.MIN}
|
||||
aria-valuemax={DIVIDER_CONSTRAINTS.MAX}
|
||||
tabindex="0"
|
||||
onmousedown={handleMouseDown}
|
||||
ontouchstart={handleTouchStart}
|
||||
ondblclick={handleDoubleClick}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<div class="handle-line"></div>
|
||||
<div class="handle-grip">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.resize-handle {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
var(--color-primary, #3b82f6) 20%,
|
||||
var(--color-primary, #3b82f6) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.resize-handle:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.resize-handle:focus-visible {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.handle-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.resize-handle:hover .handle-line,
|
||||
.resize-handle.dragging .handle-line {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.handle-grip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover .handle-grip,
|
||||
.resize-handle.dragging .handle-grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle-grip span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SplitPaneContainer Component
|
||||
* Main container that handles split-screen layout.
|
||||
*/
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSplitPanelContext } from '../stores/split-panel.svelte.js';
|
||||
import AppPanel from './AppPanel.svelte';
|
||||
import PanelControls from './PanelControls.svelte';
|
||||
import ResizeHandle from './ResizeHandle.svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className = '' }: Props = $props();
|
||||
|
||||
const splitPanel = getSplitPanelContext();
|
||||
|
||||
// Grid template based on divider position
|
||||
let gridTemplate = $derived(
|
||||
splitPanel.isActive && splitPanel.rightPanel ? `${splitPanel.dividerPosition}% 6px 1fr` : '1fr'
|
||||
);
|
||||
|
||||
function handleResize(position: number) {
|
||||
splitPanel.setDividerPosition(position);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
splitPanel.resetDividerPosition();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="split-pane-container {className}"
|
||||
class:split-active={splitPanel.isActive && splitPanel.rightPanel}
|
||||
style:--grid-template={gridTemplate}
|
||||
>
|
||||
<div class="main-panel">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if splitPanel.isActive && splitPanel.rightPanel}
|
||||
<ResizeHandle
|
||||
position={splitPanel.dividerPosition}
|
||||
onResize={handleResize}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<div class="side-panel">
|
||||
<AppPanel panel={splitPanel.rightPanel} />
|
||||
<PanelControls
|
||||
panelName={splitPanel.rightPanel.name || splitPanel.rightPanel.appId}
|
||||
onSwap={() => splitPanel.swapPanels()}
|
||||
onClose={() => splitPanel.closePanel()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.split-pane-container {
|
||||
display: grid;
|
||||
grid-template-columns: var(--grid-template, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ensure proper stacking */
|
||||
.split-active .main-panel {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.split-active .side-panel {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hide side panel on mobile via media query as fallback */
|
||||
@media (max-width: 1023px) {
|
||||
.split-pane-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.side-panel,
|
||||
.split-pane-container :global(.resize-handle) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
packages/shared-splitscreen/src/index.ts
Normal file
46
packages/shared-splitscreen/src/index.ts
Normal file
|
|
@ -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';
|
||||
251
packages/shared-splitscreen/src/stores/split-panel.svelte.ts
Normal file
251
packages/shared-splitscreen/src/stores/split-panel.svelte.ts
Normal file
|
|
@ -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<PanelConfig | null>(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<SplitPanelStore>(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;
|
||||
}
|
||||
}
|
||||
88
packages/shared-splitscreen/src/types.ts
Normal file
88
packages/shared-splitscreen/src/types.ts
Normal file
|
|
@ -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;
|
||||
13
packages/shared-splitscreen/src/utils/index.ts
Normal file
13
packages/shared-splitscreen/src/utils/index.ts
Normal file
|
|
@ -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';
|
||||
97
packages/shared-splitscreen/src/utils/local-storage.ts
Normal file
97
packages/shared-splitscreen/src/utils/local-storage.ts
Normal file
|
|
@ -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<SplitScreenState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SplitScreenState>): 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<SplitScreenState> | 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,
|
||||
};
|
||||
}
|
||||
65
packages/shared-splitscreen/src/utils/url-state.ts
Normal file
65
packages/shared-splitscreen/src/utils/url-state.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
18
packages/shared-splitscreen/tsconfig.json
Normal file
18
packages/shared-splitscreen/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
></div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => handleItemClick(item)}
|
||||
class="pill glass-pill fan-pill"
|
||||
class:danger-pill={item.danger}
|
||||
class:active-pill={item.active}
|
||||
class:has-submenu={item.submenu && item.submenu.length > 0}
|
||||
class:submenu-open={openSubmenuId === item.id}
|
||||
<div
|
||||
class="fan-pill-wrapper"
|
||||
class:has-split-button={item.showSplitButton}
|
||||
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
|
||||
>
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt="" class="pill-image-icon" />
|
||||
{:else if item.icon === 'mana'}
|
||||
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d={getIcon('mana')} />
|
||||
</svg>
|
||||
{:else if item.icon}
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon(item.icon)}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{#if item.active}
|
||||
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon('check')}
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.submenu && item.submenu.length > 0}
|
||||
<svg
|
||||
class="chevron-submenu"
|
||||
class:rotated={openSubmenuId === item.id}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<button
|
||||
onclick={(e) => handleItemClick(item, e)}
|
||||
class="pill glass-pill fan-pill"
|
||||
class:danger-pill={item.danger}
|
||||
class:active-pill={item.active}
|
||||
class:has-submenu={item.submenu && item.submenu.length > 0}
|
||||
class:submenu-open={openSubmenuId === item.id}
|
||||
>
|
||||
{#if item.imageUrl}
|
||||
<img src={item.imageUrl} alt="" class="pill-image-icon" />
|
||||
{:else if item.icon === 'mana'}
|
||||
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d={getIcon('mana')} />
|
||||
</svg>
|
||||
{:else if item.icon}
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon(item.icon)}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="pill-label">{item.label}</span>
|
||||
{#if item.active}
|
||||
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon('check')}
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.submenu && item.submenu.length > 0}
|
||||
<svg
|
||||
class="chevron-submenu"
|
||||
class:rotated={openSubmenuId === item.id}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon('chevronDown')}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if item.showSplitButton && item.onSplitClick}
|
||||
<button
|
||||
onclick={(e) => handleSplitClick(item, e)}
|
||||
class="split-button glass-pill"
|
||||
title="Open in split panel (Ctrl/Cmd+Click)"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIcon('chevronDown')}
|
||||
/>
|
||||
</svg>
|
||||
<svg class="split-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Submenu items -->
|
||||
{#if item.submenu && item.submenu.length > 0 && openSubmenuId === item.id}
|
||||
<div class="submenu-container">
|
||||
|
|
@ -516,6 +545,61 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
/* Split button wrapper */
|
||||
.fan-pill-wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
animation: fanIn 0.15s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fan-up .fan-pill-wrapper {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.fan-pill-wrapper .fan-pill {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.fan-pill-wrapper.has-split-button .fan-pill {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.split-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: 9999px;
|
||||
border-bottom-right-radius: 9999px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.split-button:hover {
|
||||
background: var(--color-primary-100, rgba(59, 130, 246, 0.15));
|
||||
border-color: var(--color-primary-200, rgba(59, 130, 246, 0.3));
|
||||
}
|
||||
|
||||
:global(.dark) .split-button:hover {
|
||||
background: var(--color-primary-900, rgba(59, 130, 246, 0.2));
|
||||
border-color: var(--color-primary-800, rgba(59, 130, 246, 0.4));
|
||||
}
|
||||
|
||||
.split-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Footer for custom content (e.g., a11y toggles) */
|
||||
.dropdown-footer {
|
||||
animation: fanIn 0.15s ease-out forwards;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@
|
|||
function createAppDropdownItems(
|
||||
apps: PillAppItem[],
|
||||
allAppsUrl?: string,
|
||||
allAppsText?: string
|
||||
allAppsText?: string,
|
||||
openInPanelHandler?: (appId: string, url: string) => void
|
||||
): PillDropdownItem[] {
|
||||
const items: PillDropdownItem[] = apps.map((app) => ({
|
||||
id: app.id,
|
||||
|
|
@ -117,7 +118,19 @@
|
|||
// Use image icon if available, otherwise use grid as fallback
|
||||
imageUrl: app.icon,
|
||||
icon: app.icon ? undefined : 'grid',
|
||||
onClick: () => {
|
||||
onClick: (event?: MouseEvent) => {
|
||||
// Check for modifier keys (Ctrl/Cmd + Click opens in panel)
|
||||
if (
|
||||
event &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
openInPanelHandler &&
|
||||
app.url &&
|
||||
!app.isCurrent
|
||||
) {
|
||||
openInPanelHandler(app.id, app.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.isCurrent) {
|
||||
// Navigate to home route for current app
|
||||
window.location.href = '/';
|
||||
|
|
@ -127,6 +140,10 @@
|
|||
},
|
||||
active: app.isCurrent,
|
||||
disabled: false,
|
||||
// Show split button if handler is provided and app is not current
|
||||
showSplitButton: !!openInPanelHandler && !app.isCurrent && !!app.url,
|
||||
onSplitClick:
|
||||
openInPanelHandler && app.url ? () => openInPanelHandler(app.id, app.url!) : undefined,
|
||||
}));
|
||||
|
||||
// Add "All Apps" link at the end if href is provided
|
||||
|
|
@ -228,6 +245,10 @@
|
|||
showA11yQuickToggles?: boolean;
|
||||
/** Desktop navigation position (mobile always at bottom) */
|
||||
desktopPosition?: 'top' | 'bottom';
|
||||
/** Called when an app should be opened in a split panel */
|
||||
onOpenInPanel?: (appId: string, url: string) => void;
|
||||
/** Toolbar content snippet (shown in sidebar mode) */
|
||||
toolbarContent?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -270,6 +291,8 @@
|
|||
onA11yReduceMotionChange,
|
||||
showA11yQuickToggles = false,
|
||||
desktopPosition = 'top',
|
||||
onOpenInPanel,
|
||||
toolbarContent,
|
||||
}: Props = $props();
|
||||
|
||||
// Type guards for elements
|
||||
|
|
@ -320,8 +343,15 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Dropdown direction: up on mobile (nav at bottom), down on desktop/sidebar
|
||||
const dropdownDirection = $derived<'up' | 'down'>(isMobile && !isSidebarMode ? 'up' : 'down');
|
||||
// Dropdown direction: up when nav is at bottom (mobile or desktop-bottom), down otherwise
|
||||
const dropdownDirection = $derived<'up' | 'down'>(
|
||||
// Mobile: always up (nav at bottom) unless in sidebar mode
|
||||
(isMobile && !isSidebarMode) ||
|
||||
// Desktop with bottom position: up unless in sidebar mode
|
||||
(!isMobile && desktopPosition === 'bottom' && !isSidebarMode)
|
||||
? 'up'
|
||||
: 'down'
|
||||
);
|
||||
|
||||
function toggleSidebarMode() {
|
||||
const newValue = !isSidebarMode;
|
||||
|
|
@ -412,7 +442,14 @@
|
|||
chevronDown: 'M19 9l-7 7-7-7',
|
||||
chevronUp: 'M5 15l7-7 7 7',
|
||||
chevronLeft: 'M15 19l-7-7 7-7',
|
||||
chevronRight: 'M9 5l7 7-7 7',
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
// Layout icons
|
||||
sidebar: 'M3 3h7v18H3V3zm9 0h9v18h-9V3z', // Sidebar layout icon
|
||||
layoutBottom: 'M3 3h18v9H3V3zm0 12h18v6H3v-6z', // Bottom bar layout icon
|
||||
panelRight: 'M9 3h12v18H9V3zM3 3h3v18H3V3z', // Panel right icon
|
||||
minimize: 'M4 12h16', // Minimize (minus) icon
|
||||
maximize: 'M4 8h16M4 16h16', // Two lines for expand
|
||||
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
|
||||
gridSmall:
|
||||
|
|
@ -442,7 +479,7 @@
|
|||
<!-- Logo pill / App Switcher -->
|
||||
{#if showAppSwitcher && appItems.length > 0}
|
||||
<PillDropdown
|
||||
items={createAppDropdownItems(appItems, allAppsHref, allAppsLabel)}
|
||||
items={createAppDropdownItems(appItems, allAppsHref, allAppsLabel, onOpenInPanel)}
|
||||
direction={dropdownDirection}
|
||||
label={appName}
|
||||
icon="grid"
|
||||
|
|
@ -770,28 +807,24 @@
|
|||
<!-- Control Button (right position in horizontal mode, bottom in sidebar mode) -->
|
||||
{#if !isSidebarMode}
|
||||
<div class="pill glass-pill segmented-control">
|
||||
<button onclick={collapseNav} class="segment-btn" title="Collapse navigation">
|
||||
<button onclick={collapseNav} class="segment-btn" title="Navigation minimieren">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath('chevronLeft')}
|
||||
d={getIconPath('chevronRight')}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="segment-divider"></div>
|
||||
<button
|
||||
onclick={toggleSidebarMode}
|
||||
class="segment-btn"
|
||||
title="Switch to sidebar navigation"
|
||||
>
|
||||
<button onclick={toggleSidebarMode} class="segment-btn" title="Zur Sidebar wechseln">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath('chevronDown')}
|
||||
d={getIconPath('sidebar')}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -800,26 +833,37 @@
|
|||
|
||||
<!-- Control Button (bottom position in sidebar mode) -->
|
||||
{#if isSidebarMode}
|
||||
<!-- Toolbar content (if provided) -->
|
||||
{#if toolbarContent}
|
||||
<div class="pill-divider sidebar-divider"></div>
|
||||
<div class="sidebar-toolbar-content">
|
||||
{@render toolbarContent()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="sidebar-spacer"></div>
|
||||
<div class="pill glass-pill segmented-control sidebar-segmented">
|
||||
<button onclick={toggleSidebarMode} class="segment-btn" title="Switch to top navigation">
|
||||
<button
|
||||
onclick={toggleSidebarMode}
|
||||
class="segment-btn"
|
||||
title="Zur Bottom-Navigation wechseln"
|
||||
>
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath('chevronUp')}
|
||||
d={getIconPath('layoutBottom')}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="segment-divider"></div>
|
||||
<button onclick={collapseNav} class="segment-btn" title="Collapse navigation">
|
||||
<button onclick={collapseNav} class="segment-btn" title="Sidebar minimieren">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath('chevronLeft')}
|
||||
d={getIconPath('chevronRight')}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -1097,6 +1141,89 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* Toolbar content in sidebar mode */
|
||||
.sidebar-toolbar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar-toolbar-content {
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar-toolbar-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content :global(.toolbar-bar) {
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content :global(.toolbar-content) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content :global(.pill-toolbar-btn),
|
||||
.sidebar-toolbar-content :global(.pill-dropdown .trigger-button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content :global(.pill-toolbar-btn:hover),
|
||||
.sidebar-toolbar-content :global(.pill-dropdown .trigger-button:hover) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar-toolbar-content :global(.pill-toolbar-btn:hover),
|
||||
:global(.dark) .sidebar-toolbar-content :global(.pill-dropdown .trigger-button:hover) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Style for PillViewSwitcher in sidebar */
|
||||
.sidebar-toolbar-content :global(.pill-view-switcher) {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-toolbar-content :global(.pill-view-switcher .view-option) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Mobile: Sidebar container adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-container {
|
||||
|
|
@ -1286,17 +1413,17 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* FAB for collapsed state */
|
||||
/* FAB for collapsed state - positioned at right */
|
||||
.nav-fab {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.875rem;
|
||||
border-radius: 0 0 1rem 0;
|
||||
border-radius: 0 0 0 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
|
@ -1306,17 +1433,17 @@
|
|||
.nav-fab.desktop-bottom {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
border-radius: 0 1rem 0 0;
|
||||
border-radius: 1rem 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: FAB always at bottom left */
|
||||
/* Mobile: FAB always at bottom right */
|
||||
@media (max-width: 768px) {
|
||||
.nav-fab {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 0 1rem 0 0;
|
||||
right: 0;
|
||||
border-radius: 1rem 0 0 0;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.875rem);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export interface PillDropdownItem {
|
|||
icon?: string;
|
||||
/** Image URL for icon (data URL or regular URL) */
|
||||
imageUrl?: string;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
/** Click handler (receives optional MouseEvent for modifier key detection) */
|
||||
onClick?: (event?: MouseEvent) => void;
|
||||
/** Whether item is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether item should be styled as danger/destructive */
|
||||
|
|
@ -43,6 +43,10 @@ export interface PillDropdownItem {
|
|||
divider?: boolean;
|
||||
/** Nested submenu items */
|
||||
submenu?: PillDropdownItem[];
|
||||
/** Whether to show a split button for opening in panel */
|
||||
showSplitButton?: boolean;
|
||||
/** Click handler for split button */
|
||||
onSplitClick?: () => void;
|
||||
}
|
||||
|
||||
export interface PillAppItem {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export const MANACORE_SHARED_PACKAGES = [
|
|||
'@manacore/shared-profile-ui',
|
||||
'@manacore/shared-i18n',
|
||||
'@manacore/shared-api-client',
|
||||
'@manacore/shared-splitscreen',
|
||||
] as const;
|
||||
|
||||
export interface ViteConfigOptions {
|
||||
|
|
|
|||
563
pnpm-lock.yaml
generated
563
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue