From 28286d126c95e6c3f22a9ebf0e792028bc48031c Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 23 Mar 2026 22:34:57 +0100 Subject: [PATCH] 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) --- .../web/src/routes/(app)/decks/+page.svelte | 45 +++++++++++- .../lib/components/albums/AlbumGrid.svelte | 54 +++++++++++++- .../lib/components/gallery/PhotoGrid.svelte | 61 +++++++++++++++- .../apps/web/src/lib/i18n/locales/de.json | 7 ++ .../apps/web/src/lib/i18n/locales/en.json | 7 ++ .../apps/web/src/lib/stores/photos.svelte.ts | 21 ++++++ .../apps/web/src/routes/(app)/+page.svelte | 55 +++++++++++++- .../src/routes/(app)/favorites/+page.svelte | 73 ++++++++++++++++++- 8 files changed, 316 insertions(+), 7 deletions(-) diff --git a/apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte b/apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte index 9e5803cc5..d97609892 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/decks/+page.svelte @@ -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), + }, + ]; + } @@ -67,7 +95,10 @@
{#each deckStore.decks as deck (deck.id)} - handleDeckClick(deck.id)} /> + +
handleContextMenu(e, deck)}> + handleDeckClick(deck.id)} /> +
{/each}
{/if} @@ -75,3 +106,13 @@ + +{#if contextMenu.target} + (contextMenu = { visible: false, x: 0, y: 0, target: null })} + /> +{/if} diff --git a/apps/photos/apps/web/src/lib/components/albums/AlbumGrid.svelte b/apps/photos/apps/web/src/lib/components/albums/AlbumGrid.svelte index 104351739..aadd37914 100644 --- a/apps/photos/apps/web/src/lib/components/albums/AlbumGrid.svelte +++ b/apps/photos/apps/web/src/lib/components/albums/AlbumGrid.svelte @@ -1,6 +1,9 @@
{#each albums as album (album.id)} - onAlbumClick(album)} /> + +
handleContextMenu(e, album)}> + onAlbumClick(album)} /> +
{/each}
+ { + contextMenuVisible = false; + contextMenuAlbum = null; + }} +/> + {#if loading}
diff --git a/apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte b/apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte index 77a7610eb..b7fde05a6 100644 --- a/apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte +++ b/apps/photos/apps/web/src/lib/components/gallery/PhotoGrid.svelte @@ -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(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(null); let observer: IntersectionObserver; @@ -45,10 +90,24 @@
{#each photos as photo (photo.id)} - onPhotoClick(photo)} /> + +
handleContextMenu(e, photo)}> + onPhotoClick(photo)} /> +
{/each}
+ { + contextMenuVisible = false; + contextMenuPhoto = null; + }} +/> + {#if loading}
diff --git a/apps/photos/apps/web/src/lib/i18n/locales/de.json b/apps/photos/apps/web/src/lib/i18n/locales/de.json index a07e3fdcf..6589b0bc3 100644 --- a/apps/photos/apps/web/src/lib/i18n/locales/de.json +++ b/apps/photos/apps/web/src/lib/i18n/locales/de.json @@ -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", diff --git a/apps/photos/apps/web/src/lib/i18n/locales/en.json b/apps/photos/apps/web/src/lib/i18n/locales/en.json index 87cabe23b..7ee5e124d 100644 --- a/apps/photos/apps/web/src/lib/i18n/locales/en.json +++ b/apps/photos/apps/web/src/lib/i18n/locales/en.json @@ -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", diff --git a/apps/photos/apps/web/src/lib/stores/photos.svelte.ts b/apps/photos/apps/web/src/lib/stores/photos.svelte.ts index 3ed586e33..4d92f9e6d 100644 --- a/apps/photos/apps/web/src/lib/stores/photos.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/photos.svelte.ts @@ -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 */ diff --git a/apps/presi/apps/web/src/routes/(app)/+page.svelte b/apps/presi/apps/web/src/routes/(app)/+page.svelte index 7d7fedda1..fcee29046 100644 --- a/apps/presi/apps/web/src/routes/(app)/+page.svelte +++ b/apps/presi/apps/web/src/routes/(app)/+page.svelte @@ -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}
{#each decksStore.decks as deck (deck.id)} +
handleContextMenu(e, deck)} >
+ { + contextMenuVisible = false; + contextMenuDeck = null; + }} +/> + {#if showCreateModal}
diff --git a/apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte index f3a1fe49d..3d18b292d 100644 --- a/apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/favorites/+page.svelte @@ -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 => q !== undefined) ); + + // Context menu state + let contextMenuVisible = $state(false); + let contextMenuX = $state(0); + let contextMenuY = $state(0); + let contextMenuQuote = $state(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); + } + }, + }, + ]; + } @@ -75,8 +130,22 @@
{#each favoriteQuotes as quote (quote.id)} - + +
handleContextMenu(e, quote)}> + +
{/each}
{/if}
+ + { + contextMenuVisible = false; + contextMenuQuote = null; + }} +/>