Files
immich-folio/docs/dev/2026-02-15-photo-stories-v1.md

209 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 in `content/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. `![caption](asset:UUID)`). The renderer resolves these to proxied URLs with ThumbHash placeholders. No images are stored locally — they flow through the existing `/api/image` proxy 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/](file:///Users/ralfo/git/immich-lightbox/content/stories/)
New directory for story Markdown files. Each story is a `.md` file with YAML frontmatter:
```yaml
---
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.
![Sanssouci Palace at dawn](asset:<uuid>)
More narrative text explaining the moment, the light, the feeling.
![Two images side by side](asset:<uuid1>)
![](asset:<uuid2>)
A closing paragraph.
```
**Authoring rules:**
- Standard Markdown for text (paragraphs, headings, blockquotes, emphasis)
- Images use `![caption](asset:<UUID>)` syntax — the custom renderer swaps these for proxied `next/image` with 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 `cover` frontmatter UUID is used for the story card thumbnail and OG image
---
### Configuration
#### [MODIFY] [gallery.yaml](file:///Users/ralfo/git/immich-lightbox/content/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](file:///Users/ralfo/git/immich-lightbox/lib/config.ts)
Add a `stories` property to `AppConfig`:
```typescript
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](file:///Users/ralfo/git/immich-lightbox/lib/storyParser.ts)
Parses a story Markdown file into a structured block array for rendering:
```typescript
type StoryBlock =
| { type: 'text'; html: string }
| { type: 'image'; assetId: string; caption?: string; blurDataURL?: string; aspectRatio?: number }
| { type: 'image-pair'; images: [StoryImageData, StoryImageData] };
```
**Logic:**
1. Parse frontmatter with `gray-matter`
2. Split the body into blocks separated by blank lines
3. Detect `![caption](asset:UUID)` patterns
4. Two consecutive image lines → group as `image-pair`
5. Text blocks → render to HTML with a lightweight Markdown renderer (use `marked` or inline custom parser since the about page already does line-splitting — but for stories we want headings, emphasis, blockquotes)
6. For each image block, call `immich.getAssetInfo()` to fetch ThumbHash and aspect ratio
---
### Pages
#### [NEW] [app/stories/page.tsx](file:///Users/ralfo/git/immich-lightbox/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](file:///Users/ralfo/git/immich-lightbox/app/stories/%5Bslug%5D/page.tsx)
**Story detail page** at `/stories/<slug>`.
Server component that:
1. Reads the Markdown file from `content/stories/<slug>.md`
2. Parses it through `storyParser.ts`
3. Fetches ThumbHash/aspect data for all referenced images
4. Renders the block array as a vertical scroll layout with alternating text and full-bleed images
5. Generates metadata (title, OG image from cover)
---
### Styling
#### [NEW] [app/stories/stories.css](file:///Users/ralfo/git/immich-lightbox/app/stories/stories.css)
Story index page styles — card grid similar to the subpage grid.
#### [NEW] [app/stories/[slug]/story.css](file:///Users/ralfo/git/immich-lightbox/app/stories/%5Bslug%5D/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 Grid `1fr 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 `FadeIn` component
---
### Navigation
#### [MODIFY] [layout.tsx](file:///Users/ralfo/git/immich-lightbox/app/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](file:///Users/ralfo/git/immich-lightbox/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
1. **Unit test for `storyParser`** — add `lib/__tests__/storyParser.test.ts`:
- Parses a single image `![caption](asset:UUID)` into an `image` block
- Parses two consecutive images into an `image-pair` block
- Parses plain text into a `text` block 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`
2. **Unit test for `loadStories`** — add to `lib/__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`
3. **Build check** — `npm run build` passes with no TypeScript errors
4. **Lint check** — `npm run lint` passes clean
### Manual Verification
1. Create a sample story at `content/stories/test-story.md` with mixed text + images
2. Run `npm run dev`, navigate to `/stories` — confirm the story card appears with cover image
3. 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
4. Test mobile (375px) — confirm image pairs stack vertically and text is readable
5. Check `/stories` link appears in header nav
6. Check that removing all `content/stories/*.md` files makes the "Stories" nav link disappear