485 lines
26 KiB
Markdown
485 lines
26 KiB
Markdown
# Photography
|
|
|
|
Design and implementation plan for the photography section of levineuwirth.org. This is the source of truth for the section's architecture, authoring conventions, and build pipeline. It sits alongside `WRITING.md` and `HOMEPAGE.md` as authoritative spec.
|
|
|
|
## Status
|
|
|
|
Pre-implementation. Decisions locked; phased build to follow.
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
- A first-class photography section with the same architectural rigor as essays, music, and poetry.
|
|
- Custom metadata system tailored to photographs (location, capture date, camera, lens, film, exposure, palette) that is *distinct from* but *consistent with* the essay frontmatter system.
|
|
- Multiple ways to browse the same corpus: masonry, uniform grid, chronological, map, contact sheet, by tag, by series.
|
|
- Static-friendly throughout: every page renders at build time; JS is layered on top, never required.
|
|
- Web-optimized images only in the repo. Originals (RAW, full-resolution exports) live outside source control.
|
|
|
|
---
|
|
|
|
## Decisions (locked)
|
|
|
|
| Topic | Decision |
|
|
|-------|----------|
|
|
| Content model | Hybrid: flat singles + collection directories (mirrors `content/poetry/`) |
|
|
| Portal | Yes — 9th entry in `homePortals` |
|
|
| EXIF | Auto-extract via build-time tool, written to sidecar; frontmatter overrides |
|
|
| Map library | Leaflet, vendored locally |
|
|
| Map tiles | CartoDB Positron (free, attribution-only, monochrome) |
|
|
| Default geo precision | `city` (~10 km rounding); per-photo override allowed |
|
|
| Default visual mode | Masonry (native aspect ratios) |
|
|
| Mode toggle | Built day one: masonry / grid / chronological / map; persisted to localStorage |
|
|
| Contact sheet | Secondary view at `/photography/contact-sheet/` |
|
|
| Lightbox | Darkroom mode scoped to photography pages |
|
|
| Color palette | 5-color k-means strip, auto-extracted at build time |
|
|
| Feed | Separate `/photography/feed.xml` with thumbnails |
|
|
| Originals storage | Outside the repo; web-optimized JPEGs only are committed |
|
|
| `Filters/Images.hs` | Extended with a richer wrapper for photography-page images |
|
|
| Crop policy | CSS `object-fit: cover` for now; build-time crop later if needed |
|
|
|
|
---
|
|
|
|
## Content model & directory structure
|
|
|
|
```
|
|
content/photography/
|
|
├── index.md # /photography/ landing copy & frontmatter
|
|
├── reykjavik-rooftops.md # flat single (single photo entry)
|
|
├── reykjavik-rooftops/ # OR directory form for richer entries
|
|
│ ├── index.md
|
|
│ ├── photo.jpg # web-optimized; max 2400px long edge
|
|
│ ├── photo.exif.yaml # generated by build (gitignored)
|
|
│ └── photo.palette.yaml # generated by build (gitignored)
|
|
└── copenhagen-2025/ # series (collection)
|
|
├── index.md # series landing
|
|
├── 01-canal-morning.md
|
|
├── 02-tivoli-evening.md
|
|
└── ...
|
|
```
|
|
|
|
- **Flat single entries** are appropriate when the photo's metadata + a short caption are all you want.
|
|
- **Directory single entries** are used when the photo has co-located assets or longer prose.
|
|
- **Collection directories** group multiple photos under a series. Series have their own landing page at `/photography/{series}/`; individual photos live at `/photography/{series}/{photo}/`.
|
|
|
|
The hybrid pattern parallels `content/poetry/` and reuses the existing routing patterns in `build/Patterns.hs`.
|
|
|
|
---
|
|
|
|
## Frontmatter schema
|
|
|
|
All fields except `title` and one of (`date`, `captured`) are optional. Author-written values always win over auto-extracted EXIF.
|
|
|
|
```yaml
|
|
title: "Reykjavík Rooftops"
|
|
date: 2026-04-27 # publication date on this site
|
|
captured: 2026-03-15 # when the shutter clicked
|
|
|
|
# Location
|
|
location: "Reykjavík, Iceland" # human-readable
|
|
geo: [64.146, -21.940] # decimal lat/lon — author-set or from EXIF
|
|
geo-precision: city # exact | km | city | hidden (default: city)
|
|
|
|
# Camera & exposure (auto-fillable from EXIF)
|
|
camera: "Pentax K1000"
|
|
lens: "50mm f/1.4"
|
|
film: "Kodak Portra 400" # film stock for analog shots
|
|
exposure: "1/125 f/8 ISO 400" # combined string OR individual fields:
|
|
# shutter: "1/125"
|
|
# aperture: "f/8"
|
|
# iso: 400
|
|
# focal-length: "50mm"
|
|
|
|
# Process
|
|
process: "scanned" # scanned | digital-raw | darkroom | lightroom | jpeg-ooc
|
|
|
|
# Organization
|
|
series: copenhagen-2025 # optional — slug of containing series
|
|
tags: # hierarchical, slash-separated
|
|
- photography
|
|
- photography/landscape
|
|
- photography/film
|
|
featured: true # appears in curated landing rotation
|
|
nsfw: false # gate behind a click-through if true
|
|
|
|
# Display
|
|
orientation: portrait # portrait | landscape | square (auto-detected if omitted)
|
|
photo: photo.jpg # filename relative to entry — required for directory form
|
|
|
|
# License & provenance
|
|
license: "CC BY-SA 4.0" # license name; canonical URL auto-resolved
|
|
# for known shortcodes (CC variants, CC0,
|
|
# public domain). Omit for "All Rights Reserved".
|
|
license-url: "https://..." # explicit override; only needed for
|
|
# custom licenses or non-canonical wording
|
|
links: # outbound links — Wikimedia Commons,
|
|
- "Wikimedia Commons | https://commons.wikimedia.org/wiki/File:Foo.jpg"
|
|
- "Flickr | https://flickr.com/photos/levi/123"
|
|
# Flickr, exhibition catalog, print-sale
|
|
# page, etc. Same "Name | URL" pipe
|
|
# syntax used by authors/affiliation.
|
|
|
|
# Palette (auto-filled by build; override only for artistic reasons)
|
|
palette:
|
|
- "#2a3f5f"
|
|
- "#d4a574"
|
|
- "#7c8a8a"
|
|
- "#1a1a1a"
|
|
- "#e8d8c0"
|
|
```
|
|
|
|
### Field semantics
|
|
|
|
- **`date`** — when the photo was published to this site. Used for feed ordering and "recently added."
|
|
- **`captured`** — when the photograph was made. Used for `/by-year/` indexes and chronological mode.
|
|
- **`geo` + `geo-precision`** — coordinates and the precision at which they're rendered. The build rounds `geo` according to `geo-precision` before writing it to map data and the rendered page. Original precision is never delivered to the browser.
|
|
- **`tags`** — uses the existing slash-hierarchy tag system (`build/Tags.hs`). The top-level `photography` tag is implicit for all entries; sub-tags like `photography/landscape`, `photography/portrait`, `photography/film`, `photography/architecture` are freeform.
|
|
- **`series`** — slug-only. The series's own metadata lives in `content/photography/{series}/index.md`.
|
|
- **`license`** — license name as displayed (e.g., `"CC BY-SA 4.0"`). Canonical URL auto-resolved at build time for known shortcodes (`CC BY 4.0`, `CC BY-SA 4.0`, `CC BY-NC 4.0`, `CC BY-NC-SA 4.0`, `CC BY-ND 4.0`, `CC BY-NC-ND 4.0`, `CC0`, `Public Domain`). Author-supplied `license-url:` always wins. Omit for "All Rights Reserved" (renders as plain text without a link).
|
|
- **`links`** — outbound list of named external URLs. Same `"Name | URL"` syntax as `authors:` and `affiliation:`. Entries without a URL are dropped. Used for Wikimedia Commons, Flickr, exhibition catalog, print-sale page, etc.
|
|
|
|
### What is *not* in photography frontmatter
|
|
|
|
These essay fields do not apply and are not exposed on photography pages:
|
|
|
|
- Epistemic profile (`status`, `confidence`, `evidence`, `scope`, `novelty`, `practicality`)
|
|
- `abstract` (use a brief caption in the body instead)
|
|
- `further-reading`, `bibliography`, `csl`
|
|
- Reading time, word count
|
|
- Backlinks, similar-links (text-embedding signals are noise on visual content)
|
|
- TOC
|
|
|
|
---
|
|
|
|
## Routing & generated pages
|
|
|
|
| URL | Source | Notes |
|
|
|-----|--------|-------|
|
|
| `/photography/` | `content/photography/index.md` | Landing; default masonry view + mode toggle |
|
|
| `/photography/{slug}/` | `content/photography/{slug}.md` or `{slug}/index.md` | Single-photo page |
|
|
| `/photography/{series}/` | `content/photography/{series}/index.md` | Series landing |
|
|
| `/photography/{series}/{photo}/` | `content/photography/{series}/{photo}.md` | Photo within a series |
|
|
| `/photography/by-year/` | Auto-generated | Year index with photo counts |
|
|
| `/photography/by-year/{year}/` | Auto-generated | All photos with `captured` in that year |
|
|
| `/photography/contact-sheet/` | Auto-generated | Frame-numbered grid of all photos |
|
|
| `/photography/map/` | Auto-generated | Leaflet map of geo-tagged photos |
|
|
| `/photography/{tag}/` | Auto-generated | Tag pages via existing `Tags.hs` |
|
|
| `/photography/feed.xml` | Auto-generated | Atom feed with thumbnails |
|
|
|
|
Tag pages (`/photography/landscape/`, etc.) come "for free" from the existing tag system — sub-tags of `photography` automatically generate their own index pages.
|
|
|
|
---
|
|
|
|
## Visual system
|
|
|
|
### Modes (toggle from day one)
|
|
|
|
The `/photography/` landing offers four browsing modes via a toggle in the page header. Selection persists to localStorage under `photography-mode`, mirroring the existing settings panel pattern.
|
|
|
|
1. **Masonry** *(default)* — variable-height cells respecting native aspect ratios. CSS Grid with `grid-auto-rows: 1px` + `grid-row-end: span N` computed from each image's aspect ratio (shipped in HTML to avoid layout shift).
|
|
2. **Grid** — uniform square cells using `object-fit: cover`. Rhythmic, scannable.
|
|
3. **Chronological** — single column, large, ordered by `captured` (desc). One photo per row with caption beneath. Closest aesthetic to reading mode.
|
|
4. **Map** — switches the page to the `/photography/map/` route. (Or: inlines the map in place of the grid; decide during Phase 4.)
|
|
|
|
### Contact sheet (separate URL)
|
|
|
|
`/photography/contact-sheet/` renders all photos with thin white borders, frame numbers in the corner, and a subtle film-grain texture. Distinct from the toggle modes — a deep-cut alternate view, not a primary mode.
|
|
|
|
### Color palette strip
|
|
|
|
Beneath each photo on its detail page, a thin row of 5 swatches drawn from `palette` frontmatter. Hover reveals the hex value. CSS-only; no JS needed.
|
|
|
|
### Darkroom lightbox
|
|
|
|
Scoped to `body[data-page-type="photography"]` so essays' lightbox is unaffected.
|
|
|
|
- Page chrome fades to deep black on open (not just an overlay)
|
|
- Subtle vignette behind the photo
|
|
- Caption + key metadata appear below in muted Spectral italic
|
|
- Arrow keys / swipe navigate within the current series (or within the current page's photo list)
|
|
- `i` key toggles full EXIF reveal
|
|
- `Escape` closes; click outside the photo closes
|
|
|
|
### Visual identity rationale
|
|
|
|
Masonry default + clean grid alternate gives the considered/organic tone of essays a visual analog: each photo's geometry is honored, but a uniform mode is one click away for comparison. Contact sheet stays as a distinctive alternate URL — referenced from `/photography/` but not the default — so the analog aesthetic is available without being prescriptive.
|
|
|
|
---
|
|
|
|
## Image storage & pipeline
|
|
|
|
### What lives where
|
|
|
|
- **Originals (RAW, full-resolution exports)**: outside the repo. Levi's local archive (external drive, NAS, or backup service). Not Levi's responsibility to define for this plan; only the *contract* matters: originals never enter source control.
|
|
- **Web-optimized JPEGs**: committed to the repo at `content/photography/{slug}/photo.jpg` (or alongside `.md` for flat singles). Long edge ≤ 2400px, quality 85, sRGB, EXIF stripped before commit.
|
|
- **WebP companions**: generated at build time by the existing `tools/convert-images.sh`; gitignored (already covered by the existing `content/**/*.webp` rule on line 105 of `.gitignore`).
|
|
- **EXIF sidecar (`{photo}.exif.yaml`)**: generated at build time; gitignored.
|
|
- **Palette sidecar (`{photo}.palette.yaml`)**: generated at build time; gitignored.
|
|
|
|
### Defense-in-depth gitignore additions
|
|
|
|
Append to `.gitignore`:
|
|
|
|
```
|
|
# Photography: generated sidecars (recreated by build pipeline)
|
|
content/photography/**/*.exif.yaml
|
|
content/photography/**/*.palette.yaml
|
|
|
|
# Photography: defense-in-depth — refuse to commit RAW or oversize originals.
|
|
# To intentionally commit one (rare), use `git add -f path/to/file`.
|
|
content/photography/**/*.cr2
|
|
content/photography/**/*.cr3
|
|
content/photography/**/*.nef
|
|
content/photography/**/*.arw
|
|
content/photography/**/*.dng
|
|
content/photography/**/*.raf
|
|
content/photography/**/*.orf
|
|
content/photography/**/*.tif
|
|
content/photography/**/*.tiff
|
|
content/photography/**/*.psd
|
|
```
|
|
|
|
### Import workflow
|
|
|
|
`tools/import-photo.sh` (Phase 3): given a path to an original and a target slug, the script:
|
|
|
|
1. Resizes to ≤ 2400px long edge as JPEG quality 85, sRGB.
|
|
2. Strips all EXIF from the delivered JPEG with `exiftool -all=`.
|
|
3. Writes the EXIF sidecar to `{slug}/photo.exif.yaml` (so the metadata is preserved for display, but not embedded in the file shipped to viewers).
|
|
4. Computes the 5-color palette and writes `{slug}/photo.palette.yaml`.
|
|
5. Drops a frontmatter stub at `{slug}/index.md` ready for editing.
|
|
|
|
Until that script exists, Phase 1 + 2 work with manually prepared JPEGs.
|
|
|
|
### Build-pipeline integration
|
|
|
|
New steps slot into the Makefile alongside the existing `convert-images` and `pdf-thumbs` targets, all gated on tool availability (silent skip if missing, matching the `embed.py` pattern):
|
|
|
|
```
|
|
make build:
|
|
...
|
|
→ tools/extract-exif.py (gated on `exiftool` or `Pillow`)
|
|
→ tools/extract-palette.py (gated on Python + colorthief)
|
|
→ tools/build-map-data.py (always runs; no external deps)
|
|
→ tools/convert-images.sh (existing)
|
|
→ hakyll-build (existing)
|
|
...
|
|
```
|
|
|
|
---
|
|
|
|
## `Filters/Images.hs` extension
|
|
|
|
Currently `Filters/Images.hs` emits `<picture>` with WebP companion + lazy-loading for any image in any document. Photography pages need a richer wrapper that includes:
|
|
|
|
- The palette strip beneath the photo
|
|
- A small EXIF metadata block (camera, lens, exposure, captured-date) toggled by an "ⓘ" button
|
|
- A figure caption (Pandoc already handles this)
|
|
- The `data-photography="true"` attribute that scopes the darkroom lightbox
|
|
|
|
The cleanest approach: extend `Filters/Images.hs` to detect when the document being processed is a photography page (via document metadata or path pattern) and emit the richer wrapper in that case. Essays continue to get the simple `<picture>` wrapper unchanged.
|
|
|
|
Alternative considered: render the richer wrapper from the template instead of the filter. Rejected because the template loses the per-image palette/EXIF lookup; doing it in the filter keeps the data flow Pandoc-native.
|
|
|
|
---
|
|
|
|
## Map architecture
|
|
|
|
### Library & tiles
|
|
|
|
- **Leaflet 1.9.x** vendored to `static/leaflet/`. No CDN. `tools/download-leaflet.sh` mirrors the `download-pdfjs.sh` pattern.
|
|
- **CartoDB Positron** raster tiles. Free for any volume, attribution required ("© OpenStreetMap contributors © CARTO"). Monochrome, doesn't fight the typography.
|
|
- Fallback: if CartoDB ever rate-limits or disappears, swap to Stadia Maps or self-hosted.
|
|
|
|
### Build-time data
|
|
|
|
`tools/build-map-data.py` walks `content/photography/`, reads each entry's `geo` + `geo-precision`, applies the precision rounding, and emits `_site/photography/map.json`:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"slug": "reykjavik-rooftops",
|
|
"title": "Reykjavík Rooftops",
|
|
"url": "/photography/reykjavik-rooftops/",
|
|
"thumb": "/photography/reykjavik-rooftops/photo.jpg",
|
|
"lat": 64.15,
|
|
"lon": -21.94,
|
|
"captured": "2026-03-15"
|
|
}
|
|
]
|
|
```
|
|
|
|
### Geo precision
|
|
|
|
Applied at build time before any data leaves the build directory:
|
|
|
|
| Precision | Rounding | Approximate |
|
|
|-----------|----------|-------------|
|
|
| `exact` | 4 decimals | ~10 m |
|
|
| `km` | 2 decimals | ~1 km |
|
|
| `city` *(default)* | 1 decimal | ~10 km |
|
|
| `hidden` | omit from `map.json` entirely | not pinned |
|
|
|
|
### Page-scoped JS/CSS
|
|
|
|
`static/leaflet/leaflet.js`, `static/leaflet/leaflet.css`, and `static/js/photography-map.js` are loaded **only** on `/photography/map/` via the per-page `js:` frontmatter mechanism (already supported — see `WRITING.md`). Other photography pages stay lightweight.
|
|
|
|
### Marker behavior
|
|
|
|
- Click marker → photo page.
|
|
- Marker thumbnail tooltip on hover (uses Leaflet's tooltip API).
|
|
- Marker clustering when zoomed out, via `leaflet.markercluster` plugin (also vendored).
|
|
|
|
---
|
|
|
|
## Templates
|
|
|
|
New files under `templates/`:
|
|
|
|
| File | Role |
|
|
|------|------|
|
|
| `photography.html` | Single photo page chrome — figure, palette strip, metadata, navigation within series |
|
|
| `photography-index.html` | `/photography/` landing — mode toggle, masonry/grid/chrono modes |
|
|
| `photography-series.html` | `/photography/{series}/` landing — series intro + photo list |
|
|
| `photography-map.html` | `/photography/map/` — Leaflet container, vendored JS/CSS |
|
|
| `photography-contact-sheet.html` | `/photography/contact-sheet/` — frame-numbered grid |
|
|
| `photography-by-year.html` | `/photography/by-year/{year}/` — chronological year index |
|
|
|
|
New partial:
|
|
|
|
| File | Role |
|
|
|------|------|
|
|
| `partials/photo-card.html` | Reusable photo-card markup for grids and listings |
|
|
| `partials/photo-meta.html` | Camera/lens/exposure/captured block, toggleable |
|
|
| `partials/photo-palette.html` | 5-swatch palette strip |
|
|
|
|
Existing partials reused unchanged: `nav.html`, `head.html`, `footer.html`.
|
|
|
|
---
|
|
|
|
## Build module structure
|
|
|
|
New Haskell modules under `build/`:
|
|
|
|
- **`build/Photography.hs`** — patterns, routing rules, contexts specific to photography. Separated from `Site.hs` for the same reason `Catalog.hs` and `Authors.hs` are separated: scoped concerns, easier to reason about.
|
|
|
|
Edits to existing modules:
|
|
|
|
- **`build/Patterns.hs`** — add `photographyPattern`, `photographySinglesPattern`, `photographySeriesPattern`, `photographyAssetsPattern`.
|
|
- **`build/Compilers.hs`** — add `photographyCompiler` (essay-pipeline minus epistemic/reading-time/backlinks/TOC).
|
|
- **`build/Contexts.hs`** — add `photographyCtx` with photo-specific fields, sidecar merge logic.
|
|
- **`build/Site.hs`** — add `("Photography", "photography")` to `homePortals`; wire photography rules from `Photography.hs`.
|
|
- **`build/Filters/Images.hs`** — extend to emit richer wrapper on photography pages.
|
|
|
|
---
|
|
|
|
## Phased implementation
|
|
|
|
Each phase has explicit exit criteria. Don't move to the next phase until the current one passes.
|
|
|
|
### Phase 1 — Skeleton end-to-end ✓
|
|
|
|
- [x] Add `photographyPattern` family to `Patterns.hs`
|
|
- [x] Create `build/Photography.hs` with routing rules
|
|
- [x] Add `photographyCompiler` to `Compilers.hs`
|
|
- [x] Add `photographyCtx` to `Contexts.hs`
|
|
- [x] Add `("Photography", "photography")` to `homePortals` in `Site.hs`
|
|
- [x] Create `templates/photography.html`, `templates/photography-index.html`, `templates/partials/photo-card.html`
|
|
- [x] Add minimal `static/css/photography.css` (no modes yet — single column is fine)
|
|
- [x] Manually drop one prepared JPEG into `content/photography/{slug}/` and author its frontmatter by hand
|
|
- [x] Verify the photography portal appears in nav, the landing page lists the photo, the photo page renders
|
|
- [x] **Bonus**: license + outbound-links metadata (auto-resolved canonical URLs for known CC variants)
|
|
|
|
**Exit criteria**: A photo renders at `/photography/{slug}/` with correct nav portal, basic styling, and is reachable from `/photography/` and `/library.html`. **Met.**
|
|
|
|
### Phase 2 — Visual system & toggle ✓
|
|
|
|
- [x] Build masonry layout in `photography.css` (CSS grid + computed row spans)
|
|
- [x] Build uniform-grid mode
|
|
- [x] Build chronological mode
|
|
- [x] `static/js/photography-modes.js` — toggle UI, localStorage persistence
|
|
- [x] Extend `static/js/lightbox.js` with darkroom mode branch (gated on `body[data-page-type="photography"]`)
|
|
- [x] Body `data-page-type="photography"` attribute (added in Phase 1 as a free hook)
|
|
|
|
**Exit criteria**: `/photography/` switches between three modes smoothly; localStorage persists choice; lightbox enters darkroom mode on photography pages only. **Met.** Visual verification pending Levi's run of the dev server.
|
|
|
|
### Phase 3 — EXIF, palette, and import pipelines ✓
|
|
|
|
- [x] `tools/extract-exif.py` (uses `exiftool` if present, falls back to `Pillow`)
|
|
- [x] `tools/extract-palette.py` (Python + colorthief)
|
|
- [x] `tools/import-photo.sh` (resize, strip EXIF from delivered file, write sidecars, scaffold frontmatter)
|
|
- [x] Wire both into the Makefile, gated on `.venv` (silent-skip pattern matching `embed.py`)
|
|
- [x] Extend `photographyCtx` to merge sidecar EXIF + palette into the template context (frontmatter wins)
|
|
- [x] Update `.gitignore` with photography sidecars and RAW patterns
|
|
- [ ] **Deferred** — Extend `Filters/Images.hs` with the richer photography wrapper
|
|
- [ ] **Deferred** — Build `partials/photo-meta.html` and `partials/photo-palette.html`
|
|
|
|
The two deferred items are now redundant with the Phase 1 template structure: the per-photo metadata block and palette strip live in `templates/photography.html` directly and consume `photographyCtx` fields. Extending `Filters/Images.hs` would only matter if Levi later writes long-form "photo essays" with multiple inline photos that should each carry the rich wrapper — defer until that content type exists.
|
|
|
|
**Exit criteria**: A photo with no manually authored camera/lens/exposure/palette frontmatter still displays full metadata + palette strip on its detail page, sourced entirely from sidecars. **Met.** Verified with `canto31.jpg` (no frontmatter `captured:`, `camera:`, etc.; `captured-display` and palette swatches both flowed from sidecar through to rendered HTML).
|
|
|
|
### Phase 4 — Map ✓
|
|
|
|
- [x] `tools/download-leaflet.sh` — vendors Leaflet 1.9.4 + leaflet.markercluster 1.5.3 to `static/leaflet/`, sha256-pinned
|
|
- [x] **`build/Photography.hs` map.json rule** (Haskell, not Python — cleaner: Hakyll already has the metadata, no extra dep)
|
|
- [x] `templates/photography-map.html`
|
|
- [x] `static/js/photography-map.js`
|
|
- [x] Add map mode to the toggle on `/photography/`
|
|
- [x] CartoDB Positron tile attribution wired into the page
|
|
|
|
**Exit criteria**: `/photography/map/` shows pins at city precision, marker thumbnails on hover, clicking a pin navigates to the photo. Leaflet JS/CSS load only on the map page. **Met.**
|
|
|
|
Implementation notes worth knowing:
|
|
|
|
- `map.json` is generated by Hakyll (`photographyMapDataRule` in `build/Photography.hs`), not a Python step. The original spec called for `tools/build-map-data.py`; Haskell turned out cleaner because Hakyll already has every photo's frontmatter loaded and the precision-rounding logic is six lines.
|
|
- `geo-precision: hidden` photos are dropped from `map.json` entirely.
|
|
- Pin URLs are stripped of trailing `index.html` so click-through goes directly to the canonical directory URL with no implicit redirect.
|
|
- UTF-8 in titles is decoded via `Data.Text.Lazy.Encoding.decodeUtf8` rather than `LBS.unpack` to avoid double-encoding bugs (em-dashes, accents, etc.).
|
|
- Map page is tile-rate-limit-friendly: scroll-zoom is disabled until the user clicks into the map (prevents accidental zoom while scrolling past), tiles cached aggressively (`fetch(..., {cache: 'force-cache'})`).
|
|
- `leaflet.markercluster` is loaded but degrades gracefully to plain `L.featureGroup` if the plugin failed to load.
|
|
|
|
### Phase 5 — Auxiliary surfaces ✓
|
|
|
|
- [x] `/photography/by-year/` and `/photography/by-year/{year}/`
|
|
- [x] `/photography/contact-sheet/`
|
|
- [x] `/photography/feed.xml` (Atom with thumbnails embedded inline in entry descriptions)
|
|
- [x] Series landing pages auto-generated from collection directories
|
|
- [x] Photography shelf on `/library.html`
|
|
- [x] Tag pages (`/photography/landscape/`, etc.) wired into the existing tag system; the bare `photography` tag is filtered out of the expansion in `Tags.getExpandedTags` to avoid colliding with the section-landing route
|
|
|
|
**Exit criteria**: All routing-table URLs from this document resolve and are linked from somewhere reachable. **Met.**
|
|
|
|
Implementation notes worth knowing:
|
|
|
|
- A new `allPhotoEntries` pattern in `Patterns.hs` enumerates every photographic file (top-level entries + series children); used by surfaces that need every frame (by-year, contact-sheet, feed, map). The original `photographyPattern` (top-level only) feeds the main `/photography/` landing and the library shelf, where a series should appear as a single aggregate card rather than once for the landing plus once per child.
|
|
- Series detection is purely structural: a directory has siblings ↔ it's a series. No `series: true` flag in frontmatter. `photographyEntryRules` uses a `Set String` of series-slugs computed once at rule-gen time to branch template selection (`photography-series.html` vs `photography.html`).
|
|
- Sibling photo URLs are canonical directory form: `/photography/<series>/<photo>/`.
|
|
- The `sectionOwnedTopLevelTags` filter in `Tags.hs` is named generally so other portal tags can be added if their content types ever feed `tagIndexable`.
|
|
- By-year extraction reads frontmatter `captured:` first, falling back to `date:`. Photos with neither are silently dropped from by-year only; they remain visible everywhere else. (Future improvement: also fall back to the EXIF sidecar's `captured:` so frontmatter-free photos appear automatically.)
|
|
|
|
---
|
|
|
|
## Open / deferred questions
|
|
|
|
These are non-blocking but worth tracking:
|
|
|
|
- **Build-time crop** — currently `object-fit: cover`. If the contact-sheet aesthetic feels weak with center-crops, introduce a crop-aware build step (Phase 6 or later).
|
|
- **`nsfw` gate** — frontmatter field is reserved but no UI is planned in initial phases. Add when first needed.
|
|
- **Print availability / contact info** — out of scope for v1; revisit if Levi wants to sell prints.
|
|
- **Diptych / triptych layouts** — frontmatter-driven pairing exists in concept (`pair: other-slug`) but unimplemented; defer until there's actual content that demands it.
|
|
- **Random photo entry point** — `/photography/random` redirect; trivial JS, defer.
|
|
- **EXIF reliability for film** — non-concern per design discussion; sidecar/frontmatter merge handles missing EXIF gracefully.
|
|
- **High-res download links** — out of scope. Originals are not online.
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- `WRITING.md` — frontmatter conventions for essays (template for the photography schema's structure)
|
|
- `HOMEPAGE.md` — homepage portal grid
|
|
- `build/Patterns.hs` — current content pattern definitions
|
|
- `build/Tags.hs` — slash-hierarchy tag system (reused for photography tags)
|
|
- `build/Filters/Images.hs` — current image filter (to be extended)
|
|
- `static/css/gallery.css` — exhibit/overlay system (reference for darkroom lightbox)
|
|
- `tools/convert-images.sh` — WebP companion generation (reused as-is)
|