mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
cbfe995f7b
commit
0f634b2540
11 changed files with 349 additions and 263 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
33
apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts
Normal file
33
apps/mana/apps/web/src/lib/stores/bottom-bar.svelte.ts
Normal 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 = {};
|
||||
},
|
||||
};
|
||||
|
|
@ -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))
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue