-
+
Setup Required
-
- Immich Folio is running, but it looks like your configuration files or environment variables are missing.
+
+ Immich Folio is running, but it looks like your configuration files or environment
+ variables are missing.
-
-
To get started, follow these steps in your repository or mounted content/ volume:
+
+
+ To get started, follow these steps in your repository or mounted{' '}
+ content/ volume:
+
- - Copy
.env.example to .env.local and fill in your Immich API URL and Key
- - Copy
settings.yaml.example to settings.yaml
- - Copy
gallery.yaml.example to gallery.yaml
- - Copy
about.md.example to about.md (optional)
+ -
+ Copy
.env.example to .env.local and fill in your
+ Immich API URL and Key
+
+ -
+ Copy
settings.yaml.example to settings.yaml
+
+ -
+ Copy
gallery.yaml.example to gallery.yaml
+
+ -
+ Copy
about.md.example to about.md (optional)
+
- Open gallery.yaml and add the album IDs you want to display, then restart your server.
+ Open gallery.yaml and add the album IDs you want to display, then{' '}
+ restart your server.
diff --git a/app/map/map.css b/app/map/map.css
index 5875b27..36b3801 100644
--- a/app/map/map.css
+++ b/app/map/map.css
@@ -1,174 +1,174 @@
/* ── Map Page ──────────────────────────────────────────────── */
.map-page {
- display: flex;
- flex-direction: column;
- min-height: calc(100vh - var(--header-height, 64px));
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - var(--header-height, 64px));
}
.map-page__header {
- padding: 2rem var(--page-padding, 2rem) 1rem;
+ padding: 2rem var(--page-padding, 2rem) 1rem;
}
.map-page__title {
- font-family: var(--font-heading);
- font-size: clamp(1.5rem, 4vw, 2.5rem);
- font-weight: 600;
- margin: 0.5rem 0 0.25rem;
- color: var(--text-primary);
+ font-family: var(--font-heading);
+ font-size: clamp(1.5rem, 4vw, 2.5rem);
+ font-weight: 600;
+ margin: 0.5rem 0 0.25rem;
+ color: var(--text-primary);
}
.map-page__subtitle {
- font-family: var(--font-body);
- font-size: 0.875rem;
- color: var(--text-secondary);
- margin: 0;
+ font-family: var(--font-body);
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ margin: 0;
}
/* ── Map Container ─────────────────────────────────────────── */
.map-container {
- flex: 1;
- min-height: 500px;
- margin: 0 var(--page-padding, 2rem) 2rem;
- border-radius: var(--radius, 0);
- overflow: hidden;
- border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
+ flex: 1;
+ min-height: 500px;
+ margin: 0 var(--page-padding, 2rem) 2rem;
+ border-radius: var(--radius, 0);
+ overflow: hidden;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
}
.map-container__loading {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 500px;
- color: var(--text-secondary);
- font-family: var(--font-body);
- font-size: 0.875rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 500px;
+ color: var(--text-secondary);
+ font-family: var(--font-body);
+ font-size: 0.875rem;
}
/* ── Map Popups ────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
- background: var(--bg-primary, #1a1a1a) !important;
- color: var(--text-primary, #fff) !important;
- border-radius: var(--radius, 4px) !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
- border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
- padding: 0 !important;
- overflow: hidden;
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-primary, #fff) !important;
+ border-radius: var(--radius, 4px) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+ padding: 0 !important;
+ overflow: hidden;
}
.leaflet-popup-tip {
- background: var(--bg-primary, #1a1a1a) !important;
- border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
- border-top: none !important;
- border-left: none !important;
+ background: var(--bg-primary, #1a1a1a) !important;
+ border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+ border-top: none !important;
+ border-left: none !important;
}
.leaflet-popup-content {
- margin: 0 !important;
- min-width: 200px;
+ margin: 0 !important;
+ min-width: 200px;
}
.map-popup {
- font-family: var(--font-body);
+ font-family: var(--font-body);
}
.map-popup__cover {
- width: 100%;
- height: 120px;
- object-fit: cover;
- display: block;
+ width: 100%;
+ height: 120px;
+ object-fit: cover;
+ display: block;
}
.map-popup__body {
- padding: 0.75rem 1rem;
+ padding: 0.75rem 1rem;
}
.map-popup__city {
- font-family: var(--font-heading);
- font-size: 0.9375rem;
- font-weight: 600;
- margin: 0 0 0.125rem;
- color: var(--text-primary);
+ font-family: var(--font-heading);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin: 0 0 0.125rem;
+ color: var(--text-primary);
}
.map-popup__country {
- font-family: var(--font-caption);
- font-size: 0.6875rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-secondary);
- margin: 0 0 0.5rem;
+ font-family: var(--font-caption);
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-secondary);
+ margin: 0 0 0.5rem;
}
.map-popup__count {
- font-family: var(--font-caption);
- font-size: 0.75rem;
- color: var(--text-secondary);
- margin-bottom: 0.5rem;
+ font-family: var(--font-caption);
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
}
.map-popup__albums {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
}
.map-popup__album-link {
- font-size: 0.8125rem;
- color: var(--accent, #e60012);
- text-decoration: none;
- transition: opacity 0.15s;
+ font-size: 0.8125rem;
+ color: var(--accent, #e60012);
+ text-decoration: none;
+ transition: opacity 0.15s;
}
.map-popup__album-link:hover {
- opacity: 0.7;
+ opacity: 0.7;
}
/* ── Custom Marker ─────────────────────────────────────────── */
.map-marker {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border-radius: 50%;
- background: var(--accent, #e60012);
- color: #fff;
- font-family: var(--font-caption);
- font-size: 0.6875rem;
- font-weight: 700;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
- border: 2px solid rgba(255, 255, 255, 0.9);
- cursor: pointer;
- transition: transform 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--accent, #e60012);
+ color: #fff;
+ font-family: var(--font-caption);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ border: 2px solid rgba(255, 255, 255, 0.9);
+ cursor: pointer;
+ transition: transform 0.15s ease;
}
.map-marker:hover {
- transform: scale(1.15);
+ transform: scale(1.15);
}
/* ── Leaflet overrides ─────────────────────────────────────── */
.leaflet-control-attribution {
- background: var(--bg-primary, #1a1a1a) !important;
- color: var(--text-secondary, #888) !important;
- font-size: 0.625rem !important;
- opacity: 0.7;
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-secondary, #888) !important;
+ font-size: 0.625rem !important;
+ opacity: 0.7;
}
.leaflet-control-attribution a {
- color: var(--text-secondary, #888) !important;
+ color: var(--text-secondary, #888) !important;
}
.leaflet-control-zoom a {
- background: var(--bg-primary, #1a1a1a) !important;
- color: var(--text-primary, #fff) !important;
- border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
+ background: var(--bg-primary, #1a1a1a) !important;
+ color: var(--text-primary, #fff) !important;
+ border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
}
.leaflet-control-zoom a:hover {
- background: var(--bg-secondary, #222) !important;
-}
\ No newline at end of file
+ background: var(--bg-secondary, #222) !important;
+}
diff --git a/app/themes/classic.css b/app/themes/classic.css
index 386509c..0ffa6f0 100644
--- a/app/themes/classic.css
+++ b/app/themes/classic.css
@@ -198,7 +198,7 @@
justify-content: center;
}
-[data-preset='classic'] .subpage-toc__entry+.subpage-toc__entry::before {
+[data-preset='classic'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '◆';
padding: 0 14px;
font-size: 0.45rem;
@@ -260,4 +260,4 @@
font-size: 0.55rem;
letter-spacing: 0.2em;
color: var(--accent);
-}
\ No newline at end of file
+}
diff --git a/app/themes/editorial.css b/app/themes/editorial.css
index 0d40dac..aa8a4a2 100644
--- a/app/themes/editorial.css
+++ b/app/themes/editorial.css
@@ -181,7 +181,7 @@
margin-bottom: 64px;
}
-[data-preset='editorial'] .subpage-toc__entry+.subpage-toc__entry::before {
+[data-preset='editorial'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '/';
padding: 0 12px;
font-family: var(--font-caption);
@@ -234,4 +234,4 @@
border-left: 3px solid var(--accent);
margin-bottom: -16px;
width: 40px;
-}
\ No newline at end of file
+}
diff --git a/app/themes/minimal.css b/app/themes/minimal.css
index 16ee321..85ca08d 100644
--- a/app/themes/minimal.css
+++ b/app/themes/minimal.css
@@ -198,7 +198,7 @@
margin-bottom: 48px;
}
-[data-preset='minimal'] .subpage-toc__entry+.subpage-toc__entry::before {
+[data-preset='minimal'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '—';
padding: 0 10px;
font-size: 0.6rem;
@@ -242,4 +242,4 @@
[data-preset='minimal'] .subpage-section__rule {
background: var(--text-primary);
height: 2px;
-}
\ No newline at end of file
+}
diff --git a/app/themes/monograph.css b/app/themes/monograph.css
index b1a7eb5..3b38de9 100644
--- a/app/themes/monograph.css
+++ b/app/themes/monograph.css
@@ -164,7 +164,7 @@
}
/* Main: extra generous margins */
-[data-preset='monograph']~body .main {
+[data-preset='monograph'] ~ body .main {
padding: 80px 60px 120px;
}
@@ -203,7 +203,7 @@
border-bottom: none;
}
-[data-preset='monograph'] .subpage-toc__entry+.subpage-toc__entry::before {
+[data-preset='monograph'] .subpage-toc__entry + .subpage-toc__entry::before {
display: none;
}
@@ -395,4 +395,4 @@
[data-preset='monograph'] .album-header__title {
font-size: 2rem;
}
-}
\ No newline at end of file
+}
diff --git a/app/themes/noir.css b/app/themes/noir.css
index 66ce7b7..8ebe27a 100644
--- a/app/themes/noir.css
+++ b/app/themes/noir.css
@@ -111,7 +111,7 @@
margin-bottom: 56px;
}
-[data-preset='noir'] .subpage-toc__entry+.subpage-toc__entry::before {
+[data-preset='noir'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '|';
padding: 0 12px;
font-family: var(--font-caption);
@@ -161,4 +161,4 @@
background: linear-gradient(to right, var(--accent), transparent);
opacity: 0.35;
height: 1px;
-}
\ No newline at end of file
+}
diff --git a/app/tokens.css b/app/tokens.css
index 2f889b2..b57ebb8 100644
--- a/app/tokens.css
+++ b/app/tokens.css
@@ -251,4 +251,4 @@ button:focus-visible,
input:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 2px;
-}
\ No newline at end of file
+}
diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css
index 94bf362..7af929a 100644
--- a/components/Lightbox.module.css
+++ b/components/Lightbox.module.css
@@ -55,7 +55,9 @@
-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);
+ transition:
+ opacity var(--transition-normal),
+ transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.imageLoaded {
@@ -251,7 +253,6 @@
}
@media (hover: none) {
-
.close,
.nav,
.counter {
@@ -265,4 +266,4 @@
.image {
border-radius: 0;
-}
\ No newline at end of file
+}
diff --git a/docs/gallery-config.md b/docs/gallery-config.md
index d88be2a..260e748 100644
--- a/docs/gallery-config.md
+++ b/docs/gallery-config.md
@@ -7,6 +7,7 @@ cp content/gallery.yaml.example content/gallery.yaml
```
**Contents:**
+
- [Hero Images](#hero-images)
- [Standalone Albums](#standalone-albums)
- [Subpages & Categories](#subpages)
@@ -33,7 +34,8 @@ hero:
## Standalone Albums
-Albums shown directly on the homepage as cards.
+Albums shown directly on the homepage as cards.
+
> [!NOTE]
> The thumbnail image for each album grid card is automatically synced with the explicit **\"Cover Image\"** you select for that album inside the Immich Web UI.
@@ -50,15 +52,15 @@ The overall **Subpage Cover Image** shown on the homepage is automatically inher
```yaml
subpages:
- - name: Japan # → /japan
- title: "Trip to Japan" # Optional: overrides the page heading
- subtitle: "2024 adventures" # Optional: adds a subline under the heading
+ - name: Japan # → /japan
+ title: 'Trip to Japan' # Optional: overrides the page heading
+ subtitle: '2024 adventures' # Optional: adds a subline under the heading
albums:
- - 33333333-3333-3333-3333-333333333333 # Tokyo
- - 44444444-4444-4444-4444-444444444444 # Kyoto
+ - 33333333-3333-3333-3333-333333333333 # Tokyo
+ - 44444444-4444-4444-4444-444444444444 # Kyoto
- - name: Wedding – Smith # → /wedding-smith
- password: clientpass123 # Optional: password-protected
+ - name: Wedding – Smith # → /wedding-smith
+ password: clientpass123 # Optional: password-protected
albums:
- 55555555-5555-5555-5555-555555555555
```
@@ -67,9 +69,9 @@ Alternatively, you can use the object notation (recommended):
```yaml
subpages:
- "Japan":
- title: "Trip to Japan"
- subtitle: "2024 adventures"
+ 'Japan':
+ title: 'Trip to Japan'
+ subtitle: '2024 adventures'
albums:
- 33333333-3333-3333-3333-333333333333
```
@@ -80,18 +82,18 @@ When a subpage contains many albums you can split them into **named sections**.
Each section can have:
-| Field | Type | Required | Description |
-|---|---|---|---|
-| `title` | string | ✅ | Section heading + anchor name |
-| `description` | string | ➖ | Optional subline under the heading |
-| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
+| Field | Type | Required | Description |
+| ------------- | ------ | -------- | ------------------------------------------- |
+| `title` | string | ✅ | Section heading + anchor name |
+| `description` | string | ➖ | Optional subline under the heading |
+| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
Within the `albums` list you can use a plain UUID or override the display title:
```yaml
albums:
- - "album-uuid" # → uses the Immich album name
- - "album-uuid": My Title # → displays "My Title" instead
+ - 'album-uuid' # → uses the Immich album name
+ - 'album-uuid': My Title # → displays "My Title" instead
```
**Full example:**
@@ -100,29 +102,28 @@ albums:
subpages:
- name: Japan
title: Japan
- subtitle: "Reisen durch ein Land voller Kontraste."
+ subtitle: 'Reisen durch ein Land voller Kontraste.'
sections:
- title: Tokyo
- description: "Megacity, Neonlichter, Stille im Lärm."
+ description: 'Megacity, Neonlichter, Stille im Lärm.'
albums:
- - "33333333-3333-3333-3333-333333333333"
- - "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht
+ - '33333333-3333-3333-3333-333333333333'
+ - '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht
- title: Kyoto
- description: "Tempel und Bambuswälder."
+ description: 'Tempel und Bambuswälder.'
albums:
- - "55555555-5555-5555-5555-555555555555": Fushimi Inari
- - "66666666-6666-6666-6666-666666666666"
+ - '55555555-5555-5555-5555-555555555555': Fushimi Inari
+ - '66666666-6666-6666-6666-666666666666'
- title: Osaka
- albums: # description is optional
- - "77777777-7777-7777-7777-777777777777"
+ albums: # description is optional
+ - '77777777-7777-7777-7777-777777777777'
```
> [!NOTE]
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
-
## Grid Layout
Configure the photo grid globally or per-subpage:
@@ -130,17 +131,17 @@ Configure the photo grid globally or per-subpage:
```yaml
# Global defaults
grid:
- columns: 3 # number of columns (default: 3)
- gap: 12 # gap in pixels (default: 12)
- aspectRatio: "1" # "1", "3/2", "auto", etc. (default: "1")
- layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
+ columns: 3 # number of columns (default: 3)
+ gap: 12 # gap in pixels (default: 12)
+ aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
+ layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
subpages:
- name: Japan
- grid: # per-subpage override
+ grid: # per-subpage override
columns: 4
layout: uniform
- aspectRatio: "3/2"
+ aspectRatio: '3/2'
albums:
- ...
```
@@ -150,7 +151,7 @@ subpages:
Show camera/lens/settings when hovering over photos (enabled by default):
```yaml
-exifOnHover: false # set to false to disable
+exifOnHover: false # set to false to disable
```
## Footer
diff --git a/docs/ideas.md b/docs/ideas.md
index 20f367f..3070883 100644
--- a/docs/ideas.md
+++ b/docs/ideas.md
@@ -4,14 +4,14 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
## 🏆 Quick Wins (High Impact / Low Effort)
-| Idea | Effort | Impact |
-|------|--------|--------|
-| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
-| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
-| **Download originals** | Low | High — client delivery (Button in lightbox) |
-| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
-| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
-| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
+| Idea | Effort | Impact |
+| ------------------------------ | ------ | -------------------------------------------------- |
+| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
+| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
+| **Download originals** | Low | High — client delivery (Button in lightbox) |
+| **Photo of the Day** | Low | Medium — return visits (Auto-rotate hero image) |
+| **Keyboard shortcut hints** | Low | Medium — `?` button for shortcuts (arrows, Esc, F) |
+| **Photo sharing** | Low | Medium — "Copy link" button in lightbox |
---
@@ -29,7 +29,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
## 📖 Deep Dive Feature Plan: Photo Stories / Essays
-*Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator.*
+_Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator._
### Proposed Architecture
diff --git a/docs/theming.md b/docs/theming.md
index a6fcca2..89ab4d5 100644
--- a/docs/theming.md
+++ b/docs/theming.md
@@ -14,18 +14,19 @@ That's it. Your entire gallery switches to the minimal theme.
## Built-in Presets
-| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
-| ------------- | ------------------------------ | ------------ | ---------------------------------- | ----------- | ------------ | ----- |
-| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
-| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
-| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
-| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
-| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
-| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
+| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
+| ------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
+| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
+| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
+| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
+| **classic** | Gilded gallery, warm luxury | 🟡 `#c49a3c` | Cinzel + Crimson Pro | Minimal | Passepartout | ✗ |
+| **noir** | Darkroom analog, film noir | 🟠 `#ff6b35` | Libre Baskerville + Source Sans 3 | Fullbleed | Passepartout | ✓ |
+| **monograph** | Typographic, book-like | ⬛ `#333333` | Instrument Serif + Inter | Typographic | None | ✗ |
Default is `studio` if no theme is specified.
### Theme Gallery
+
_From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
@@ -38,6 +39,7 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
### Grid View Examples
+
diff --git a/hooks/useExif.ts b/hooks/useExif.ts
index 6319a39..dca9bb3 100644
--- a/hooks/useExif.ts
+++ b/hooks/useExif.ts
@@ -1,34 +1,34 @@
import { useState, useCallback } from 'react';
export interface ExifData {
- make?: string | null;
- model?: string | null;
- lensModel?: string | null;
- focalLength?: number | null;
- fNumber?: number | null;
- exposureTime?: string | null;
- iso?: number | null;
- city?: string | null;
- country?: string | null;
+ make?: string | null;
+ model?: string | null;
+ lensModel?: string | null;
+ focalLength?: number | null;
+ fNumber?: number | null;
+ exposureTime?: string | null;
+ iso?: number | null;
+ city?: string | null;
+ country?: string | null;
}
export function useExif() {
- const [exifData, setExifData] = useState(null);
- const [exifLoading, setExifLoading] = useState(false);
+ const [exifData, setExifData] = useState(null);
+ const [exifLoading, setExifLoading] = useState(false);
- const fetchExif = useCallback((url: string) => {
- setExifLoading(true);
- setExifData(null);
- fetch(url)
- .then((res) => (res.ok ? res.json() : null))
- .then((data) => setExifData(data))
- .catch(() => setExifData(null))
- .finally(() => setExifLoading(false));
- }, []);
+ const fetchExif = useCallback((url: string) => {
+ setExifLoading(true);
+ setExifData(null);
+ fetch(url)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => setExifData(data))
+ .catch(() => setExifData(null))
+ .finally(() => setExifLoading(false));
+ }, []);
- const clearExif = useCallback(() => {
- setExifData(null);
- }, []);
+ const clearExif = useCallback(() => {
+ setExifData(null);
+ }, []);
- return { exifData, exifLoading, fetchExif, clearExif };
+ return { exifData, exifLoading, fetchExif, clearExif };
}
diff --git a/hooks/useSwipe.ts b/hooks/useSwipe.ts
index 2f06da6..60061f4 100644
--- a/hooks/useSwipe.ts
+++ b/hooks/useSwipe.ts
@@ -1,32 +1,32 @@
import { useRef, useCallback } from 'react';
interface UseSwipeProps {
- onSwipeLeft: () => void;
- onSwipeRight: () => void;
- threshold?: number;
+ onSwipeLeft: () => void;
+ onSwipeRight: () => void;
+ threshold?: number;
}
export function useSwipe({ onSwipeLeft, onSwipeRight, threshold = 60 }: UseSwipeProps) {
- const touchStartX = useRef(0);
- const touchStartY = useRef(0);
+ const touchStartX = useRef(0);
+ const touchStartY = useRef(0);
- const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
- touchStartX.current = e.touches[0].clientX;
- touchStartY.current = e.touches[0].clientY;
- }, []);
+ const handleTouchStart = useCallback((e: React.TouchEvent | TouchEvent) => {
+ touchStartX.current = e.touches[0].clientX;
+ touchStartY.current = e.touches[0].clientY;
+ }, []);
- const handleTouchEnd = useCallback(
- (e: React.TouchEvent | TouchEvent) => {
- const dx = e.changedTouches[0].clientX - touchStartX.current;
- const dy = e.changedTouches[0].clientY - touchStartY.current;
+ const handleTouchEnd = useCallback(
+ (e: React.TouchEvent | TouchEvent) => {
+ const dx = e.changedTouches[0].clientX - touchStartX.current;
+ const dy = e.changedTouches[0].clientY - touchStartY.current;
- if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) {
- if (dx > 0) onSwipeRight();
- else onSwipeLeft();
- }
- },
- [onSwipeLeft, onSwipeRight, threshold],
- );
+ if (Math.abs(dx) > threshold && Math.abs(dx) > Math.abs(dy)) {
+ if (dx > 0) onSwipeRight();
+ else onSwipeLeft();
+ }
+ },
+ [onSwipeLeft, onSwipeRight, threshold],
+ );
- return { handleTouchStart, handleTouchEnd };
+ return { handleTouchStart, handleTouchEnd };
}
diff --git a/lib/auth.ts b/lib/auth.ts
index c01307f..3f02903 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -85,9 +85,9 @@ export function authenticate(slug: string, password: string): string | null {
if (isBcryptHash(sp.password)) {
console.error(
`\n❌ SECURITY ERROR: Subpage "${slug}" is using an outdated bcrypt password hash.\n` +
- ` Bcrypt dependency has been removed to reduce bundle size.\n` +
- ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
- ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
+ ` Bcrypt dependency has been removed to reduce bundle size.\n` +
+ ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` +
+ ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`,
);
return null;
}
@@ -101,7 +101,7 @@ export function authenticate(slug: string, password: string): string | null {
const recommendedHash = generateScryptHash(sp.password);
console.warn(
`\n⚠️ SECURITY WARNING: Subpage "${slug}" is using a plaintext password in gallery.yaml.\n` +
- ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
+ ` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
);
}
}
diff --git a/lib/config/index.ts b/lib/config/index.ts
index d6dee36..0606645 100644
--- a/lib/config/index.ts
+++ b/lib/config/index.ts
@@ -1,195 +1,265 @@
import { env } from '../env';
import { loadYaml, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
-import { slugify, AppConfig, SubpageConfig, SubpageSectionConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema';
+import {
+ slugify,
+ AppConfig,
+ SubpageConfig,
+ SubpageSectionConfig,
+ GalleryYaml,
+ SettingsYaml,
+ GridConfig,
+} from './schema';
export * from './schema';
export * from './theme';
let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
- const isDev = process.env.NODE_ENV !== 'production';
- if (_config && !isDev) return _config;
+ const isDev = process.env.NODE_ENV !== 'production';
+ if (_config && !isDev) return _config;
- const apiUrl = env.IMMICH_API_URL;
- const apiKey = env.IMMICH_API_KEY;
- const authSecret = env.AUTH_SECRET || apiKey;
+ const apiUrl = env.IMMICH_API_URL;
+ const apiKey = env.IMMICH_API_KEY;
+ const authSecret = env.AUTH_SECRET || apiKey;
- if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
- console.warn('\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');
- }
+ if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
+ console.warn(
+ '\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',
+ );
+ }
- const gallery = loadYaml('gallery.yaml');
- const settings = loadYaml('settings.yaml') || {};
-
- if (!gallery || !apiKey || !apiUrl) {
- // Return dummy config if gallery.yaml is missing
- _config = {
- immich: { apiUrl: `${apiUrl}/api`, apiKey },
- authSecret,
- albums: [],
- standaloneAlbums: [],
- subpages: [],
- siteTitle: env.SITE_TITLE || 'Immich Folio',
- siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
- seo: { title: 'Setup Required', description: 'Please configure Immich Folio', noIndex: true, noFollow: true },
- heroImages: [],
- exifOnHover: true,
- grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
- theme: resolveTheme('studio'),
- footer: null,
- legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
- map: false,
- transitions: false,
- albumOverrides: {},
- cacheTtl: env.CACHE_TTL * 1000,
- rateLimitRpm: env.RATE_LIMIT_RPM,
- needsSetup: true
- };
- return _config;
- }
-
- const theme = resolveTheme(settings.theme);
-
- const albumOverrides: Record = {};
-
- function processAlbumEntry(entry: string | Record, context: string): string {
- if (typeof entry === 'string') {
- return validateUuid(entry, context);
- }
- const [uuid, name] = Object.entries(entry)[0];
- const validatedUuid = validateUuid(uuid, context);
- albumOverrides[validatedUuid] = name;
- return validatedUuid;
- }
-
- const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => processAlbumEntry(entry, 'gallery.yaml albums'));
- if (standaloneAlbumIds.length === 0 && (!gallery.subpages || (Array.isArray(gallery.subpages) ? gallery.subpages.length === 0 : Object.keys(gallery.subpages).length === 0))) {
- throw new Error('gallery.yaml must define at least one album or subpage');
- }
-
- let subpages: SubpageConfig[] = [];
-
- if (Array.isArray(gallery.subpages)) {
- subpages = gallery.subpages.map((sp) => {
- if (!sp.name) {
- throw new Error(`Subpage "(unnamed)" must have a name`);
- }
-
- // Parse sections if present
- let sections: SubpageSectionConfig[] | undefined;
- let albumIds: string[];
-
- if (sp.sections && sp.sections.length > 0) {
- sections = sp.sections.map((sec) => ({
- title: sec.title,
- slug: slugify(sec.title),
- description: sec.description,
- albumIds: sec.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`)),
- }));
- // flat union of all section albums
- albumIds = sections.flatMap((s) => s.albumIds);
- // Also include any top-level albums (outside sections)
- const topLevel = (sp.albums ?? []).map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
- albumIds = [...topLevel, ...albumIds];
- } else {
- const albums = sp.albums ?? [];
- if (albums.length === 0) {
- throw new Error(`Subpage "${sp.name}" must have albums or sections`);
- }
- albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
- }
-
- return {
- name: sp.name,
- slug: slugify(sp.name),
- title: sp.title,
- subtitle: sp.subtitle,
- albumIds,
- sections,
- password: sp.password,
- ...(sp.grid ? {
- grid: {
- ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
- ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
- ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
- ...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
- },
- } : {}),
- };
- });
- } else if (gallery.subpages) {
- subpages = Object.entries(gallery.subpages).map(([name, value]) => {
- if (Array.isArray(value)) {
- return { name, slug: slugify(name), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)) };
- }
- interface SubpageObjectValue { title?: string; subtitle?: string; albums?: Array>; password?: string; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; }
- const sp = value as SubpageObjectValue;
- const albumEntries = sp.albums || [];
- return {
- name,
- slug: slugify(name),
- title: sp.title,
- subtitle: sp.subtitle,
- albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
- password: sp.password,
- ...(sp.grid ? {
- grid: {
- ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
- ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
- ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
- ...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}),
- },
- } : {}),
- };
- });
- }
-
- const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
- const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
+ const gallery = loadYaml('gallery.yaml');
+ const settings = loadYaml('settings.yaml') || {};
+ if (!gallery || !apiKey || !apiUrl) {
+ // Return dummy config if gallery.yaml is missing
_config = {
- immich: { apiUrl: `${apiUrl}/api`, apiKey },
- authSecret,
- albums: allAlbumIds,
- standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
- subpages,
- siteTitle: settings.title ?? env.SITE_TITLE,
- siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
- seo: {
- title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
- description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio',
- noIndex: settings.seo?.noIndex === true,
- noFollow: settings.seo?.noFollow === true,
- },
- heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => validateUuid(id, 'gallery.yaml hero')) : [],
- exifOnHover: settings.exifOnHover !== false,
- grid: {
- columns: settings.grid?.columns ?? 3,
- gap: settings.grid?.gap ?? 12,
- aspectRatio: settings.grid?.aspectRatio ?? '1',
- layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '') ? settings.grid!.layout : 'masonry') as GridConfig['layout'],
- },
- theme,
- footer: settings.footer ? { name: settings.footer.name, instagram: settings.footer.instagram, email: settings.footer.email, website: settings.footer.website } : null,
- legal: {
- enabled: settings.legal?.enabled === true,
- name: settings.legal?.name || '',
- address: settings.legal?.address || '',
- zipCity: settings.legal?.zipCity || '',
- country: settings.legal?.country || '',
- email: settings.legal?.email,
- phone: settings.legal?.phone,
- taxId: settings.legal?.taxId,
- vatId: settings.legal?.vatId,
- extraInfo: settings.legal?.extraInfo,
- },
- map: settings.map === true,
- transitions: settings.transitions !== false,
- albumOverrides,
- cacheTtl: env.CACHE_TTL * 1000,
- rateLimitRpm: env.RATE_LIMIT_RPM,
- needsSetup: false
+ immich: { apiUrl: `${apiUrl}/api`, apiKey },
+ authSecret,
+ albums: [],
+ standaloneAlbums: [],
+ subpages: [],
+ siteTitle: env.SITE_TITLE || 'Immich Folio',
+ siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
+ seo: {
+ title: 'Setup Required',
+ description: 'Please configure Immich Folio',
+ noIndex: true,
+ noFollow: true,
+ },
+ heroImages: [],
+ exifOnHover: true,
+ grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
+ theme: resolveTheme('studio'),
+ footer: null,
+ legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
+ map: false,
+ transitions: false,
+ albumOverrides: {},
+ cacheTtl: env.CACHE_TTL * 1000,
+ rateLimitRpm: env.RATE_LIMIT_RPM,
+ needsSetup: true,
};
return _config;
+ }
+
+ const theme = resolveTheme(settings.theme);
+
+ const albumOverrides: Record = {};
+
+ function processAlbumEntry(entry: string | Record, context: string): string {
+ if (typeof entry === 'string') {
+ return validateUuid(entry, context);
+ }
+ const [uuid, name] = Object.entries(entry)[0];
+ const validatedUuid = validateUuid(uuid, context);
+ albumOverrides[validatedUuid] = name;
+ return validatedUuid;
+ }
+
+ const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
+ processAlbumEntry(entry, 'gallery.yaml albums'),
+ );
+ if (
+ standaloneAlbumIds.length === 0 &&
+ (!gallery.subpages ||
+ (Array.isArray(gallery.subpages)
+ ? gallery.subpages.length === 0
+ : Object.keys(gallery.subpages).length === 0))
+ ) {
+ throw new Error('gallery.yaml must define at least one album or subpage');
+ }
+
+ let subpages: SubpageConfig[] = [];
+
+ if (Array.isArray(gallery.subpages)) {
+ subpages = gallery.subpages.map((sp) => {
+ if (!sp.name) {
+ throw new Error(`Subpage "(unnamed)" must have a name`);
+ }
+
+ // Parse sections if present
+ let sections: SubpageSectionConfig[] | undefined;
+ let albumIds: string[];
+
+ if (sp.sections && sp.sections.length > 0) {
+ sections = sp.sections.map((sec) => ({
+ title: sec.title,
+ slug: slugify(sec.title),
+ description: sec.description,
+ albumIds: sec.albums.map((entry) =>
+ processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
+ ),
+ }));
+ // flat union of all section albums
+ albumIds = sections.flatMap((s) => s.albumIds);
+ // Also include any top-level albums (outside sections)
+ const topLevel = (sp.albums ?? []).map((entry) =>
+ processAlbumEntry(entry, `subpage "${sp.name}"`),
+ );
+ albumIds = [...topLevel, ...albumIds];
+ } else {
+ const albums = sp.albums ?? [];
+ if (albums.length === 0) {
+ throw new Error(`Subpage "${sp.name}" must have albums or sections`);
+ }
+ albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
+ }
+
+ return {
+ name: sp.name,
+ slug: slugify(sp.name),
+ title: sp.title,
+ subtitle: sp.subtitle,
+ albumIds,
+ sections,
+ password: sp.password,
+ ...(sp.grid
+ ? {
+ grid: {
+ ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
+ ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
+ ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
+ ...(sp.grid.layout != null
+ ? {
+ layout: (VALID_LAYOUTS.includes(sp.grid.layout)
+ ? sp.grid.layout
+ : 'masonry') as GridConfig['layout'],
+ }
+ : {}),
+ },
+ }
+ : {}),
+ };
+ });
+ } else if (gallery.subpages) {
+ subpages = Object.entries(gallery.subpages).map(([name, value]) => {
+ if (Array.isArray(value)) {
+ return {
+ name,
+ slug: slugify(name),
+ albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
+ };
+ }
+ interface SubpageObjectValue {
+ title?: string;
+ subtitle?: string;
+ albums?: Array>;
+ password?: string;
+ grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
+ }
+ const sp = value as SubpageObjectValue;
+ const albumEntries = sp.albums || [];
+ return {
+ name,
+ slug: slugify(name),
+ title: sp.title,
+ subtitle: sp.subtitle,
+ albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
+ password: sp.password,
+ ...(sp.grid
+ ? {
+ grid: {
+ ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
+ ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
+ ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
+ ...(sp.grid.layout != null
+ ? {
+ layout: (VALID_LAYOUTS.includes(sp.grid.layout)
+ ? sp.grid.layout
+ : 'masonry') as GridConfig['layout'],
+ }
+ : {}),
+ },
+ }
+ : {}),
+ };
+ });
+ }
+
+ const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
+ const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
+
+ _config = {
+ immich: { apiUrl: `${apiUrl}/api`, apiKey },
+ authSecret,
+ albums: allAlbumIds,
+ standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
+ subpages,
+ siteTitle: settings.title ?? env.SITE_TITLE,
+ siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
+ seo: {
+ title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
+ description:
+ settings.seo?.description ||
+ settings.subtitle ||
+ env.SITE_SUBTITLE ||
+ 'A curated photography portfolio',
+ noIndex: settings.seo?.noIndex === true,
+ noFollow: settings.seo?.noFollow === true,
+ },
+ heroImages: gallery.hero
+ ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
+ validateUuid(id, 'gallery.yaml hero'),
+ )
+ : [],
+ exifOnHover: settings.exifOnHover !== false,
+ grid: {
+ columns: settings.grid?.columns ?? 3,
+ gap: settings.grid?.gap ?? 12,
+ aspectRatio: settings.grid?.aspectRatio ?? '1',
+ layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
+ ? settings.grid!.layout
+ : 'masonry') as GridConfig['layout'],
+ },
+ theme,
+ footer: settings.footer
+ ? {
+ name: settings.footer.name,
+ instagram: settings.footer.instagram,
+ email: settings.footer.email,
+ website: settings.footer.website,
+ }
+ : null,
+ legal: {
+ enabled: settings.legal?.enabled === true,
+ name: settings.legal?.name || '',
+ address: settings.legal?.address || '',
+ zipCity: settings.legal?.zipCity || '',
+ country: settings.legal?.country || '',
+ email: settings.legal?.email,
+ phone: settings.legal?.phone,
+ taxId: settings.legal?.taxId,
+ vatId: settings.legal?.vatId,
+ extraInfo: settings.legal?.extraInfo,
+ },
+ map: settings.map === true,
+ transitions: settings.transitions !== false,
+ albumOverrides,
+ cacheTtl: env.CACHE_TTL * 1000,
+ rateLimitRpm: env.RATE_LIMIT_RPM,
+ needsSetup: false,
+ };
+ return _config;
}
diff --git a/lib/config/parser.ts b/lib/config/parser.ts
index 4bdec1a..2820fe6 100644
--- a/lib/config/parser.ts
+++ b/lib/config/parser.ts
@@ -3,25 +3,25 @@ import path from 'path';
import yaml from 'js-yaml';
export function loadYaml(filename: string): T | null {
- const yamlPath = path.join(process.cwd(), 'content', filename);
+ const yamlPath = path.join(process.cwd(), 'content', filename);
- if (!fs.existsSync(yamlPath)) {
- return null;
- }
+ if (!fs.existsSync(yamlPath)) {
+ return null;
+ }
- const raw = fs.readFileSync(yamlPath, 'utf8');
- return (yaml.load(raw) || {}) as T;
+ const raw = fs.readFileSync(yamlPath, 'utf8');
+ return (yaml.load(raw) || {}) as T;
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function validateUuid(id: string, context: string): string {
- const trimmed = id.trim();
- if (!UUID_REGEX.test(trimmed)) {
- // If we are in setup mode or using dummy data, don't crash the build.
- // Return a valid dummy UUID so Next.js doesn't panic.
- console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
- return '00000000-0000-0000-0000-000000000000';
- }
- return trimmed;
+ const trimmed = id.trim();
+ if (!UUID_REGEX.test(trimmed)) {
+ // If we are in setup mode or using dummy data, don't crash the build.
+ // Return a valid dummy UUID so Next.js doesn't panic.
+ console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
+ return '00000000-0000-0000-0000-000000000000';
+ }
+ return trimmed;
}
diff --git a/lib/config/schema.ts b/lib/config/schema.ts
index 8f06c34..0f2c867 100644
--- a/lib/config/schema.ts
+++ b/lib/config/schema.ts
@@ -1,91 +1,91 @@
export interface SubpageSectionConfig {
- title: string;
- slug: string; // auto-derived from title, used as anchor
- description?: string;
- albumIds: string[];
+ title: string;
+ slug: string; // auto-derived from title, used as anchor
+ description?: string;
+ albumIds: string[];
}
export interface SubpageConfig {
- name: string;
- slug: string;
- title?: string;
- subtitle?: string;
- albumIds: string[]; // flat list (all albums, incl. from sections)
- sections?: SubpageSectionConfig[];
- password?: string;
- grid?: Partial;
+ name: string;
+ slug: string;
+ title?: string;
+ subtitle?: string;
+ albumIds: string[]; // flat list (all albums, incl. from sections)
+ sections?: SubpageSectionConfig[];
+ password?: string;
+ grid?: Partial;
}
export interface FooterConfig {
- name?: string;
- instagram?: string;
- email?: string;
- website?: string;
+ name?: string;
+ instagram?: string;
+ email?: string;
+ website?: string;
}
export interface LegalConfig {
- enabled: boolean;
- name: string;
- address: string;
- zipCity: string;
- country: string;
- email?: string;
- phone?: string;
- taxId?: string;
- vatId?: string;
- extraInfo?: string;
+ enabled: boolean;
+ name: string;
+ address: string;
+ zipCity: string;
+ country: string;
+ email?: string;
+ phone?: string;
+ taxId?: string;
+ vatId?: string;
+ extraInfo?: string;
}
export interface ThemeConfig {
- preset: string;
- accent: string;
- fonts: { heading: string; body: string; caption: string };
- radius: number;
- photoFrame: 'none' | 'passepartout' | 'shadow';
- grain: boolean;
- headerDot: boolean;
- heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
+ preset: string;
+ accent: string;
+ fonts: { heading: string; body: string; caption: string };
+ radius: number;
+ photoFrame: 'none' | 'passepartout' | 'shadow';
+ grain: boolean;
+ headerDot: boolean;
+ heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
}
export interface GridConfig {
- columns: number;
- gap: number;
- aspectRatio: string;
- layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
+ columns: number;
+ gap: number;
+ aspectRatio: string;
+ layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
}
export interface AppConfig {
- immich: { apiUrl: string; apiKey: string };
- authSecret: string;
- albums: string[];
- standaloneAlbums: string[];
- subpages: SubpageConfig[];
- siteTitle: string;
- siteSubtitle: string;
- seo: {
- title: string;
- description: string;
- noIndex: boolean;
- noFollow: boolean;
- };
- heroImages: string[];
- exifOnHover: boolean;
- grid: GridConfig;
- theme: ThemeConfig;
- footer: FooterConfig | null;
- legal: LegalConfig;
- map: boolean;
- transitions: boolean;
- albumOverrides: Record;
- cacheTtl: number;
- rateLimitRpm: number;
- needsSetup?: boolean;
+ immich: { apiUrl: string; apiKey: string };
+ authSecret: string;
+ albums: string[];
+ standaloneAlbums: string[];
+ subpages: SubpageConfig[];
+ siteTitle: string;
+ siteSubtitle: string;
+ seo: {
+ title: string;
+ description: string;
+ noIndex: boolean;
+ noFollow: boolean;
+ };
+ heroImages: string[];
+ exifOnHover: boolean;
+ grid: GridConfig;
+ theme: ThemeConfig;
+ footer: FooterConfig | null;
+ legal: LegalConfig;
+ map: boolean;
+ transitions: boolean;
+ albumOverrides: Record;
+ cacheTtl: number;
+ rateLimitRpm: number;
+ needsSetup?: boolean;
}
export interface GalleryYaml {
- hero?: string | string[];
- albums?: string[];
- subpages?:
+ hero?: string | string[];
+ albums?: string[];
+ subpages?:
| Record>>
| Array<{
name: string;
@@ -93,33 +93,33 @@ export interface GalleryYaml {
subtitle?: string;
albums?: Array>;
sections?: Array<{
- title: string;
- description?: string;
- albums: Array>;
+ title: string;
+ description?: string;
+ albums: Array>;
}>;
password?: string;
grid?: {
- columns?: number;
- gap?: number;
- aspectRatio?: string;
- layout?: string;
+ columns?: number;
+ gap?: number;
+ aspectRatio?: string;
+ layout?: string;
};
- }>;
+ }>;
}
export interface SettingsYaml {
+ title?: string;
+ subtitle?: string;
+ seo?: {
title?: string;
- subtitle?: string;
- seo?: {
- title?: string;
- description?: string;
- noIndex?: boolean;
- noFollow?: boolean;
- };
- exifOnHover?: boolean;
- map?: boolean;
- transitions?: boolean;
- theme?:
+ description?: string;
+ noIndex?: boolean;
+ noFollow?: boolean;
+ };
+ exifOnHover?: boolean;
+ map?: boolean;
+ transitions?: boolean;
+ theme?:
| string
| {
preset?: string;
@@ -130,22 +130,22 @@ export interface SettingsYaml {
grain?: boolean;
headerDot?: boolean;
heroStyle?: string;
- };
- grid?: {
- columns?: number;
- gap?: number;
- aspectRatio?: string;
- layout?: string;
- };
- footer?: FooterConfig;
- legal?: Partial;
+ };
+ grid?: {
+ columns?: number;
+ gap?: number;
+ aspectRatio?: string;
+ layout?: string;
+ };
+ footer?: FooterConfig;
+ legal?: Partial;
}
export function slugify(name: string): string {
- return name
- .toLowerCase()
- .normalize('NFD')
- .replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-+|-+$/g, '');
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
}
diff --git a/lib/config/theme.ts b/lib/config/theme.ts
index 411beff..231ee92 100644
--- a/lib/config/theme.ts
+++ b/lib/config/theme.ts
@@ -1,122 +1,133 @@
import type { ThemeConfig, SettingsYaml } from './schema';
export const THEME_PRESETS: Record = {
- studio: {
- preset: 'studio',
- accent: '#e60012',
- fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
- radius: 0,
- photoFrame: 'passepartout',
- grain: true,
- headerDot: true,
- heroStyle: 'split',
- },
- minimal: {
- preset: 'minimal',
- accent: '#000000',
- fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
- radius: 0,
- photoFrame: 'none',
- grain: false,
- headerDot: false,
- heroStyle: 'fullbleed',
- },
- editorial: {
- preset: 'editorial',
- accent: '#8B2500',
- fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
- radius: 0,
- photoFrame: 'shadow',
- grain: false,
- headerDot: false,
- heroStyle: 'split',
- },
- classic: {
- preset: 'classic',
- accent: '#c49a3c',
- fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
- radius: 12,
- photoFrame: 'passepartout',
- grain: false,
- headerDot: true,
- heroStyle: 'minimal',
- },
- noir: {
- preset: 'noir',
- accent: '#ff6b35',
- fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
- radius: 0,
- photoFrame: 'passepartout',
- grain: true,
- headerDot: false,
- heroStyle: 'fullbleed',
- },
- monograph: {
- preset: 'monograph',
- accent: '#333333',
- fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
- radius: 0,
- photoFrame: 'none',
- grain: false,
- headerDot: false,
- heroStyle: 'typographic',
- },
- botanica: {
- preset: 'botanica',
- accent: '#4a7c59',
- fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
- radius: 8,
- photoFrame: 'none',
- grain: false,
- headerDot: true,
- heroStyle: 'split',
- },
+ studio: {
+ preset: 'studio',
+ accent: '#e60012',
+ fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
+ radius: 0,
+ photoFrame: 'passepartout',
+ grain: true,
+ headerDot: true,
+ heroStyle: 'split',
+ },
+ minimal: {
+ preset: 'minimal',
+ accent: '#000000',
+ fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
+ radius: 0,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'fullbleed',
+ },
+ editorial: {
+ preset: 'editorial',
+ accent: '#8B2500',
+ fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
+ radius: 0,
+ photoFrame: 'shadow',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'split',
+ },
+ classic: {
+ preset: 'classic',
+ accent: '#c49a3c',
+ fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
+ radius: 12,
+ photoFrame: 'passepartout',
+ grain: false,
+ headerDot: true,
+ heroStyle: 'minimal',
+ },
+ noir: {
+ preset: 'noir',
+ accent: '#ff6b35',
+ fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
+ radius: 0,
+ photoFrame: 'passepartout',
+ grain: true,
+ headerDot: false,
+ heroStyle: 'fullbleed',
+ },
+ monograph: {
+ preset: 'monograph',
+ accent: '#333333',
+ fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
+ radius: 0,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: false,
+ heroStyle: 'typographic',
+ },
+ botanica: {
+ preset: 'botanica',
+ accent: '#4a7c59',
+ fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
+ radius: 8,
+ photoFrame: 'none',
+ grain: false,
+ headerDot: true,
+ heroStyle: 'split',
+ },
};
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
-export const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
+export const VALID_HERO_STYLES = [
+ 'split',
+ 'fullbleed',
+ 'minimal',
+ 'stacked',
+ 'typographic',
+ 'mosaic',
+];
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
- if (!raw) return { ...THEME_PRESETS.studio };
+ if (!raw) return { ...THEME_PRESETS.studio };
- if (typeof raw === 'string') {
- const preset = THEME_PRESETS[raw];
- if (!preset) {
- throw new Error(`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`);
- }
- return { ...preset };
+ if (typeof raw === 'string') {
+ const preset = THEME_PRESETS[raw];
+ if (!preset) {
+ throw new Error(
+ `Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
+ );
}
+ return { ...preset };
+ }
- const baseName = raw.preset ?? 'studio';
- const base = THEME_PRESETS[baseName];
- if (!base) {
- throw new Error(`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`);
- }
+ const baseName = raw.preset ?? 'studio';
+ const base = THEME_PRESETS[baseName];
+ if (!base) {
+ throw new Error(
+ `Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
+ );
+ }
- return {
- preset: baseName,
- accent: raw.accent ?? base.accent,
- fonts: {
- heading: raw.fonts?.heading ?? base.fonts.heading,
- body: raw.fonts?.body ?? base.fonts.body,
- caption: raw.fonts?.caption ?? base.fonts.caption,
- },
- radius: raw.radius ?? base.radius,
- photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
- ? (raw.photoFrame as ThemeConfig['photoFrame'])
- : base.photoFrame,
- grain: raw.grain ?? base.grain,
- headerDot: raw.headerDot ?? base.headerDot,
- heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
- ? (raw.heroStyle as ThemeConfig['heroStyle'])
- : base.heroStyle,
- };
+ return {
+ preset: baseName,
+ accent: raw.accent ?? base.accent,
+ fonts: {
+ heading: raw.fonts?.heading ?? base.fonts.heading,
+ body: raw.fonts?.body ?? base.fonts.body,
+ caption: raw.fonts?.caption ?? base.fonts.caption,
+ },
+ radius: raw.radius ?? base.radius,
+ photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '')
+ ? (raw.photoFrame as ThemeConfig['photoFrame'])
+ : base.photoFrame,
+ grain: raw.grain ?? base.grain,
+ headerDot: raw.headerDot ?? base.headerDot,
+ heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '')
+ ? (raw.heroStyle as ThemeConfig['heroStyle'])
+ : base.heroStyle,
+ };
}
export function getGoogleFontsUrl(theme: ThemeConfig): string {
- const weights = '300;400;500;600';
- const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
- const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
- return `https://fonts.googleapis.com/css2?${params}&display=swap`;
+ const weights = '300;400;500;600';
+ const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])];
+ const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&');
+ return `https://fonts.googleapis.com/css2?${params}&display=swap`;
}
diff --git a/lib/env.ts b/lib/env.ts
index f2dd0e6..d479e6a 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -29,10 +29,12 @@ function parseEnv(): Env {
const apiKey = process.env.IMMICH_API_KEY || '';
const cacheTtlStr = process.env.CACHE_TTL;
- const cacheTtl = cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
+ const cacheTtl =
+ cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
const rateLimitStr = process.env.RATE_LIMIT_RPM;
- const rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
+ const rateLimit =
+ rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
return {
IMMICH_API_URL: apiUrl,
diff --git a/lib/immich.ts b/lib/immich.ts
index b980fc7..2b0a30e 100644
--- a/lib/immich.ts
+++ b/lib/immich.ts
@@ -55,7 +55,6 @@ export interface ImmichExifInfo {
description: string | null;
}
-
export type ImageSize = 'thumbnail' | 'preview' | 'original';
/** Enriched subpage with album metadata (for rendering cards). */
@@ -325,7 +324,6 @@ class ImmichClient {
return asset;
}
-
/**
* Check if the Immich server is reachable.
*/
diff --git a/lib/mapService.ts b/lib/mapService.ts
index aaa4458..a225270 100644
--- a/lib/mapService.ts
+++ b/lib/mapService.ts
@@ -4,13 +4,13 @@ import { cache } from './cache';
/** A clustered map marker — one per unique city/country. */
export interface MapLocation {
- city: string;
- country: string;
- lat: number;
- lng: number;
- photoCount: number;
- coverAssetId: string;
- albums: { name: string; slug: string; subpageSlug?: string }[];
+ city: string;
+ country: string;
+ lat: number;
+ lng: number;
+ photoCount: number;
+ coverAssetId: string;
+ albums: { name: string; slug: string; subpageSlug?: string }[];
}
/**
@@ -18,68 +18,68 @@ export interface MapLocation {
* Returns one entry per unique city+country with averaged lat/lng.
*/
export async function getMapData(): Promise {
- const config = getConfig();
- const cacheKey = 'map-data';
- const cached = cache.get(cacheKey);
- if (cached) return cached;
+ const config = getConfig();
+ const cacheKey = 'map-data';
+ const cached = cache.get(cacheKey);
+ if (cached) return cached;
- const albums = await immich.getAlbums();
+ const albums = await immich.getAlbums();
- // Build a lookup: album ID → { name, slug, subpageSlug? }
- const albumMeta = new Map();
- for (const a of albums) {
- // Check if this album belongs to a subpage
- const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
- albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
+ // Build a lookup: album ID → { name, slug, subpageSlug? }
+ const albumMeta = new Map();
+ for (const a of albums) {
+ // Check if this album belongs to a subpage
+ const sp = config.subpages.find((s) => s.albumIds.includes(a.id));
+ albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
+ }
+
+ // Fetch full album data (with assets) for each
+ const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id)));
+
+ // Bucket assets by city+country
+ const buckets = new Map<
+ string,
+ { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set }
+ >();
+
+ for (const album of fullAlbums) {
+ if (!album) continue;
+ for (const asset of album.assets) {
+ const exif = asset.exifInfo;
+ if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
+
+ const key = `${exif.city}|${exif.country}`;
+ let bucket = buckets.get(key);
+ if (!bucket) {
+ bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
+ buckets.set(key, bucket);
+ }
+ bucket.lats.push(exif.latitude);
+ bucket.lngs.push(exif.longitude);
+ bucket.count++;
+ bucket.albumIds.add(album.id);
}
+ }
- // Fetch full album data (with assets) for each
- const fullAlbums = await Promise.all(albums.map((a) => immich.getAlbum(a.id)));
+ // Convert buckets to MapLocation[]
+ const locations: MapLocation[] = [];
+ for (const [key, bucket] of buckets) {
+ const [city, country] = key.split('|');
+ const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
+ const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
+ locations.push({
+ city,
+ country,
+ lat,
+ lng,
+ photoCount: bucket.count,
+ coverAssetId: bucket.coverAssetId,
+ albums: [...bucket.albumIds]
+ .map((id) => albumMeta.get(id))
+ .filter((m): m is NonNullable => !!m),
+ });
+ }
- // Bucket assets by city+country
- const buckets = new Map<
- string,
- { lats: number[]; lngs: number[]; count: number; coverAssetId: string; albumIds: Set }
- >();
-
- for (const album of fullAlbums) {
- if (!album) continue;
- for (const asset of album.assets) {
- const exif = asset.exifInfo;
- if (!exif?.latitude || !exif?.longitude || !exif?.city || !exif?.country) continue;
-
- const key = `${exif.city}|${exif.country}`;
- let bucket = buckets.get(key);
- if (!bucket) {
- bucket = { lats: [], lngs: [], count: 0, coverAssetId: asset.id, albumIds: new Set() };
- buckets.set(key, bucket);
- }
- bucket.lats.push(exif.latitude);
- bucket.lngs.push(exif.longitude);
- bucket.count++;
- bucket.albumIds.add(album.id);
- }
- }
-
- // Convert buckets to MapLocation[]
- const locations: MapLocation[] = [];
- for (const [key, bucket] of buckets) {
- const [city, country] = key.split('|');
- const lat = bucket.lats.reduce((a, b) => a + b, 0) / bucket.lats.length;
- const lng = bucket.lngs.reduce((a, b) => a + b, 0) / bucket.lngs.length;
- locations.push({
- city,
- country,
- lat,
- lng,
- photoCount: bucket.count,
- coverAssetId: bucket.coverAssetId,
- albums: [...bucket.albumIds]
- .map((id) => albumMeta.get(id))
- .filter((m): m is NonNullable => !!m),
- });
- }
-
- cache.set(cacheKey, locations, config.cacheTtl);
- return locations;
+ cache.set(cacheKey, locations, config.cacheTtl);
+ return locations;
}