mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41: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';
|
} from '@mana/shared-icons';
|
||||||
import { imagesStore } from './stores/images.svelte';
|
import { imagesStore } from './stores/images.svelte';
|
||||||
import { pictureViewStore } from './stores/view.svelte';
|
import { pictureViewStore } from './stores/view.svelte';
|
||||||
|
import ImageLightbox from './components/ImageLightbox.svelte';
|
||||||
import {
|
import {
|
||||||
useAllImages,
|
useAllImages,
|
||||||
useAllImageTags,
|
useAllImageTags,
|
||||||
|
|
@ -393,75 +394,35 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail modal (fixed overlay — stays outside the container-query root) -->
|
<!-- Detail lightbox (fixed overlay — stays outside the container-query root).
|
||||||
{#if selectedImage}
|
Picture-specific actions (Favorit, Archivieren) go into the `actions`
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
snippet; the base lightbox handles layout, backdrop, ESC + close. -->
|
||||||
<div
|
<ImageLightbox image={selectedImage} onClose={() => (selectedImage = null)}>
|
||||||
class="relative max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-border bg-card"
|
{#snippet actions()}
|
||||||
>
|
{#if selectedImage}
|
||||||
<div class="relative flex items-center justify-center bg-black">
|
<button
|
||||||
{#if selectedImage.publicUrl}
|
type="button"
|
||||||
<img
|
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
|
||||||
src={selectedImage.publicUrl}
|
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"
|
||||||
alt={selectedImage.prompt}
|
>
|
||||||
class="max-h-[60vh] w-full object-contain"
|
<Heart
|
||||||
/>
|
size={14}
|
||||||
{:else}
|
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
|
||||||
<div class="flex h-64 items-center justify-center">
|
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
|
||||||
<SquaresFour size={64} class="text-muted-foreground/30" />
|
/>
|
||||||
</div>
|
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
|
||||||
{/if}
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
<div class="p-4">
|
onclick={() => selectedImage && handleArchive(selectedImage)}
|
||||||
<p class="text-sm text-foreground">{selectedImage.prompt}</p>
|
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"
|
||||||
{#if selectedImage.model}
|
>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">Modell: {selectedImage.model}</p>
|
<Archive size={14} class="text-muted-foreground" />
|
||||||
{/if}
|
Archivieren
|
||||||
{#if selectedImage.width && selectedImage.height}
|
</button>
|
||||||
<p class="text-xs text-muted-foreground">
|
{/if}
|
||||||
{selectedImage.width} × {selectedImage.height}
|
{/snippet}
|
||||||
</p>
|
</ImageLightbox>
|
||||||
{/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}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.picture-list {
|
.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 { CATEGORY_LABELS } from '../constants';
|
||||||
import GarmentForm from '../components/GarmentForm.svelte';
|
import GarmentForm from '../components/GarmentForm.svelte';
|
||||||
import GarmentTryOnButton from '../components/GarmentTryOnButton.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 {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -40,6 +42,9 @@
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let markingWorn = $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() {
|
async function handleMarkWorn() {
|
||||||
if (!garment) return;
|
if (!garment) return;
|
||||||
markingWorn = true;
|
markingWorn = true;
|
||||||
|
|
@ -247,15 +252,9 @@
|
||||||
</header>
|
</header>
|
||||||
<div class="flex gap-3 overflow-x-auto pb-1">
|
<div class="flex gap-3 overflow-x-auto pb-1">
|
||||||
{#each soloTryOns as image (image.id)}
|
{#each soloTryOns as image (image.id)}
|
||||||
<!-- Picture module doesn't have a /picture/image/[id] route;
|
<button
|
||||||
it opens generations inline via a modal in its ListView.
|
type="button"
|
||||||
Linking to the full publicUrl in a new tab gives the user
|
onclick={() => (lightboxImage = image)}
|
||||||
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"
|
|
||||||
class="group block flex-shrink-0 overflow-hidden rounded-xl border border-border bg-muted transition-all hover:border-primary/50"
|
class="group block flex-shrink-0 overflow-hidden rounded-xl border border-border bg-muted transition-all hover:border-primary/50"
|
||||||
title={image.prompt}
|
title={image.prompt}
|
||||||
>
|
>
|
||||||
|
|
@ -267,7 +266,7 @@
|
||||||
class="h-40 w-28 object-cover transition-transform group-hover:scale-[1.02]"
|
class="h-40 w-28 object-cover transition-transform group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -318,3 +317,18 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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