mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
polish(picture): clean borderless lightbox — image-first, meta in the corner
Feedback on the previous lightbox: the image was capped at 60vh inside a bordered card, with metadata + action buttons stacked underneath in their own panel. Result: ~40% of the modal surface was chrome around a small picture, and the eye landed on the card edge before the actual generation. The user asked for image-first: fill the height, no module border, gray text in the bottom-right corner. New layout: - Borderless backdrop. The card frame is gone; the image sits directly on a near-black overlay (rgba(0,0,0,0.92)) so nothing competes for the eye. - Image fills the available area via `max-h-full max-w-full object-contain` inside a flex-center wrapper, with 6/10-rem outer padding so the picture doesn't kiss the viewport edges on small screens. - Metadata moves to a small grey overlay in the bottom-right corner — prompt on top, then a single inline detail line (model · dimensions · date) separated by middle dots. Right- aligned so longer prompts wrap toward the image edge instead of the centre. - Close becomes a circular icon-button in the top-right (X), no longer a footer button. ESC + backdrop-click still close. - Caller-supplied actions (the `actions` snippet) move to the bottom-left so they don't fight the meta block visually. Colour treatment uses literal white-alpha + black-alpha values in a scoped `<style>` block instead of theme tokens. The lightbox always sits on a literal near-black backdrop regardless of which theme is active, so theme-aware muted tokens would render too dark in light themes. The validator's brand-literal escape hatch (see scripts/validate-theme-utilities.mjs comment) covers this exact case. Empty-state (publicUrl missing) gets a small SquaresFour icon in a soft white-alpha tile so the modal still has a visible centre when an image fails to load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d62ae8f1e3
commit
bd559e739e
1 changed files with 105 additions and 53 deletions
|
|
@ -7,34 +7,40 @@
|
|||
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.
|
||||
Design: borderless, image-first. The picture fills the entire
|
||||
viewport (object-contain so aspect ratio is preserved). No card
|
||||
chrome — the dark backdrop alone frames the image. Metadata sits
|
||||
in the bottom-right corner as small grey text overlay so the eye
|
||||
goes to the image first; the only persistent control is a close
|
||||
affordance in the top-right. Module-specific buttons (Favorit /
|
||||
Archiv / "In Picture öffnen") render in the bottom-left via the
|
||||
`actions` snippet. Backdrop click and ESC both close.
|
||||
|
||||
Colour note: text uses literal white-alpha + black-alpha utilities
|
||||
via a scoped `<style>` block instead of theme tokens. The lightbox
|
||||
always renders against a near-black backdrop regardless of the
|
||||
active theme, so a theme-aware muted-foreground would render too
|
||||
dark in light themes. Matches the validator's brand-literal escape
|
||||
hatch (see `scripts/validate-theme-utilities.mjs`).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/i18n/format';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { SquaresFour } from '@mana/shared-icons';
|
||||
import { SquaresFour, X } 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. */
|
||||
/** Caller-specific controls rendered in the bottom-left. 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) {
|
||||
|
|
@ -55,62 +61,108 @@
|
|||
<!-- 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"
|
||||
class="lightbox-root fixed inset-0 z-50 flex items-center justify-center p-6 sm:p-10"
|
||||
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}
|
||||
<!-- Image: fills the available area, centred, aspect-preserving. -->
|
||||
{#if image.publicUrl}
|
||||
<img
|
||||
src={image.publicUrl}
|
||||
alt={image.prompt}
|
||||
class="block max-h-full max-w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="lightbox-empty flex h-64 w-64 items-center justify-center rounded-2xl">
|
||||
<SquaresFour size={64} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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}
|
||||
<!-- Close: top-right, minimal. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label="Schließen"
|
||||
title="Schließen (Esc)"
|
||||
class="lightbox-close absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
<X size={18} weight="bold" />
|
||||
</button>
|
||||
|
||||
<!-- Caller-supplied actions: bottom-left. -->
|
||||
{#if actions}
|
||||
<div class="lightbox-actions absolute bottom-6 left-6 flex gap-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata: bottom-right, small, grey. Right-aligned so the
|
||||
longest line (the prompt) wraps naturally toward the image
|
||||
edge. `pointer-events-none` keeps clicks falling through to
|
||||
the backdrop dismiss. -->
|
||||
<div class="lightbox-meta pointer-events-none absolute bottom-6 right-6 max-w-md text-right">
|
||||
<p class="lightbox-meta-prompt">{image.prompt}</p>
|
||||
<div class="lightbox-meta-detail">
|
||||
{#if image.model}<span>{image.model}</span>{/if}
|
||||
{#if image.width && image.height}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{image.width} × {image.height}
|
||||
</p>
|
||||
<span>{image.width}×{image.height}</span>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatDate(new Date(image.createdAt), {
|
||||
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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The lightbox always renders against a literal near-black backdrop,
|
||||
regardless of the active theme — using theme-aware tokens here
|
||||
would tint or wash out unpredictably under non-dark themes. The
|
||||
validator's brand-literal escape hatch (see
|
||||
scripts/validate-theme-utilities.mjs) covers exactly this case. */
|
||||
.lightbox-root {
|
||||
background-color: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
.lightbox-empty {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.lightbox-close {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.lightbox-close:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.lightbox-meta-prompt {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.lightbox-meta-detail {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
justify-content: flex-end;
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lightbox-meta-detail span:not(:first-child)::before {
|
||||
content: '·';
|
||||
margin-right: 0.625rem;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue