Files
immich-folio/components/Lightbox.module.css
2026-10-02 18:14:23 +02:00

931 lines
22 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ── Lightbox Module ──────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--bg-overlay);
display: flex;
align-items: center;
justify-content: center;
animation: lightbox-fade-in var(--transition-normal) ease forwards;
}
/* The overlay is translucent (0.85–0.92), so the fixed page chrome under it
showed through: the header's wordmark and red dot, and the proofing bar,
as ghosts beside the photo. They cannot be used while the viewer is open,
so they are hidden for as long as it is mounted (it is portaled straight
into <body>). */
:global(body):has(> .overlay) :global(.header),
:global(body):has(> .overlay) :global(.proofing-sticky-bar),
:global(body):has(> .overlay) :global(.scroll-to-top) {
visibility: hidden;
}
/* The overlay is dark in both modes — see "Studio-Inspired Lightbox Defaults"
below and the studio-modern block, which both paint it near-black. In light
mode the page's text tokens are near-black too, so every control that reads
them (close, arrows, counter, the bottom-bar buttons, and the focus ring in
app/tokens.css, which is var(--text-primary)) sat at 1.1–2:1. Inside the
overlay they are pointed at the lightbox tokens instead. Dark mode is left
alone: its tokens are already light, and tinted per preset. */
:global([data-theme='light']) .overlay {
--text-primary: var(--lightbox-text);
--text-secondary: var(--lightbox-text-secondary);
--text-muted: var(--lightbox-text-muted);
--border-subtle: var(--lightbox-border);
}
@keyframes lightbox-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay.closing {
animation: lightbox-fade-out var(--transition-fast) ease forwards;
}
@keyframes lightbox-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.imageContainer {
position: relative;
max-width: 95vw;
max-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
overflow: hidden;
}
.image {
max-width: 95vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-sm);
user-select: none;
-webkit-user-drag: none;
opacity: 0;
transform: scale(0.96);
transition:
opacity var(--transition-normal),
transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.imageLoaded {
opacity: 1;
transform: scale(1);
}
/*
* The photograph enters with a fade and a slight scale. Under a slideshow
* that repeats every few seconds, which is exactly the motion this preference
* is about — so it simply appears instead. It still fades in from nothing, so
* a half-loaded image is never shown.
*/
@media (prefers-reduced-motion: reduce) {
.image {
transform: none;
transition: none;
}
.imageLoaded {
transform: none;
}
}
.close {
position: fixed;
top: 20px;
right: 24px;
z-index: 1001;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
transition: color var(--transition-fast);
}
.close:hover {
color: var(--text-primary);
}
.close svg {
width: 28px;
height: 28px;
}
.nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1001;
background: var(--lightbox-control-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--lightbox-control-border);
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.nav:hover {
background: var(--lightbox-control-bg-hover);
color: var(--text-primary);
}
.navPrev {
left: 20px;
}
.navNext {
right: 20px;
}
.nav svg {
width: 20px;
height: 20px;
}
/* ── Bottom bar ───────────────────────────────────── */
/*
* One row rather than five separately anchored controls.
*
* Each button used to carry its own fixed offset, which collided in two ways:
* the info button widens when its label becomes "Hide info" and grew into the
* favourite button beside it, and the left-hand controls ran into the centred
* counter once the viewport was narrow enough. A flex row cannot collide with
* itself, and a control that is conditionally absent now leaves no hole.
*/
.bottomBar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
/* The bar spans the viewport, so it must not swallow clicks on the photo. */
pointer-events: none;
}
.bottomBarGroup {
display: flex;
align-items: center;
gap: 8px;
/* Equal flex on both sides is what keeps the counter centred. */
flex: 1 1 0;
min-width: 0;
pointer-events: auto;
}
.bottomBarLeft {
justify-content: flex-start;
}
.bottomBarRight {
justify-content: flex-end;
}
.counter {
flex: 0 0 auto;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 300;
letter-spacing: 0.1em;
}
/* ── EXIF Info Panel ──────────────────────────────── */
.infoToggle {
/* Icon and label sit on one line — the buttons carried an emoji before, which
needed no alignment of its own. */
display: inline-flex;
align-items: center;
gap: 6px;
z-index: 1001;
background: var(--lightbox-control-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--lightbox-control-border);
border-radius: var(--radius-md);
padding: 8px 12px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
font-family: var(--font-sans);
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.infoToggle:hover {
background: var(--lightbox-control-bg-hover);
color: var(--text-primary);
}
/* A saved favourite. Set by class rather than inline, so a preset's own
colour for the toggle (studio-modern's) still applies to the unsaved state —
the inline `inherit` it used to fall back to picked up the page's near-black
text in light mode (1.08:1). The extra class outranks that preset block,
which comes later in this file, hover included: studio-modern hovers in
the accent, which a dark custom accent drops to about 1.3:1. */
.bottomBar .infoToggle.favActive,
.bottomBar .infoToggle.favActive:hover {
color: var(--lightbox-fav);
font-weight: 600;
}
/* Anchors are not buttons; strip the underline so it matches its neighbours. */
.downloadToggle {
text-decoration: none;
}
/* Shown only when the clipboard API is unavailable. */
.copyManual {
position: fixed;
bottom: 76px;
left: 24px;
z-index: 1001;
display: flex;
flex-direction: column;
gap: 6px;
max-width: min(28rem, calc(100vw - 48px));
padding: 12px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
}
.copyManualLabel {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
}
.copyManualInput {
width: 100%;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.8rem;
}
@media (max-width: 600px) {
/*
* Four controls, a counter and the close button do not fit on one line at
* phone width. Wrapping is the honest answer: the bottom edge already
* belongs to the EXIF strip in some presets, so there is nowhere to move to.
*/
.bottomBar {
padding: 12px 16px;
gap: 8px;
flex-wrap: wrap;
}
.bottomBarGroup {
flex: 0 1 auto;
}
/* Must not grow — a stretched counter pushes the row into the close button. */
.counter {
flex: 0 0 auto;
}
.copyManual {
left: 16px;
bottom: 66px;
}
}
.exifPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 76px;
right: 24px;
z-index: 1001;
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
padding: 20px;
min-width: 240px;
animation: exif-slide-up var(--transition-normal) ease forwards;
}
/* EXPERIMENTAL: editorial caption (Immich asset description) above the tech rows */
.exifCaption {
margin: 0 0 12px;
max-width: 280px;
font-family: var(--font-caption);
font-size: 0.85rem;
line-height: 1.5;
font-style: italic;
color: rgba(255, 255, 255, 0.85);
}
@keyframes exif-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.exifRow {
display: flex;
/* `space-between` distributes the *free* space, so a value long enough to
leave none ran straight into its own label (#514). An explicit gap is the
floor; the panel has no max-width and grows to keep it. */
gap: 16px;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--lb-panel-border);
}
.exifRow:last-child {
border-bottom: none;
}
.exifLabel {
font-size: 0.75rem;
color: var(--lb-panel-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.exifValue {
font-size: 0.85rem;
color: var(--lb-panel-text);
font-weight: 400;
/* Right-aligned so a value that does wrap stays flush with the ones above it,
rather than starting a ragged second column. */
text-align: right;
}
/* Marks the ISO row so a preset can keep its label — "200" alone reads
as nothing once the labels are hidden. */
.exifRowIso {
display: flex;
}
/* ── Keyboard shortcuts ─────────────────────────────
The panel has no trigger of its own — `?` or `h` opens it. It sits where a
trigger would have been, bottom left, clear of the EXIF panel opposite. */
.shortcutsPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 76px;
left: 24px;
z-index: 1001;
animation: exif-slide-up var(--transition-normal) ease forwards;
min-width: 240px;
padding: 16px 20px;
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
}
.shortcutsTitle {
margin: 0 0 10px;
font-family: var(--font-sans);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--lb-panel-muted);
}
.shortcutsList {
margin: 0;
}
/* Two columns, not space-between: a ragged left edge on the descriptions is
what makes a shortcut list hard to scan. */
.shortcutsRow {
display: flex;
align-items: baseline;
gap: 16px;
padding: 6px 0;
border-bottom: 1px solid var(--lb-panel-border);
}
.shortcutsRow:last-child {
border-bottom: none;
}
.shortcutsKeys {
display: flex;
flex: 0 0 66px;
gap: 4px;
}
.shortcutsLabel {
margin: 0;
font-size: 0.8rem;
color: var(--lb-panel-text);
}
.kbd {
min-width: 22px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
font-family: var(--font-caption);
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
color: var(--lb-panel-text-dim);
}
/* The panels stay dark while the page around them turns light, so their
contents cannot follow the theme here. Preset tinting is lost in light mode;
an unreadable panel is the worse trade (#511). */
:global([data-theme='light']) .exifPanel,
:global([data-theme='light']) .shortcutsPanel {
--lb-panel-text: rgba(255, 255, 255, 0.92);
--lb-panel-text-dim: rgba(255, 255, 255, 0.62);
--lb-panel-muted: rgba(255, 255, 255, 0.5);
--lb-panel-border: rgba(255, 255, 255, 0.12);
}
/* No keyboard, nothing to show. */
@media (hover: none) {
.shortcutsPanel {
display: none;
}
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.nav {
width: 40px;
height: 40px;
}
.navPrev {
left: 8px;
}
.navNext {
right: 8px;
}
}
/* ── Studio-Inspired Lightbox Defaults ───────────── */
.overlay {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.close,
.nav,
.counter {
opacity: 0.5;
transition: opacity 400ms ease;
}
.overlay:hover .close,
.overlay:hover .nav,
.overlay:hover .counter {
opacity: 1;
}
/* ── Watermark Overlay ────────────────────────────── */
.watermark {
position: absolute;
z-index: 10;
pointer-events: none;
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #ffffff;
text-shadow:
0 1px 4px rgba(0, 0, 0, 0.9),
0 0 10px rgba(0, 0, 0, 0.7);
padding: 12px 16px;
user-select: none;
}
.watermark_bottom_right {
bottom: 12px;
right: 16px;
}
.watermark_bottom_left {
bottom: 12px;
left: 16px;
}
.watermark_center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media (hover: none) {
.close,
.nav,
.counter {
opacity: 1;
}
}
.imageContainer {
border-radius: 0;
}
.image {
border-radius: 0;
}
/* ══ Studio Modern ═══════════════════════════════════════════════
Topbar with a mono counter, square outlined controls and a
film-edge EXIF strip along the bottom instead of a popup panel. */
:global([data-preset='studio-modern']) .overlay {
background: rgba(10, 10, 10, 0.92);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global([data-preset='studio-modern']) .imageContainer,
:global([data-preset='studio-modern']) .image {
max-width: 88vw;
max-height: 78vh;
}
/* Counter → top left, monospace */
/*
* This preset puts the viewer's chrome along the top edge. That is now said
* once, on the bar, instead of by each control: when every button carried its
* own offset, a button that changed width walked into its neighbour.
*/
:global([data-preset='studio-modern']) .bottomBar {
top: 0;
bottom: auto;
/* The close button owns the top-right corner, so the bar stops short of it.
That is the 76px the individual controls used to reserve by hand. */
padding: 20px 76px 20px 32px;
}
/* Counter first, so it keeps the left edge it had in this preset. */
:global([data-preset='studio-modern']) .counter {
order: -1;
font-family: var(--font-caption);
font-size: 0.6875rem;
letter-spacing: 0.15em;
color: rgba(255, 255, 255, 0.6);
}
:global([data-preset='studio-modern']) .close {
top: 20px;
right: 32px;
color: rgba(255, 255, 255, 0.7);
}
:global([data-preset='studio-modern']) .close:hover {
color: var(--accent-text-dark, var(--accent));
}
:global([data-preset='studio-modern']) .close svg {
width: 24px;
height: 24px;
}
/* Square outlined navigation */
:global([data-preset='studio-modern']) .nav {
width: 52px;
height: 52px;
border-radius: 0;
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.6);
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
:global([data-preset='studio-modern']) .nav:hover {
background: none;
border-color: var(--accent);
color: var(--accent-text-dark, var(--accent));
}
:global([data-preset='studio-modern']) .navPrev {
left: 24px;
}
:global([data-preset='studio-modern']) .navNext {
right: 24px;
}
/* Info + favourite toggles move up into the topbar */
:global([data-preset='studio-modern']) .infoToggle {
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0;
padding: 8px 14px;
font-family: var(--font-caption);
font-size: 0.625rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
}
:global([data-preset='studio-modern']) .infoToggle:hover {
background: none;
border-color: var(--accent);
color: var(--accent-text-dark, var(--accent));
}
/* The EXIF strip owns the bottom edge in this preset, so the panel opens from
the top instead. */
:global([data-preset='studio-modern']) .shortcutsPanel {
top: 58px;
bottom: auto;
left: 32px;
border-radius: 0;
background: rgba(10, 10, 10, 0.94);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global([data-preset='studio-modern']) .shortcutsTitle,
:global([data-preset='studio-modern']) .kbd {
border-radius: 0;
font-family: var(--font-caption);
}
/* Film-edge strip replaces the popup panel */
:global([data-preset='studio-modern']) .exifPanel {
top: auto;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 12px;
min-width: 0;
padding: 18px 32px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0;
}
:global([data-preset='studio-modern']) .exifPanel::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 auto;
margin-right: 5px;
}
:global([data-preset='studio-modern']) .exifRow {
display: inline-flex;
align-items: baseline;
padding: 0;
border-bottom: none;
}
:global([data-preset='studio-modern']) .exifRow + .exifRow::before {
content: '·';
margin-right: 12px;
color: rgba(255, 255, 255, 0.35);
}
:global([data-preset='studio-modern']) .exifLabel {
display: none;
}
:global([data-preset='studio-modern']) .exifRowIso .exifLabel {
display: inline;
margin-right: 6px;
font-family: var(--font-caption);
font-size: 0.6875rem;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.85);
}
:global([data-preset='studio-modern']) .exifValue {
font-family: var(--font-caption);
font-size: 0.6875rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.85);
}
/* The "Loading…" / "no EXIF" fallback keeps its own label visible */
:global([data-preset='studio-modern']) .exifRow:only-child .exifLabel {
display: inline;
font-family: var(--font-caption);
font-size: 0.6875rem;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.6);
}
@media (max-width: 640px) {
:global([data-preset='studio-modern']) .bottomBar {
/* Same reserved corner as on desktop: the close button sits there. */
padding: 16px 76px 16px 16px;
}
:global([data-preset='studio-modern']) .navPrev {
left: 8px;
}
:global([data-preset='studio-modern']) .navNext {
right: 8px;
}
}
/* ══ Zoom (#467) ═════════════════════════════════════════════════
The photo keeps its fit-size box; zooming is a transform on the images in
it (hooks/useZoom.ts), so the layout never moves. */
/* The viewer owns every touch on a zoomable photo: a pinch zooms the photo,
not the page. Swipes still arrive as touch events (hooks/useSwipe.ts). */
.zoomable {
touch-action: none;
cursor: zoom-in;
}
.zoomable.zoomed {
cursor: grab;
}
.zoomable.zoomed:active {
cursor: grabbing;
}
/* Once zoom has been used the photo may outgrow its box; the overlay, which
covers the viewport, does the clipping instead. */
.zoomEngaged {
overflow: visible;
}
.overlayZoom {
overflow: hidden;
}
/* The full-resolution file sits exactly on the preview's box. It takes no
pointer events, so gestures always land on the same element. */
.zoomImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.zoomImageLoaded {
opacity: 1;
}
.zoomToggle {
padding: 8px 10px;
}
.zoomSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: zoom-spin 800ms linear infinite;
}
@keyframes zoom-spin {
to {
transform: rotate(360deg);
}
}
/* "Not available" — a toast that fades on its own. */
.zoomNotice {
position: fixed;
left: 50%;
bottom: 76px;
z-index: 1001;
max-width: min(28rem, calc(100vw - 32px));
padding: 8px 14px;
transform: translateX(-50%);
background: rgba(20, 20, 20, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-md);
color: rgba(255, 255, 255, 0.9);
font-family: var(--font-sans);
font-size: 0.8rem;
text-align: center;
pointer-events: none;
animation: zoom-notice 4s ease forwards;
}
@keyframes zoom-notice {
0%,
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
:global([data-preset='studio-modern']) .zoomNotice {
top: 76px;
bottom: auto;
}
@media (prefers-reduced-motion: reduce) {
/* Still shown long enough to read, then gone without a fade. */
.zoomNotice {
animation: zoom-notice-static 4s steps(1, end) forwards;
}
.zoomSpinner {
animation-duration: 2s;
}
}
@keyframes zoom-notice-static {
to {
opacity: 0;
}
}