feat(manacore/web): add unified context menu system for workbench and app pages

Adds right-click context menus to workbench cards, minimized tabs, PillNavigation,
and item-level context menus for todo, calendar, contacts, habits, notes, places,
and moodlit modules. Uses a shared builder pattern with app-specific actions
registered via AppDescriptor.contextMenuActions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 21:18:05 +02:00
parent 2dd0812757
commit 2f87cf9d9a
20 changed files with 919 additions and 177 deletions

View file

@ -7,6 +7,7 @@
*/
import { registerApp } from './registry';
import { Plus } from '@manacore/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -18,6 +19,17 @@ registerApp({
list: { load: () => import('$lib/modules/todo/ListView.svelte') },
detail: { load: () => import('$lib/modules/todo/views/DetailView.svelte') },
},
contextMenuActions: [
{
id: 'new-task',
label: 'Neue Aufgabe',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'todo', action: 'new' } })
),
},
],
collection: 'tasks',
paramKey: 'taskId',
dragType: 'task',
@ -53,6 +65,17 @@ registerApp({
list: { load: () => import('$lib/modules/calendar/ListView.svelte') },
detail: { load: () => import('$lib/modules/calendar/views/DetailView.svelte') },
},
contextMenuActions: [
{
id: 'new-event',
label: 'Neuer Termin',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'calendar', action: 'new' } })
),
},
],
collection: 'events',
paramKey: 'eventId',
dragType: 'event',
@ -120,6 +143,17 @@ registerApp({
list: { load: () => import('$lib/modules/contacts/ListView.svelte') },
detail: { load: () => import('$lib/modules/contacts/views/DetailView.svelte') },
},
contextMenuActions: [
{
id: 'new-contact',
label: 'Neuer Kontakt',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'contacts', action: 'new' } })
),
},
],
collection: 'contacts',
paramKey: 'contactId',
dragType: 'contact',
@ -142,6 +176,17 @@ registerApp({
views: {
list: { load: () => import('$lib/modules/habits/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-habit',
label: 'Neues Habit',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'habits', action: 'new' } })
),
},
],
collection: 'habits',
paramKey: 'habitId',
dragType: 'habit',
@ -149,19 +194,19 @@ registerApp({
transformIncoming: {
task: (source) => ({
title: source.title as string,
emoji: '\u{1F4AA}',
icon: 'barbell',
color: '#6366f1',
}),
},
getDisplayData: (item) => ({
title: `${item.emoji as string} ${item.title as string}`,
title: item.title as string,
subtitle: undefined,
}),
createItem: async (data) => {
const { habitsStore } = await import('$lib/modules/habits/stores/habits.svelte');
const habit = await habitsStore.createHabit({
title: data.title as string,
emoji: (data.emoji as string) ?? '\u{2B50}',
icon: (data.icon as string) ?? 'star',
color: (data.color as string) ?? '#6366f1',
});
return habit.id;
@ -175,6 +220,17 @@ registerApp({
views: {
list: { load: () => import('$lib/modules/notes/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-note',
label: 'Neue Notiz',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'notes', action: 'new' } })
),
},
],
collection: 'notes',
paramKey: 'noteId',
dragType: 'note',

View file

@ -10,6 +10,18 @@ import type { DragType } from '@manacore/shared-ui/dnd';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyComponent = import('svelte').Component<any, any>;
export type ContextMenuLocation = 'card' | 'tab' | 'nav' | 'page';
export interface AppContextMenuAction {
id: string;
label: string;
icon?: AnyComponent;
shortcut?: string;
/** Only show in certain contexts (default: all) */
showIn?: ContextMenuLocation[];
action: () => void;
}
export interface ViewLoader {
load: () => Promise<{ default: AnyComponent }>;
}
@ -41,6 +53,9 @@ export interface AppDescriptor {
>;
createItem?: (data: Record<string, unknown>) => Promise<string>;
getDisplayData?: (item: Record<string, unknown>) => EntityDisplayData;
// -- Context Menu (optional) --
contextMenuActions?: AppContextMenuAction[];
}
export interface DropResult {

View file

@ -26,6 +26,7 @@
onMaximize: (id: string) => void;
onRemove: (id: string) => void;
onTogglePicker: () => void;
onTabContextMenu?: (e: MouseEvent, pageId: string) => void;
addLabel?: string;
page: Snippet<[CarouselPage, number]>;
picker?: Snippet;
@ -40,6 +41,7 @@
onMaximize,
onRemove,
onTogglePicker,
onTabContextMenu,
addLabel = 'Hinzufügen',
page: pageSnippet,
picker,
@ -136,7 +138,8 @@
{#if minimizedPages.length > 0}
<div class="minimized-tabs">
{#each minimizedPages as p (p.id)}
<div class="minimized-tab">
<!-- 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}

View file

@ -30,6 +30,7 @@
title?: string;
color?: string;
icon?: Component;
onContextMenu?: (e: MouseEvent) => void;
// Snippet overrides
header_left?: Snippet;
badge?: Snippet;
@ -47,6 +48,7 @@
onResize,
onMoveLeft,
onMoveRight,
onContextMenu,
title = '',
color = '#6B7280',
icon: IconComponent,
@ -125,7 +127,8 @@
? `height: ${heightPx}px; min-height: 0;`
: ''}"
>
<div class="drag-handle-bar" draggable="true">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="drag-handle-bar" draggable="true" oncontextmenu={onContextMenu}>
{#if onMoveLeft}
<button
class="move-btn move-left"

View file

@ -22,6 +22,7 @@
onResize?: (widthPx: number, heightPx?: number) => void;
onMoveLeft?: () => void;
onMoveRight?: () => void;
onContextMenu?: (e: MouseEvent) => void;
}
let {
@ -35,6 +36,7 @@
onResize,
onMoveLeft,
onMoveRight,
onContextMenu,
}: Props = $props();
let app = $derived(getApp(appId));
@ -280,6 +282,7 @@
{onResize}
{onMoveLeft}
{onMoveRight}
{onContextMenu}
>
{#if loadError}
<div class="load-state">

View file

@ -0,0 +1,141 @@
import type { ContextMenuItem } from '@manacore/shared-ui';
import type { AppDescriptor, ContextMenuLocation } from '$lib/app-registry/types';
import {
CornersOut,
CornersIn,
Minus,
CaretLeft,
CaretRight,
X,
ArrowSquareOut,
Link,
ArrowLineUp,
} from '@manacore/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)
);
for (const action of appActions) {
items.push({
id: action.id,
label: action.label,
icon: action.icon,
shortcut: action.shortcut,
action: action.action,
});
}
if (appActions.length > 0) {
items.push({ id: 'div-app', label: '', type: 'divider' });
}
// 2. Window management (location-dependent)
if (ctx.location === 'card') {
if (ctx.onMaximize) {
items.push({
id: 'maximize',
label: ctx.maximized ? 'Verkleinern' : 'Maximieren',
icon: ctx.maximized ? CornersIn : CornersOut,
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',
label: 'Nach links',
icon: CaretLeft,
action: ctx.onMoveLeft,
});
}
if (ctx.onMoveRight) {
items.push({
id: 'move-right',
label: 'Nach rechts',
icon: CaretRight,
action: ctx.onMoveRight,
});
}
items.push({ id: 'div-window', label: '', type: 'divider' });
}
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',
label: 'Maximieren',
icon: CornersOut,
action: ctx.onMaximize,
});
}
items.push({ id: 'div-window', label: '', type: 'divider' });
}
// 3. Navigation actions (always)
const route = ctx.appRoute ?? `/${ctx.appId}`;
items.push({
id: 'open-route',
label: 'In neuem Tab öffnen',
icon: ArrowSquareOut,
action: () => window.open(route, '_blank'),
});
items.push({
id: 'copy-link',
label: 'Link kopieren',
icon: Link,
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' });
items.push({
id: 'close',
label: 'Schließen',
icon: X,
variant: 'danger',
action: ctx.onClose,
});
}
}
return items;
}

View file

@ -0,0 +1,5 @@
export { buildContextMenuItems, type ContextMenuContext } from './build-context-menu';
export {
createWorkbenchContextMenu,
type WorkbenchContextMenuState,
} from './use-workbench-context-menu.svelte';

View file

@ -0,0 +1,36 @@
import type { ContextMenuItem } from '@manacore/shared-ui';
export interface WorkbenchContextMenuState {
visible: boolean;
x: number;
y: number;
target: string | null;
}
export function createWorkbenchContextMenu() {
let state = $state<WorkbenchContextMenuState>({
visible: false,
x: 0,
y: 0,
target: null,
});
let items = $state<ContextMenuItem[]>([]);
return {
get state() {
return state;
},
get items() {
return items;
},
open(e: MouseEvent, appId: string, menuItems: ContextMenuItem[]) {
e.preventDefault();
state = { visible: true, x: e.clientX, y: e.clientY, target: appId };
items = menuItems;
},
close() {
state = { visible: false, x: state.x, y: state.y, target: null };
items = [];
},
};
}

View file

@ -8,8 +8,9 @@
import { db } from '$lib/data/database';
import type { LocalEvent } from './types';
import { eventsStore } from './stores/events.svelte';
import { Plus } from '@manacore/shared-icons';
import { Plus, PencilSimple, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@ -69,6 +70,48 @@
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; event: LocalEvent | null }>({
visible: false,
x: 0,
y: 0,
event: null,
});
function handleItemContextMenu(e: MouseEvent, event: LocalEvent) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, event };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.event
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.event) {
navigate('detail', { eventId: ctxMenu.event.id });
}
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.event) {
eventsStore.deleteEvent(ctxMenu.event.id);
}
},
},
]
: []
);
// Quick event creation
let newTitle = $state('');
@ -145,6 +188,7 @@
_siblingIds: todayEvents.map((e) => e.id),
_siblingKey: 'eventId',
})}
oncontextmenu={(e) => handleItemContextMenu(e, event)}
use:dragSource={{
type: 'event',
data: () => ({
@ -192,6 +236,14 @@
<p class="empty">Keine Termine heute</p>
{/if}
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, event: null })}
/>
</div>
<style>

View file

@ -8,8 +8,9 @@
import { db } from '$lib/data/database';
import type { LocalContact } from './types';
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star } from '@manacore/shared-icons';
import { Plus, Star, PencilSimple, Trash, StarFour } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@ -66,6 +67,58 @@
return (f + l).toUpperCase() || '?';
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; contact: LocalContact | null }>({
visible: false,
x: 0,
y: 0,
contact: null,
});
function handleItemContextMenu(e: MouseEvent, contact: LocalContact) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, contact };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.contact
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.contact) {
navigate('detail', { contactId: ctxMenu.contact.id });
}
},
},
{
id: 'favorite',
label: ctxMenu.contact.isFavorite ? 'Favorit entfernen' : 'Als Favorit',
icon: Star,
action: () => {
if (ctxMenu.contact) {
contactsStore.toggleFavorite(ctxMenu.contact.id);
}
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.contact) {
contactsStore.deleteContact(ctxMenu.contact.id);
}
},
},
]
: []
);
// Quick create
let newName = $state('');
@ -110,6 +163,7 @@
_siblingIds: filtered().map((c) => c.id),
_siblingKey: 'contactId',
})}
oncontextmenu={(e) => handleItemContextMenu(e, contact)}
use:dragSource={{
type: 'contact',
data: () => ({
@ -153,6 +207,14 @@
<p class="empty">Keine Kontakte gefunden</p>
{/if}
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, contact: null })}
/>
</div>
<style>

View file

@ -14,6 +14,10 @@
import { habitsStore } from './stores/habits.svelte';
import type { Habit, HabitLog } from './types';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { DynamicIcon } from '@manacore/shared-ui/atoms';
import { IconPicker } from '@manacore/shared-ui/molecules';
import { PencilSimple, Trash, Pause, Play } from '@manacore/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
@ -51,28 +55,10 @@
let animatingId = $state<string | null>(null);
let showCreate = $state(false);
let newTitle = $state('');
let newEmoji = $state('\u2b50');
let newIcon = $state('star');
let newColor = $state('#8b5cf6');
let showEmojiPicker = $state(false);
let showIconPicker = $state(false);
const QUICK_EMOJIS = [
'\u2615',
'\ud83d\udca7',
'\ud83d\udcaa',
'\ud83e\uddd8',
'\ud83c\udfc3',
'\ud83d\udcda',
'\ud83c\udf4e',
'\ud83d\udc8a',
'\ud83c\udf7a',
'\ud83d\udecc',
'\ud83c\udfb5',
'\ud83d\udeb4',
'\ud83d\udcdd',
'\ud83d\ude2e\u200d\ud83d\udca8',
'\ud83e\uddfc',
'\u2b50',
];
const QUICK_COLORS = [
'#ef4444',
'#f97316',
@ -97,15 +83,64 @@
if (!newTitle.trim()) return;
await habitsStore.createHabit({
title: newTitle.trim(),
emoji: newEmoji,
icon: newIcon,
color: newColor,
});
newTitle = '';
newEmoji = '\u2b50';
newIcon = 'star';
showCreate = false;
showEmojiPicker = false;
showIconPicker = false;
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; habit: Habit | null }>({
visible: false,
x: 0,
y: 0,
habit: null,
});
function handleItemContextMenu(e: MouseEvent, habit: Habit) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, habit };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.habit
? [
{
id: 'log',
label: 'Loggen',
icon: Play,
action: () => {
if (ctxMenu.habit) handleTap(ctxMenu.habit.id);
},
},
{
id: 'archive',
label: ctxMenu.habit.isArchived ? 'Aktivieren' : 'Archivieren',
icon: ctxMenu.habit.isArchived ? Play : Pause,
action: () => {
if (ctxMenu.habit)
habitsStore.updateHabit(ctxMenu.habit.id, {
isArchived: !ctxMenu.habit.isArchived,
});
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.habit) habitsStore.deleteHabit(ctxMenu.habit.id);
},
},
]
: []
);
function handleCreateKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -113,7 +148,7 @@
}
if (e.key === 'Escape') {
showCreate = false;
showEmojiPicker = false;
showIconPicker = false;
}
}
</script>
@ -129,8 +164,11 @@
class:over-target={overTarget}
class:pulse={animatingId === habit.id}
onclick={() => handleTap(habit.id)}
oncontextmenu={(e) => handleItemContextMenu(e, habit)}
>
<span class="tally-emoji">{habit.emoji}</span>
<span class="tally-icon">
<DynamicIcon name={habit.icon} size={20} weight="bold" />
</span>
<span class="tally-count" style:color={habit.color}>
{count}{#if habit.targetPerDay}<span class="tally-target">/{habit.targetPerDay}</span
>{/if}
@ -154,11 +192,11 @@
<div class="create-row">
<button
type="button"
class="emoji-btn"
class="icon-btn"
style:background={newColor}
onclick={() => (showEmojiPicker = !showEmojiPicker)}
onclick={() => (showIconPicker = !showIconPicker)}
>
{newEmoji}
<DynamicIcon name={newIcon} size={16} weight="bold" class="text-white" />
</button>
<input
class="create-input"
@ -168,19 +206,16 @@
autofocus
/>
</div>
{#if showEmojiPicker}
<div class="emoji-row">
{#each QUICK_EMOJIS as e}
<button
type="button"
class="emoji-opt"
class:selected={newEmoji === e}
onclick={() => {
newEmoji = e;
showEmojiPicker = false;
}}>{e}</button
>
{/each}
{#if showIconPicker}
<div class="icon-picker-wrapper">
<IconPicker
selectedIcon={newIcon}
onIconChange={(i) => {
newIcon = i;
showIconPicker = false;
}}
size="sm"
/>
</div>
{/if}
<div class="color-row">
@ -200,7 +235,7 @@
class="btn-cancel"
onclick={() => {
showCreate = false;
showEmojiPicker = false;
showIconPicker = false;
}}>Abbrechen</button
>
<button type="submit" class="btn-create" disabled={!newTitle.trim()}>Erstellen</button>
@ -216,7 +251,9 @@
{@const habit = habitMap.get(log.habitId)}
{#if habit}
<div class="log-row">
<span class="log-emoji">{habit.emoji}</span>
<span class="log-icon" style:color={habit.color}>
<DynamicIcon name={habit.icon} size={14} weight="regular" />
</span>
<span class="log-name">{habit.title}</span>
<span class="log-time">{formatTime(log.timestamp)}</span>
</div>
@ -225,6 +262,14 @@
</div>
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, habit: null })}
/>
{#if activeHabits.length === 0 && !showCreate}
<div class="empty">
<p>Noch keine Habits angelegt.</p>
@ -264,6 +309,7 @@
box-shadow 0.15s;
user-select: none;
touch-action: manipulation;
color: var(--color-foreground, #fff);
}
.tally-item:hover {
@ -284,8 +330,10 @@
background: rgba(34, 197, 94, 0.06);
}
.tally-emoji {
font-size: 1.25rem;
.tally-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
@ -334,8 +382,9 @@
font-size: 0.8125rem;
}
.log-emoji {
font-size: 0.875rem;
.log-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
@ -389,23 +438,31 @@
gap: 0.5rem;
}
.emoji-btn {
.icon-btn {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.emoji-btn:hover {
.icon-btn:hover {
transform: scale(1.1);
}
.icon-picker-wrapper {
max-height: 14rem;
overflow-y: auto;
border-radius: 0.5rem;
padding: 0.5rem;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.create-input {
flex: 1;
background: transparent;
@ -423,31 +480,6 @@
color: var(--color-muted-foreground);
}
.emoji-row {
display: flex;
flex-wrap: wrap;
gap: 0.125rem;
}
.emoji-opt {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
background: transparent;
border: 2px solid transparent;
cursor: pointer;
}
.emoji-opt:hover {
background: rgba(255, 255, 255, 0.08);
}
.emoji-opt.selected {
border-color: var(--color-primary, #6366f1);
}
.color-row {
display: flex;
gap: 0.25rem;

View file

@ -6,6 +6,9 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalMood } from './types';
import { moodsStore } from './stores/moods.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { Trash, Power } from '@manacore/shared-icons';
let moods = $state<LocalMood[]>([]);
let activeMoodId = $state<string | null>(null);
@ -29,6 +32,48 @@
if (colors.length === 1) return `background: ${colors[0]}`;
return `background: linear-gradient(135deg, ${colors.join(', ')})`;
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; mood: LocalMood | null }>({
visible: false,
x: 0,
y: 0,
mood: null,
});
function handleItemContextMenu(e: MouseEvent, mood: LocalMood) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, mood };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.mood
? [
{
id: 'activate',
label: activeMoodId === ctxMenu.mood.id ? 'Deaktivieren' : 'Aktivieren',
icon: Power,
action: () => {
if (ctxMenu.mood)
activeMoodId = activeMoodId === ctxMenu.mood.id ? null : ctxMenu.mood.id;
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.mood) {
if (activeMoodId === ctxMenu.mood.id) activeMoodId = null;
moodsStore.deleteMood(ctxMenu.mood.id);
}
},
},
]
: []
);
</script>
<div class="flex h-full flex-col gap-4 p-4">
@ -52,6 +97,7 @@
{#each moods as mood (mood.id)}
<button
onclick={() => (activeMoodId = activeMoodId === mood.id ? null : mood.id)}
oncontextmenu={(e) => handleItemContextMenu(e, mood)}
class="group flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-white/5
{activeMoodId === mood.id ? 'ring-1 ring-white/30' : ''}"
>
@ -65,4 +111,12 @@
<p class="py-8 text-center text-sm text-white/30">Keine Moods</p>
{/if}
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, mood: null })}
/>
</div>

View file

@ -7,6 +7,8 @@
import { notesStore } from './stores/notes.svelte';
import type { Note } from './types';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { PencilSimple, Trash, PushPin } from '@manacore/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
@ -61,6 +63,52 @@
e.stopPropagation();
await notesStore.togglePin(id);
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; note: Note | null }>({
visible: false,
x: 0,
y: 0,
note: null,
});
function handleItemContextMenu(e: MouseEvent, note: Note) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, note };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.note
? [
{
id: 'edit',
label: 'Bearbeiten',
icon: PencilSimple,
action: () => {
if (ctxMenu.note) startEdit(ctxMenu.note);
},
},
{
id: 'pin',
label: ctxMenu.note.isPinned ? 'Lösen' : 'Pinnen',
icon: PushPin,
action: () => {
if (ctxMenu.note) notesStore.togglePin(ctxMenu.note.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.note) handleDelete(ctxMenu.note.id);
},
},
]
: []
);
</script>
<div class="app-view">
@ -111,7 +159,11 @@
</div>
{:else}
<!-- Note row -->
<button class="note-item" onclick={() => startEdit(note)}>
<button
class="note-item"
onclick={() => startEdit(note)}
oncontextmenu={(e) => handleItemContextMenu(e, note)}
>
{#if note.color}
<span class="color-dot" style="background: {note.color}"></span>
{/if}
@ -137,6 +189,14 @@
{#if notes.length === 0}
<p class="empty">Tippe oben, um eine Notiz zu erstellen.</p>
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, note: null })}
/>
</div>
<style>

View file

@ -8,8 +8,9 @@
import type { LocalPlace } from './types';
import { placesStore } from './stores/places.svelte';
import { trackingStore } from './stores/tracking.svelte';
import { Star, MapPin, Plus } from '@manacore/shared-icons';
import { Star, MapPin, Plus, PencilSimple, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@ -92,6 +93,52 @@
navigate('detail', { placeId, _siblingIds: ids, _siblingKey: 'placeId' });
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; place: LocalPlace | null }>({
visible: false,
x: 0,
y: 0,
place: null,
});
function handleItemContextMenu(e: MouseEvent, place: LocalPlace) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, place };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.place
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.place) openDetail(ctxMenu.place.id);
},
},
{
id: 'favorite',
label: ctxMenu.place.isFavorite ? 'Favorit entfernen' : 'Als Favorit',
icon: Star,
action: () => {
if (ctxMenu.place) placesStore.toggleFavorite(ctxMenu.place.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.place) placesStore.deletePlace(ctxMenu.place.id);
},
},
]
: []
);
function formatCoords(lat: number, lng: number): string {
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
@ -150,6 +197,7 @@
<button
class="place-item"
onclick={() => openDetail(place.id)}
oncontextmenu={(e) => handleItemContextMenu(e, place)}
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(place.id, payload.data as unknown as TagDragData),
@ -195,6 +243,14 @@
{/each}
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, place: null })}
/>
{#if filtered().length === 0 && !search}
<div class="empty">
<p>Noch keine Orte gespeichert.</p>

View file

@ -14,8 +14,15 @@
} from './queries';
import { tasksStore } from './stores/tasks.svelte';
import { toastStore } from '@manacore/shared-ui/toast';
import { Circle, Check } from '@manacore/shared-icons';
import {
Circle,
Check,
PencilSimple,
Trash,
ArrowCounterClockwise,
} from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { dropTarget, dragSource } from '@manacore/shared-ui/dnd';
import type { TagDragData } from '@manacore/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@ -74,6 +81,57 @@
newTitle = '';
}
// Context menu
let ctxMenu = $state<{
visible: boolean;
x: number;
y: number;
task: import('./types').Task | null;
}>({
visible: false,
x: 0,
y: 0,
task: null,
});
function handleItemContextMenu(e: MouseEvent, task: import('./types').Task) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, task };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.task
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.task) navigate('detail', { taskId: ctxMenu.task.id });
},
},
{
id: 'complete',
label: ctxMenu.task.isCompleted ? 'Wieder öffnen' : 'Erledigen',
icon: ctxMenu.task.isCompleted ? ArrowCounterClockwise : Check,
action: () => {
if (ctxMenu.task) tasksStore.toggleComplete(ctxMenu.task.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.task) tasksStore.deleteTask(ctxMenu.task.id);
},
},
]
: []
);
async function toggleComplete(e: Event, id: string) {
e.stopPropagation();
const task = tasks.find((t) => t.id === id);
@ -126,6 +184,7 @@
_siblingIds: filtered().map((t) => t.id),
_siblingKey: 'taskId',
})}
oncontextmenu={(e) => handleItemContextMenu(e, task)}
class="task-item"
use:dragSource={{
type: 'task',
@ -176,6 +235,14 @@
<p class="empty">Keine Aufgaben</p>
{/if}
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, task: null })}
/>
</div>
<style>

View file

@ -5,6 +5,14 @@
import TaskItem from './TaskItem.svelte';
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import {
PencilSimple,
Check,
ArrowCounterClockwise,
Trash,
Circle,
} from '@manacore/shared-icons';
interface Props {
tasks: Task[];
@ -23,33 +31,65 @@
});
// Context menu
let contextMenu = $state<{ x: number; y: number; task: Task } | null>(null);
let ctxMenu = $state<{ visible: boolean; x: number; y: number; task: Task | null }>({
visible: false,
x: 0,
y: 0,
task: null,
});
function handleContextMenu(task: Task, e: MouseEvent) {
contextMenu = { x: e.clientX, y: e.clientY, task };
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, task };
}
function closeContextMenu() {
contextMenu = null;
}
async function handleSetPriority(priority: TaskPriority) {
if (!contextMenu) return;
await tasksStore.updateTask(contextMenu.task.id, { priority });
closeContextMenu();
}
async function handleComplete() {
if (!contextMenu) return;
await tasksStore.toggleComplete(contextMenu.task.id);
closeContextMenu();
}
async function handleDelete() {
if (!contextMenu) return;
await tasksStore.deleteTask(contextMenu.task.id);
closeContextMenu();
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.task
? [
{
id: 'open',
label: $_('todo.edit'),
icon: PencilSimple,
action: () => {
if (ctxMenu.task) onOpenTask(ctxMenu.task);
},
},
{
id: 'complete',
label: ctxMenu.task.isCompleted ? $_('todo.reopen') : $_('todo.markDone'),
icon: ctxMenu.task.isCompleted ? ArrowCounterClockwise : Check,
action: () => {
if (ctxMenu.task) tasksStore.toggleComplete(ctxMenu.task.id);
},
},
{ id: 'div-priority', label: '', type: 'divider' as const },
...(['urgent', 'high', 'medium', 'low'] as TaskPriority[]).map((p) => ({
id: `priority-${p}`,
label:
p === 'urgent'
? $_('todo.priorityUrgent')
: p === 'high'
? $_('todo.priorityHigh')
: p === 'medium'
? $_('todo.priorityMedium')
: $_('todo.priorityLow'),
icon: Circle,
action: () => {
if (ctxMenu.task) tasksStore.updateTask(ctxMenu.task.id, { priority: p });
},
})),
{ id: 'div-delete', label: '', type: 'divider' as const },
{
id: 'delete',
label: $_('common.delete'),
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.task) tasksStore.deleteTask(ctxMenu.task.id);
},
},
]
: []
);
// DnD handlers
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
@ -108,65 +148,10 @@
</div>
{/if}
<!-- Context Menu -->
{#if contextMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[9990]" onclick={closeContextMenu}></div>
<div
class="fixed z-[9991] min-w-[160px] rounded-lg border border-border bg-card p-1 shadow-xl"
style="left: {contextMenu.x}px; top: {contextMenu.y}px"
>
<button
onclick={() => {
onOpenTask(contextMenu!.task);
closeContextMenu();
}}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{$_('todo.edit')}
</button>
<button
onclick={handleComplete}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{contextMenu.task.isCompleted ? $_('todo.reopen') : $_('todo.markDone')}
</button>
<div class="my-1 border-t border-border"></div>
<div class="px-3 py-1 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">
{$_('todo.priority')}
</div>
{#each ['urgent', 'high', 'medium', 'low'] as p}
<button
onclick={() => handleSetPriority(p as TaskPriority)}
class="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-muted
{contextMenu.task.priority === p ? 'text-primary font-medium' : 'text-foreground'}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {p === 'urgent'
? '#ef4444'
: p === 'high'
? '#f59e0b'
: p === 'medium'
? '#3b82f6'
: '#6b7280'}"
></span>
{p === 'urgent'
? $_('todo.priorityUrgent')
: p === 'high'
? $_('todo.priorityHigh')
: p === 'medium'
? $_('todo.priorityMedium')
: $_('todo.priorityLow')}
</button>
{/each}
<div class="my-1 border-t border-border"></div>
<button
onclick={handleDelete}
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-red-500/10"
>
{$_('common.delete')}
</button>
</div>
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, task: null })}
/>

View file

@ -20,7 +20,10 @@
PillDropdownItem,
SpotlightAction,
ContentSearcher,
ContextMenuItem,
} from '@manacore/shared-ui';
import { ContextMenu } from '@manacore/shared-ui';
import { createWorkbenchContextMenu } from '$lib/context-menu';
import type { InputBarAdapter } from '$lib/quick-input/types';
import { getAdapterLoader } from '$lib/quick-input/registry';
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
@ -140,6 +143,28 @@
},
});
// ── Navigation Context Menu ──────────────────────────────
const navCtxMenu = createWorkbenchContextMenu();
function makeNavContextMenu(href: string): (e: MouseEvent) => void {
return (e: MouseEvent) => {
e.preventDefault();
const items: ContextMenuItem[] = [
{
id: 'open-new-tab',
label: 'In neuem Tab öffnen',
action: () => window.open(href, '_blank'),
},
{
id: 'copy-link',
label: 'Link kopieren',
action: () => navigator.clipboard.writeText(window.location.origin + href),
},
];
navCtxMenu.open(e, href, items);
};
}
// ── Navigation ──────────────────────────────────────────
let baseNavItems = $derived<PillNavItem[]>([
{
@ -149,11 +174,31 @@
onClick: handleTagStripToggle,
active: isTagStripVisible,
},
{ href: '/', label: $_('nav.home'), icon: 'home' },
{ href: '/spiral', label: $_('nav.spiral'), icon: 'spiral' },
{ href: '/credits', label: $_('nav.credits'), icon: 'creditCard' },
{ href: '/profile', label: $_('nav.profile'), icon: 'user' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{ href: '/', label: $_('nav.home'), icon: 'home', onContextMenu: makeNavContextMenu('/') },
{
href: '/spiral',
label: $_('nav.spiral'),
icon: 'spiral',
onContextMenu: makeNavContextMenu('/spiral'),
},
{
href: '/credits',
label: $_('nav.credits'),
icon: 'creditCard',
onContextMenu: makeNavContextMenu('/credits'),
},
{
href: '/profile',
label: $_('nav.profile'),
icon: 'user',
onContextMenu: makeNavContextMenu('/profile'),
},
{
href: '/settings',
label: $_('nav.settings'),
icon: 'settings',
onContextMenu: makeNavContextMenu('/settings'),
},
]);
let isAdmin = $derived(authStore.user?.role === 'admin');
@ -488,6 +533,15 @@
{/if}
</div>
<!-- Navigation Context Menu -->
<ContextMenu
visible={navCtxMenu.state.visible}
x={navCtxMenu.state.x}
y={navCtxMenu.state.y}
items={navCtxMenu.items}
onClose={() => navCtxMenu.close()}
/>
<!-- Guest Welcome Modal -->
{#if guestMode}
<GuestWelcomeModal

View file

@ -7,6 +7,8 @@
import { createAppSettingsStore } from '@manacore/shared-stores';
import { DragPreview } from '@manacore/shared-ui/dnd';
import type { DragType } from '@manacore/shared-ui/dnd';
import { ContextMenu } from '@manacore/shared-ui';
import { buildContextMenuItems, createWorkbenchContextMenu } from '$lib/context-menu';
function resolveEntity(type: string, data: Record<string, unknown>) {
const app = getAppByDragType(type as DragType);
@ -165,6 +167,40 @@
persistState();
}
const ctxMenu = createWorkbenchContextMenu();
function handleCardContextMenu(e: MouseEvent, appId: string, idx: number) {
const app = getApp(appId);
if (!app) return;
const entry = openApps.find((a) => a.appId === appId);
const items = buildContextMenuItems({
location: 'card',
appId,
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,
});
ctxMenu.open(e, appId, items);
}
function handleTabContextMenu(e: MouseEvent, appId: string) {
const app = getApp(appId);
if (!app) return;
const items = buildContextMenuItems({
location: 'tab',
appId,
app,
onRestore: () => handleRestoreApp(appId),
onMaximize: () => handleMaximizeApp(appId),
onClose: () => handleRemoveApp(appId),
});
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);
@ -193,6 +229,7 @@
onMaximize={handleMaximizeApp}
onRemove={handleRemoveApp}
onTogglePicker={() => (showPicker = !showPicker)}
onTabContextMenu={handleTabContextMenu}
addLabel="App hinzufügen"
>
{#snippet page(p)}
@ -208,6 +245,7 @@
onResize={(w, h) => handleResize(p.id, w, h)}
onMoveLeft={idx > 0 ? () => handleMoveLeft(p.id) : undefined}
onMoveRight={idx < openApps.length - 1 ? () => handleMoveRight(p.id) : undefined}
onContextMenu={(e) => handleCardContextMenu(e, p.id, idx)}
/>
{/snippet}
{#snippet picker()}
@ -218,6 +256,14 @@
/>
{/snippet}
</PageCarousel>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenu.items}
onClose={() => ctxMenu.close()}
/>
</div>
<style>

View file

@ -474,7 +474,12 @@
<!-- Navigation Items -->
{#each items as item}
{#if item.onClick}
<button onclick={item.onClick} class="pill glass-pill" class:active={item.active}>
<button
onclick={item.onClick}
oncontextmenu={item.onContextMenu}
class="pill glass-pill"
class:active={item.active}
>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
@ -492,7 +497,12 @@
<span class="pill-label">{item.label}</span>
</button>
{:else}
<a href={item.href} class="pill glass-pill" class:active={isActive(item.href)}>
<a
href={item.href}
oncontextmenu={item.onContextMenu}
class="pill glass-pill"
class:active={isActive(item.href)}
>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">

View file

@ -24,6 +24,8 @@ export interface PillNavItem {
onClick?: () => void;
/** Whether this item is currently active/selected (for toggle buttons) */
active?: boolean;
/** Right-click handler for context menu */
onContextMenu?: (e: MouseEvent) => void;
}
export interface PillDropdownItem {