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:
Till JS 2026-04-24 14:37:58 +02:00
parent 9fbdc14869
commit 800fc9ae5a
3 changed files with 169 additions and 79 deletions

View file

@ -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 {

View file

@ -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}

View file

@ -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>