mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
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:
parent
2dd0812757
commit
2f87cf9d9a
20 changed files with 919 additions and 177 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
5
apps/manacore/apps/web/src/lib/context-menu/index.ts
Normal file
5
apps/manacore/apps/web/src/lib/context-menu/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { buildContextMenuItems, type ContextMenuContext } from './build-context-menu';
|
||||
export {
|
||||
createWorkbenchContextMenu,
|
||||
type WorkbenchContextMenuState,
|
||||
} from './use-workbench-context-menu.svelte';
|
||||
|
|
@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue