feat: add right-click context menus to presi, manadeck, photos, and zitare

Use shared ContextMenu component across 4 more apps:
- Presi: open/delete decks
- ManaDeck: open/delete decks
- Photos: view/favorite/delete photos, open/delete albums
- Zitare: remove favorite, copy/share quotes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 22:34:57 +01:00
parent 421ef55457
commit 28286d126c
8 changed files with 316 additions and 7 deletions

View file

@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import { Button } from '@manacore/shared-ui';
import { Button, ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import DeckCard from '$lib/components/deck/DeckCard.svelte';
import CreateDeckModal from '$lib/components/deck/CreateDeckModal.svelte';
import type { Deck } from '$lib/types/deck';
let showCreateModal = $state(false);
let contextMenu = $state({ visible: false, x: 0, y: 0, target: null as Deck | null });
onMount(() => {
deckStore.fetchDecks();
@ -15,6 +17,32 @@
function handleDeckClick(deckId: string) {
goto(`/decks/${deckId}`);
}
function handleContextMenu(e: MouseEvent, deck: Deck) {
e.preventDefault();
contextMenu = { visible: true, x: e.clientX, y: e.clientY, target: deck };
}
function getContextMenuItems(deck: Deck): ContextMenuItem[] {
return [
{
id: 'open',
label: 'Open',
action: () => handleDeckClick(deck.id),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'delete',
label: 'Delete',
variant: 'danger',
action: () => deckStore.deleteDeck(deck.id),
},
];
}
</script>
<svelte:head>
@ -67,7 +95,10 @@
<!-- Decks Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each deckStore.decks as deck (deck.id)}
<DeckCard {deck} onclick={() => handleDeckClick(deck.id)} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, deck)}>
<DeckCard {deck} onclick={() => handleDeckClick(deck.id)} />
</div>
{/each}
</div>
{/if}
@ -75,3 +106,13 @@
<!-- Create Deck Modal -->
<CreateDeckModal bind:open={showCreateModal} />
{#if 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}

View file

@ -1,6 +1,9 @@
<script lang="ts">
import type { Album } from '@photos/shared';
import AlbumCard from './AlbumCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { albumStore } from '$lib/stores/albums.svelte';
import { _ } from 'svelte-i18n';
interface Props {
albums: Album[];
@ -9,14 +12,63 @@
}
let { albums, loading, onAlbumClick }: Props = $props();
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuAlbum = $state<Album | null>(null);
function handleContextMenu(e: MouseEvent, album: Album) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuAlbum = album;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuAlbum) return [];
const album = contextMenuAlbum;
return [
{
id: 'open',
label: $_('contextMenu.open'),
action: () => onAlbumClick(album),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'delete',
label: $_('common.delete'),
variant: 'danger',
action: () => albumStore.deleteAlbum(album.id),
},
];
}
</script>
<div class="album-grid">
{#each albums as album (album.id)}
<AlbumCard {album} onClick={() => onAlbumClick(album)} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, album)}>
<AlbumCard {album} onClick={() => onAlbumClick(album)} />
</div>
{/each}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuAlbum = null;
}}
/>
{#if loading}
<div class="loading-indicator">
<div class="spinner"></div>

View file

@ -2,6 +2,9 @@
import { onMount } from 'svelte';
import type { Photo } from '@photos/shared';
import PhotoCard from './PhotoCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { photoStore } from '$lib/stores/photos.svelte';
import { _ } from 'svelte-i18n';
interface Props {
photos: Photo[];
@ -13,6 +16,48 @@
let { photos, loading, hasMore, onPhotoClick, onLoadMore }: Props = $props();
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuPhoto = $state<Photo | null>(null);
function handleContextMenu(e: MouseEvent, photo: Photo) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuPhoto = photo;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuPhoto) return [];
const photo = contextMenuPhoto;
return [
{
id: 'view',
label: $_('contextMenu.view'),
action: () => onPhotoClick(photo),
},
{
id: 'favorite',
label: photo.isFavorited
? $_('contextMenu.removeFromFavorites')
: $_('contextMenu.addToFavorites'),
action: () => photoStore.toggleFavorite(photo.id),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'delete',
label: $_('common.delete'),
variant: 'danger',
action: () => photoStore.deletePhoto(photo.id),
},
];
}
let loadMoreRef = $state<HTMLDivElement | null>(null);
let observer: IntersectionObserver;
@ -45,10 +90,24 @@
<div class="photo-grid">
{#each photos as photo (photo.id)}
<PhotoCard {photo} onClick={() => onPhotoClick(photo)} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, photo)}>
<PhotoCard {photo} onClick={() => onPhotoClick(photo)} />
</div>
{/each}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuPhoto = null;
}}
/>
{#if loading}
<div class="loading-indicator">
<div class="spinner"></div>

View file

@ -107,6 +107,13 @@
"signUp": "Registrieren",
"forgotPassword": "Passwort vergessen?"
},
"contextMenu": {
"view": "Anzeigen",
"open": "Öffnen",
"toggleFavorite": "Favorit umschalten",
"addToFavorites": "Zu Favoriten hinzufügen",
"removeFromFavorites": "Aus Favoriten entfernen"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",

View file

@ -107,6 +107,13 @@
"signUp": "Sign Up",
"forgotPassword": "Forgot Password?"
},
"contextMenu": {
"view": "View",
"open": "Open",
"toggleFavorite": "Toggle Favorite",
"addToFavorites": "Add to Favorites",
"removeFromFavorites": "Remove from Favorites"
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -149,6 +149,27 @@ export const photoStore = {
}
},
/**
* Delete a photo
*/
async deletePhoto(mediaId: string) {
try {
const result = await api.delete(`/photos/${mediaId}`);
if (result.error) {
error = result.error.message;
return false;
}
photos = photos.filter((p) => p.id !== mediaId);
if (selectedPhoto?.id === mediaId) {
selectedPhoto = null;
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete photo';
return false;
}
},
/**
* Clear all state
*/

View file

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { PageHeader } from '@manacore/shared-ui';
import { PageHeader, ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import {
Plus,
Presentation,
@ -10,6 +10,7 @@
DotsThreeVertical,
Clock,
Stack,
ArrowSquareOut,
} from '@manacore/shared-icons';
let showCreateModal = $state(false);
@ -19,6 +20,45 @@
let newDeckDescription = $state('');
let isCreating = $state(false);
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuDeck = $state<(typeof decksStore.decks)[0] | null>(null);
function handleContextMenu(e: MouseEvent, deck: (typeof decksStore.decks)[0]) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuDeck = deck;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuDeck) return [];
const deck = contextMenuDeck;
return [
{
id: 'open',
label: 'Open',
icon: ArrowSquareOut,
action: () => goto(`/deck/${deck.id}`),
},
{
id: 'divider',
label: '',
type: 'divider',
},
{
id: 'delete',
label: 'Delete',
icon: Trash,
variant: 'danger',
action: () => confirmDelete({ id: deck.id, title: deck.title }),
},
];
}
onMount(() => {
decksStore.loadDecks();
});
@ -106,8 +146,10 @@
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.decks as deck (deck.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow"
oncontextmenu={(e) => handleContextMenu(e, deck)}
>
<a href="/deck/{deck.id}" class="block">
<div
@ -148,6 +190,17 @@
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuDeck = null;
}}
/>
<!-- Create Deck Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">

View file

@ -3,8 +3,9 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { getQuoteById } from '@zitare/content';
import { getQuoteById, getQuoteText, type Quote } from '@zitare/content';
import QuoteCard from '$lib/components/QuoteCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
// Get favorite quotes
let favoriteQuotes = $derived(
@ -12,6 +13,60 @@
.map((f) => getQuoteById(f.quoteId))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
);
// Context menu state
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuQuote = $state<Quote | null>(null);
function handleContextMenu(e: MouseEvent, quote: Quote) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
contextMenuQuote = quote;
contextMenuVisible = true;
}
function getContextMenuItems(): ContextMenuItem[] {
if (!contextMenuQuote) return [];
const quote = contextMenuQuote;
return [
{
id: 'remove-favorite',
label: $_('favorites.removeFromFavorites', { default: 'Aus Favoriten entfernen' }),
variant: 'danger',
action: () => favoritesStore.toggle(quote.id),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
id: 'copy',
label: $_('common.copyQuote', { default: 'Zitat kopieren' }),
action: () => {
const text = getQuoteText(quote);
navigator.clipboard.writeText(`"${text}" — ${quote.author}`);
},
},
{
id: 'share',
label: $_('common.share', { default: 'Teilen' }),
action: async () => {
const text = `"${getQuoteText(quote)}" — ${quote.author}`;
if (navigator.share) {
try {
await navigator.share({ text });
} catch {
// User cancelled or share failed, ignore
}
} else {
await navigator.clipboard.writeText(text);
}
},
},
];
}
</script>
<svelte:head>
@ -75,8 +130,22 @@
<!-- Favorites list -->
<div class="space-y-6">
{#each favoriteQuotes as quote (quote.id)}
<QuoteCard {quote} showCategory showSource />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, quote)}>
<QuoteCard {quote} showCategory showSource />
</div>
{/each}
</div>
{/if}
</div>
<ContextMenu
visible={contextMenuVisible}
x={contextMenuX}
y={contextMenuY}
items={getContextMenuItems()}
onClose={() => {
contextMenuVisible = false;
contextMenuQuote = null;
}}
/>