feat(manacore/web): workbench with app pages carousel on home

Replace the static app-grid home page with a workbench carousel where
each app renders as a paper-sheet page that can be minimized, maximized,
reordered, and resized:

- AppPage.svelte: paper-sheet shell with lazy-loaded app modules
- AppPagePicker.svelte: picker showing all 23 available apps
- App component registry: maps appId to dynamic AppView imports
- Horizontal fokus-track carousel with snap-scroll
- Edit FAB with width pills (S/M/L/XL)
- Minimized tabs bar for collapsed apps
- Drag-and-drop reordering + arrow buttons in edit mode
- Workbench state persisted to IndexedDB via shared-stores
- Default layout: Todo + Calendar + Contacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 14:13:28 +02:00
parent 3d124f04a4
commit f2af192172
4 changed files with 1249 additions and 428 deletions

View file

@ -0,0 +1,357 @@
<!--
AppPage — Paper-sheet wrapper for any app in the workbench carousel.
Lazy-loads the app's AppView component and renders it inside the page shell.
-->
<script lang="ts">
import {
X,
Minus,
DotsSixVertical,
CornersOut,
CornersIn,
ArrowLeft,
ArrowRight,
SpinnerGap,
} from '@manacore/shared-icons';
import { getAppEntry } from './app-registry';
import type { Component } from 'svelte';
interface Props {
appId: string;
pageWidth: string;
maximized?: boolean;
editMode?: boolean;
isFirst?: boolean;
isLast?: boolean;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
}
let {
appId,
pageWidth,
maximized = false,
editMode = false,
isFirst = false,
isLast = false,
onClose,
onMinimize,
onMaximize,
onMoveLeft,
onMoveRight,
}: Props = $props();
let appEntry = $derived(getAppEntry(appId));
let appName = $derived(appEntry?.name ?? appId);
let appColor = $derived(appEntry?.color ?? '#6B7280');
// Lazy-load app component
let AppComponent = $state<Component | null>(null);
let loadError = $state(false);
$effect(() => {
AppComponent = null;
loadError = false;
if (appEntry) {
appEntry.load().then(
(mod) => (AppComponent = mod.default),
() => (loadError = true)
);
}
});
</script>
<div
class="app-page"
class:maximized
class:editing={editMode}
style="width: {maximized ? '100%' : pageWidth}"
>
<div class="drag-handle-bar">
<span class="drag-handle"><DotsSixVertical size={14} /></span>
</div>
<!-- Edit controls -->
{#if editMode}
<div class="edit-controls">
<div class="move-btns">
{#if !isFirst && onMoveLeft}
<button class="edit-btn" onclick={onMoveLeft} title="Nach links">
<ArrowLeft size={14} />
</button>
{/if}
{#if !isLast && onMoveRight}
<button class="edit-btn" onclick={onMoveRight} title="Nach rechts">
<ArrowRight size={14} />
</button>
{/if}
</div>
<button class="edit-btn delete-btn" onclick={onClose} title="App entfernen">
<X size={14} />
</button>
</div>
{/if}
<!-- Header -->
<div class="page-header">
<div class="header-left">
<span class="app-dot" style="background-color: {appColor}"></span>
<span class="app-name">{appName}</span>
</div>
{#if !editMode}
<div class="header-actions">
{#if onMinimize}
<button class="header-btn" onclick={onMinimize} title="Minimieren">
<Minus size={14} />
</button>
{/if}
{#if onMaximize}
<button
class="header-btn"
onclick={onMaximize}
title={maximized ? 'Verkleinern' : 'Maximieren'}
>
{#if maximized}<CornersIn size={14} />{:else}<CornersOut size={14} />{/if}
</button>
{/if}
<button class="header-btn" onclick={onClose} title="Schließen">
<X size={14} />
</button>
</div>
{/if}
</div>
<!-- App content -->
<div class="page-body">
{#if loadError}
<div class="load-state">
<p>App konnte nicht geladen werden</p>
</div>
{:else if AppComponent}
<AppComponent />
{:else}
<div class="load-state">
<SpinnerGap size={24} class="spinner" />
</div>
{/if}
</div>
</div>
<style>
.app-page {
flex: 0 0 auto;
min-height: 60vh;
background: #fffef5;
border-radius: 0.375rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
animation: fadeIn 0.25s ease-out;
overflow: hidden;
}
:global(.dark) .app-page {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.app-page.editing {
box-shadow:
0 2px 12px rgba(139, 92, 246, 0.12),
0 0 0 2px rgba(139, 92, 246, 0.3);
}
:global(.dark) .app-page.editing {
box-shadow:
0 2px 12px rgba(139, 92, 246, 0.2),
0 0 0 2px rgba(139, 92, 246, 0.4);
}
.app-page.maximized {
position: fixed;
inset: 0;
z-index: 50;
width: 100% !important;
min-height: 100vh;
border-radius: 0;
box-shadow: none;
animation: fadeInScale 0.2s ease-out;
}
@keyframes fadeInScale {
from {
opacity: 0.8;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.drag-handle-bar {
display: flex;
justify-content: center;
padding: 0.25rem 0 0;
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 14px;
color: #d1d5db;
cursor: grab;
border-radius: 0.25rem;
transition: color 0.15s;
}
.drag-handle:hover {
color: #9ca3af;
}
.drag-handle:active {
cursor: grabbing;
}
:global(.dark) .drag-handle {
color: #3f3b38;
}
:global(.dark) .drag-handle:hover {
color: #6b7280;
}
/* Edit controls */
.edit-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .edit-controls {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.move-btns {
display: flex;
gap: 0.25rem;
}
.edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.edit-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .edit-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
.delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
/* Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.app-name {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
:global(.dark) .app-name {
color: #f3f4f6;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.125rem;
}
.header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.header-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
/* Body */
.page-body {
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.load-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #9ca3af;
font-size: 0.8125rem;
}
.load-state :global(.spinner) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,167 @@
<!--
AppPagePicker — Shows available apps to add as pages to the workbench.
-->
<script lang="ts">
import { X } from '@manacore/shared-icons';
import { APP_REGISTRY } from './app-registry';
interface Props {
onSelect: (appId: string) => void;
onClose: () => void;
activeAppIds?: string[];
}
let { onSelect, onClose, activeAppIds = [] }: Props = $props();
let availableApps = $derived(APP_REGISTRY.filter((app) => !activeAppIds.includes(app.id)));
</script>
<div class="app-picker">
<div class="picker-header">
<h3 class="picker-title">App hinzufügen</h3>
<button class="close-btn" onclick={onClose} title="Schließen"><X size={16} /></button>
</div>
<div class="picker-list">
{#each availableApps as app, i (app.id)}
{#if i > 0}<div class="divider"></div>{/if}
<button class="app-option" onclick={() => onSelect(app.id)}>
<div class="app-dot" style="background-color: {app.color}"></div>
<span class="app-name">{app.name}</span>
</button>
{/each}
{#if availableApps.length === 0}
<div class="empty-state"><p>Alle Apps sind bereits geöffnet</p></div>
{/if}
</div>
</div>
<style>
.app-picker {
flex: 0 0 auto;
width: min(300px, 85vw);
min-height: 60vh;
max-height: 80vh;
background: #fffef5;
border-radius: 0.375rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
animation: slideIn 0.25s ease-out;
}
:global(.dark) .app-picker {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.picker-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin: 0;
}
:global(.dark) .picker-title {
color: #f3f4f6;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 0.375rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.picker-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.75rem;
}
.divider {
height: 1px;
background: rgba(0, 0, 0, 0.06);
margin: 0 0.5rem;
}
:global(.dark) .divider {
background: rgba(255, 255, 255, 0.06);
}
.app-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.375rem;
transition: background 0.15s;
text-align: left;
}
.app-option:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .app-option:hover {
background: rgba(255, 255, 255, 0.06);
}
.app-dot {
width: 10px;
height: 10px;
border-radius: 9999px;
flex-shrink: 0;
}
.app-name {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .app-name {
color: #f3f4f6;
}
.empty-state {
padding: 2rem 1rem;
text-align: center;
}
.empty-state p {
font-size: 0.8125rem;
color: #9ca3af;
}
</style>

View file

@ -0,0 +1,159 @@
/**
* App Component Registry Maps app IDs to lazy-loaded AppView components.
*
* Each entry provides the dynamic import for embedding in the workbench carousel.
*/
import type { Component } from 'svelte';
export interface AppEntry {
id: string;
name: string;
color: string;
load: () => Promise<{ default: Component }>;
}
export const APP_REGISTRY: AppEntry[] = [
{
id: 'todo',
name: 'Todo',
color: '#8B5CF6',
load: () => import('$lib/modules/todo/AppView.svelte'),
},
{
id: 'calendar',
name: 'Kalender',
color: '#3B82F6',
load: () => import('$lib/modules/calendar/AppView.svelte'),
},
{
id: 'contacts',
name: 'Kontakte',
color: '#22C55E',
load: () => import('$lib/modules/contacts/AppView.svelte'),
},
{
id: 'chat',
name: 'Chat',
color: '#6366F1',
load: () => import('$lib/modules/chat/AppView.svelte'),
},
{
id: 'times',
name: 'Times',
color: '#F59E0B',
load: () => import('$lib/modules/times/AppView.svelte'),
},
{
id: 'zitare',
name: 'Zitare',
color: '#EC4899',
load: () => import('$lib/modules/zitare/AppView.svelte'),
},
{
id: 'cards',
name: 'Cards',
color: '#EF4444',
load: () => import('$lib/modules/cards/AppView.svelte'),
},
{
id: 'picture',
name: 'Picture',
color: '#8B5CF6',
load: () => import('$lib/modules/picture/AppView.svelte'),
},
{
id: 'mukke',
name: 'Mukke',
color: '#F97316',
load: () => import('$lib/modules/mukke/AppView.svelte'),
},
{
id: 'photos',
name: 'Photos',
color: '#06B6D4',
load: () => import('$lib/modules/photos/AppView.svelte'),
},
{
id: 'storage',
name: 'Storage',
color: '#6B7280',
load: () => import('$lib/modules/storage/AppView.svelte'),
},
{
id: 'nutriphi',
name: 'Nutriphi',
color: '#22C55E',
load: () => import('$lib/modules/nutriphi/AppView.svelte'),
},
{
id: 'planta',
name: 'Planta',
color: '#16A34A',
load: () => import('$lib/modules/planta/AppView.svelte'),
},
{
id: 'presi',
name: 'Presi',
color: '#A855F7',
load: () => import('$lib/modules/presi/AppView.svelte'),
},
{
id: 'inventar',
name: 'Inventar',
color: '#78716C',
load: () => import('$lib/modules/inventar/AppView.svelte'),
},
{
id: 'memoro',
name: 'Memoro',
color: '#F59E0B',
load: () => import('$lib/modules/memoro/AppView.svelte'),
},
{
id: 'questions',
name: 'Questions',
color: '#2563EB',
load: () => import('$lib/modules/questions/AppView.svelte'),
},
{
id: 'skilltree',
name: 'SkillTree',
color: '#D946EF',
load: () => import('$lib/modules/skilltree/AppView.svelte'),
},
{
id: 'moodlit',
name: 'Moodlit',
color: '#F97316',
load: () => import('$lib/modules/moodlit/AppView.svelte'),
},
{
id: 'citycorners',
name: 'CityCorners',
color: '#14B8A6',
load: () => import('$lib/modules/citycorners/AppView.svelte'),
},
{
id: 'uload',
name: 'uLoad',
color: '#0EA5E9',
load: () => import('$lib/modules/uload/AppView.svelte'),
},
{
id: 'calc',
name: 'Calc',
color: '#6B7280',
load: () => import('$lib/modules/calc/AppView.svelte'),
},
{
id: 'playground',
name: 'Playground',
color: '#9CA3AF',
load: () => import('$lib/modules/playground/AppView.svelte'),
},
];
export function getAppEntry(appId: string): AppEntry | undefined {
return APP_REGISTRY.find((a) => a.id === appId);
}

File diff suppressed because it is too large Load diff