mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(mana/web): named workbench scenes (Home, Deep Work, …)
Users can now define multiple named layouts of the workbench homepage and switch between them. Each scene holds its own openApps list with per-app window state (minimized / maximized / size). Scene list syncs cross-device via mana-sync; the active scene id is per-device (localStorage) so device A doesn't pull device B into a different scene. - new `workbenchScenes` Dexie table, registered in manaCoreConfig - `workbenchScenesStore` (Dexie liveQuery) with scene CRUD + per-scene app mutations; auto-seeds a default "Home" scene on first run - SceneTabs pill bar above the carousel with dnd reorder + context menu (rename / duplicate / delete); SceneRenameDialog and a reusable ConfirmDialog for the destructive path - workbench +page.svelte refactored to delegate all openApps mutations to the store; the carousel itself is unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d37483a0f9
commit
4eb5dfe4a0
11 changed files with 1413 additions and 106 deletions
|
|
@ -0,0 +1,179 @@
|
|||
<!--
|
||||
ConfirmDialog — minimal reusable confirm modal.
|
||||
|
||||
Backdrop click cancels. Escape cancels. Confirm button is auto-focused
|
||||
with the danger variant when `variant="danger"`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
show: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
show,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Bestätigen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
let pending = $state(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!show) return;
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="cd-backdrop" onclick={onCancel}>
|
||||
<div
|
||||
class="cd-dialog"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cd-title"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="cd-title" class="cd-title">{title}</h3>
|
||||
<p class="cd-message">{message}</p>
|
||||
<div class="cd-actions">
|
||||
<button class="cd-btn cd-btn-cancel" onclick={onCancel} disabled={pending}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
class="cd-btn"
|
||||
class:cd-btn-danger={variant === 'danger'}
|
||||
class:cd-btn-primary={variant !== 'danger'}
|
||||
onclick={handleConfirm}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? '…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cd-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
.cd-dialog {
|
||||
background: #fffef5;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
animation: cd-pop 0.18s ease-out;
|
||||
}
|
||||
:global(.dark) .cd-dialog {
|
||||
background: #252220;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@keyframes cd-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.cd-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
:global(.dark) .cd-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
.cd-message {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:global(.dark) .cd-message {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.cd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cd-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.cd-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cd-btn-cancel {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
}
|
||||
.cd-btn-cancel:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
:global(.dark) .cd-btn-cancel {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .cd-btn-cancel:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.cd-btn-primary {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
.cd-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.cd-btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.cd-btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
<!--
|
||||
SceneRenameDialog — modal for creating or renaming a workbench scene.
|
||||
|
||||
Single dialog handles both flows; the parent decides which by passing an
|
||||
initial name (empty for create, current name for rename) and a title.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
show: boolean;
|
||||
title: string;
|
||||
initialName?: string;
|
||||
initialIcon?: string;
|
||||
confirmLabel?: string;
|
||||
onSubmit: (name: string, icon: string | undefined) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
show,
|
||||
title,
|
||||
initialName = '',
|
||||
initialIcon = '',
|
||||
confirmLabel = 'Speichern',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let icon = $state('');
|
||||
let pending = $state(false);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Reset local fields each time the dialog opens with new props.
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
name = initialName;
|
||||
icon = initialIcon;
|
||||
// Focus on next tick once the input is mounted.
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (pending || !name.trim()) return;
|
||||
pending = true;
|
||||
try {
|
||||
await onSubmit(name.trim(), icon.trim() || undefined);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!show) return;
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="srd-backdrop" onclick={onCancel}>
|
||||
<div
|
||||
class="srd-dialog"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
aria-labelledby="srd-title"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="srd-title" class="srd-title">{title}</h3>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="srd-fields">
|
||||
<label class="srd-field srd-field-icon">
|
||||
<span class="srd-label">Icon</span>
|
||||
<input
|
||||
class="srd-input srd-icon-input"
|
||||
type="text"
|
||||
maxlength="2"
|
||||
placeholder="🏠"
|
||||
bind:value={icon}
|
||||
/>
|
||||
</label>
|
||||
<label class="srd-field srd-field-name">
|
||||
<span class="srd-label">Name</span>
|
||||
<input
|
||||
class="srd-input"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
placeholder="z.B. Deep Work"
|
||||
bind:this={inputEl}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="srd-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="srd-btn srd-btn-cancel"
|
||||
onclick={onCancel}
|
||||
disabled={pending}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="srd-btn srd-btn-primary" disabled={pending || !name.trim()}>
|
||||
{pending ? '…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.srd-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
.srd-dialog {
|
||||
background: #fffef5;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
animation: srd-pop 0.18s ease-out;
|
||||
}
|
||||
:global(.dark) .srd-dialog {
|
||||
background: #252220;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@keyframes srd-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.srd-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
:global(.dark) .srd-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
.srd-fields {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.srd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.srd-field-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 64px;
|
||||
}
|
||||
.srd-field-name {
|
||||
flex: 1;
|
||||
}
|
||||
.srd-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .srd-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.srd-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.srd-input:focus {
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
:global(.dark) .srd-input {
|
||||
background: #1f1c1a;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #f9fafb;
|
||||
}
|
||||
.srd-icon-input {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.srd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.srd-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.srd-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.srd-btn-cancel {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
}
|
||||
.srd-btn-cancel:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
:global(.dark) .srd-btn-cancel {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .srd-btn-cancel:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.srd-btn-primary {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
.srd-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
<!--
|
||||
SceneTabs — pill bar above the workbench carousel listing the user's
|
||||
Scenes (named workbench layouts). Click a pill to switch, right-click for
|
||||
rename/duplicate/delete, drag to reorder, "+" to create.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { Plus, Pencil, Copy, Trash } from '@mana/shared-icons';
|
||||
import type { WorkbenchScene } from '$lib/types/workbench-scenes';
|
||||
|
||||
interface Props {
|
||||
scenes: WorkbenchScene[];
|
||||
activeSceneId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onRequestRename: (id: string) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onRequestDelete: (id: string) => void;
|
||||
onReorder: (fromId: string, toId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
scenes,
|
||||
activeSceneId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRequestRename,
|
||||
onDuplicate,
|
||||
onRequestDelete,
|
||||
onReorder,
|
||||
}: Props = $props();
|
||||
|
||||
// ── Context menu state (local to this component) ───────
|
||||
let menuVisible = $state(false);
|
||||
let menuX = $state(0);
|
||||
let menuY = $state(0);
|
||||
let menuItems = $state<ContextMenuItem[]>([]);
|
||||
|
||||
function openMenu(e: MouseEvent, scene: WorkbenchScene) {
|
||||
e.preventDefault();
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
id: 'rename',
|
||||
label: 'Umbenennen',
|
||||
icon: Pencil,
|
||||
action: () => onRequestRename(scene.id),
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplizieren',
|
||||
icon: Copy,
|
||||
action: () => onDuplicate(scene.id),
|
||||
},
|
||||
];
|
||||
// Only allow delete if more than one scene exists.
|
||||
if (scenes.length > 1) {
|
||||
items.push({ id: 'div', label: '', type: 'divider' });
|
||||
items.push({
|
||||
id: 'delete',
|
||||
label: 'Löschen',
|
||||
icon: Trash,
|
||||
variant: 'danger',
|
||||
action: () => onRequestDelete(scene.id),
|
||||
});
|
||||
}
|
||||
menuItems = items;
|
||||
menuX = e.clientX;
|
||||
menuY = e.clientY;
|
||||
menuVisible = true;
|
||||
}
|
||||
|
||||
// ── Drag reorder ────────────────────────────────────────
|
||||
let dragId = $state<string | null>(null);
|
||||
|
||||
function handleDragStart(e: DragEvent, id: string) {
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="scene-tabs">
|
||||
<div class="scene-tabs-scroll">
|
||||
{#each scenes as scene (scene.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<button
|
||||
type="button"
|
||||
class="scene-pill"
|
||||
class:active={scene.id === activeSceneId}
|
||||
class:dragging={dragId === scene.id}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, scene.id)}
|
||||
ondragover={handleDragOver}
|
||||
ondrop={(e) => handleDrop(e, scene.id)}
|
||||
ondragend={handleDragEnd}
|
||||
onclick={() => onSelect(scene.id)}
|
||||
ondblclick={() => onRequestRename(scene.id)}
|
||||
oncontextmenu={(e) => openMenu(e, scene)}
|
||||
title={scene.name}
|
||||
>
|
||||
{#if scene.icon}
|
||||
<span class="scene-icon">{scene.icon}</span>
|
||||
{/if}
|
||||
<span class="scene-name">{scene.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="scene-add" onclick={onCreate} title="Neue Szene">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
visible={menuVisible}
|
||||
x={menuX}
|
||||
y={menuY}
|
||||
items={menuItems}
|
||||
onClose={() => (menuVisible = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.scene-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem 0;
|
||||
}
|
||||
.scene-tabs-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.625rem;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.scene-tabs-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
:global(.dark) .scene-tabs-scroll {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.scene-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
max-width: 180px;
|
||||
}
|
||||
.scene-pill:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
}
|
||||
.scene-pill.active {
|
||||
background: #fffef5;
|
||||
color: #111827;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.scene-pill.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
:global(.dark) .scene-pill {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .scene-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .scene-pill.active {
|
||||
background: #2f2b29;
|
||||
color: #f9fafb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.scene-icon {
|
||||
font-size: 0.95rem;
|
||||
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: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.scene-add:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
:global(.dark) .scene-add:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -59,6 +59,7 @@ db.version(1).stores({
|
|||
// ─── Core / Mana (appId: 'mana') ───
|
||||
userSettings: 'id, key',
|
||||
dashboardConfigs: 'id',
|
||||
workbenchScenes: 'id, order',
|
||||
automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]',
|
||||
|
||||
// ─── Todo (appId: 'todo') ───
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import type { ModuleConfig } from '$lib/data/module-registry';
|
|||
|
||||
export const manaCoreConfig: ModuleConfig = {
|
||||
appId: 'mana',
|
||||
tables: [{ name: 'userSettings' }, { name: 'dashboardConfigs' }, { name: 'automations' }],
|
||||
tables: [
|
||||
{ name: 'userSettings' },
|
||||
{ name: 'dashboardConfigs' },
|
||||
{ name: 'workbenchScenes' },
|
||||
{ name: 'automations' },
|
||||
],
|
||||
};
|
||||
|
||||
export const tagsCoreConfig: ModuleConfig = {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@
|
|||
import type { Habit, HabitLog } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { toastStore } from '@mana/shared-ui/toast';
|
||||
import { DynamicIcon } from '@mana/shared-ui/atoms';
|
||||
import { IconPicker } from '@mana/shared-ui/molecules';
|
||||
import { PencilSimple, Trash, Pause, Play } from '@mana/shared-icons';
|
||||
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
|
|
@ -64,6 +66,21 @@
|
|||
setTimeout(() => (animatingId = null), 300);
|
||||
}
|
||||
|
||||
async function handleVoiceComplete(blob: Blob, durationMs: number) {
|
||||
const result = await habitsStore.logFromVoice(blob, durationMs, 'de');
|
||||
if (!result) {
|
||||
toastStore.error('Habit nicht erkannt. Versuche den Namen direkt zu sagen, z.B. "Kaffee".');
|
||||
return;
|
||||
}
|
||||
toastStore.success(`${result.habitTitle} geloggt`);
|
||||
// Reuse the existing pulse animation by finding the matching habit id
|
||||
const matched = habits.find((h) => h.title === result.habitTitle);
|
||||
if (matched) {
|
||||
animatingId = matched.id;
|
||||
setTimeout(() => (animatingId = null), 300);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
|
|
@ -140,6 +157,14 @@
|
|||
</script>
|
||||
|
||||
<div class="habits-list-view">
|
||||
<!-- Voice quick-log -->
|
||||
<VoiceCaptureBar
|
||||
idleLabel="Habit sprechen"
|
||||
feature="habits-voice-log"
|
||||
reason="Habit-Logs werden in deinem persönlichen Kalender gespeichert. Dafür brauchst du ein Mana-Konto."
|
||||
onComplete={handleVoiceComplete}
|
||||
/>
|
||||
|
||||
<!-- Tally Grid -->
|
||||
<div class="tally-grid">
|
||||
{#each activeHabits as habit (habit.id)}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,46 @@ import {
|
|||
} from '$lib/data/time-blocks/recurrence';
|
||||
import type { LocalHabit, LocalHabitLog, HabitSchedule } from '../types';
|
||||
|
||||
/**
|
||||
* Normalize for fuzzy comparison: lowercase, strip diacritics,
|
||||
* collapse whitespace. "Kaffee" / "kaffee" / "Kaffée " all collapse
|
||||
* to "kaffee".
|
||||
*/
|
||||
function normalize(s: string): string {
|
||||
return s
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap client-side substring matching from spoken transcript to
|
||||
* habit title. Used as a fast path before falling back to the LLM
|
||||
* parse-habit endpoint. Returns the first habit whose normalized
|
||||
* title appears as a whole word inside the normalized transcript,
|
||||
* or vice versa for very short titles ("Tee" inside "Grüner Tee").
|
||||
*
|
||||
* Word-boundary matching avoids false positives like "Bier" matching
|
||||
* a transcript that contains "ausprobiert".
|
||||
*/
|
||||
function matchHabitToTranscript(transcript: string, habits: LocalHabit[]): LocalHabit | null {
|
||||
const normTranscript = normalize(transcript);
|
||||
if (!normTranscript) return null;
|
||||
const words = new Set(normTranscript.split(/[^a-z0-9äöüß]+/i).filter((w) => w.length >= 3));
|
||||
for (const habit of habits) {
|
||||
const normTitle = normalize(habit.title);
|
||||
if (normTitle.length < 3) continue;
|
||||
// Whole-word title appears in transcript
|
||||
if (words.has(normTitle)) return habit;
|
||||
// Multi-word title: every token must be present as a word
|
||||
const titleWords = normTitle.split(' ').filter((w) => w.length >= 3);
|
||||
if (titleWords.length > 1 && titleWords.every((w) => words.has(w))) return habit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const habitsStore = {
|
||||
async createHabit(data: {
|
||||
title: string;
|
||||
|
|
@ -78,6 +118,85 @@ export const habitsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Voice quick-log. The user taps the mic, says e.g. "kaffee" or
|
||||
* "30 minuten gelaufen", and we log the right habit. Two-step:
|
||||
*
|
||||
* 1. Substring pre-match against habit titles. Catches the easy
|
||||
* cases ("kaffee" → "Kaffee") without an LLM round-trip.
|
||||
* 2. If nothing matches, send transcript + habit titles to
|
||||
* /api/v1/voice/parse-habit which asks mana-llm to pick one
|
||||
* from the list. Handles the harder cases ("gelaufen" →
|
||||
* "Laufen", "rauchen" → "Zigarette").
|
||||
*
|
||||
* If neither step finds a habit, returns null and the caller can
|
||||
* surface a "habit nicht erkannt" hint instead of silently logging
|
||||
* nothing. The transcribe step itself never throws — failures show
|
||||
* up as null too.
|
||||
*/
|
||||
async logFromVoice(
|
||||
blob: Blob,
|
||||
_durationMs: number,
|
||||
language = 'de'
|
||||
): Promise<{ logId: string; habitTitle: string } | null> {
|
||||
// Step 1: speech to text
|
||||
let transcript: string;
|
||||
try {
|
||||
const form = new FormData();
|
||||
const ext = blob.type.includes('webm')
|
||||
? '.webm'
|
||||
: blob.type.includes('mp4')
|
||||
? '.m4a'
|
||||
: '.audio';
|
||||
form.append('file', blob, `habit${ext}`);
|
||||
if (language) form.append('language', language);
|
||||
|
||||
const sttResponse = await fetch('/api/v1/voice/transcribe', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!sttResponse.ok) return null;
|
||||
const sttResult = (await sttResponse.json()) as { text: string };
|
||||
transcript = (sttResult.text ?? '').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!transcript) return null;
|
||||
|
||||
// Step 2: pick a habit. Substring fast path first, LLM fallback.
|
||||
const habits = (await habitTable.toArray()).filter((h) => !h.deletedAt && !h.isArchived);
|
||||
if (habits.length === 0) return null;
|
||||
|
||||
const matched = matchHabitToTranscript(transcript, habits);
|
||||
const note = transcript;
|
||||
if (matched) {
|
||||
const log = await this.logHabit(matched.id, note);
|
||||
return { logId: log.id, habitTitle: matched.title };
|
||||
}
|
||||
|
||||
// LLM fallback
|
||||
try {
|
||||
const response = await fetch('/api/v1/voice/parse-habit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transcript,
|
||||
habits: habits.map((h) => h.title),
|
||||
language,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const parsed = (await response.json()) as { match: string | null; note: string | null };
|
||||
if (!parsed.match) return null;
|
||||
const target = habits.find((h) => h.title === parsed.match);
|
||||
if (!target) return null;
|
||||
const log = await this.logHabit(target.id, parsed.note ?? note);
|
||||
return { logId: log.id, habitTitle: target.title };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async logHabit(habitId: string, note?: string) {
|
||||
const habit = await habitTable.get(habitId);
|
||||
const now = new Date();
|
||||
|
|
|
|||
322
apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts
Normal file
322
apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* Workbench Scenes Store — local-first, cross-device synced.
|
||||
*
|
||||
* Scenes (named workbench layouts) are persisted in the unified Mana Dexie
|
||||
* database under the `workbenchScenes` table and reach other devices via
|
||||
* the mana-sync engine (field-level LWW). The currently active scene id is
|
||||
* a per-device preference and lives in localStorage so device A doesn't
|
||||
* pull device B into a different scene.
|
||||
*
|
||||
* Reactive surface: `scenes`, `activeSceneId`, `activeScene`, `openApps`,
|
||||
* `initialized`. The store subscribes to a Dexie liveQuery on module init,
|
||||
* so writes from other tabs and remote sync pulls flow back into the UI
|
||||
* without a manual refresh.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { liveQuery, type Subscription } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type {
|
||||
LocalWorkbenchScene,
|
||||
WorkbenchScene,
|
||||
WorkbenchSceneApp,
|
||||
} from '$lib/types/workbench-scenes';
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
// ─── Reactive state ───────────────────────────────────────────
|
||||
|
||||
let scenesState = $state<WorkbenchScene[]>([]);
|
||||
let activeSceneIdState = $state<string | null>(null);
|
||||
let initializedState = $state(false);
|
||||
|
||||
let subscription: Subscription | null = null;
|
||||
|
||||
function readActiveIdFromStorage(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_SCENE_LS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeActiveIdToStorage(id: string | null) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (id) localStorage.setItem(ACTIVE_SCENE_LS_KEY, id);
|
||||
else localStorage.removeItem(ACTIVE_SCENE_LS_KEY);
|
||||
} catch {
|
||||
/* storage quota / disabled — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toScene(local: LocalWorkbenchScene): WorkbenchScene {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
icon: local.icon,
|
||||
openApps: local.openApps ?? [],
|
||||
order: local.order,
|
||||
};
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function ensureSeedScene(): Promise<string> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const seed: LocalWorkbenchScene = {
|
||||
id,
|
||||
name: 'Home',
|
||||
openApps: DEFAULT_HOME_APPS,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.table<LocalWorkbenchScene>(TABLE).add(seed);
|
||||
return id;
|
||||
}
|
||||
|
||||
function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null {
|
||||
if (scenes.length === 0) return null;
|
||||
if (current && scenes.some((s) => s.id === current)) return current;
|
||||
return scenes[0].id;
|
||||
}
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────
|
||||
|
||||
async function patchScene(
|
||||
id: string,
|
||||
patch: Partial<Pick<LocalWorkbenchScene, 'name' | 'icon' | 'openApps' | 'order'>>
|
||||
) {
|
||||
await db.table<LocalWorkbenchScene>(TABLE).update(id, {
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
async function patchActiveScene(fn: (apps: WorkbenchSceneApp[]) => WorkbenchSceneApp[]) {
|
||||
const id = activeSceneIdState;
|
||||
if (!id) return;
|
||||
const current = scenesState.find((s) => s.id === id);
|
||||
if (!current) return;
|
||||
await patchScene(id, { openApps: fn(current.openApps) });
|
||||
}
|
||||
|
||||
// ─── Public store ─────────────────────────────────────────────
|
||||
|
||||
export const workbenchScenesStore = {
|
||||
get scenes() {
|
||||
return scenesState;
|
||||
},
|
||||
get activeSceneId() {
|
||||
return activeSceneIdState;
|
||||
},
|
||||
get activeScene() {
|
||||
return scenesState.find((s) => s.id === activeSceneIdState) ?? null;
|
||||
},
|
||||
get openApps(): WorkbenchSceneApp[] {
|
||||
return this.activeScene?.openApps ?? [];
|
||||
},
|
||||
get initialized() {
|
||||
return initializedState;
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
if (!browser || initializedState) return;
|
||||
|
||||
// Seed a Home scene on first run so the UI never has zero scenes.
|
||||
const count = await db.table(TABLE).count();
|
||||
if (count === 0) {
|
||||
await ensureSeedScene();
|
||||
}
|
||||
|
||||
activeSceneIdState = readActiveIdFromStorage();
|
||||
|
||||
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
|
||||
next: (rows) => {
|
||||
const visible = rows
|
||||
.filter((r) => !r.deletedAt)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(toScene);
|
||||
scenesState = visible;
|
||||
|
||||
const next = pickActiveId(visible, activeSceneIdState);
|
||||
if (next !== activeSceneIdState) {
|
||||
activeSceneIdState = next;
|
||||
writeActiveIdToStorage(next);
|
||||
}
|
||||
initializedState = true;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('[workbench-scenes] liveQuery failed:', err);
|
||||
initializedState = true;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
dispose() {
|
||||
subscription?.unsubscribe();
|
||||
subscription = null;
|
||||
},
|
||||
|
||||
// ── Scene CRUD ───────────────────────────────────────────
|
||||
|
||||
setActiveScene(id: string) {
|
||||
if (!scenesState.some((s) => s.id === id)) return;
|
||||
activeSceneIdState = id;
|
||||
writeActiveIdToStorage(id);
|
||||
},
|
||||
|
||||
async createScene(opts: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
seedApps?: WorkbenchSceneApp[];
|
||||
setActive?: boolean;
|
||||
}): Promise<string> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const maxOrder = scenesState.reduce((m, s) => Math.max(m, s.order), -1);
|
||||
const row: LocalWorkbenchScene = {
|
||||
id,
|
||||
name: opts.name.trim() || 'Neue Szene',
|
||||
icon: opts.icon,
|
||||
openApps: opts.seedApps ? structuredClone(opts.seedApps) : [],
|
||||
order: maxOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.table<LocalWorkbenchScene>(TABLE).add(row);
|
||||
if (opts.setActive !== false) {
|
||||
activeSceneIdState = id;
|
||||
writeActiveIdToStorage(id);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
async renameScene(id: string, name: string, icon?: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
await patchScene(id, { name: trimmed, ...(icon !== undefined ? { icon } : {}) });
|
||||
},
|
||||
|
||||
async duplicateScene(id: string) {
|
||||
const src = scenesState.find((s) => s.id === id);
|
||||
if (!src) return;
|
||||
await this.createScene({
|
||||
name: `${src.name} Kopie`,
|
||||
icon: src.icon,
|
||||
seedApps: src.openApps,
|
||||
setActive: true,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteScene(id: string) {
|
||||
// Refuse to delete the very last scene — the workbench always needs one.
|
||||
if (scenesState.length <= 1) return;
|
||||
await db.table<LocalWorkbenchScene>(TABLE).update(id, {
|
||||
deletedAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
// liveQuery will recompute scenesState; pickActiveId then advances
|
||||
// activeSceneId to the first remaining scene if needed.
|
||||
},
|
||||
|
||||
async reorderScenes(fromId: string, toId: string) {
|
||||
if (fromId === toId) return;
|
||||
const ordered = [...scenesState];
|
||||
const fromIdx = ordered.findIndex((s) => s.id === fromId);
|
||||
const toIdx = ordered.findIndex((s) => s.id === toId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
const [moved] = ordered.splice(fromIdx, 1);
|
||||
ordered.splice(toIdx, 0, moved);
|
||||
// Renumber and persist only the rows whose order actually changed.
|
||||
await Promise.all(
|
||||
ordered.map((s, i) => (s.order === i ? null : patchScene(s.id, { order: i })))
|
||||
);
|
||||
},
|
||||
|
||||
// ── Per-scene app mutations (operate on the active scene) ─
|
||||
|
||||
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 }];
|
||||
});
|
||||
},
|
||||
|
||||
async removeApp(appId: string) {
|
||||
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))
|
||||
);
|
||||
},
|
||||
|
||||
async resizeApp(appId: string, widthPx: number, heightPx?: number) {
|
||||
await patchActiveScene((apps) =>
|
||||
apps.map((a) =>
|
||||
a.appId === appId ? { ...a, widthPx, ...(heightPx !== undefined ? { heightPx } : {}) } : a
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
async moveAppLeft(appId: string) {
|
||||
await patchActiveScene((apps) => {
|
||||
const idx = apps.findIndex((a) => a.appId === appId);
|
||||
if (idx <= 0) return apps;
|
||||
const next = [...apps];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
async moveAppRight(appId: string) {
|
||||
await patchActiveScene((apps) => {
|
||||
const idx = apps.findIndex((a) => a.appId === appId);
|
||||
if (idx === -1 || idx >= apps.length - 1) return apps;
|
||||
const next = [...apps];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
async reorderApps(fromId: string, toId: string) {
|
||||
if (fromId === toId) return;
|
||||
await patchActiveScene((apps) => {
|
||||
const fromIdx = apps.findIndex((a) => a.appId === fromId);
|
||||
const toIdx = apps.findIndex((a) => a.appId === toId);
|
||||
if (fromIdx === -1 || toIdx === -1) return apps;
|
||||
const next = [...apps];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, moved);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
};
|
||||
36
apps/mana/apps/web/src/lib/types/workbench-scenes.ts
Normal file
36
apps/mana/apps/web/src/lib/types/workbench-scenes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* 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
|
||||
* quickly change context (e.g. "Home", "Deep Work", "Travel").
|
||||
*
|
||||
* Scenes are persisted in the unified Mana Dexie database under the
|
||||
* `workbenchScenes` table and sync cross-device via mana-sync. The
|
||||
* currently active scene id is stored per-device in localStorage so
|
||||
* device A doesn't yank device B into a different scene.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
export interface WorkbenchSceneApp {
|
||||
appId: string;
|
||||
minimized: boolean;
|
||||
maximized?: boolean;
|
||||
widthPx?: number;
|
||||
heightPx?: number;
|
||||
}
|
||||
|
||||
/** A user-defined named layout of the workbench. */
|
||||
export interface WorkbenchScene {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Optional emoji shown in the scene tab. */
|
||||
icon?: string;
|
||||
openApps: WorkbenchSceneApp[];
|
||||
/** Sort order in the scene tab bar. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */
|
||||
export interface LocalWorkbenchScene extends BaseRecord, WorkbenchScene {}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
<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 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';
|
||||
import { getApp, getAppByDragType } from '$lib/app-registry';
|
||||
import { onMount } from 'svelte';
|
||||
import { createAppSettingsStore } from '@mana/shared-stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
|
||||
import { DragPreview } from '@mana/shared-ui/dnd';
|
||||
import type { DragType } from '@mana/shared-ui/dnd';
|
||||
import { ContextMenu } from '@mana/shared-ui';
|
||||
|
|
@ -23,7 +26,7 @@
|
|||
};
|
||||
}
|
||||
|
||||
// ── Persisted workbench state ───────────────────────────
|
||||
// ── Default card width (responsive) ─────────────────────
|
||||
const DESKTOP_WIDTH = 480;
|
||||
let DEFAULT_WIDTH = $state(DESKTOP_WIDTH);
|
||||
|
||||
|
|
@ -36,63 +39,19 @@
|
|||
return () => window.removeEventListener('resize', updateDefaultWidth);
|
||||
});
|
||||
|
||||
interface WorkbenchSettings extends Record<string, unknown> {
|
||||
openApps: {
|
||||
appId: string;
|
||||
minimized: boolean;
|
||||
maximized?: boolean;
|
||||
widthPx?: number;
|
||||
heightPx?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const workbenchStore = createAppSettingsStore<WorkbenchSettings>('workbench-settings', {
|
||||
openApps: [
|
||||
{ appId: 'todo', minimized: false },
|
||||
{ appId: 'calendar', minimized: false },
|
||||
{ appId: 'contacts', minimized: false },
|
||||
{ appId: 'habits', minimized: false },
|
||||
{ appId: 'notes', minimized: false },
|
||||
{ appId: 'finance', minimized: false },
|
||||
],
|
||||
});
|
||||
|
||||
let openApps = $state<
|
||||
{
|
||||
appId: string;
|
||||
minimized: boolean;
|
||||
maximized?: boolean;
|
||||
widthPx?: number;
|
||||
heightPx?: number;
|
||||
}[]
|
||||
>([
|
||||
{ appId: 'todo', minimized: false },
|
||||
{ appId: 'calendar', minimized: false },
|
||||
{ appId: 'contacts', minimized: false },
|
||||
{ appId: 'habits', minimized: false },
|
||||
{ appId: 'notes', minimized: false },
|
||||
{ appId: 'finance', minimized: false },
|
||||
]);
|
||||
|
||||
// Load persisted state once on mount (not reactive — avoids loop with persistState)
|
||||
// ── Scene store wiring ──────────────────────────────────
|
||||
onMount(() => {
|
||||
const s = workbenchStore.settings;
|
||||
if (s.openApps?.length) openApps = [...s.openApps];
|
||||
workbenchScenesStore.initialize();
|
||||
});
|
||||
onDestroy(() => {
|
||||
workbenchScenesStore.dispose();
|
||||
});
|
||||
|
||||
function persistState() {
|
||||
workbenchStore.update({
|
||||
openApps: openApps.map((a) => ({
|
||||
appId: a.appId,
|
||||
minimized: a.minimized,
|
||||
maximized: a.maximized,
|
||||
widthPx: a.widthPx,
|
||||
heightPx: a.heightPx,
|
||||
})),
|
||||
});
|
||||
}
|
||||
let scenes = $derived(workbenchScenesStore.scenes);
|
||||
let activeSceneId = $derived(workbenchScenesStore.activeSceneId);
|
||||
let openApps = $derived(workbenchScenesStore.openApps);
|
||||
|
||||
// ── Map to CarouselPage[] ───────────────────────────────
|
||||
// ── Map openApps → CarouselPage[] ───────────────────────
|
||||
let carouselPages = $derived<CarouselPage[]>(
|
||||
openApps.map((a) => {
|
||||
const entry = getApp(a.appId);
|
||||
|
|
@ -114,64 +73,37 @@
|
|||
|
||||
let showPicker = $state(false);
|
||||
|
||||
// ── App CRUD ────────────────────────────────────────────
|
||||
// ── App CRUD (delegated to active scene) ────────────────
|
||||
function handleAddApp(appId: string) {
|
||||
if (!openApps.some((a) => a.appId === appId)) {
|
||||
openApps = [...openApps, { appId, minimized: false }];
|
||||
} else {
|
||||
openApps = openApps.map((a) => (a.appId === appId ? { ...a, minimized: false } : a));
|
||||
}
|
||||
workbenchScenesStore.addApp(appId);
|
||||
showPicker = false;
|
||||
persistState();
|
||||
}
|
||||
|
||||
function handleRemoveApp(id: string) {
|
||||
openApps = openApps.filter((a) => a.appId !== id);
|
||||
persistState();
|
||||
workbenchScenesStore.removeApp(id);
|
||||
}
|
||||
|
||||
function handleMinimizeApp(id: string) {
|
||||
openApps = openApps.map((a) => (a.appId === id ? { ...a, minimized: true } : a));
|
||||
persistState();
|
||||
workbenchScenesStore.minimizeApp(id);
|
||||
}
|
||||
|
||||
function handleRestoreApp(id: string) {
|
||||
openApps = openApps.map((a) => (a.appId === id ? { ...a, minimized: false } : a));
|
||||
persistState();
|
||||
workbenchScenesStore.restoreApp(id);
|
||||
}
|
||||
|
||||
function handleMaximizeApp(id: string) {
|
||||
openApps = openApps.map((a) =>
|
||||
a.appId === id ? { ...a, maximized: !a.maximized, minimized: false } : a
|
||||
);
|
||||
persistState();
|
||||
workbenchScenesStore.toggleMaximizeApp(id);
|
||||
}
|
||||
|
||||
function handleResize(id: string, widthPx: number, heightPx?: number) {
|
||||
openApps = openApps.map((a) =>
|
||||
a.appId === id ? { ...a, widthPx, ...(heightPx !== undefined ? { heightPx } : {}) } : a
|
||||
);
|
||||
persistState();
|
||||
workbenchScenesStore.resizeApp(id, widthPx, heightPx);
|
||||
}
|
||||
|
||||
function handleMoveLeft(id: string) {
|
||||
const idx = openApps.findIndex((a) => a.appId === id);
|
||||
if (idx <= 0) return;
|
||||
const apps = [...openApps];
|
||||
[apps[idx - 1], apps[idx]] = [apps[idx], apps[idx - 1]];
|
||||
openApps = apps;
|
||||
persistState();
|
||||
workbenchScenesStore.moveAppLeft(id);
|
||||
}
|
||||
|
||||
function handleMoveRight(id: string) {
|
||||
const idx = openApps.findIndex((a) => a.appId === id);
|
||||
if (idx === -1 || idx >= openApps.length - 1) return;
|
||||
const apps = [...openApps];
|
||||
[apps[idx], apps[idx + 1]] = [apps[idx + 1], apps[idx]];
|
||||
openApps = apps;
|
||||
persistState();
|
||||
workbenchScenesStore.moveAppRight(id);
|
||||
}
|
||||
function handleReorder(fromId: string, toId: string) {
|
||||
workbenchScenesStore.reorderApps(fromId, toId);
|
||||
}
|
||||
|
||||
// ── Card / tab context menus ────────────────────────────
|
||||
const ctxMenu = createWorkbenchContextMenu();
|
||||
|
||||
function handleCardContextMenu(e: MouseEvent, appId: string, idx: number) {
|
||||
|
|
@ -206,15 +138,43 @@
|
|||
ctxMenu.open(e, appId, items);
|
||||
}
|
||||
|
||||
function handleReorder(fromId: string, toId: string) {
|
||||
const fromIdx = openApps.findIndex((a) => a.appId === fromId);
|
||||
const toIdx = openApps.findIndex((a) => a.appId === toId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
const apps = [...openApps];
|
||||
const [moved] = apps.splice(fromIdx, 1);
|
||||
apps.splice(toIdx, 0, moved);
|
||||
openApps = apps;
|
||||
persistState();
|
||||
// ── Scene CRUD dialogs ──────────────────────────────────
|
||||
type SceneDialogMode =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'rename'; id: string; name: string; icon?: string };
|
||||
let sceneDialog = $state<SceneDialogMode | null>(null);
|
||||
let sceneToDelete = $state<{ id: string; name: string } | null>(null);
|
||||
|
||||
function handleCreateScene() {
|
||||
sceneDialog = { kind: 'create' };
|
||||
}
|
||||
function handleRequestRename(id: string) {
|
||||
const scene = scenes.find((s) => s.id === id);
|
||||
if (!scene) return;
|
||||
sceneDialog = { kind: 'rename', id, name: scene.name, icon: scene.icon };
|
||||
}
|
||||
async function handleSubmitSceneDialog(name: string, icon: string | undefined) {
|
||||
const mode = sceneDialog;
|
||||
if (!mode) return;
|
||||
if (mode.kind === 'create') {
|
||||
await workbenchScenesStore.createScene({ name, icon });
|
||||
} else {
|
||||
await workbenchScenesStore.renameScene(mode.id, name, icon);
|
||||
}
|
||||
sceneDialog = null;
|
||||
}
|
||||
function handleDuplicateScene(id: string) {
|
||||
workbenchScenesStore.duplicateScene(id);
|
||||
}
|
||||
function handleRequestDeleteScene(id: string) {
|
||||
const scene = scenes.find((s) => s.id === id);
|
||||
if (!scene) return;
|
||||
sceneToDelete = { id, name: scene.name };
|
||||
}
|
||||
async function handleConfirmDeleteScene() {
|
||||
if (!sceneToDelete) return;
|
||||
await workbenchScenesStore.deleteScene(sceneToDelete.id);
|
||||
sceneToDelete = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -225,6 +185,17 @@
|
|||
<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}
|
||||
|
|
@ -269,6 +240,28 @@
|
|||
items={ctxMenu.items}
|
||||
onClose={() => ctxMenu.close()}
|
||||
/>
|
||||
|
||||
<SceneRenameDialog
|
||||
show={sceneDialog !== null}
|
||||
title={sceneDialog?.kind === 'rename' ? 'Szene umbenennen' : 'Neue Szene'}
|
||||
initialName={sceneDialog?.kind === 'rename' ? sceneDialog.name : ''}
|
||||
initialIcon={sceneDialog?.kind === 'rename' ? (sceneDialog.icon ?? '') : ''}
|
||||
confirmLabel={sceneDialog?.kind === 'rename' ? 'Speichern' : 'Erstellen'}
|
||||
onSubmit={handleSubmitSceneDialog}
|
||||
onCancel={() => (sceneDialog = null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
show={sceneToDelete !== null}
|
||||
title="Szene löschen"
|
||||
message={sceneToDelete
|
||||
? `„${sceneToDelete.name}" wird endgültig entfernt. Die Apps selbst bleiben erhalten — nur dieses Layout geht verloren.`
|
||||
: ''}
|
||||
confirmLabel="Löschen"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmDeleteScene}
|
||||
onCancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* POST /api/v1/voice/parse-habit
|
||||
*
|
||||
* Pick the right habit out of the user's habit list given a spoken
|
||||
* transcript. Used by the Habits voice quick-log flow: the user says
|
||||
* "kaffee" or "ich bin 30 minuten gelaufen" and we need to figure out
|
||||
* which of their existing habits this maps to.
|
||||
*
|
||||
* The browser does a cheap substring pre-match before calling this —
|
||||
* so by the time we get here, the obvious cases ("kaffee" → habit
|
||||
* named "Kaffee") have already been resolved client-side. This
|
||||
* endpoint exists for the harder cases where the spoken word doesn't
|
||||
* literally appear in any habit title ("gelaufen" → "Laufen",
|
||||
* "rauchen" → "Zigarette").
|
||||
*
|
||||
* Request: { transcript: string, habits: string[], language?: string }
|
||||
* Response: { match: string | null, note: string | null }
|
||||
*
|
||||
* `match` is one of the input habit titles verbatim, or null if the
|
||||
* LLM couldn't pick one with confidence. `note` is any extra context
|
||||
* the LLM extracted (e.g., "5 km" or "stark"). The browser does the
|
||||
* habit-id lookup itself, so the endpoint never sees IDs.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 500;
|
||||
const MAX_HABITS = 50;
|
||||
const LLM_TIMEOUT_MS = 8000;
|
||||
const DEFAULT_MODEL = 'ollama/gemma3:4b';
|
||||
|
||||
interface ParseResult {
|
||||
match: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
function fallback(): ParseResult {
|
||||
return { match: null, note: null };
|
||||
}
|
||||
|
||||
function buildPrompt(transcript: string, habits: string[], language: string): string {
|
||||
const langName = language === 'de' ? 'German' : language === 'en' ? 'English' : language;
|
||||
return [
|
||||
`The user spoke a habit log entry in ${langName}. Pick which habit they meant.`,
|
||||
'',
|
||||
'Habits the user has defined (these are the ONLY allowed match values):',
|
||||
...habits.map((h) => ` - ${h}`),
|
||||
'',
|
||||
'Return ONLY a JSON object with these exact keys:',
|
||||
' - match: one of the habit names above, copied verbatim, or null if none fits',
|
||||
' - note: any extra context the user mentioned (duration, intensity, count, …) or null',
|
||||
'',
|
||||
'Rules:',
|
||||
'- If the spoken text plausibly maps to one habit (e.g. "gelaufen" → "Laufen", ',
|
||||
' "rauchen" → "Zigarette"), pick it.',
|
||||
'- If multiple habits could fit, pick the most likely one.',
|
||||
'- If nothing fits with reasonable confidence, return null. Do not guess wildly.',
|
||||
'- The match value MUST be copied verbatim from the list above. Never invent a name.',
|
||||
'- Output JSON only, no markdown, no commentary, no code fences.',
|
||||
'',
|
||||
`Transcript: ${JSON.stringify(transcript)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function coerce(raw: unknown, allowedHabits: string[]): ParseResult {
|
||||
if (!raw || typeof raw !== 'object') return fallback();
|
||||
const r = raw as Record<string, unknown>;
|
||||
const matchRaw = typeof r.match === 'string' ? r.match.trim() : null;
|
||||
// Only honor a match value that's verbatim in the allowed list — the
|
||||
// model might paraphrase ("Joggen" instead of "Laufen") and we'd rather
|
||||
// drop the match than log against a non-existent habit.
|
||||
const match = matchRaw && allowedHabits.includes(matchRaw) ? matchRaw : null;
|
||||
const note = typeof r.note === 'string' && r.note.trim() ? r.note.trim() : null;
|
||||
return { match, note };
|
||||
}
|
||||
|
||||
function extractJson(text: string): unknown {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
const body = fenced ? fenced[1] : text;
|
||||
const start = body.indexOf('{');
|
||||
const end = body.lastIndexOf('}');
|
||||
if (start === -1 || end === -1 || end < start) return null;
|
||||
try {
|
||||
return JSON.parse(body.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { transcript?: string; habits?: string[]; language?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(fallback());
|
||||
}
|
||||
|
||||
const transcript = (body.transcript ?? '').slice(0, MAX_TRANSCRIPT_CHARS).trim();
|
||||
const habits = Array.isArray(body.habits)
|
||||
? body.habits
|
||||
.filter((h): h is string => typeof h === 'string' && h.trim().length > 0)
|
||||
.slice(0, MAX_HABITS)
|
||||
: [];
|
||||
const language = body.language ?? 'de';
|
||||
if (!transcript || habits.length === 0) return json(fallback());
|
||||
|
||||
const llmUrl = env.MANA_LLM_URL || env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
|
||||
|
||||
let response: Response;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
|
||||
try {
|
||||
response = await fetch(`${llmUrl.replace(/\/$/, '')}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_MODEL,
|
||||
stream: false,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Output JSON only. No prose.' },
|
||||
{ role: 'user', content: buildPrompt(transcript, habits, language) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return json(fallback());
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) return json(fallback());
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return json(fallback());
|
||||
}
|
||||
|
||||
const content =
|
||||
(payload as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message
|
||||
?.content ?? '';
|
||||
const parsed = extractJson(content);
|
||||
return json(coerce(parsed, habits));
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue