feat: improve lightbox rendering, hero styles, and config loading

This commit is contained in:
ralksta
2026-03-08 16:16:15 +01:00
parent 09f358559e
commit 01da5e1baa
7 changed files with 437 additions and 189 deletions

View File

@@ -57,7 +57,8 @@
.main {
max-width: var(--max-width);
margin: 0 auto;
padding: 104px 24px 96px; /* 56px header + 48px original padding */
padding: 104px 24px 96px;
/* 56px header + 48px original padding */
}
/* Simple CSS Page Fade-In (Replaces Framer Motion) */
@@ -70,6 +71,7 @@
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -80,7 +82,8 @@
.main:has(.hero),
.main:has(.about) {
max-width: none;
padding-top: 56px; /* Still need space for header */
padding-top: 56px;
/* Still need space for header */
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
@@ -179,6 +182,95 @@
opacity: 1;
}
/* ── Hero: Fullbleed Layout ─────────────────────── */
.hero--fullbleed {
position: relative;
min-height: calc(100vh - 56px);
overflow: hidden;
}
/* HeroCarousel images fill the entire container */
.hero--fullbleed>span,
.hero--fullbleed>img {
position: absolute !important;
inset: 0;
width: 100% !important;
height: 100% !important;
object-fit: cover;
}
.hero__fullbleed-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 64px 40px;
/* Multi-stop for contrast on any image brightness */
background:
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.0) 70%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.45) 100%);
}
.hero--fullbleed .hero__title {
color: #fff;
font-size: 4rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7), 0 8px 48px rgba(0, 0, 0, 0.6);
}
.hero--fullbleed .hero__subtitle {
color: rgba(255, 255, 255, 0.92);
margin-bottom: 32px;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
}
.hero--fullbleed .hero__nav {
background: rgba(0, 0, 0, 0.38);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 14px 28px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.hero--fullbleed .hero__nav-link {
color: rgba(255, 255, 255, 0.7);
text-decoration-color: rgba(255, 255, 255, 0.25);
}
.hero--fullbleed .hero__nav-link:hover {
color: #fff;
text-decoration-color: #fff;
}
/* ── Hero: Minimal Layout ───────────────────────── */
.hero--minimal {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 56px);
padding: 64px 40px;
}
.hero--minimal .hero__content {
text-align: center;
max-width: 500px;
}
.hero--minimal .hero__title {
font-size: 4.5rem;
font-weight: 300;
letter-spacing: 0.08em;
margin-bottom: 12px;
}
.hero--minimal .hero__subtitle {
margin-bottom: 48px;
}
/* ── Hero: Stacked Layout ───────────────────────── */
.hero--stacked {
display: flex;

View File

@@ -174,9 +174,56 @@ export default async function HomePage() {
);
}
// ── Default: split / fullbleed / minimal (existing layouts) ────
// ── Minimal: pure text, no image, simple centered ──────────────
if (heroStyle === 'minimal') {
return (
<div className="hero">
<div className="hero hero--minimal">
<div className="hero__content">
<FadeIn delay={0}>
<h1 className="hero__title">{config.siteTitle}</h1>
</FadeIn>
{config.siteSubtitle && (
<FadeIn delay={100}>
<p className="hero__subtitle">{config.siteSubtitle}</p>
</FadeIn>
)}
<FadeIn delay={200}>
<nav className="hero__nav">
<HeroNavLinks subpages={subpages} albums={albums} />
</nav>
</FadeIn>
</div>
</div>
);
}
// ── Fullbleed: full-viewport image + centered overlay ───────────
if (heroStyle === 'fullbleed') {
return (
<div className="hero hero--fullbleed">
<HeroCarousel images={heroData} />
<div className="hero__fullbleed-overlay">
<FadeIn delay={0}>
<h1 className="hero__title">{config.siteTitle}</h1>
</FadeIn>
{config.siteSubtitle && (
<FadeIn delay={100}>
<p className="hero__subtitle">{config.siteSubtitle}</p>
</FadeIn>
)}
<FadeIn delay={200}>
<nav className="hero__nav">
<HeroNavLinks subpages={subpages} albums={albums} />
</nav>
</FadeIn>
</div>
</div>
);
}
// ── Split: left text panel, right image (default) ───────────────
return (
<div className="hero hero--split">
{/* ── Left Panel ──────────────────────────────── */}
<div className="hero__left">
<div className="hero__content">

View File

@@ -7,6 +7,9 @@
*
* Hero styles and grid layouts require server re-render, so
* those sections show a copyable YAML snippet instead.
*
* Layout: tabbed panel (Theme / Grid / Server) anchored bottom-right,
* away from Next.js debug toolbar which sits bottom-left.
*/
'use client';
@@ -17,6 +20,9 @@ const PRESETS = ['studio', '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'];
const ASPECT_RATIOS = ['1', '3/2', '2/3', '4/3', '16/9', 'auto'];
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 }> = {
@@ -53,13 +59,17 @@ function setVar(name: string, value: string) {
export function DevToolbar() {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<Tab>('theme');
const [preset, setPreset] = useState('');
const [frame, setFrame] = useState('');
const [theme, setTheme] = useState('');
const [grain, setGrain] = useState('');
const [exifOnHover, setExifOnHover] = useState(true);
const [copiedYaml, setCopiedYaml] = useState('');
const [gridCols, setGridCols] = useState(3);
const [gridGap, setGridGap] = useState(12);
const [aspectRatio, setAspectRatio] = useState('1');
const [gridLayout, setGridLayout] = useState('masonry');
// Read initial values from DOM
useLayoutEffect(() => {
@@ -71,8 +81,12 @@ export function DevToolbar() {
const style = getComputedStyle(document.documentElement);
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10);
const ar = style.getPropertyValue('--grid-aspect-ratio').trim() || '1';
const layout = getAttr('data-grid-layout') || 'masonry';
if (!isNaN(cols)) setGridCols(cols);
if (!isNaN(gap)) setGridGap(gap);
setAspectRatio(ar);
setGridLayout(layout);
}, []);
const switchPreset = useCallback((p: string) => {
@@ -140,6 +154,17 @@ export function DevToolbar() {
});
}, []);
const updateAspectRatio = useCallback((ar: string) => {
setAspectRatio(ar);
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
el.style.setProperty('--grid-aspect-ratio', ar);
});
}, []);
const toggleExifOnHover = useCallback(() => {
setExifOnHover((prev) => !prev);
}, []);
const copyYaml = useCallback((section: string, yaml: string) => {
navigator.clipboard.writeText(yaml);
setCopiedYaml(section);
@@ -147,9 +172,17 @@ export function DevToolbar() {
}, []);
const currentYaml = `theme:
preset: ${preset}
photoFrame: ${frame}
grain: ${grain === 'true'}`;
preset: "${preset}"
photoFrame: "${frame}"
grain: ${grain === 'true'}
grid:
columns: ${gridCols}
gap: ${gridGap}
aspectRatio: "${aspectRatio}"
layout: "${gridLayout}"
exifOnHover: ${exifOnHover}`;
// Keyboard shortcut: Ctrl/Cmd + Shift + T
useEffect(() => {
@@ -165,20 +198,20 @@ export function DevToolbar() {
return (
<>
{/* ── Floating Toggle Button ─────────────────────── */}
{/* ── Floating Toggle Button — bottom-right ── */}
<button
onClick={() => setOpen(!open)}
aria-label="Dev Toolbar"
style={{
position: 'fixed',
bottom: 20,
left: 20,
right: 20,
zIndex: 99999,
width: 44,
height: 44,
borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.2)',
background: open ? 'linear-gradient(135deg, #f43f5e, #a855f7)' : 'rgba(30,30,30,0.9)',
background: 'rgba(24,24,27,0.95)',
backdropFilter: 'blur(12px)',
color: '#fff',
fontSize: 18,
@@ -193,18 +226,16 @@ export function DevToolbar() {
{open ? '✕' : '🎨'}
</button>
{/* ── Panel ──────────────────────────────────────── */}
{/* ── Panel — anchored bottom-right ────────── */}
{open && (
<div
style={{
position: 'fixed',
bottom: 76,
left: 20,
right: 20,
zIndex: 99998,
width: 300,
maxHeight: 'calc(100vh - 120px)',
overflowY: 'auto',
background: 'rgba(18,18,22,0.96)',
background: 'rgba(18,18,22,0.97)',
backdropFilter: 'blur(20px)',
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.1)',
@@ -212,13 +243,13 @@ export function DevToolbar() {
color: '#e4e4e7',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: 12,
padding: 0,
overflow: 'hidden',
}}
>
{/* ── Header ──────────────────────────── */}
<div
style={{
padding: '14px 16px 10px',
padding: '12px 16px 10px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
@@ -241,100 +272,140 @@ export function DevToolbar() {
</span>
</div>
{/* ── Preset ──────────────────────────── */}
<Section title="Preset">
<PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
</Section>
{/* ── Dark / Light ────────────────────── */}
<Section title="Mode">
<PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
</Section>
{/* ── Photo Frame ─────────────────────── */}
<Section title="Photo Frame">
<PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
</Section>
{/* ── Grain ───────────────────────────── */}
<Section title="Film Grain">
<button
onClick={toggleGrain}
{/* ── Tab Bar ─────────────────────────── */}
<div
style={{
...pillStyle,
background:
grain === 'true'
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
: 'rgba(255,255,255,0.06)',
color: grain === 'true' ? '#fff' : '#a1a1aa',
border:
grain === 'true'
? '1px solid rgba(255,255,255,0.2)'
: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
borderBottom: '1px solid rgba(255,255,255,0.08)',
padding: '0 8px',
}}
>
{grain === 'true' ? '● On' : '○ Off'}
{(['theme', 'grid', 'server'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1,
padding: '8px 4px',
background: 'none',
border: 'none',
borderBottom: tab === t ? '2px solid #e4e4e7' : '2px solid transparent',
color: tab === t ? '#fff' : '#52525b',
fontSize: 11,
fontWeight: tab === t ? 600 : 400,
cursor: 'pointer',
textTransform: 'capitalize',
letterSpacing: '0.04em',
transition: 'all 0.15s ease',
marginBottom: -1,
}}
>
{t}
</button>
</Section>
))}
</div>
{/* ── Grid Controls ───────────────────── */}
<Section title="Grid">
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
Cols: {gridCols}
</label>
{/* ── Tab Content ──────────────────────── */}
<div style={{ padding: '12px 16px' }}>
{/* ── THEME TAB ── */}
{tab === 'theme' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<Label>Preset</Label>
<PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
</div>
<div>
<Label>Mode</Label>
<PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
</div>
<div>
<Label>Photo Frame</Label>
<PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
</div>
<div>
<Label>Film Grain</Label>
<ToggleButton
on={grain === 'true'}
onClick={toggleGrain}
/>
</div>
<div>
<Label>EXIF on Hover</Label>
<ToggleButton on={exifOnHover} onClick={toggleExifOnHover} />
</div>
</div>
)}
{/* ── GRID TAB ── */}
{tab === 'grid' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<Label>Columns: {gridCols}</Label>
<input
type="range"
min={1}
max={6}
value={gridCols}
onChange={(e) => updateGridCols(Number(e.target.value))}
style={{ flex: 1, accentColor: '#a855f7' }}
style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }}
/>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
Gap: {gridGap}px
</label>
<div>
<Label>Gap: {gridGap}px</Label>
<input
type="range"
min={0}
max={32}
value={gridGap}
onChange={(e) => updateGridGap(Number(e.target.value))}
style={{ flex: 1, accentColor: '#a855f7' }}
style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }}
/>
</div>
</Section>
<div>
<Label>Aspect Ratio</Label>
<PillGrid items={ASPECT_RATIOS} active={aspectRatio} onSelect={updateAspectRatio} />
</div>
</div>
)}
{/* ── Hero Style (info only) ──────────── */}
<Section title="Hero Style (server)">
{/* ── SERVER TAB ── */}
{tab === 'server' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<Label>Hero Style — click to copy YAML</Label>
<PillGrid
items={HERO_STYLES}
active=""
onSelect={(h) => {
copyYaml('hero', `theme:\n heroStyle: ${h}`);
}}
onSelect={(h) => copyYaml('hero', `theme:\n heroStyle: "${h}"`)}
/>
{copiedYaml === 'hero' && <CopyBadge />}
</Section>
{/* ── Grid Layout (info only) ─────────── */}
<Section title="Grid Layout (server)">
</div>
<div>
<Label>Grid Layout — click to apply & copy</Label>
<PillGrid
items={GRID_LAYOUTS}
active=""
active={gridLayout}
onSelect={(l) => {
copyYaml('layout', `grid:\n layout: ${l}`);
setGridLayout(l);
document.documentElement.setAttribute('data-grid-layout', l);
copyYaml('layout', `grid:\n layout: "${l}"`);
}}
/>
{copiedYaml === 'layout' && <CopyBadge />}
</Section>
</div>
<p style={{ color: '#52525b', fontSize: 10, margin: 0, lineHeight: 1.5 }}>
These settings require a server re-render. Click to copy the YAML snippet, then paste into{' '}
<code style={{ color: '#a1a1aa' }}>content/settings.yaml</code>.
</p>
</div>
)}
</div>
{/* ── Export YAML ──────────────────────── */}
<div
style={{
padding: '12px 16px 16px',
padding: '10px 16px 14px',
borderTop: '1px solid rgba(255,255,255,0.08)',
}}
>
@@ -342,12 +413,12 @@ export function DevToolbar() {
onClick={() => copyYaml('all', currentYaml)}
style={{
width: '100%',
padding: '10px 0',
padding: '9px 0',
borderRadius: 10,
border: 'none',
background:
copiedYaml === 'all' ? '#22c55e' : 'linear-gradient(135deg, #a855f7, #6366f1)',
color: '#fff',
copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)',
color: copiedYaml === 'all' ? '#fff' : '#111',
fontWeight: 600,
fontSize: 12,
cursor: 'pointer',
@@ -366,9 +437,8 @@ export function DevToolbar() {
/* ── Sub-components ───────────────────────────────── */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Label({ children }: { children: React.ReactNode }) {
return (
<div style={{ padding: '12px 16px 8px' }}>
<div
style={{
fontSize: 10,
@@ -376,18 +446,41 @@ function Section({ title, children }: { title: string; children: React.ReactNode
textTransform: 'uppercase',
letterSpacing: '0.1em',
color: '#71717a',
marginBottom: 8,
marginBottom: 6,
}}
>
{title}
</div>
{children}
</div>
);
}
function ToggleButton({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
padding: '5px 14px',
borderRadius: 8,
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.12s ease',
background: on
? '#166534'
: 'rgba(255,255,255,0.06)',
color: on ? '#86efac' : '#71717a',
border: on
? '1px solid #166534'
: '1px solid rgba(255,255,255,0.08)',
}}
>
{on ? '● On' : '○ Off'}
</button>
);
}
const pillStyle: React.CSSProperties = {
padding: '6px 12px',
padding: '5px 11px',
borderRadius: 8,
fontSize: 11,
fontWeight: 500,
@@ -406,7 +499,7 @@ function PillGrid({
onSelect: (item: string) => void;
}) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{items.map((item) => (
<button
key={item}
@@ -415,13 +508,13 @@ function PillGrid({
...pillStyle,
background:
item === active
? 'linear-gradient(135deg, #a855f7, #6366f1)'
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.06)',
color: item === active ? '#fff' : '#a1a1aa',
color: item === active ? '#111' : '#71717a',
border:
item === active
? '1px solid rgba(255,255,255,0.2)'
: '1px solid rgba(255,255,255,0.06)',
? '1px solid rgba(255,255,255,0.7)'
: '1px solid rgba(255,255,255,0.08)',
}}
>
{item}
@@ -442,7 +535,7 @@ function CopyBadge() {
fontWeight: 500,
}}
>
✓ YAML copied — paste into gallery.yaml
✓ YAML copied — paste into settings.yaml
</span>
);
}

View File

@@ -53,7 +53,8 @@
border-radius: var(--radius-sm);
user-select: none;
-webkit-user-drag: none;
opacity: 1; /* Default to visible for robustness */
opacity: 1;
/* Default to visible for robustness */
transition: opacity var(--transition-normal);
}

View File

@@ -12,6 +12,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import styles from './Lightbox.module.css';
@@ -45,6 +46,12 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
const touchStartY = useRef<number>(0);
const current = assets[currentIndex];
const [mounted, setMounted] = useState(false);
// Mount guard — createPortal needs document.body (client-only)
useEffect(() => {
setMounted(true);
}, []);
// Fetch EXIF data on demand
const fetchExif = useCallback((url: string) => {
@@ -118,7 +125,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
[onClose],
);
return (
const lightboxJsx = (
<div
className={styles.overlay}
ref={overlayRef}
@@ -277,4 +284,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
)}
</div>
);
if (!mounted) return null;
return createPortal(lightboxJsx, document.body);
}

View File

@@ -22,6 +22,8 @@ theme:
# accent: "#e60012"
# photoFrame: "passepartout" # none, passepartout, shadow
# grain: true
# heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic
# Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6)
# ── Grid configuration ───────────────────────────────────────────
grid:

View File

@@ -333,7 +333,10 @@ export function getGoogleFontsUrl(theme: ThemeConfig): string {
let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
if (_config) return _config;
// In development, skip the cache so YAML changes are picked up on the next
// request without a full server restart.
const isDev = process.env.NODE_ENV !== 'production';
if (_config && !isDev) return _config;
const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY;