refactor(workbench): replace minimize tabs + scene tabs with unified bottom bar

Removes the minimize/restore system entirely (scenes make it redundant)
and merges the top-level SceneTabs into a single inline bottom bar that
renders inside the layout's bottom-stack.

Chrome tab-group style: active scene shows its app tabs inline after it,
inactive scenes appear as compact pills. App tabs show module icons
instead of color dots, no fullscreen/close buttons (use context menu).

Architecture:
- New bottomBarStore (svelte $state) lets pages inject a component into
  the layout's bottom-stack without a Svelte slot mechanism
- SceneAppBar component extracted for clean separation
- PageCarousel stripped to pure carousel (no scene/bar responsibilities)
- bottomChromeHeight accounts for the bar when present (+36px)

Removed: minimized field from WorkbenchSceneApp/CarouselPage, Minus
button from PageShell, minimizeApp/restoreApp from store, onMinimize/
onRestore from context menu builder, SceneTabs component usage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 19:22:35 +02:00
parent cbfe995f7b
commit 0f634b2540
11 changed files with 349 additions and 263 deletions

View file

@ -1,10 +1,9 @@
<!--
PageCarousel — Shared horizontal carousel with drag reorder, minimized tabs, and add button.
Used by workbench (home) and todo routes.
PageCarousel — Shared horizontal carousel with drag reorder and add button.
The scene+app bar is rendered in the layout's bottom-stack via bottomBarStore.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { Plus, X, ArrowsOut } from '@mana/shared-icons';
import { Plus } from '@mana/shared-icons';
import type { Snippet } from 'svelte';
import type { CarouselPage } from './types';
@ -13,9 +12,8 @@
defaultWidth?: number;
showPicker: boolean;
onReorder: (fromId: string, toId: string) => void;
onRestore: (id: string) => void;
onMaximize: (id: string) => void;
onRemove: (id: string) => void;
onMaximize?: (id: string) => void;
onRemove?: (id: string) => void;
onTogglePicker: () => void;
onTabContextMenu?: (e: MouseEvent, pageId: string) => void;
addLabel?: string;
@ -28,24 +26,18 @@
defaultWidth = 480,
showPicker,
onReorder,
onRestore,
onMaximize,
onRemove,
onMaximize: _onMaximize,
onRemove: _onRemove,
onTogglePicker,
onTabContextMenu,
onTabContextMenu: _onTabContextMenu,
addLabel = 'Hinzufügen',
page: pageSnippet,
picker,
}: Props = $props();
let expandedPages = $derived(pages.filter((p) => !p.minimized));
let minimizedPages = $derived(pages.filter((p) => p.minimized));
// ── Drag reorder ────────────────────────────────────────
let dragId = $state<string | null>(null);
function handleDragStart(e: DragEvent, id: string) {
// Only allow page reorder drag from the drag-handle, not from items inside the page
const target = e.target as HTMLElement;
if (!target.closest('.drag-handle')) {
e.preventDefault();
@ -57,42 +49,36 @@
e.dataTransfer.setData('text/plain', id);
}
}
function handleDragOver(e: DragEvent) {
if (!dragId) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function handleDrop(e: DragEvent, targetId: string) {
e.preventDefault();
if (!dragId || dragId === targetId) return;
onReorder(dragId, targetId);
dragId = null;
}
function handleDragEnd() {
dragId = null;
}
// ── Picker scroll ───────────────────────────────────────
let pickerEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (showPicker && pickerEl) {
if (showPicker && pickerEl)
pickerEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
});
</script>
<div class="carousel-root">
<!-- Carousel track -->
<div class="fokus-track" style="--sheet-width: {defaultWidth}px">
{#each expandedPages as p, idx (p.id)}
{#each pages as p, idx (p.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="page-drag-wrapper"
class:dragging={dragId === p.id}
data-page-id={p.id}
ondragstart={(e) => handleDragStart(e, p.id)}
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, p.id)}
@ -102,52 +88,22 @@
</div>
{/each}
<!-- Picker / add button -->
{#if expandedPages.length === 0}
{#if pages.length === 0}
<div class="empty-wrapper">
{#if showPicker && picker}
{@render picker()}
{:else}
<button class="add-card alone" onclick={onTogglePicker}>
<Plus size={24} />
<span class="add-label">{addLabel}</span>
<Plus size={24} /><span class="add-label">{addLabel}</span>
</button>
{/if}
</div>
{:else if showPicker && picker}
<div bind:this={pickerEl}>
{@render picker()}
</div>
<div bind:this={pickerEl}>{@render picker()}</div>
{:else}
<button class="add-card" onclick={onTogglePicker} title={addLabel}>
<Plus size={18} />
</button>
<button class="add-card" onclick={onTogglePicker} title={addLabel}><Plus size={18} /></button>
{/if}
</div>
<!-- Minimized tabs -->
{#if minimizedPages.length > 0}
<div class="minimized-tabs">
{#each minimizedPages as p (p.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="minimized-tab" oncontextmenu={(e) => onTabContextMenu?.(e, p.id)}>
<span class="tab-dot" style="background-color: {p.color}"></span>
<button class="tab-title" onclick={() => onRestore(p.id)}>
{p.title}
</button>
<button class="tab-maximize" onclick={() => onMaximize(p.id)} title="Maximieren">
<ArrowsOut size={12} />
</button>
<button class="tab-close" onclick={() => onRemove(p.id)} title={$_('common.close')}>
<X size={12} />
</button>
</div>
{/each}
<button class="tab-add" onclick={onTogglePicker} title={addLabel}>
<Plus size={14} />
</button>
</div>
{/if}
</div>
<style>
@ -156,8 +112,6 @@
flex-direction: column;
flex: 1;
}
/* Carousel track */
.fokus-track {
display: flex;
gap: 1rem;
@ -175,7 +129,6 @@
.fokus-track::-webkit-scrollbar {
display: none;
}
.page-drag-wrapper {
flex: 0 0 auto;
transition: opacity 0.15s;
@ -183,8 +136,6 @@
.page-drag-wrapper.dragging {
opacity: 0.4;
}
/* Add button */
.add-card {
flex: 0 0 auto;
width: 48px;
@ -223,106 +174,4 @@
font-size: 0.875rem;
font-weight: 500;
}
/* Minimized tabs */
.minimized-tabs {
position: fixed;
bottom: var(--bottom-chrome-height, 4.5rem);
left: 50%;
transform: translateX(-50%);
z-index: 91;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
border-radius: 0.75rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
animation: slideUp 0.25s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.minimized-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: background 0.15s;
}
.minimized-tab:hover {
background: hsl(var(--color-surface-hover));
}
.tab-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
.tab-title {
border: none;
background: none;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
}
.tab-title:hover {
color: hsl(var(--color-primary));
}
.tab-maximize,
.tab-close {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
opacity: 0;
transition: all 0.15s;
}
.minimized-tab:hover .tab-maximize,
.minimized-tab:hover .tab-close {
opacity: 1;
}
.tab-maximize:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
.tab-close:hover {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.08);
}
.tab-add {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
transition: all 0.15s;
margin-left: 0.125rem;
}
.tab-add:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
</style>

View file

@ -7,7 +7,6 @@
import { _ } from 'svelte-i18n';
import {
X,
Minus,
DotsSixVertical,
CornersOut,
CornersIn,
@ -21,7 +20,6 @@
heightPx?: number;
maximized?: boolean;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
@ -44,7 +42,6 @@
heightPx,
maximized = false,
onClose,
onMinimize,
onMaximize,
onResize,
onMoveLeft,
@ -146,19 +143,6 @@
{/if}
<span class="drag-handle-icon"><DotsSixVertical size={14} /></span>
<div class="window-actions">
{#if onMinimize}
<button
class="window-btn"
onclick={(e) => {
e.stopPropagation();
onMinimize();
}}
draggable="false"
title="Minimieren"
>
<Minus size={12} />
</button>
{/if}
{#if onMaximize}
<button
class="window-btn"

View file

@ -1,9 +1,11 @@
import type { Component } from 'svelte';
export interface CarouselPage {
id: string;
minimized: boolean;
maximized?: boolean;
widthPx: number;
heightPx?: number;
title: string;
color: string;
icon?: Component;
}

View file

@ -18,7 +18,6 @@
heightPx?: number;
maximized?: boolean;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
@ -32,7 +31,6 @@
heightPx,
maximized = false,
onClose,
onMinimize,
onMaximize,
onResize,
onMoveLeft,
@ -305,7 +303,6 @@
color={appColor}
icon={appIcon}
{onClose}
{onMinimize}
{onMaximize}
{onResize}
{onMoveLeft}

View file

@ -0,0 +1,223 @@
<!--
SceneAppBar — Combined scene + app tabs bar (Chrome tab-group style).
Rendered by the layout's bottom-stack via bottomBarStore.
-->
<script lang="ts">
import { Plus } from '@mana/shared-icons';
import type { CarouselPage } from '$lib/components/page-carousel/types';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
interface Props {
scenes: WorkbenchScene[];
activeSceneId: string | null;
pages: CarouselPage[];
onSceneSelect: (id: string) => void;
onSceneCreate: () => void;
onSceneContextMenu: (e: MouseEvent, scene: WorkbenchScene) => void;
onAppClick: (id: string) => void;
onAppContextMenu: (e: MouseEvent, id: string) => void;
onAddApp: () => void;
}
let {
scenes,
activeSceneId,
pages,
onSceneSelect,
onSceneCreate,
onSceneContextMenu,
onAppClick,
onAppContextMenu,
onAddApp,
}: Props = $props();
</script>
<div class="scene-app-bar">
{#each scenes as scene (scene.id)}
{@const isActive = scene.id === activeSceneId}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
type="button"
class="scene-pill"
class:active={isActive}
onclick={() => onSceneSelect(scene.id)}
oncontextmenu={(e) => onSceneContextMenu(e, scene)}
>
{#if scene.icon}
<span class="scene-icon">{scene.icon}</span>
{/if}
<span class="scene-name">{scene.name}</span>
</button>
<!-- App tabs appear inline right after the active scene pill -->
{#if isActive && pages.length > 0}
{#each pages as p (p.id)}
{@const AppIcon = p.icon}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
class="app-tab"
onclick={() => onAppClick(p.id)}
oncontextmenu={(e) => onAppContextMenu(e, p.id)}
>
{#if AppIcon}
<span class="app-icon" style="color: {p.color}">
<AppIcon size={12} weight="fill" />
</span>
{:else}
<span class="app-dot" style="background-color: {p.color}"></span>
{/if}
<span class="app-title">{p.title}</span>
</button>
{/each}
<button class="app-add" onclick={onAddApp} title="App hinzufügen">
<Plus size={12} />
</button>
{#if scenes.length > 1}
<span class="bar-sep"></span>
{/if}
{/if}
{/each}
<button type="button" class="scene-add" onclick={onSceneCreate} title="Neue Szene">
<Plus size={12} />
</button>
</div>
<style>
.scene-app-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 0.125rem;
padding: 0.3125rem 0.625rem;
margin: 0 auto;
width: fit-content;
max-width: calc(100vw - 2rem);
pointer-events: auto;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow-x: auto;
scrollbar-width: none;
}
.scene-app-bar::-webkit-scrollbar {
display: none;
}
.bar-sep {
width: 1px;
height: 16px;
background: hsl(var(--color-border));
flex-shrink: 0;
margin: 0 0.25rem;
}
/* Scene pills — bold group headers */
.scene-pill {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
flex-shrink: 0;
border: none;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.3125rem 0.625rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
max-width: 140px;
}
.scene-pill:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.scene-pill.active {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: inset 0 0 0 1px hsl(var(--color-primary) / 0.25);
}
.scene-icon {
font-size: 0.8125rem;
line-height: 1;
}
.scene-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-add {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.scene-add:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-primary));
}
/* App tabs — lighter, inline after active scene */
.app-tab {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.375rem;
border-radius: 0.3125rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.app-tab:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.app-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.app-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.app-title {
font-size: 0.6875rem;
font-weight: 400;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-add {
border: none;
background: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
padding: 0.1875rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
transition: all 0.15s;
margin-left: 0.0625rem;
flex-shrink: 0;
}
.app-add:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
</style>

View file

@ -3,36 +3,28 @@ import type { AppDescriptor, ContextMenuLocation } from '$lib/app-registry/types
import {
CornersOut,
CornersIn,
Minus,
CaretLeft,
CaretRight,
X,
ArrowSquareOut,
Link,
ArrowLineUp,
} from '@mana/shared-icons';
export interface ContextMenuContext {
location: ContextMenuLocation;
appId: string;
app: AppDescriptor;
/** Is the card currently maximized? */
maximized?: boolean;
// Window management callbacks (optional per location)
onMaximize?: () => void;
onMinimize?: () => void;
onRestore?: () => void;
onClose?: () => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
/** Override route (default: /${appId}) */
appRoute?: string;
}
export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
// 1. App-specific actions
const appActions = (ctx.app.contextMenuActions ?? []).filter(
(a) => !a.showIn || a.showIn.includes(ctx.location)
);
@ -51,7 +43,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[
items.push({ id: 'div-app', label: '', type: 'divider' });
}
// 2. Window management (location-dependent)
if (ctx.location === 'card') {
if (ctx.onMaximize) {
items.push({
@ -61,14 +52,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[
action: ctx.onMaximize,
});
}
if (ctx.onMinimize) {
items.push({
id: 'minimize',
label: 'Minimieren',
icon: Minus,
action: ctx.onMinimize,
});
}
if (ctx.onMoveLeft) {
items.push({
id: 'move-left',
@ -89,14 +72,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[
}
if (ctx.location === 'tab') {
if (ctx.onRestore) {
items.push({
id: 'restore',
label: 'Wiederherstellen',
icon: ArrowLineUp,
action: ctx.onRestore,
});
}
if (ctx.onMaximize) {
items.push({
id: 'maximize',
@ -108,7 +83,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[
items.push({ id: 'div-window', label: '', type: 'divider' });
}
// 3. Navigation actions (always)
const route = ctx.appRoute ?? `/${ctx.appId}`;
items.push({
id: 'open-route',
@ -123,7 +97,6 @@ export function buildContextMenuItems(ctx: ContextMenuContext): ContextMenuItem[
action: () => navigator.clipboard.writeText(window.location.origin + route),
});
// 4. Close (at the end, danger variant) — only for card/tab
if (ctx.location === 'card' || ctx.location === 'tab') {
if (ctx.onClose) {
items.push({ id: 'div-close', label: '', type: 'divider' });

View file

@ -0,0 +1,33 @@
/**
* Bottom Bar Slot allows pages to inject a component into the layout's
* bottom-stack (above notifications, below the page content).
*
* Usage:
* Page sets: bottomBarStore.set(MyBarComponent, { myProp: value })
* Layout reads: bottomBarStore.component / bottomBarStore.props
* Page clears: bottomBarStore.clear() (onDestroy)
*/
import type { Component } from 'svelte';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let barComponent = $state<Component<any> | null>(null);
let barProps = $state<Record<string, unknown>>({});
export const bottomBarStore = {
get component() {
return barComponent;
},
get props() {
return barProps;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
set(component: Component<any>, props: Record<string, unknown> = {}) {
barComponent = component;
barProps = props;
},
clear() {
barComponent = null;
barProps = {};
},
};

View file

@ -26,9 +26,9 @@ const TABLE = 'workbenchScenes';
const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId';
const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [
{ appId: 'todo', minimized: false },
{ appId: 'calendar', minimized: false },
{ appId: 'notes', minimized: false },
{ appId: 'todo' },
{ appId: 'calendar' },
{ appId: 'notes' },
];
// ─── Reactive state ───────────────────────────────────────────
@ -251,10 +251,8 @@ export const workbenchScenesStore = {
async addApp(appId: string) {
await patchActiveScene((apps) => {
if (apps.some((a) => a.appId === appId)) {
return apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a));
}
return [...apps, { appId, minimized: false }];
if (apps.some((a) => a.appId === appId)) return apps;
return [...apps, { appId }];
});
},
@ -262,21 +260,9 @@ export const workbenchScenesStore = {
await patchActiveScene((apps) => apps.filter((a) => a.appId !== appId));
},
async minimizeApp(appId: string) {
await patchActiveScene((apps) =>
apps.map((a) => (a.appId === appId ? { ...a, minimized: true } : a))
);
},
async restoreApp(appId: string) {
await patchActiveScene((apps) =>
apps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a))
);
},
async toggleMaximizeApp(appId: string) {
await patchActiveScene((apps) =>
apps.map((a) => (a.appId === appId ? { ...a, maximized: !a.maximized, minimized: false } : a))
apps.map((a) => (a.appId === appId ? { ...a, maximized: !a.maximized } : a))
);
},

View file

@ -2,7 +2,7 @@
* Workbench Scenes user-defined named layouts of the workbench (homepage).
*
* Each scene is a named bundle of "open apps" with their window state
* (minimized / maximized / size). Users can switch between scenes to
* (maximized / size). Users can switch between scenes to
* quickly change context (e.g. "Home", "Deep Work", "Travel").
*
* Scenes are persisted in the unified Mana Dexie database under the
@ -15,7 +15,6 @@ import type { BaseRecord } from '@mana/local-store';
export interface WorkbenchSceneApp {
appId: string;
minimized: boolean;
maximized?: boolean;
widthPx?: number;
heightPx?: number;

View file

@ -8,6 +8,7 @@
import KeyboardShortcutsModal from '$lib/components/KeyboardShortcutsModal.svelte';
import SessionWarning from '$lib/components/SessionWarning.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import { locale, _ } from 'svelte-i18n';
import {
@ -249,7 +250,9 @@
}
// Bottom chrome height: calculated from state, not measured (avoids reflow loop)
const bottomChromeHeight = $derived((isCollapsed ? 0 : 80) + (isTagStripVisible ? 44 : 0) + 72);
const bottomChromeHeight = $derived(
(isCollapsed ? 0 : 80) + (isTagStripVisible ? 44 : 0) + 72 + (bottomBarStore.component ? 36 : 0)
);
// ── DnD context ─────────────────────────────────────────
let tagDropHandler = $state<((tagId: string, payload: DragPayload) => void) | null>(null);
@ -602,6 +605,12 @@
<div class="min-h-screen bg-background">
<!-- Bottom Stack: all fixed-bottom elements in one flex container -->
<div class="bottom-stack" style:--bottom-chrome-height="{bottomChromeHeight}px">
<!-- Page-injected bottom bar (e.g. workbench scene+app tabs) -->
{#if bottomBarStore.component}
{@const BarComponent = bottomBarStore.component}
<BarComponent {...bottomBarStore.props} />
{/if}
<!-- One-time encryption intro — sits at the top of the stack so
it can't be obscured by the QuickInputBar / TagStrip / PillNav.
Self-gates on isVaultUnlocked() so guests never see it. -->

View file

@ -1,7 +1,7 @@
<script lang="ts">
import AppPage from '$lib/components/workbench/AppPage.svelte';
import AppPagePicker from '$lib/components/workbench/AppPagePicker.svelte';
import SceneTabs from '$lib/components/workbench/scenes/SceneTabs.svelte';
import SceneAppBar from '$lib/components/workbench/SceneAppBar.svelte';
import SceneRenameDialog from '$lib/components/workbench/scenes/SceneRenameDialog.svelte';
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
@ -9,11 +9,14 @@
import { onMount, onDestroy } from 'svelte';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { bottomBarStore } from '$lib/stores/bottom-bar.svelte';
import { DragPreview } from '@mana/shared-ui/dnd';
import type { DragType } from '@mana/shared-ui/dnd';
import { ContextMenu } from '@mana/shared-ui';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { Pencil, Copy, Trash } from '@mana/shared-icons';
import { _ } from 'svelte-i18n';
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
function resolveEntity(type: string, data: Record<string, unknown>) {
const app = getAppByDragType(type as DragType);
@ -46,6 +49,7 @@
});
onDestroy(() => {
workbenchScenesStore.dispose();
bottomBarStore.clear();
});
let scenes = $derived(workbenchScenesStore.scenes);
@ -67,7 +71,6 @@
const entry = getApp(a.appId);
return {
id: a.appId,
minimized: a.minimized,
maximized: a.maximized,
widthPx: a.widthPx ?? DEFAULT_WIDTH,
heightPx: a.heightPx,
@ -77,12 +80,35 @@
return t !== k ? t : (entry?.name ?? a.appId);
})(),
color: entry?.color ?? '#6B7280',
icon: entry?.icon,
};
})
);
let showPicker = $state(false);
function scrollToPage(id: string) {
const el = document.querySelector(`[data-page-id="${id}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
// ── Register SceneAppBar in the layout's bottom-stack ───
$effect(() => {
if (scenes.length > 0) {
bottomBarStore.set(SceneAppBar, {
scenes,
activeSceneId,
pages: carouselPages,
onSceneSelect: (id: string) => workbenchScenesStore.setActiveScene(id),
onSceneCreate: handleCreateScene,
onSceneContextMenu: handleSceneContextMenu,
onAppClick: scrollToPage,
onAppContextMenu: (e: MouseEvent, id: string) => handleTabContextMenu(e, id),
onAddApp: () => (showPicker = !showPicker),
});
}
});
// ── App CRUD (delegated to active scene) ────────────────
function handleAddApp(appId: string) {
workbenchScenesStore.addApp(appId);
@ -91,12 +117,6 @@
function handleRemoveApp(id: string) {
workbenchScenesStore.removeApp(id);
}
function handleMinimizeApp(id: string) {
workbenchScenesStore.minimizeApp(id);
}
function handleRestoreApp(id: string) {
workbenchScenesStore.restoreApp(id);
}
function handleMaximizeApp(id: string) {
workbenchScenesStore.toggleMaximizeApp(id);
}
@ -126,7 +146,6 @@
app,
maximized: entry?.maximized,
onMaximize: () => handleMaximizeApp(appId),
onMinimize: () => handleMinimizeApp(appId),
onClose: () => handleRemoveApp(appId),
onMoveLeft: idx > 0 ? () => handleMoveLeft(appId) : undefined,
onMoveRight: idx < openApps.length - 1 ? () => handleMoveRight(appId) : undefined,
@ -141,13 +160,41 @@
location: 'tab',
appId,
app,
onRestore: () => handleRestoreApp(appId),
onMaximize: () => handleMaximizeApp(appId),
onClose: () => handleRemoveApp(appId),
});
ctxMenu.open(e, appId, items);
}
function handleSceneContextMenu(e: MouseEvent, scene: WorkbenchScene) {
e.preventDefault();
const items: ContextMenuItem[] = [
{
id: 'rename',
label: 'Umbenennen',
icon: Pencil,
action: () => handleRequestRename(scene.id),
},
{
id: 'duplicate',
label: 'Duplizieren',
icon: Copy,
action: () => handleDuplicateScene(scene.id),
},
];
if (scenes.length > 1) {
items.push({ id: 'div', label: '', type: 'divider' });
items.push({
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger',
action: () => handleRequestDeleteScene(scene.id),
});
}
ctxMenu.open(e, scene.id, items);
}
// ── Scene CRUD dialogs ──────────────────────────────────
type SceneDialogMode =
| { kind: 'create' }
@ -195,27 +242,12 @@
<DragPreview {resolveEntity} />
<div class="workbench">
<SceneTabs
{scenes}
{activeSceneId}
onSelect={(id) => workbenchScenesStore.setActiveScene(id)}
onCreate={handleCreateScene}
onRequestRename={handleRequestRename}
onDuplicate={handleDuplicateScene}
onRequestDelete={handleRequestDeleteScene}
onReorder={(fromId, toId) => workbenchScenesStore.reorderScenes(fromId, toId)}
/>
<PageCarousel
pages={carouselPages}
defaultWidth={DEFAULT_WIDTH}
{showPicker}
onReorder={handleReorder}
onRestore={handleRestoreApp}
onMaximize={handleMaximizeApp}
onRemove={handleRemoveApp}
onTogglePicker={() => (showPicker = !showPicker)}
onTabContextMenu={handleTabContextMenu}
addLabel="App hinzufügen"
>
{#snippet page(p)}
@ -226,7 +258,6 @@
heightPx={p.heightPx}
maximized={p.maximized}
onClose={() => handleRemoveApp(p.id)}
onMinimize={() => handleMinimizeApp(p.id)}
onMaximize={() => handleMaximizeApp(p.id)}
onResize={(w, h) => handleResize(p.id, w, h)}
onMoveLeft={idx > 0 ? () => handleMoveLeft(p.id) : undefined}