mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
45063b88be
commit
ecda4535d8
4 changed files with 231 additions and 63 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue