diff --git a/.gitignore b/.gitignore
index c166a07..9c59875 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,7 @@ next-env.d.ts
# internal dev docs
dev-docs/
+
+# generated screenshots
+docs/screenshots/
+
diff --git a/README.md b/README.md
index 4c94f40..5b40bef 100644
--- a/README.md
+++ b/README.md
@@ -78,63 +78,21 @@ CACHE_TTL=300 # seconds, default: 300
RATE_LIMIT_RPM=120 # requests/min/IP, default: 120
```
-### Gallery Config (`content/gallery.yaml`)
+### Gallery Config
+
+All gallery structure — hero images, albums, subpages, grid layout, footer — is defined in `content/gallery.yaml`.
+
+→ **[Gallery Configuration Guide](docs/gallery-config.md)**
+
+### Theming
+
+Customize the visual identity of your gallery with built-in presets or fine-grained control over colors, fonts, corners, photo frames, and more.
```yaml
-# Hero image(s) — single UUID or list for carousel
-hero: 00000000-0000-0000-0000-000000000000
-# hero:
-# - uuid-1
-# - uuid-2
-
-# Standalone albums on the homepage
-albums:
- - 11111111-1111-1111-1111-111111111111
- - 22222222-2222-2222-2222-222222222222
-
-# EXIF on hover (default: true)
-# exifOnHover: false
-
-# Global grid layout
-# grid:
-# columns: 3
-# gap: 12
-# aspectRatio: "1"
-# layout: masonry # "masonry" or "uniform"
-
-# Subpages — group albums into collections
-subpages:
- - name: Japan
- grid:
- columns: 4
- layout: uniform
- aspectRatio: '3/2'
- albums:
- - uuid-tokyo
- - uuid-kyoto
-
- - name: Wedding – Smith
- password: clientpass123
- albums:
- - uuid-wedding
+theme: minimal # or: studio, editorial, classic
```
-See `content/gallery.yaml.example` for the full reference.
-
-### About Page (`content/about.md`)
-
-```markdown
----
-portrait: asset-uuid
-name: Your Name
-location: City, Country
-gear:
- - Camera Body
- - Favorite Lens
----
-
-Your bio text here.
-```
+→ **[Theming Guide](docs/theming.md)**
## Docker
diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx
index 4ce325e..53a6510 100644
--- a/app/api/og/route.tsx
+++ b/app/api/og/route.tsx
@@ -1,12 +1,14 @@
/**
* Dynamic OG image generator — renders social share previews.
* Uses next/og (ImageResponse) to create 1200×630 cards.
+ * Reads accent color from theme config.
*
* GET /api/og?title=Album+Name&subtitle=12+photos
*/
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
+import { getConfig } from '@/lib/config';
export const runtime = 'edge';
@@ -14,6 +16,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = searchParams.get('title') || 'Gallery';
const subtitle = searchParams.get('subtitle') || '';
+ const { theme } = getConfig();
return new ImageResponse(
@@ -75,7 +78,7 @@ export async function GET(request: NextRequest) {
style={{
width: '60px',
height: '2px',
- backgroundColor: '#d4af37',
+ backgroundColor: theme.accent,
marginTop: '40px',
}}
/>
diff --git a/app/globals.css b/app/globals.css
index af104f7..f7f3a0e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -279,12 +279,10 @@
flex-direction: column;
justify-content: flex-end;
padding: 24px;
- background: linear-gradient(
- to top,
- rgba(0, 0, 0, 0.8) 0%,
- rgba(0, 0, 0, 0.3) 40%,
- transparent 100%
- );
+ background: linear-gradient(to top,
+ rgba(0, 0, 0, 0.8) 0%,
+ rgba(0, 0, 0, 0.3) 40%,
+ transparent 100%);
opacity: 0;
transition: opacity var(--transition-normal);
}
@@ -414,12 +412,10 @@
/* ── Loading States ───────────────────────────────── */
.skeleton {
- background: linear-gradient(
- 90deg,
- var(--bg-card) 0%,
- var(--bg-card-hover) 50%,
- var(--bg-card) 100%
- );
+ background: linear-gradient(90deg,
+ var(--bg-card) 0%,
+ var(--bg-card-hover) 50%,
+ var(--bg-card) 100%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-sm);
@@ -604,10 +600,11 @@
font-weight: 300;
}
-/* ── Studio-Inspired Defaults ────────────────────── */
+/* ── Theme Conditionals ──────────────────────────── */
+/* Features toggled via data attributes on , set by theme config */
-/* Red dot in header */
-.header__nav::before {
+/* ─ Header dot (accent circle in nav) ─ */
+[data-header-dot='true'] .header__nav::before {
content: '';
display: block;
width: 10px;
@@ -618,6 +615,78 @@
flex-shrink: 0;
}
+/* ─ Passepartout photo frame ─ */
+[data-photo-frame='passepartout'] .photo-grid__item {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ padding: 8px 8px 24px;
+ margin-bottom: var(--grid-gap, 24px);
+ background: #111;
+ overflow: hidden;
+}
+
+[data-photo-frame='passepartout'][data-theme='light'] .photo-grid__item {
+ border-color: rgba(0, 0, 0, 0.1);
+ background: #fff;
+}
+
+[data-photo-frame='passepartout'] .photo-grid__item-exif {
+ position: relative;
+ bottom: auto;
+ left: auto;
+ right: auto;
+ background: none;
+ padding: 8px 0 0;
+ color: var(--text-muted);
+ font-family: var(--font-caption);
+ font-style: italic;
+ font-size: 0.75rem;
+ letter-spacing: 0.02em;
+ white-space: normal;
+ opacity: 1;
+ text-align: center;
+}
+
+[data-photo-frame='passepartout'] .photo-grid__item:hover .photo-grid__item-exif {
+ opacity: 1;
+}
+
+/* ─ Shadow photo frame ─ */
+[data-photo-frame='shadow'] .photo-grid__item {
+ border-radius: var(--radius-sm);
+ box-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
+ overflow: hidden;
+}
+
+[data-photo-frame='shadow'][data-theme='light'] .photo-grid__item {
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.12);
+}
+
+/* ─ Film grain overlay ─ */
+[data-grain='true'] .photo-grid__item::after {
+ content: '';
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ right: 8px;
+ bottom: 24px;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
+ background-size: 128px 128px;
+ pointer-events: none;
+ z-index: 1;
+ mix-blend-mode: overlay;
+}
+
+/* Grain without passepartout — fill the whole item */
+[data-grain='true'][data-photo-frame='none'] .photo-grid__item::after,
+[data-grain='true'][data-photo-frame='shadow'] .photo-grid__item::after {
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+/* ── Gallery Aesthetics (all themes) ─────────────── */
+
/* Generous spacing — gallery exhibition feel */
.main {
padding: 64px 40px 120px;
@@ -639,68 +708,11 @@
letter-spacing: 0.2em;
}
-/* Photo grid — passepartout white borders */
+/* Photo grid gap */
.photo-grid {
column-gap: var(--grid-gap, 24px);
}
-.photo-grid__item {
- border: 1px solid rgba(255, 255, 255, 0.12);
- padding: 8px 8px 24px;
- margin-bottom: var(--grid-gap, 24px);
- background: #111;
- border-radius: 0;
- cursor: pointer;
- position: relative;
- overflow: visible;
-}
-
-[data-theme='light'] .photo-grid__item {
- border-color: rgba(0, 0, 0, 0.1);
- background: #fff;
-}
-
-.photo-grid__item img {
- border-radius: 0;
-}
-
-/* Film grain overlay */
-.photo-grid__item::after {
- content: '';
- position: absolute;
- top: 8px;
- left: 8px;
- right: 8px;
- bottom: 24px;
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
- background-size: 128px 128px;
- pointer-events: none;
- z-index: 1;
- mix-blend-mode: overlay;
-}
-
-/* Print-style captions (replaces EXIF overlay) */
-.photo-grid__item-exif {
- position: relative;
- bottom: auto;
- left: auto;
- right: auto;
- background: none;
- padding: 8px 0 0;
- color: var(--text-muted);
- font-family: var(--font-caption, 'EB Garamond', Georgia, serif);
- font-style: italic;
- font-size: 0.75rem;
- letter-spacing: 0.02em;
- white-space: normal;
- opacity: 1;
- text-align: center;
-}
-
-.photo-grid__item:hover .photo-grid__item-exif {
- opacity: 1;
-}
-
/* Shutter-click feedback */
.photo-grid__item:active img {
filter: brightness(0.6);
@@ -740,9 +752,9 @@
color: var(--accent);
}
-/* Album cards — sharper, no rounded corners */
+/* Album cards */
.album-card {
- border-radius: 0;
+ border-radius: var(--radius-md);
border-color: rgba(255, 255, 255, 0.08);
}
@@ -755,7 +767,7 @@
transform: none;
}
-/* Subpage grid — clean */
+/* Subpage grid */
.subpage-grid__item {
border: 1px solid rgba(255, 255, 255, 0.08);
}
@@ -764,19 +776,17 @@
border-color: rgba(0, 0, 0, 0.08);
}
-/* Nav link accent on hover */
+/* Accent hover effects (works with any accent color) */
.header__nav-link:hover,
.header__nav-link.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
-/* Footer — red accent on hover */
.footer__link:hover {
color: var(--accent);
}
-/* Scroll-to-top — red accent on hover */
.scroll-to-top:hover {
border-color: var(--accent);
color: var(--accent);
@@ -846,7 +856,7 @@
opacity: 1;
}
- .photo-grid__item {
+ [data-photo-frame='passepartout'] .photo-grid__item {
padding: 6px 6px 18px;
}
-}
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index 4d14f86..a6892e8 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,6 +1,7 @@
/**
* Root layout — site header with portfolio-style navigation.
* Shows all subpages + standalone albums as nav links.
+ * Injects theme CSS custom properties from gallery.yaml config.
*/
import type { Metadata } from 'next';
@@ -10,6 +11,7 @@ import { SubpageNav } from '@/components/SubpageNav';
import { ThemeToggle } from '@/components/ThemeToggle';
import { ScrollToTop } from '@/components/ScrollToTop';
import { Footer } from '@/components/Footer';
+import { getConfig, getGoogleFontsUrl } from '@/lib/config';
const siteTitle = process.env.SITE_TITLE || 'Gallery';
const siteDescription = 'A curated photography portfolio';
@@ -35,8 +37,35 @@ export const metadata: Metadata = {
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
+ const { theme } = getConfig();
+ const fontsUrl = getGoogleFontsUrl(theme);
+
+ // Inject theme values as CSS custom properties
+ const themeVars: Record = {
+ '--accent': theme.accent,
+ '--accent-dim': `${theme.accent}1f`,
+ '--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`,
+ '--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
+ '--font-caption': `'${theme.fonts.caption}', Georgia, serif`,
+ '--radius-sm': `${theme.radius}px`,
+ '--radius-md': `${Math.round(theme.radius * 1.5)}px`,
+ '--radius-lg': `${theme.radius * 2}px`,
+ };
+
return (
-
+
+
+
+
+
+