mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
421ef55457
commit
28286d126c
8 changed files with 316 additions and 7 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue