refactor(workbench): redesign page cards — rounder corners, unified header, remove DnD

- Increase border-radius to 1.25rem for page cards and add button
- Merge toolbar bar and header into single row (title left, actions right)
- Remove drag-and-drop reorder in favor of arrow buttons
- Make window action icons larger (24px bold) with more spacing
- Title icon monochrome with reduced opacity
- Remove onReorder prop and handleReorder from all carousel consumers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 23:02:10 +02:00
parent d2c9795405
commit f5ad492371
5 changed files with 72 additions and 214 deletions

View file

@ -1,5 +1,5 @@
<!-- <!--
PageCarousel — Shared horizontal carousel with drag reorder and add button. PageCarousel — Shared horizontal carousel with add button.
The scene+app bar is rendered in the layout's bottom-stack via bottomBarStore. The scene+app bar is rendered in the layout's bottom-stack via bottomBarStore.
--> -->
<script lang="ts"> <script lang="ts">
@ -11,7 +11,6 @@
pages: CarouselPage[]; pages: CarouselPage[];
defaultWidth?: number; defaultWidth?: number;
showPicker: boolean; showPicker: boolean;
onReorder: (fromId: string, toId: string) => void;
onRestore?: (id: string) => void; onRestore?: (id: string) => void;
onMaximize?: (id: string) => void; onMaximize?: (id: string) => void;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
@ -26,7 +25,6 @@
pages, pages,
defaultWidth = 480, defaultWidth = 480,
showPicker, showPicker,
onReorder,
onRestore: _onRestore, onRestore: _onRestore,
onMaximize: _onMaximize, onMaximize: _onMaximize,
onRemove: _onRemove, onRemove: _onRemove,
@ -37,35 +35,6 @@
picker, picker,
}: Props = $props(); }: Props = $props();
let dragId = $state<string | null>(null);
function handleDragStart(e: DragEvent, id: string) {
const target = e.target as HTMLElement;
if (!target.closest('.drag-handle')) {
e.preventDefault();
return;
}
dragId = id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
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;
}
let pickerEl = $state<HTMLDivElement | null>(null); let pickerEl = $state<HTMLDivElement | null>(null);
$effect(() => { $effect(() => {
if (showPicker && pickerEl) if (showPicker && pickerEl)
@ -76,16 +45,7 @@
<div class="carousel-root"> <div class="carousel-root">
<div class="fokus-track" style="--sheet-width: {defaultWidth}px"> <div class="fokus-track" style="--sheet-width: {defaultWidth}px">
{#each pages as p, idx (p.id)} {#each pages as p, idx (p.id)}
<div <div class="page-wrapper" role="listitem" data-page-id={p.id}>
class="page-drag-wrapper"
role="listitem"
class:dragging={dragId === p.id}
data-page-id={p.id}
ondragstart={(e) => handleDragStart(e, p.id)}
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, p.id)}
ondragend={handleDragEnd}
>
{@render pageSnippet(p, idx)} {@render pageSnippet(p, idx)}
</div> </div>
{/each} {/each}
@ -131,12 +91,8 @@
.fokus-track::-webkit-scrollbar { .fokus-track::-webkit-scrollbar {
display: none; display: none;
} }
.page-drag-wrapper { .page-wrapper {
flex: 0 0 auto; flex: 0 0 auto;
transition: opacity 0.15s;
}
.page-drag-wrapper.dragging {
opacity: 0.4;
} }
.add-card { .add-card {
flex: 0 0 auto; flex: 0 0 auto;

View file

@ -1,18 +1,11 @@
<!-- <!--
PageShell — Shared card wrapper for pages in a carousel. PageShell — Shared card wrapper for pages in a carousel.
Provides: drag handle, header, resize handle, maximized mode. Provides: header, resize handle, maximized mode.
Used by workbench (AppPage) and todo (TodoPage). Used by workbench (AppPage) and todo (TodoPage).
--> -->
<script lang="ts"> <script lang="ts">
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { import { X, CornersOut, CornersIn, CaretLeft, CaretRight } from '@mana/shared-icons';
X,
DotsSixVertical,
CornersOut,
CornersIn,
CaretLeft,
CaretRight,
} from '@mana/shared-icons';
import type { Snippet, Component } from 'svelte'; import type { Snippet, Component } from 'svelte';
interface Props { interface Props {
@ -127,72 +120,15 @@
? `height: ${heightPx}px; min-height: 0;` ? `height: ${heightPx}px; min-height: 0;`
: ''}" : ''}"
> >
<!-- svelte-ignore a11y_interactive_supports_focus --> <!-- Header with window actions -->
<div class="drag-handle-bar" draggable="true" oncontextmenu={onContextMenu} role="toolbar"> <div class="page-header" oncontextmenu={onContextMenu} role="banner">
{#if onMoveLeft}
<button
class="move-btn move-left"
onclick={(e) => {
e.stopPropagation();
onMoveLeft();
}}
draggable="false"
title="Nach links"
>
<CaretLeft size={12} />
</button>
{/if}
<span class="drag-handle-icon"><DotsSixVertical size={14} /></span>
<div class="window-actions">
{#if onMaximize}
<button
class="window-btn"
onclick={(e) => {
e.stopPropagation();
onMaximize();
}}
draggable="false"
title={maximized ? 'Verkleinern' : 'Maximieren'}
>
{#if maximized}<CornersIn size={12} />{:else}<CornersOut size={12} />{/if}
</button>
{/if}
<button
class="window-btn window-btn-close"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
draggable="false"
title={$_('common.close')}
>
<X size={12} />
</button>
</div>
{#if onMoveRight}
<button
class="move-btn move-right"
onclick={(e) => {
e.stopPropagation();
onMoveRight();
}}
draggable="false"
title="Nach rechts"
>
<CaretRight size={12} />
</button>
{/if}
</div>
<!-- Header -->
<div class="page-header" ondragstart={(e) => e.preventDefault()} role="banner">
<div class="header-left"> <div class="header-left">
{#if header_left} {#if header_left}
{@render header_left()} {@render header_left()}
{:else} {:else}
{#if IconComponent} {#if IconComponent}
<span class="header-icon" style="color: {color}"> <span class="header-icon">
<IconComponent size={24} weight="fill" /> <IconComponent size={28} weight="bold" />
</span> </span>
{:else} {:else}
<span class="color-dot" style="background-color: {color}"></span> <span class="color-dot" style="background-color: {color}"></span>
@ -204,8 +140,6 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
ondragstart={(e) => e.preventDefault()}
draggable="false"
title={`${title} in neuem Tab öffnen`} title={`${title} in neuem Tab öffnen`}
> >
{title} {title}
@ -218,6 +152,57 @@
{@render badge()} {@render badge()}
{/if} {/if}
</div> </div>
<div class="window-actions">
{#if onMoveLeft}
<button
class="window-btn"
onclick={(e) => {
e.stopPropagation();
onMoveLeft();
}}
title="Nach links"
>
<CaretLeft size={24} weight="bold" />
</button>
{/if}
{#if onMoveRight}
<button
class="window-btn"
onclick={(e) => {
e.stopPropagation();
onMoveRight();
}}
title="Nach rechts"
>
<CaretRight size={24} weight="bold" />
</button>
{/if}
{#if onMaximize}
<button
class="window-btn"
onclick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={maximized ? 'Verkleinern' : 'Maximieren'}
>
{#if maximized}<CornersIn size={24} weight="bold" />{:else}<CornersOut
size={24}
weight="bold"
/>{/if}
</button>
{/if}
<button
class="window-btn window-btn-close"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
title={$_('common.close')}
>
<X size={24} weight="bold" />
</button>
</div>
</div> </div>
<!-- Optional toolbar (e.g. PageEditBar) --> <!-- Optional toolbar (e.g. PageEditBar) -->
@ -302,79 +287,24 @@
} }
} }
.drag-handle-bar {
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 0.2rem 0;
cursor: grab;
background: transparent;
border-bottom: none;
transition: background 0.15s;
}
.drag-handle-bar:hover {
background: transparent;
}
.drag-handle-bar:active {
cursor: grabbing;
background: transparent;
}
.move-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: transparent;
color: hsl(var(--color-muted-foreground) / 0.5);
cursor: pointer;
transition:
color 0.15s,
background 0.15s;
}
.move-btn:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface-hover));
}
.move-left {
left: 0.5rem;
}
.move-right {
right: 0.5rem;
}
.drag-handle-icon {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-muted-foreground) / 0.5);
transform: rotate(90deg);
transition: color 0.15s;
}
.drag-handle-bar:hover .drag-handle-icon {
color: hsl(var(--color-muted-foreground));
}
/* Header */ /* Header */
.page-header { .page-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.375rem 1rem; justify-content: space-between;
padding: 0.5rem 0.75rem;
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
} }
.header-icon { .header-icon {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
color: hsl(var(--color-foreground));
opacity: 0.5;
} }
.color-dot { .color-dot {
width: 0.625rem; width: 0.625rem;
@ -383,9 +313,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.page-title { .page-title {
font-size: 1.125rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: hsl(var(--color-foreground)); color: hsl(var(--color-foreground));
opacity: 0.5;
transform: translateY(1px);
} }
a.page-title-link { a.page-title-link {
text-decoration: none; text-decoration: none;
@ -398,20 +330,17 @@
} }
.window-actions { .window-actions {
position: absolute;
right: 2rem;
top: 50%;
transform: translateY(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.125rem; gap: 0.25rem;
flex-shrink: 0;
} }
.window-btn { .window-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 18px; width: 28px;
height: 18px; height: 28px;
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: transparent; background: transparent;

View file

@ -129,10 +129,6 @@
function handleMoveRight(id: string) { function handleMoveRight(id: string) {
workbenchScenesStore.moveAppRight(id); workbenchScenesStore.moveAppRight(id);
} }
function handleReorder(fromId: string, toId: string) {
workbenchScenesStore.reorderApps(fromId, toId);
}
// ── Card / tab context menus ──────────────────────────── // ── Card / tab context menus ────────────────────────────
const ctxMenu = createWorkbenchContextMenu(); const ctxMenu = createWorkbenchContextMenu();
@ -246,7 +242,6 @@
pages={carouselPages} pages={carouselPages}
defaultWidth={DEFAULT_WIDTH} defaultWidth={DEFAULT_WIDTH}
{showPicker} {showPicker}
onReorder={handleReorder}
onTogglePicker={() => (showPicker = !showPicker)} onTogglePicker={() => (showPicker = !showPicker)}
addLabel="App hinzufügen" addLabel="App hinzufügen"
> >

View file

@ -120,16 +120,6 @@
openPages = openPages.map((p) => (p.id === id ? { ...p, widthPx } : p)); openPages = openPages.map((p) => (p.id === id ? { ...p, widthPx } : p));
} }
function handleReorder(fromId: string, toId: string) {
const fromIdx = openPages.findIndex((p) => p.id === fromId);
const toIdx = openPages.findIndex((p) => p.id === toId);
if (fromIdx === -1 || toIdx === -1) return;
const pages = [...openPages];
const [moved] = pages.splice(fromIdx, 1);
pages.splice(toIdx, 0, moved);
openPages = pages;
}
function navigateToContact(contact: Contact) { function navigateToContact(contact: Contact) {
window.location.href = `/contacts/${contact.id}`; window.location.href = `/contacts/${contact.id}`;
} }
@ -177,7 +167,6 @@
pages={carouselPages} pages={carouselPages}
defaultWidth={DEFAULT_WIDTH} defaultWidth={DEFAULT_WIDTH}
{showPicker} {showPicker}
onReorder={handleReorder}
onRestore={handleRestorePage} onRestore={handleRestorePage}
onMaximize={handleMaximizePage} onMaximize={handleMaximizePage}
onRemove={handleRemovePage} onRemove={handleRemovePage}

View file

@ -181,16 +181,6 @@
} }
} }
function handleReorder(fromId: string, toId: string) {
const fromIdx = openPages.findIndex((p) => p.id === fromId);
const toIdx = openPages.findIndex((p) => p.id === toId);
if (fromIdx === -1 || toIdx === -1) return;
const pages = [...openPages];
const [moved] = pages.splice(fromIdx, 1);
pages.splice(toIdx, 0, moved);
openPages = pages;
}
// ── Custom page CRUD ──────────────────────────────────── // ── Custom page CRUD ────────────────────────────────────
function handleCreateCustomPage() { function handleCreateCustomPage() {
const id = `custom-${crypto.randomUUID().slice(0, 8)}`; const id = `custom-${crypto.randomUUID().slice(0, 8)}`;
@ -257,7 +247,6 @@
pages={carouselPages} pages={carouselPages}
defaultWidth={DEFAULT_WIDTH} defaultWidth={DEFAULT_WIDTH}
showPicker={showPagePicker} showPicker={showPagePicker}
onReorder={handleReorder}
onRestore={handleRestorePage} onRestore={handleRestorePage}
onMaximize={handleMaximizePage} onMaximize={handleMaximizePage}
onRemove={handleRemovePage} onRemove={handleRemovePage}