feat: improve lightbox rendering, hero styles, and config loading
This commit is contained in:
@@ -57,7 +57,8 @@
|
|||||||
.main {
|
.main {
|
||||||
max-width: var(--max-width);
|
max-width: var(--max-width);
|
||||||
margin: 0 auto;
|
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) */
|
/* Simple CSS Page Fade-In (Replaces Framer Motion) */
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -80,7 +82,8 @@
|
|||||||
.main:has(.hero),
|
.main:has(.hero),
|
||||||
.main:has(.about) {
|
.main:has(.about) {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
padding-top: 56px; /* Still need space for header */
|
padding-top: 56px;
|
||||||
|
/* Still need space for header */
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
@@ -179,6 +182,95 @@
|
|||||||
opacity: 1;
|
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 Layout ───────────────────────── */
|
||||||
.hero--stacked {
|
.hero--stacked {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
51
app/page.tsx
51
app/page.tsx
@@ -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 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 (
|
return (
|
||||||
<div className="hero">
|
<div className="hero hero--split">
|
||||||
{/* ── Left Panel ──────────────────────────────── */}
|
{/* ── Left Panel ──────────────────────────────── */}
|
||||||
<div className="hero__left">
|
<div className="hero__left">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
*
|
*
|
||||||
* Hero styles and grid layouts require server re-render, so
|
* Hero styles and grid layouts require server re-render, so
|
||||||
* those sections show a copyable YAML snippet instead.
|
* 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';
|
'use client';
|
||||||
@@ -17,6 +20,9 @@ const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph
|
|||||||
const FRAMES = ['none', 'passepartout', 'shadow'];
|
const FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||||
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
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)
|
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
|
||||||
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
|
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
|
||||||
@@ -53,13 +59,17 @@ function setVar(name: string, value: string) {
|
|||||||
|
|
||||||
export function DevToolbar() {
|
export function DevToolbar() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tab, setTab] = useState<Tab>('theme');
|
||||||
const [preset, setPreset] = useState('');
|
const [preset, setPreset] = useState('');
|
||||||
const [frame, setFrame] = useState('');
|
const [frame, setFrame] = useState('');
|
||||||
const [theme, setTheme] = useState('');
|
const [theme, setTheme] = useState('');
|
||||||
const [grain, setGrain] = useState('');
|
const [grain, setGrain] = useState('');
|
||||||
|
const [exifOnHover, setExifOnHover] = useState(true);
|
||||||
const [copiedYaml, setCopiedYaml] = useState('');
|
const [copiedYaml, setCopiedYaml] = useState('');
|
||||||
const [gridCols, setGridCols] = useState(3);
|
const [gridCols, setGridCols] = useState(3);
|
||||||
const [gridGap, setGridGap] = useState(12);
|
const [gridGap, setGridGap] = useState(12);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState('1');
|
||||||
|
const [gridLayout, setGridLayout] = useState('masonry');
|
||||||
|
|
||||||
// Read initial values from DOM
|
// Read initial values from DOM
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -71,8 +81,12 @@ export function DevToolbar() {
|
|||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
|
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
|
||||||
const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 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(cols)) setGridCols(cols);
|
||||||
if (!isNaN(gap)) setGridGap(gap);
|
if (!isNaN(gap)) setGridGap(gap);
|
||||||
|
setAspectRatio(ar);
|
||||||
|
setGridLayout(layout);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const switchPreset = useCallback((p: string) => {
|
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) => {
|
const copyYaml = useCallback((section: string, yaml: string) => {
|
||||||
navigator.clipboard.writeText(yaml);
|
navigator.clipboard.writeText(yaml);
|
||||||
setCopiedYaml(section);
|
setCopiedYaml(section);
|
||||||
@@ -147,9 +172,17 @@ export function DevToolbar() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentYaml = `theme:
|
const currentYaml = `theme:
|
||||||
preset: ${preset}
|
preset: "${preset}"
|
||||||
photoFrame: ${frame}
|
photoFrame: "${frame}"
|
||||||
grain: ${grain === 'true'}`;
|
grain: ${grain === 'true'}
|
||||||
|
|
||||||
|
grid:
|
||||||
|
columns: ${gridCols}
|
||||||
|
gap: ${gridGap}
|
||||||
|
aspectRatio: "${aspectRatio}"
|
||||||
|
layout: "${gridLayout}"
|
||||||
|
|
||||||
|
exifOnHover: ${exifOnHover}`;
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl/Cmd + Shift + T
|
// Keyboard shortcut: Ctrl/Cmd + Shift + T
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -165,20 +198,20 @@ export function DevToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Floating Toggle Button ─────────────────────── */}
|
{/* ── Floating Toggle Button — bottom-right ── */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
aria-label="Dev Toolbar"
|
aria-label="Dev Toolbar"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 20,
|
right: 20,
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
border: '2px solid rgba(255,255,255,0.2)',
|
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)',
|
backdropFilter: 'blur(12px)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -193,18 +226,16 @@ export function DevToolbar() {
|
|||||||
{open ? '✕' : '🎨'}
|
{open ? '✕' : '🎨'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* ── Panel ──────────────────────────────────────── */}
|
{/* ── Panel — anchored bottom-right ────────── */}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 76,
|
bottom: 76,
|
||||||
left: 20,
|
right: 20,
|
||||||
zIndex: 99998,
|
zIndex: 99998,
|
||||||
width: 300,
|
width: 300,
|
||||||
maxHeight: 'calc(100vh - 120px)',
|
background: 'rgba(18,18,22,0.97)',
|
||||||
overflowY: 'auto',
|
|
||||||
background: 'rgba(18,18,22,0.96)',
|
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
@@ -212,13 +243,13 @@ export function DevToolbar() {
|
|||||||
color: '#e4e4e7',
|
color: '#e4e4e7',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: 0,
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ──────────────────────────── */}
|
{/* ── Header ──────────────────────────── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 16px 10px',
|
padding: '12px 16px 10px',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -241,100 +272,140 @@ export function DevToolbar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Preset ──────────────────────────── */}
|
{/* ── Tab Bar ─────────────────────────── */}
|
||||||
<Section title="Preset">
|
<div
|
||||||
<PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
|
style={{
|
||||||
</Section>
|
display: 'flex',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
padding: '0 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Dark / Light ────────────────────── */}
|
{/* ── Tab Content ──────────────────────── */}
|
||||||
<Section title="Mode">
|
<div style={{ padding: '12px 16px' }}>
|
||||||
<PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Photo Frame ─────────────────────── */}
|
{/* ── THEME TAB ── */}
|
||||||
<Section title="Photo Frame">
|
{tab === 'theme' && (
|
||||||
<PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
</Section>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Grain ───────────────────────────── */}
|
{/* ── GRID TAB ── */}
|
||||||
<Section title="Film Grain">
|
{tab === 'grid' && (
|
||||||
<button
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
onClick={toggleGrain}
|
<div>
|
||||||
style={{
|
<Label>Columns: {gridCols}</Label>
|
||||||
...pillStyle,
|
<input
|
||||||
background:
|
type="range"
|
||||||
grain === 'true'
|
min={1}
|
||||||
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
|
max={6}
|
||||||
: 'rgba(255,255,255,0.06)',
|
value={gridCols}
|
||||||
color: grain === 'true' ? '#fff' : '#a1a1aa',
|
onChange={(e) => updateGridCols(Number(e.target.value))}
|
||||||
border:
|
style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }}
|
||||||
grain === 'true'
|
/>
|
||||||
? '1px solid rgba(255,255,255,0.2)'
|
</div>
|
||||||
: '1px solid rgba(255,255,255,0.06)',
|
<div>
|
||||||
}}
|
<Label>Gap: {gridGap}px</Label>
|
||||||
>
|
<input
|
||||||
{grain === 'true' ? '● On' : '○ Off'}
|
type="range"
|
||||||
</button>
|
min={0}
|
||||||
</Section>
|
max={32}
|
||||||
|
value={gridGap}
|
||||||
|
onChange={(e) => updateGridGap(Number(e.target.value))}
|
||||||
|
style={{ width: '100%', accentColor: '#e4e4e7', marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Aspect Ratio</Label>
|
||||||
|
<PillGrid items={ASPECT_RATIOS} active={aspectRatio} onSelect={updateAspectRatio} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Grid Controls ───────────────────── */}
|
{/* ── SERVER TAB ── */}
|
||||||
<Section title="Grid">
|
{tab === 'server' && (
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
|
<div>
|
||||||
Cols: {gridCols}
|
<Label>Hero Style — click to copy YAML</Label>
|
||||||
</label>
|
<PillGrid
|
||||||
<input
|
items={HERO_STYLES}
|
||||||
type="range"
|
active=""
|
||||||
min={1}
|
onSelect={(h) => copyYaml('hero', `theme:\n heroStyle: "${h}"`)}
|
||||||
max={6}
|
/>
|
||||||
value={gridCols}
|
{copiedYaml === 'hero' && <CopyBadge />}
|
||||||
onChange={(e) => updateGridCols(Number(e.target.value))}
|
</div>
|
||||||
style={{ flex: 1, accentColor: '#a855f7' }}
|
<div>
|
||||||
/>
|
<Label>Grid Layout — click to apply & copy</Label>
|
||||||
</div>
|
<PillGrid
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
|
items={GRID_LAYOUTS}
|
||||||
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
|
active={gridLayout}
|
||||||
Gap: {gridGap}px
|
onSelect={(l) => {
|
||||||
</label>
|
setGridLayout(l);
|
||||||
<input
|
document.documentElement.setAttribute('data-grid-layout', l);
|
||||||
type="range"
|
copyYaml('layout', `grid:\n layout: "${l}"`);
|
||||||
min={0}
|
}}
|
||||||
max={32}
|
/>
|
||||||
value={gridGap}
|
{copiedYaml === 'layout' && <CopyBadge />}
|
||||||
onChange={(e) => updateGridGap(Number(e.target.value))}
|
</div>
|
||||||
style={{ flex: 1, accentColor: '#a855f7' }}
|
<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{' '}
|
||||||
</div>
|
<code style={{ color: '#a1a1aa' }}>content/settings.yaml</code>.
|
||||||
</Section>
|
</p>
|
||||||
|
</div>
|
||||||
{/* ── Hero Style (info only) ──────────── */}
|
)}
|
||||||
<Section title="Hero Style (server)">
|
</div>
|
||||||
<PillGrid
|
|
||||||
items={HERO_STYLES}
|
|
||||||
active=""
|
|
||||||
onSelect={(h) => {
|
|
||||||
copyYaml('hero', `theme:\n heroStyle: ${h}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{copiedYaml === 'hero' && <CopyBadge />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Grid Layout (info only) ─────────── */}
|
|
||||||
<Section title="Grid Layout (server)">
|
|
||||||
<PillGrid
|
|
||||||
items={GRID_LAYOUTS}
|
|
||||||
active=""
|
|
||||||
onSelect={(l) => {
|
|
||||||
copyYaml('layout', `grid:\n layout: ${l}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{copiedYaml === 'layout' && <CopyBadge />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Export YAML ──────────────────────── */}
|
{/* ── Export YAML ──────────────────────── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 16px 16px',
|
padding: '10px 16px 14px',
|
||||||
borderTop: '1px solid rgba(255,255,255,0.08)',
|
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -342,12 +413,12 @@ export function DevToolbar() {
|
|||||||
onClick={() => copyYaml('all', currentYaml)}
|
onClick={() => copyYaml('all', currentYaml)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '10px 0',
|
padding: '9px 0',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background:
|
background:
|
||||||
copiedYaml === 'all' ? '#22c55e' : 'linear-gradient(135deg, #a855f7, #6366f1)',
|
copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)',
|
||||||
color: '#fff',
|
color: copiedYaml === 'all' ? '#fff' : '#111',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -366,28 +437,50 @@ export function DevToolbar() {
|
|||||||
|
|
||||||
/* ── Sub-components ───────────────────────────────── */
|
/* ── Sub-components ───────────────────────────────── */
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Label({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px 16px 8px' }}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
fontSize: 10,
|
||||||
fontSize: 10,
|
fontWeight: 600,
|
||||||
fontWeight: 600,
|
textTransform: 'uppercase',
|
||||||
textTransform: 'uppercase',
|
letterSpacing: '0.1em',
|
||||||
letterSpacing: '0.1em',
|
color: '#71717a',
|
||||||
color: '#71717a',
|
marginBottom: 6,
|
||||||
marginBottom: 8,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 = {
|
const pillStyle: React.CSSProperties = {
|
||||||
padding: '6px 12px',
|
padding: '5px 11px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
@@ -406,7 +499,7 @@ function PillGrid({
|
|||||||
onSelect: (item: string) => void;
|
onSelect: (item: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
@@ -415,13 +508,13 @@ function PillGrid({
|
|||||||
...pillStyle,
|
...pillStyle,
|
||||||
background:
|
background:
|
||||||
item === active
|
item === active
|
||||||
? 'linear-gradient(135deg, #a855f7, #6366f1)'
|
? 'rgba(255,255,255,0.9)'
|
||||||
: 'rgba(255,255,255,0.06)',
|
: 'rgba(255,255,255,0.06)',
|
||||||
color: item === active ? '#fff' : '#a1a1aa',
|
color: item === active ? '#111' : '#71717a',
|
||||||
border:
|
border:
|
||||||
item === active
|
item === active
|
||||||
? '1px solid rgba(255,255,255,0.2)'
|
? '1px solid rgba(255,255,255,0.7)'
|
||||||
: '1px solid rgba(255,255,255,0.06)',
|
: '1px solid rgba(255,255,255,0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
@@ -442,7 +535,7 @@ function CopyBadge() {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✓ YAML copied — paste into gallery.yaml
|
✓ YAML copied — paste into settings.yaml
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
opacity: 1; /* Default to visible for robustness */
|
opacity: 1;
|
||||||
|
/* Default to visible for robustness */
|
||||||
transition: opacity var(--transition-normal);
|
transition: opacity var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,4 +253,4 @@
|
|||||||
|
|
||||||
.image {
|
.image {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||||
import styles from './Lightbox.module.css';
|
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 touchStartY = useRef<number>(0);
|
||||||
|
|
||||||
const current = assets[currentIndex];
|
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
|
// Fetch EXIF data on demand
|
||||||
const fetchExif = useCallback((url: string) => {
|
const fetchExif = useCallback((url: string) => {
|
||||||
@@ -118,7 +125,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
|||||||
[onClose],
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const lightboxJsx = (
|
||||||
<div
|
<div
|
||||||
className={styles.overlay}
|
className={styles.overlay}
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
@@ -277,4 +284,7 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
return createPortal(lightboxJsx, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ theme:
|
|||||||
# accent: "#e60012"
|
# accent: "#e60012"
|
||||||
# photoFrame: "passepartout" # none, passepartout, shadow
|
# photoFrame: "passepartout" # none, passepartout, shadow
|
||||||
# grain: true
|
# 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 configuration ───────────────────────────────────────────
|
||||||
grid:
|
grid:
|
||||||
|
|||||||
121
lib/config.ts
121
lib/config.ts
@@ -136,18 +136,18 @@ interface GalleryYaml {
|
|||||||
hero?: string | string[];
|
hero?: string | string[];
|
||||||
albums?: string[];
|
albums?: string[];
|
||||||
subpages?:
|
subpages?:
|
||||||
| Record<string, string[] | Array<string | Record<string, string>>>
|
| Record<string, string[] | Array<string | Record<string, string>>>
|
||||||
| Array<{
|
| Array<{
|
||||||
name: string;
|
name: string;
|
||||||
albums: Array<string | Record<string, string>>;
|
albums: Array<string | Record<string, string>>;
|
||||||
password?: string;
|
password?: string;
|
||||||
grid?: {
|
grid?: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
layout?: string;
|
layout?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw YAML structure for global settings. */
|
/** Raw YAML structure for global settings. */
|
||||||
@@ -158,17 +158,17 @@ interface SettingsYaml {
|
|||||||
map?: boolean;
|
map?: boolean;
|
||||||
transitions?: boolean;
|
transitions?: boolean;
|
||||||
theme?:
|
theme?:
|
||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
preset?: string;
|
preset?: string;
|
||||||
accent?: string;
|
accent?: string;
|
||||||
fonts?: { heading?: string; body?: string; caption?: string };
|
fonts?: { heading?: string; body?: string; caption?: string };
|
||||||
radius?: number;
|
radius?: number;
|
||||||
photoFrame?: string;
|
photoFrame?: string;
|
||||||
grain?: boolean;
|
grain?: boolean;
|
||||||
headerDot?: boolean;
|
headerDot?: boolean;
|
||||||
heroStyle?: string;
|
heroStyle?: string;
|
||||||
};
|
};
|
||||||
grid?: {
|
grid?: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
@@ -189,7 +189,7 @@ function loadYaml<T>(filename: string): T {
|
|||||||
if (filename === 'gallery.yaml') {
|
if (filename === 'gallery.yaml') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Required config not found: ${yamlPath}\n` +
|
`Required config not found: ${yamlPath}\n` +
|
||||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {} as T;
|
return {} as T;
|
||||||
@@ -333,7 +333,10 @@ export function getGoogleFontsUrl(theme: ThemeConfig): string {
|
|||||||
let _config: AppConfig | null = null;
|
let _config: AppConfig | null = null;
|
||||||
|
|
||||||
export function getConfig(): AppConfig {
|
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 apiUrl = env.IMMICH_API_URL;
|
||||||
const apiKey = env.IMMICH_API_KEY;
|
const apiKey = env.IMMICH_API_KEY;
|
||||||
@@ -342,7 +345,7 @@ export function getConfig(): AppConfig {
|
|||||||
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
|
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
|
||||||
console.warn(
|
console.warn(
|
||||||
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' +
|
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' +
|
||||||
' Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
' Please set a long random string as AUTH_SECRET in your .env for better security.\n',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,19 +401,19 @@ export function getConfig(): AppConfig {
|
|||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...(sp.grid
|
||||||
? {
|
? {
|
||||||
grid: {
|
grid: {
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||||
...(sp.grid.layout != null
|
...(sp.grid.layout != null
|
||||||
? {
|
? {
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||||
? sp.grid.layout
|
? sp.grid.layout
|
||||||
: 'masonry') as GridConfig['layout'],
|
: 'masonry') as GridConfig['layout'],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -436,19 +439,19 @@ export function getConfig(): AppConfig {
|
|||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...(sp.grid
|
||||||
? {
|
? {
|
||||||
grid: {
|
grid: {
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
||||||
...(sp.grid.layout != null
|
...(sp.grid.layout != null
|
||||||
? {
|
? {
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
||||||
? sp.grid.layout
|
? sp.grid.layout
|
||||||
: 'masonry') as GridConfig['layout'],
|
: 'masonry') as GridConfig['layout'],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -471,8 +474,8 @@ export function getConfig(): AppConfig {
|
|||||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||||
heroImages: gallery.hero
|
heroImages: gallery.hero
|
||||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||||
validateUuid(id, 'gallery.yaml hero'),
|
validateUuid(id, 'gallery.yaml hero'),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
exifOnHover: settings.exifOnHover !== false,
|
exifOnHover: settings.exifOnHover !== false,
|
||||||
grid: {
|
grid: {
|
||||||
@@ -486,11 +489,11 @@ export function getConfig(): AppConfig {
|
|||||||
theme,
|
theme,
|
||||||
footer: settings.footer
|
footer: settings.footer
|
||||||
? {
|
? {
|
||||||
name: settings.footer.name,
|
name: settings.footer.name,
|
||||||
instagram: settings.footer.instagram,
|
instagram: settings.footer.instagram,
|
||||||
email: settings.footer.email,
|
email: settings.footer.email,
|
||||||
website: settings.footer.website,
|
website: settings.footer.website,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
legal: {
|
legal: {
|
||||||
enabled: settings.legal?.enabled === true,
|
enabled: settings.legal?.enabled === true,
|
||||||
|
|||||||
Reference in New Issue
Block a user