chore(release): v0.10.0 (#400)

Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed.

See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
Ralksta
2026-08-10 09:56:20 +02:00
committed by GitHub
parent c9036fdc94
commit 2318409e75
78 changed files with 3848 additions and 553 deletions

View File

@@ -0,0 +1,133 @@
import Link from 'next/link';
interface AdminDiagnosticBannerProps {
slug: string;
subpageName?: string;
configuredAlbumCount?: number;
reason?: string;
}
export function AdminDiagnosticBanner({
slug,
subpageName,
configuredAlbumCount = 0,
reason,
}: AdminDiagnosticBannerProps) {
return (
<div
style={{
maxWidth: '680px',
margin: '60px auto',
padding: '24px',
borderRadius: '12px',
backgroundColor: '#1c1c1e',
border: '1px solid #333336',
color: '#f2f2f7',
fontFamily: 'system-ui, -apple-system, sans-serif',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<span style={{ fontSize: '24px' }}>⚠️</span>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: '#ffd60a' }}>
Admin Diagnostic: Subpage loaded with 0 photos
</h2>
</div>
<p style={{ fontSize: '14px', lineHeight: 1.6, color: '#aeaeb2', margin: '0 0 16px 0' }}>
The subpage <strong>/{slug}</strong> {subpageName ? `("${subpageName}")` : ''} is active in{' '}
<code style={{ background: '#2c2c2e', padding: '2px 6px', borderRadius: '4px' }}>
gallery.yaml
</code>
{configuredAlbumCount > 0
? `, and configured with ${configuredAlbumCount} album(s)`
: ''}
, but Immich returned no accessible albums or assets.
</p>
{reason && (
<div
style={{
background: '#2c2c2e',
padding: '12px',
borderRadius: '6px',
fontSize: '13px',
color: '#ff9f0a',
marginBottom: '16px',
}}
>
{reason}
</div>
)}
<div style={{ marginBottom: '20px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f2f2f7' }}>
Possible Causes & Checklist:
</h4>
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', color: '#d1d1d6', lineHeight: 1.8 }}>
<li>
<strong>Album Shared Status in Immich:</strong> Ensure the album in Immich is marked as{' '}
<strong>Shared (Geteilt)</strong>. Unshared albums are excluded by Immich Folio for security.
</li>
<li>
<strong>Cache Lag:</strong> If you just added the album or changed permissions, click{' '}
<em>Force Reload Fresh</em> below.
</li>
<li>
<strong>Album UUID:</strong> Check if the album UUID in your Page Builder matches the exact Immich Album ID.
</li>
</ul>
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<a
href={`/${slug}?fresh=1`}
style={{
padding: '8px 16px',
borderRadius: '6px',
backgroundColor: '#0a84ff',
color: '#ffffff',
textDecoration: 'none',
fontSize: '13px',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
}}
>
🔄 Force Reload Fresh (?fresh=1)
</a>
<Link
href="/admin"
style={{
padding: '8px 16px',
borderRadius: '6px',
backgroundColor: '#2c2c2e',
color: '#f2f2f7',
border: '1px solid #3a3a3c',
textDecoration: 'none',
fontSize: '13px',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
}}
>
⚙️ Open Page Builder (/admin)
</Link>
<Link
href="/"
style={{
padding: '8px 16px',
borderRadius: '6px',
color: '#8e8e93',
textDecoration: 'none',
fontSize: '13px',
}}
>
Back to Gallery
</Link>
</div>
</div>
);
}

View File

@@ -16,7 +16,7 @@
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
@@ -27,6 +27,7 @@ type Tab = 'theme' | 'grid' | 'server';
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
'studio-modern': { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
@@ -36,6 +37,7 @@ const PRESET_FONTS: Record<string, { heading: string; body: string; caption: str
const PRESET_ACCENTS: Record<string, string> = {
studio: 'var(--accent-studio, #e60012)',
'studio-modern': 'var(--accent-studio-modern, #e60012)',
minimal: 'var(--accent-minimal, #000000)',
editorial: 'var(--accent-editorial, #8B2500)',
classic: 'var(--accent-classic, #c49a3c)',

View File

@@ -13,6 +13,8 @@ import Image from 'next/image';
interface HeroImage {
src: string;
blurDataURL?: string;
/** Pre-formatted camera line, shown as a chip by presets that use it. */
exif?: string;
}
interface HeroCarouselProps {
@@ -42,15 +44,20 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
if (images.length === 1) {
const img = images[0];
return (
<Image
src={img.src}
alt=""
className="hero__image"
fill
sizes="50vw"
priority
{...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
/>
<>
<Image
src={img.src}
alt=""
className="hero__image"
fill
sizes="50vw"
priority
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
: {})}
/>
<HeroExifChip text={img.exif} />
</>
);
}
@@ -70,6 +77,17 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
: {})}
/>
))}
<HeroExifChip text={images[currentIndex]?.exif} />
</>
);
}
/** Camera line over the hero image. Hidden unless the preset shows it. */
function HeroExifChip({ text }: { text?: string }) {
if (!text) return null;
return (
<span className="hero__exif-chip" aria-hidden="true">
{text}
</span>
);
}

View File

@@ -164,6 +164,11 @@
color: var(--text-primary);
}
/* Proofing favourite button — sits left of the info toggle. */
.favToggle {
right: 6.5rem;
}
.exifPanel {
position: fixed;
bottom: 60px;
@@ -216,6 +221,12 @@
font-weight: 400;
}
/* Marks the ISO row so a preset can keep its label — "200" alone reads
as nothing once the labels are hidden. */
.exifRowIso {
display: flex;
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.nav {
@@ -300,3 +311,194 @@
.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 */
:global([data-preset='studio-modern']) .counter {
top: 20px;
bottom: auto;
left: 32px;
transform: none;
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);
}
: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);
}
: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 {
top: 20px;
bottom: auto;
right: 76px;
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);
}
:global([data-preset='studio-modern']) .favToggle {
right: 176px;
}
/* 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) {
/* No room next to the info toggle — park it in the opposite corner. */
:global([data-preset='studio-modern']) .favToggle {
top: auto;
bottom: 76px;
right: auto;
left: 16px;
}
:global([data-preset='studio-modern']) .navPrev {
left: 8px;
}
:global([data-preset='studio-modern']) .navNext {
right: 8px;
}
}

View File

@@ -254,9 +254,8 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
{/* Proofing favorite button */}
{proofing && current && (
<button
className={styles.infoToggle}
className={`${styles.infoToggle} ${styles.favToggle}`}
style={{
right: '6.5rem',
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
@@ -322,7 +321,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
</div>
)}
{exifData.iso && (
<div className={styles.exifRow}>
<div className={`${styles.exifRow} ${styles.exifRowIso}`}>
<span className={styles.exifLabel}>ISO</span>
<span className={styles.exifValue}>{exifData.iso}</span>
</div>