- Add Content-Security-Policy + security headers to next.config.ts
- Fix hero image cutoff on mobile iOS (40vh → 50svh)
- Mark all plan checklists as complete
- Clean up IDEAS.md: remove implemented items, add ✅ table
- Add photo stories implementation plan draft
8.4 KiB
Photo Stories / Essays
Add a Markdown-driven photo essay system to the gallery, allowing the photographer to create long-form vertical scroll pages that intersperse Immich images with narrative text — like a photo essay or editorial blog post.
Design Decisions
Important
Content authoring via Markdown files — follows the same pattern as the existing
content/about.md(frontmatter +gray-matter). Each story lives incontent/stories/<slug>.md. This keeps content in the repo, versioned with Git, and avoids any database or CMS dependency.
Important
Image references use Immich asset UUIDs — stories reference images by UUID (e.g.
). The renderer resolves these to proxied URLs with ThumbHash placeholders. No images are stored locally — they flow through the existing/api/imageproxy like everything else.
Warning
Stories are a separate content type from albums — they don't live under subpages. They get their own top-level routes at
/stories/<slug>and appear in the header nav and footer as a "Stories" link. This avoids polluting the album/subpage routing logic.
Proposed Changes
Content Layer
[NEW] content/stories/
New directory for story Markdown files. Each story is a .md file with YAML frontmatter:
---
title: "A Weekend in Potsdam"
date: 2026-02-10
cover: <immich-asset-uuid> # cover image for the story card
---
An opening paragraph of text. Sets the mood.

More narrative text explaining the moment, the light, the feeling.


A closing paragraph.
Authoring rules:
- Standard Markdown for text (paragraphs, headings, blockquotes, emphasis)
- Images use
syntax — the custom renderer swaps these for proxiednext/imagewith ThumbHash blur - Two
![...]on consecutive lines → rendered as a side-by-side pair (2-column layout) - A single
![...]→ rendered full-width (edge-to-edge) - The
coverfrontmatter UUID is used for the story card thumbnail and OG image
Configuration
[MODIFY] gallery.yaml
No changes needed — stories are auto-discovered from content/stories/*.md. This keeps configuration minimal and avoids requiring UUID registration for every story.
[MODIFY] config.ts
Add a stories property to AppConfig:
export interface StoryConfig {
slug: string;
title: string;
date: string;
cover: string; // Immich asset UUID
}
// In AppConfig:
stories: StoryConfig[];
Add a loadStories() function that scans content/stories/*.md, reads frontmatter from each, validates the cover UUID, and returns a sorted array (newest first).
Library
[NEW] storyParser.ts
Parses a story Markdown file into a structured block array for rendering:
type StoryBlock =
| { type: 'text'; html: string }
| { type: 'image'; assetId: string; caption?: string; blurDataURL?: string; aspectRatio?: number }
| { type: 'image-pair'; images: [StoryImageData, StoryImageData] };
Logic:
- Parse frontmatter with
gray-matter - Split the body into blocks separated by blank lines
- Detect
patterns - Two consecutive image lines → group as
image-pair - Text blocks → render to HTML with a lightweight Markdown renderer (use
markedor inline custom parser since the about page already does line-splitting — but for stories we want headings, emphasis, blockquotes) - For each image block, call
immich.getAssetInfo()to fetch ThumbHash and aspect ratio
Pages
[NEW] app/stories/page.tsx
Stories index page at /stories — lists all stories as cards in a masonry or vertical list.
Each card shows:
- Cover image (proxied from Immich, with ThumbHash blur)
- Title
- Date
- Click → navigates to
/stories/<slug>
Server component. Reads the story configs from getConfig().stories and fetches ThumbHash for each cover.
[NEW] app/stories/[slug]/page.tsx
Story detail page at /stories/<slug>.
Server component that:
- Reads the Markdown file from
content/stories/<slug>.md - Parses it through
storyParser.ts - Fetches ThumbHash/aspect data for all referenced images
- Renders the block array as a vertical scroll layout with alternating text and full-bleed images
- Generates metadata (title, OG image from cover)
Styling
[NEW] app/stories/stories.css
Story index page styles — card grid similar to the subpage grid.
[NEW] app/stories/[slug]/story.css
Story detail page styles:
.story— max-width container (680px for text, full-bleed for images).story__header— title + date, large serif typography.story__text— paragraph styles, headings, blockquotes. Serif body text for editorial feel.story__image— single full-width image with optional caption below.story__image-pair— two images side-by-side with equal height (CSS Grid1fr 1fr).story__caption— small italic text below images- Responsive: image pairs stack vertically on mobile (< 640px)
- Generous vertical spacing between blocks (48–64px) for a magazine feel
- Smooth fade-in animations on scroll using existing
FadeIncomponent
Navigation
[MODIFY] layout.tsx
Add a "Stories" link to the header nav (between the subpage links and "About"), conditionally rendered only when getConfig().stories.length > 0.
Dependencies
[MODIFY] package.json
Add marked (lightweight Markdown → HTML renderer, ~40KB) for rendering story text blocks. The about page's basic split('\n') approach won't suffice for stories since they need headings, emphasis, blockquotes, and links.
Summary of Files
| Action | File | Purpose |
|---|---|---|
| NEW | content/stories/ |
Story markdown files directory |
| MODIFY | lib/config.ts |
Add StoryConfig type, loadStories(), stories to AppConfig |
| NEW | lib/storyParser.ts |
Parse story MD → structured block array |
| NEW | app/stories/page.tsx |
Stories index page (list of cards) |
| NEW | app/stories/stories.css |
Styles for stories index |
| NEW | app/stories/[slug]/page.tsx |
Story detail page (essay reader) |
| NEW | app/stories/[slug]/story.css |
Styles for story reader |
| MODIFY | app/layout.tsx |
Add conditional "Stories" nav link |
| MODIFY | package.json |
Add marked dependency |
Verification Plan
Automated Tests
-
Unit test for
storyParser— addlib/__tests__/storyParser.test.ts:- Parses a single image
into animageblock - Parses two consecutive images into an
image-pairblock - Parses plain text into a
textblock with rendered HTML - Handles mixed content (text → image → text → pair → text)
- Rejects malformed
asset:references gracefully - Run:
npx vitest run lib/__tests__/storyParser.test.ts
- Parses a single image
-
Unit test for
loadStories— add tolib/__tests__/config.test.ts:- Returns empty array when no stories directory exists
- Sorts stories by date descending
- Run:
npx vitest run lib/__tests__/config.test.ts
-
Build check —
npm run buildpasses with no TypeScript errors -
Lint check —
npm run lintpasses clean
Manual Verification
- Create a sample story at
content/stories/test-story.mdwith mixed text + images - Run
npm run dev, navigate to/stories— confirm the story card appears with cover image - Click into the story — confirm text renders with proper typography, images load with blur-up placeholders, and image pairs display side-by-side on desktop
- Test mobile (375px) — confirm image pairs stack vertically and text is readable
- Check
/storieslink appears in header nav - Check that removing all
content/stories/*.mdfiles makes the "Stories" nav link disappear