mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
refactor(picture,wardrobe): extract ImageLightbox, use in garment detail
Picture.ListView's full-screen image modal (~70 lines of inline markup) grew a second caller: the new Anproben-Strip on the wardrobe garment detail page. Linking to `target="_blank"` was a placeholder — the user expects the same inline viewer Picture uses. Extract the lightbox into $lib/modules/picture/components/ImageLightbox.svelte. Picture keeps ownership because the component speaks prompt/model/dims vocabulary against `picture.types.Image`. Module-specific controls go through an `actions` snippet so each caller wires only what makes sense: - Picture ListView renders Favorit + Archivieren in its action slot (unchanged behaviour, shorter file). - Wardrobe DetailGarmentView renders a single "In Picture öffnen" deep-link — Wardrobe doesn't own Favorit/Archiv semantics, the user navigates to Picture for those. Keeps the back-ref clean: every generated image lives in Picture, Wardrobe just previews. Base lightbox handles: - Fixed overlay with backdrop click-to-close - Escape key to close - Image + prompt + model + dimensions + date - Default Schließen button - Fallback icon when publicUrl is missing No logic change for existing users of Picture; one fewer dead target="_blank" tab for Wardrobe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fbdc14869
commit
800fc9ae5a
3 changed files with 169 additions and 79 deletions
|
|
@ -25,6 +25,7 @@
|
|||
} from '@mana/shared-icons';
|
||||
import { imagesStore } from './stores/images.svelte';
|
||||
import { pictureViewStore } from './stores/view.svelte';
|
||||
import ImageLightbox from './components/ImageLightbox.svelte';
|
||||
import {
|
||||
useAllImages,
|
||||
useAllImageTags,
|
||||
|
|
@ -393,75 +394,35 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal (fixed overlay — stays outside the container-query root) -->
|
||||
{#if selectedImage}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div
|
||||
class="relative max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-border bg-card"
|
||||
>
|
||||
<div class="relative flex items-center justify-center bg-black">
|
||||
{#if selectedImage.publicUrl}
|
||||
<img
|
||||
src={selectedImage.publicUrl}
|
||||
alt={selectedImage.prompt}
|
||||
class="max-h-[60vh] w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-64 items-center justify-center">
|
||||
<SquaresFour size={64} class="text-muted-foreground/30" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-foreground">{selectedImage.prompt}</p>
|
||||
{#if selectedImage.model}
|
||||
<p class="mt-1 text-xs text-muted-foreground">Modell: {selectedImage.model}</p>
|
||||
{/if}
|
||||
{#if selectedImage.width && selectedImage.height}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{selectedImage.width} × {selectedImage.height}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{new Date(selectedImage.createdAt).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
|
||||
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
|
||||
/>
|
||||
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => selectedImage && handleArchive(selectedImage)}
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
<Archive size={14} class="text-muted-foreground" />
|
||||
Archivieren
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
onclick={() => (selectedImage = null)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Detail lightbox (fixed overlay — stays outside the container-query root).
|
||||
Picture-specific actions (Favorit, Archivieren) go into the `actions`
|
||||
snippet; the base lightbox handles layout, backdrop, ESC + close. -->
|
||||
<ImageLightbox image={selectedImage} onClose={() => (selectedImage = null)}>
|
||||
{#snippet actions()}
|
||||
{#if selectedImage}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
|
||||
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
|
||||
/>
|
||||
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectedImage && handleArchive(selectedImage)}
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<Archive size={14} class="text-muted-foreground" />
|
||||
Archivieren
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ImageLightbox>
|
||||
|
||||
<style>
|
||||
.picture-list {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
<!--
|
||||
Reusable full-screen image lightbox.
|
||||
|
||||
Extracted from picture/ListView so other modules (wardrobe garment
|
||||
detail, outfit detail, any future "show me the full image"-surface)
|
||||
can render the same viewer without duplicating the markup. Picture
|
||||
keeps ownership because the component is typed against
|
||||
`picture.types.Image` and speaks prompt/model/dims vocabulary.
|
||||
|
||||
The lightbox is dumb: it accepts an optional `image` and an
|
||||
`onClose` callback. Pass a non-null image to open, null/undefined
|
||||
to hide. Module-specific actions (Favorit-Toggle, Archiv, Download,
|
||||
"In Picture öffnen" …) go through the `actions` snippet so each
|
||||
caller wires only what makes sense there. A plain backdrop click
|
||||
and ESC both close the modal.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { SquaresFour } from '@mana/shared-icons';
|
||||
import type { Image } from '../types';
|
||||
|
||||
interface Props {
|
||||
/** Non-null to render, null/undefined to hide. */
|
||||
image: Image | null | undefined;
|
||||
onClose: () => void;
|
||||
/** Caller-specific controls rendered to the left of "Schließen".
|
||||
* Picture gallery uses this for Favorit / Archiv; wardrobe uses
|
||||
* it for a deep-link to the Picture gallery. */
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { image, onClose, actions }: Props = $props();
|
||||
|
||||
// Escape-to-close. Rebind on mount only once — the handler itself
|
||||
// checks `image` at call time so we don't need to wire it to the
|
||||
// prop's lifecycle.
|
||||
onMount(() => {
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && image) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if image}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildvorschau"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-border bg-card"
|
||||
>
|
||||
<div class="relative flex items-center justify-center bg-black">
|
||||
{#if image.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt={image.prompt}
|
||||
class="max-h-[60vh] w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-64 items-center justify-center">
|
||||
<SquaresFour size={64} class="text-muted-foreground/30" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-foreground">{image.prompt}</p>
|
||||
{#if image.model}
|
||||
<p class="mt-1 text-xs text-muted-foreground">Modell: {image.model}</p>
|
||||
{/if}
|
||||
{#if image.width && image.height}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{image.width} × {image.height}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{new Date(image.createdAt).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
import { CATEGORY_LABELS } from '../constants';
|
||||
import GarmentForm from '../components/GarmentForm.svelte';
|
||||
import GarmentTryOnButton from '../components/GarmentTryOnButton.svelte';
|
||||
import ImageLightbox from '$lib/modules/picture/components/ImageLightbox.svelte';
|
||||
import type { Image } from '$lib/modules/picture/types';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
|
|
@ -40,6 +42,9 @@
|
|||
let saving = $state(false);
|
||||
let markingWorn = $state(false);
|
||||
|
||||
// Lightbox state for the Anproben-Strip. Null = closed, Image = open.
|
||||
let lightboxImage = $state<Image | null>(null);
|
||||
|
||||
async function handleMarkWorn() {
|
||||
if (!garment) return;
|
||||
markingWorn = true;
|
||||
|
|
@ -247,15 +252,9 @@
|
|||
</header>
|
||||
<div class="flex gap-3 overflow-x-auto pb-1">
|
||||
{#each soloTryOns as image (image.id)}
|
||||
<!-- Picture module doesn't have a /picture/image/[id] route;
|
||||
it opens generations inline via a modal in its ListView.
|
||||
Linking to the full publicUrl in a new tab gives the user
|
||||
the full-resolution view without a routing detour. A proper
|
||||
lightbox can come later when we reuse Picture's modal. -->
|
||||
<a
|
||||
href={image.publicUrl ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (lightboxImage = image)}
|
||||
class="group block flex-shrink-0 overflow-hidden rounded-xl border border-border bg-muted transition-all hover:border-primary/50"
|
||||
title={image.prompt}
|
||||
>
|
||||
|
|
@ -267,7 +266,7 @@
|
|||
class="h-40 w-28 object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -318,3 +317,18 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lightbox for Solo-Try-On previews. The action slot carries a
|
||||
deep-link to the Picture gallery so the user can reach the full
|
||||
CRUD surface (Favorit, Archiv, Download) without us duplicating
|
||||
those buttons here in Wardrobe. -->
|
||||
<ImageLightbox image={lightboxImage} onClose={() => (lightboxImage = null)}>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/picture"
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
In Picture öffnen
|
||||
</a>
|
||||
{/snippet}
|
||||
</ImageLightbox>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue