feat: add right-click context menus to calendar agenda, chat, contacts, and storage

- Calendar AgendaView: edit, duplicate, delete events (reuses WeekView i18n)
- Chat ConversationList: rename, archive, delete conversations
- Contacts ContactGridView: open, favorite, call, email, delete
- Storage FileCard: replace custom dropdown with shared ContextMenu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 22:51:07 +01:00
parent 45063b88be
commit ecda4535d8
4 changed files with 231 additions and 63 deletions

View file

@ -7,7 +7,9 @@
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import type { CalendarEvent } from '@calendar/shared';
import type { CalendarEvent, CreateEventInput } from '@calendar/shared';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { _ } from 'svelte-i18n';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
@ -85,6 +87,59 @@
onEventClick(event);
}
}
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuEvent = $state<CalendarEvent | null>(null);
function handleContextMenu(event: CalendarEvent, e: MouseEvent) {
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuEvent = event;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuEvent) return [];
const event = contextMenuEvent;
return [
{
id: 'edit',
label: $_('calendar.contextMenu.edit'),
action: () => {
handleEventClick(event);
},
},
{
id: 'duplicate',
label: $_('calendar.contextMenu.duplicate'),
action: async () => {
await eventsStore.createEvent({
calendarId: event.calendarId,
title: `${event.title} (${$_('calendar.contextMenu.copy')})`,
description: event.description ?? undefined,
location: event.location ?? undefined,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
timezone: event.timezone ?? undefined,
color: event.color ?? undefined,
status: event.status ?? undefined,
});
},
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'delete',
label: $_('calendar.contextMenu.delete'),
variant: 'danger',
action: () => eventsStore.deleteEvent(event.id),
},
];
}
</script>
<div class="agenda-view">
@ -110,7 +165,15 @@
<div class="events-for-date">
{#each group.events as event}
<button class="event-item" onclick={() => handleEventClick(event)}>
<button
class="event-item"
onclick={() => handleEventClick(event)}
oncontextmenu={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(event, e);
}}
>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
@ -169,6 +232,17 @@
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuEvent = null;
}}
/>
<style>
.agenda-view {
padding: 1rem;

View file

@ -3,6 +3,7 @@
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { Conversation } from '@chat/types';
import { Plus, PencilSimple, Check, X } from '@manacore/shared-icons';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
interface Props {
conversations: Conversation[];
@ -14,6 +15,47 @@
// Edit state
let editingId = $state<string | null>(null);
let editTitle = $state('');
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuConv = $state<Conversation | null>(null);
function handleContextMenu(event: MouseEvent, conv: Conversation) {
event.preventDefault();
event.stopPropagation();
contextMenuX = event.clientX;
contextMenuY = event.clientY;
contextMenuConv = conv;
contextMenuVisible = true;
}
function getContextMenuItems(conv: Conversation): ContextMenuItem[] {
return [
{
id: 'rename',
label: 'Umbenennen',
action: () => startEdit(conv, new MouseEvent('click')),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'archive',
label: 'Archivieren',
action: () => conversationsStore.archiveConversation(conv.id),
},
{
id: 'delete',
label: 'Löschen',
variant: 'danger',
action: () => conversationsStore.deleteConversation(conv.id),
},
];
}
let isSaving = $state(false);
function formatDate(dateString: string): string {
@ -134,7 +176,8 @@
</div>
{:else}
<!-- View Mode -->
<div class="group relative">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="group relative" oncontextmenu={(e) => handleContextMenu(e, conv)}>
<a
href="/chat/{conv.id}"
class="block px-3 py-2 mx-2 rounded-lg transition-colors
@ -165,4 +208,17 @@
</div>
{/if}
</div>
{#if contextMenuConv}
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems(contextMenuConv)}
onClose={() => {
contextMenuVisible = false;
contextMenuConv = null;
}}
/>
{/if}
</div>

View file

@ -2,11 +2,13 @@
import { _ } from 'svelte-i18n';
import type { Contact } from '$lib/api/contacts';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
interface Props {
contacts: Contact[];
onContactClick: (id: string) => void;
onToggleFavorite: (e: MouseEvent, id: string) => void;
onDeleteContact?: (id: string) => void;
selectionMode?: boolean;
selectedIds?: Set<string>;
onToggleSelection?: (id: string) => void;
@ -17,12 +19,56 @@
contacts,
onContactClick,
onToggleFavorite,
onDeleteContact,
selectionMode = false,
selectedIds = new Set(),
onToggleSelection,
showNewContactCard = true,
}: Props = $props();
let contextMenu = $state({ visible: false, x: 0, y: 0, target: null as Contact | null });
function handleContextMenu(e: MouseEvent, contact: Contact) {
e.preventDefault();
e.stopPropagation();
contextMenu = { visible: true, x: e.clientX, y: e.clientY, target: contact };
}
function getContextMenuItems(contact: Contact): ContextMenuItem[] {
return [
{
id: 'open',
label: 'Öffnen',
action: () => onContactClick(contact.id),
},
{
id: 'favorite',
label: contact.isFavorite ? 'Favorit entfernen' : 'Favorit',
action: () => onToggleFavorite(new MouseEvent('click'), contact.id),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'call',
label: 'Anrufen',
disabled: !contact.phone && !contact.mobile,
action: () => window.open('tel:' + (contact.mobile || contact.phone)),
},
{
id: 'email',
label: 'E-Mail',
disabled: !contact.email,
action: () => window.open('mailto:' + contact.email),
},
{ id: 'divider-2', label: '', type: 'divider' },
{
id: 'delete',
label: 'Löschen',
variant: 'danger',
action: () => onDeleteContact?.(contact.id),
},
];
}
function handleCheckboxClick(e: MouseEvent, id: string) {
e.stopPropagation();
onToggleSelection?.(id);
@ -96,6 +142,7 @@
tabindex="0"
onclick={() => onContactClick(contact.id)}
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
oncontextmenu={(e) => handleContextMenu(e, contact)}
class="grid-card {selectionMode && selectedIds.has(contact.id) ? 'selected' : ''}"
>
<!-- Selection Checkbox -->
@ -210,6 +257,16 @@
{/each}
</div>
{#if contextMenu.visible && contextMenu.target}
<ContextMenu
visible={contextMenu.visible}
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.target)}
onClose={() => (contextMenu = { visible: false, x: 0, y: 0, target: null })}
/>
{/if}
<style>
.contact-grid {
display: grid;

View file

@ -10,6 +10,7 @@
Heart,
DotsThreeVertical,
} from '@manacore/shared-icons';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
interface Props {
file: StorageFile;
@ -19,7 +20,9 @@
let { file, onClick, onAction }: Props = $props();
let showMenu = $state(false);
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let isDragging = $state(false);
function getFileIcon(mimeType: string) {
@ -39,14 +42,36 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function handleMenuClick(e: MouseEvent) {
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
showMenu = !showMenu;
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuVisible = true;
}
function handleAction(action: string) {
showMenu = false;
onAction?.(action);
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
contextMenuX = rect.right;
contextMenuY = rect.bottom;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
return [
{ id: 'download', label: 'Herunterladen' },
{ id: 'rename', label: 'Umbenennen' },
{ id: 'share', label: 'Teilen' },
{ id: 'favorite', label: file.isFavorite ? 'Favorit entfernen' : 'Als Favorit' },
{ id: 'move', label: 'Verschieben' },
{ id: 'divider', label: '', type: 'divider' },
{ id: 'delete', label: 'Löschen', variant: 'danger' },
];
}
function handleContextMenuSelect(item: ContextMenuItem) {
onAction?.(item.id);
}
const Icon = getFileIcon(file.mimeType);
@ -56,6 +81,7 @@
class="file-card"
class:dragging={isDragging}
onclick={onClick}
oncontextmenu={handleContextMenu}
role="button"
tabindex="0"
draggable="true"
@ -85,27 +111,21 @@
onclick={handleMenuClick}
type="button"
aria-label="Aktionen für {file.name}"
aria-expanded={showMenu}
aria-haspopup="menu"
>
<DotsThreeVertical size={16} />
</button>
{#if showMenu}
<div class="menu-dropdown" role="menu" aria-label="Dateiaktionen">
<button role="menuitem" onclick={() => handleAction('download')}>Herunterladen</button>
<button role="menuitem" onclick={() => handleAction('rename')}>Umbenennen</button>
<button role="menuitem" onclick={() => handleAction('share')}>Teilen</button>
<button role="menuitem" onclick={() => handleAction('favorite')}>
{file.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
</button>
<button role="menuitem" onclick={() => handleAction('move')}>Verschieben</button>
<hr />
<button role="menuitem" class="danger" onclick={() => handleAction('delete')}>Löschen</button>
</div>
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => (contextMenuVisible = false)}
onSelect={handleContextMenuSelect}
/>
<style>
.file-card {
position: relative;
@ -189,43 +209,4 @@
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.menu-dropdown {
position: absolute;
top: 2rem;
right: 0.5rem;
min-width: 150px;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
}
.menu-dropdown button:hover {
background: rgb(var(--color-surface));
}
.menu-dropdown button.danger {
color: rgb(var(--color-error));
}
.menu-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid rgb(var(--color-border));
}
</style>