Compare commits

..

4 Commits

Author SHA1 Message Date
m038 4fe8d2b72b chore: fix server-install.sh and update .env for Grav 2.0 production deploy
- server-install.sh: use GitHub releases URL (avoids channel suffix hack)
- GRAV_VERSION bumped to 2.0.0-rc.10 in .env.example
- production-todo.md: mark Phase 2.1 env vars as done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 15:30:34 +02:00
m038 c703a09967 docs: add Playwright test improvement and expansion design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
2026-06-21 15:09:44 +02:00
m038 29e046f7f7 docs: add travel-memories design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 15:05:09 +02:00
m038 611c4a2949 docs: fill in light-mode values for surface-raised and ink-inverse 2026-06-21 14:44:49 +02:00
6 changed files with 468 additions and 12 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ WEBROOT=/home/example.com/public_html
SITE_CONFIG_DIR=/home/example.com/site-config
# Grav
GRAV_VERSION=1.7.53
GRAV_VERSION=2.0.0-rc.10
# Repos
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
+5 -7
View File
@@ -19,16 +19,14 @@ Light-mode counterpart to `design-system.md`. Only color tokens differ between t
| `--color-accent-hover` | `#185647` | `#287A68` | Hover/pressed teal |
| `--color-accent-light` | `#EBF5F2` | `#1A2E29` | Pale teal tint backgrounds |
| `--color-accent-on` | `#FFFFFF` | `#FFFFFF` | Text on accent surfaces |
| `--color-surface-raised` | ⚠️ not specified | `#2A2720` | Elevated surfaces: tooltips, hover |
| `--color-ink-inverse` | ⚠️ not specified | `#17171A` | Text on accent-coloured buttons |
| `--color-surface-raised` | `#F0EDE9` | `#2A2720` | Elevated surfaces: tooltips, hover |
| `--color-ink-inverse` | `#FFFFFF` | `#17171A` | Text on accent-coloured buttons |
### Notes on accent values
The dark accent is `#2E9880` — a lightened version of the original `#1F6B5A` to maintain contrast against near-black backgrounds.
### Gaps to resolve
### Notes on the two added tokens
Two tokens were added during dark-theme implementation without light counterparts:
- **`--color-surface-raised`**: dark is `#2A2720` (slightly above canvas). Light equivalent would be a warm off-white slightly darker than `--color-canvas` (`#FFFFFF`).
- **`--color-ink-inverse`**: dark is `#17171A` (dark text on the bright `#2E9880` accent). Light equivalent would likely be `#FFFFFF` (white text on the dark `#1F6B5A` accent) — same as `--color-accent-on`.
- **`--color-surface-raised`** (`#F0EDE9`): warm off-white, slightly darker than `--color-canvas` (`#FFFFFF`) to suggest elevation — mirrors the dark mode pattern of `#2A2720` sitting just above `#22201B`.
- **`--color-ink-inverse`** (`#FFFFFF`): white text on the dark teal accent (`#1F6B5A`). Inverse of dark mode where the lightened accent (`#2E9880`) is bright enough to carry dark text (`#17171A`).
+2 -3
View File
@@ -32,9 +32,8 @@ These are changes made in the local dev environment and committed before anythin
### 2.1 Configure .env
- [ ] Set `GRAV_VERSION=2.0.0-rc.9` in `.env`
- [ ] Set `GRAV_CHANNEL_SUFFIX=?testing` (makes the download URL resolve to the RC)
- [ ] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
- [x] Set `GRAV_VERSION=2.0.0-rc.10` in `.env` (GitHub releases URL, no channel suffix needed)
- [x] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
- [ ] Set `USER_REPO` and `MAIN_REPO` (Gitea URLs)
- [ ] Set `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` for the install-time clone
@@ -0,0 +1,138 @@
# Playwright Tests — Improvement & Expansion
**Date:** 2026-06-21
**Status:** Approved
---
## Goal
Reorganise the flat `tests/ui/` directory into feature-scoped subdirectories, add a dedicated GPX Manager test suite (end-to-end), plug three gaps in the post form suite, and extend the axe accessibility scans to two new pages.
---
## Folder Structure
Current flat layout becomes:
```
tests/
fixtures/
test-photo.jpg (existing)
test-nonimage.txt (existing)
test-route.gpx (new — minimal valid GPX XML, ~200 bytes, one trackpoint)
ui/
auth/
auth.setup.js (moved)
auth.spec.js (moved)
post/
post.spec.js (moved + P6-P8 added)
validation.spec.js (moved)
gpx/
gpx-journey.spec.js (moved)
gpx-manager.spec.js (new)
maps/
maps.spec.js (moved)
stories/
stories.spec.js (moved)
dailies/
dailies.spec.js (moved)
home/
home.spec.js (moved)
home-highlights.spec.js (moved)
nav/
nav.spec.js (moved)
trip/
trip-filter.spec.js (moved)
a11y/
accessibility.spec.js (moved + AX6-AX7 added)
helpers.js (stays at ui/ root — shared by all subdirs)
global-setup.js (unchanged)
```
`playwright.config.js` requires no changes — `testDir: './tests/ui'` recurses automatically. The `auth.setup.js` `testMatch: /auth\.setup\.js/` resolves by filename regardless of depth.
---
## New Fixture: `test-route.gpx`
Minimal valid GPX 1.1 file, one trackpoint. Accepted by Grav's media handler without triggering real GPX parsing in the app.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="43.7696" lon="11.2558"><ele>50</ele></trkpt>
</trkseg></trk>
</gpx>
```
---
## New Tests: `gpx/gpx-manager.spec.js`
All tests run against the live Grav API (end-to-end, no mocking).
### Auth
| ID | Description |
|----|-------------|
| GM1 | `/gpx-manager` with auth renders one `.gpx-trip` section per published trip |
| GM2 | `/gpx-manager` without auth shows `#grav-login` inline login form (fresh context, no storageState) |
### File Listing
| ID | Description |
|----|-------------|
| GM3 | After the page loads, the file list for italy-2026-demo either shows a table or the "No GPX files." empty state — the `.gpx-loading` placeholder must not remain |
### Upload
| ID | Description |
|----|-------------|
| GM4 | Upload `test-route.gpx` to italy-2026-demo → filename `test-route.gpx` appears in the file list table |
| GM5 | Upload a file with a non-slug name ("My Route 1.gpx", same buffer, fake name via `setInputFiles({ name, mimeType, buffer })`) → list shows `my-route-1.gpx` (slugified) |
| GM6 | Submit the upload form without selecting a file → `.gpx-status` shows "Choose a file first." |
### Delete
| ID | Description |
|----|-------------|
| GM7 | After uploading `test-route.gpx`, click its Delete button, confirm the dialog → row disappears from the file list |
### Cleanup
`afterAll` reads `tests/.auth/user.json`, extracts the session cookie, and issues Node-level `fetch` `DELETE` calls to `/api/v1/pages/trips/italy-2026-demo/media/<filename>` for any fixture files that remain (guards against mid-test failures leaving orphaned files).
---
## Post Form Additions: `post/post.spec.js`
Three new tests appended to the existing P1P5 suite. P2 (photo upload) stays skipped.
| ID | Description |
|----|-------------|
| P6 | Successful submit shows "Entry posted successfully!" text in the page (currently the test only waits for `.form-messages, .notices` without asserting content) |
| P7 | Date field is pre-filled on page load with a timestamp within 5 minutes of `Date.now()` (blueprint `default: now`, format `Y-m-d H:i`) |
| P8 | After a successful submit, title and content fields are empty (blueprint `reset: true` flushes the form) |
---
## Accessibility Additions: `a11y/accessibility.spec.js`
Two new axe scans appended to the existing AX1AX5 block.
| ID | URL | Notes |
|----|-----|-------|
| AX6 | `/gpx-manager` | API mocked with `page.route()` so the file list renders without a real upload dependency |
| AX7 | `/trips/italy-2026-demo/stories/val-dorcia-at-dawn` | Story page not yet covered by any axe scan |
---
## What Does Not Change
- `playwright.config.js` — untouched
- `tests/global-setup.js` — untouched
- All existing test IDs (A1A5, G1G5, M1M8, etc.) — test logic is unchanged; files are moved, not rewritten
- P2 — stays skipped; photo upload path needs post-form improvements first
- `helpers.js` — stays at `tests/ui/helpers.js`; import paths in moved specs update from `./helpers` to `../helpers`
@@ -0,0 +1,321 @@
# travel-memories — Design Spec
**Date:** 2026-06-21
**Status:** Draft
## Overview
`travel-memories` is a personal local web app for turning Immich photo albums into Grav CMS journal entries and story pages. It runs in Docker alongside the existing Grav dev environment, connects to an Immich instance on the local network, and guides the user through a six-phase workflow: select album → triage photos → curate selection → group into entries → write content → export to Grav. Progress is saved continuously so work can be paused and resumed at any stage.
---
## Scope
### What this covers
- Immich album browsing and photo selection (read-only access to Immich)
- A structured six-phase workflow with pause/resume at any phase
- Journal entries and story drafts output as Grav-compatible entry folders
- Persistent state with hard-refresh safety
- Back-navigation with stale warnings (no auto-deletion of downstream work)
- A notes panel for capturing memories at any stage throughout the workflow
- Playwright UI test suite covering all phases
### What this does not cover
- AI-assisted writing, title generation, or typo correction (post-export luxury step, separate tool)
- Writing to Grav via the API — export writes files directly; `make content-push` handles sync as usual
- Multi-user support
- Mobile layout (desktop tool; tablet usable)
- Any modification of Immich albums or assets
---
## Architecture
### Docker
New service added to `docker-compose.yml`:
```yaml
travel-memories:
build: ./services/travel-memories
ports:
- "8082:8082"
volumes:
- ./docs/immich-workflow:/app/state
- ./user/pages:/app/pages
env_file: .env
user: "${UID}:${GID}"
```
`UID` and `GID` must be set in the shell environment (or in a `.env` fragment) so the container user matches the host user — this prevents permission errors when writing into `user/pages/`.
### Service source layout
```
services/travel-memories/
Dockerfile
requirements.txt
app/
__init__.py ← Flask app factory
routes/
albums.py ← Phase 1: album listing + selection
triage.py ← Phase 2
curate.py ← Phase 3
group.py ← Phase 4
write.py ← Phase 5
export.py ← Phase 6
proxy.py ← Immich photo proxy (thumbs + originals)
notes.py ← Notes panel save endpoint
state.py ← Atomic JSON read/write helpers
immich.py ← Immich API client
templates/
base.html ← DaisyUI shell, notes panel, nav
phase1.html
phase2.html
phase3.html
phase4.html
phase5.html
phase6.html
static/
app.js ← Alpine.js component definitions
```
### Tech stack
- **Backend:** Python 3.12 + Flask
- **Frontend:** Tailwind CSS + DaisyUI + Alpine.js — all loaded from CDN, no build pipeline
- **DaisyUI theme:** `forest` (closest to the blog's teal palette)
- **State:** One JSON file per album at `/app/state/{album-id}.json` (maps to `docs/immich-workflow/` on host)
- **Photo serving:** All Immich requests proxied through Flask — the Immich API key is never sent to the browser
---
## Immich API
All requests use `Authorization: Bearer {IMMICH_API_KEY}` and base URL from `IMMICH_URL` env var.
| Purpose | Method | Endpoint |
|---|---|---|
| List albums | GET | `/api/albums` |
| Get album with assets | GET | `/api/albums/{id}?withoutAssets=false` |
| Get asset metadata | GET | `/api/assets/{id}` |
| Get thumbnail | GET | `/api/assets/{id}/thumbnail?size=preview` |
| Download original | GET | `/api/assets/{id}/original` |
> **Pre-implementation gate:** verify these endpoint paths against the running Immich instance before writing any code. Immich API paths have changed between versions.
---
## State model
One JSON file per album. Written atomically: always written to `{file}.tmp` then `os.rename()` to prevent corruption from crashes.
```json
{
"album_id": "abc123",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"created_at": "2026-06-21T10:00:00",
"updated_at": "2026-06-21T14:32:00",
"phase": "group",
"phase_stale": ["write"],
"photos": [
{
"id": "asset-uuid",
"original_filename": "IMG_1234.jpg",
"local_datetime": "2023-09-05T14:32:00",
"tag": "journal",
"order": 3
}
],
"groups": [
{
"id": "g1",
"photo_ids": ["asset-uuid", "asset-uuid-2"],
"entry_type": "journal",
"title": "",
"body": "",
"location_city": "",
"location_country": "",
"date": "2023-09-05",
"hero_photo_id": null,
"status": "draft"
}
],
"notes": "Arrived in Almaty — chaos at the airport. Lost one bag. The smell of the market..."
}
```
`status` values for a group: `draft`, `written`, `skipped`, `exported`. `exported` is immutable — never modified by upstream phase changes.
---
## Phases
### Phase 1 — Album selection
App fetches album list from Immich on load and displays them as cards (cover thumbnail, name, photo count). User selects one or more albums. Multi-select merges all assets into one workspace, deduplicated by asset ID. After selection the app fetches all asset metadata, initialises the state file, and advances to Phase 2.
If the state file for this album already exists, the app offers to resume or start over. "Start over" deletes the existing state file and reinitialises from the album metadata — exported entry files on disk are not touched.
### Phase 2 — Triage
Photos displayed in a responsive grid, ordered by `localDateTime`, with sticky day-group headers. Each photo shows its thumbnail, date, and time.
Click or keyboard to tag:
- **J** → journal (green border)
- **S** → story (blue border)
- **X** or **space** → skip (dimmed)
All photos must be tagged before "Done triaging" is enabled. Progress shown as a counter ("847 / 847 tagged").
### Phase 3 — Curate
Shows only journal- and story-tagged photos, in chronological order with day-group headers. Actions per photo: remove (revert to skip), swap tag (journal ↔ story). Drag to reorder within a day group. "Curate done" completes the phase.
### Phase 4 — Group
All kept photos shown as a flat chronological stream. User inserts **entry-break dividers** between photos to define group boundaries. Each resulting segment is one entry. Groups get an auto-suggested working label (`2023-09-05 · Journal`, editable). Split a group by adding a divider; merge by removing one. "Grouping done" completes the phase.
### Phase 5 — Write
One group at a time; progress indicator ("3 of 12 groups done"). The notes panel is shown inline alongside the form for this phase.
**Layout:** photos (scrollable) on the left, form on the right.
**Journal mode fields:** Title, Date (pre-filled from first photo), City, Country, Body (plain text).
**Story mode adds:** Hero photo picker (click to select from group photos), Shortcode hints (optional free-text field for notes like "gallery block here" — written to the story body as an HTML comment block at export, not processed as actual shortcodes).
"Skip for now" defers a group; it can be revisited. Every form change auto-saves to state (debounced 500ms). Phase complete when all groups are written or explicitly skipped.
### Phase 6 — Export
Summary view: N journal entries, M stories ready to export. Skipped groups shown in a collapsible list (not exported).
"Export" downloads full-resolution originals from Immich and writes Grav-compatible folders under the mounted `user/pages/` volume.
**Output paths:**
- Journal: `user/pages/01.trips/{slug}/01.dailies/{YYYY-MM-DD}-{title-slug}.entry/`
- Story: `user/pages/01.trips/{slug}/04.stories/{title-slug}.story/`
**Entry file structure** (journal example):
```
2023-09-05-arrival-in-almaty.entry/
entry.md ← frontmatter + body
photo-1.jpg
photo-2.jpg
```
**Frontmatter written (journal):**
```yaml
title: 'Arrival in Almaty'
date: '2023-09-05 14:32'
template: entry
published: true
location_city: 'Almaty'
location_country: 'Kazakhstan'
hero_image: photo-1.jpg
```
**Frontmatter written (story):**
```yaml
title: 'The Silk Road Begins'
date: '2023-09-05 14:32'
template: story
published: true
hero_image: photo-1.jpg
```
If a destination folder already exists, a per-entry overwrite prompt is shown before writing. After export, group status is set to `exported` in state. Exported entries are never touched by subsequent upstream changes.
---
## Notes panel
A persistent drawer on the right side of every phase. Free-text, auto-saved (debounced 500ms) with a "Saved ✓" / "Saving…" indicator always visible.
In Phase 5 (Write), the notes panel content is shown inline next to the active group's form as a memory aid.
A "Convert to entry" action on any selected note text promotes it to a new group appended to the Phase 4 grouping and marks Phase 5 as stale. The user stays on the current phase — no automatic navigation.
---
## Back-navigation
A phase nav bar is always visible. Clicking an earlier phase is always allowed.
Going back to Phase N marks all completed phases above N as **stale**. Stale phases show a yellow warning banner: *"You changed earlier decisions — review this phase before exporting."*
Stale does not delete content. The user re-confirms by redoing the phase or by dismissing the banner explicitly (which clears the stale flag without redoing the work).
**Stale propagation:**
| Navigate back to | Marks stale |
|---|---|
| Phase 2 (triage) | Phases 3, 4, 5 |
| Phase 3 (curate) | Phases 4, 5 |
| Phase 4 (group) | Phase 5 |
| Phase 5 (write) | Nothing (export is always fresh) |
Exported entries (`status: exported`) are never affected by stale propagation.
---
## Robustness constraints
1. **Atomic writes** — state always written via `os.rename()` from a `.tmp` file; crash during write cannot corrupt existing state
2. **Reload safety** — all authoritative state is server-side JSON; a hard browser refresh re-fetches from disk, nothing is lost
3. **Photo proxy** — all Immich asset requests route through `/proxy/thumb/{id}` and `/proxy/original/{id}` on the Flask backend; the API key never reaches the browser
4. **Docker UID/GID**`user: "${UID}:${GID}"` in docker-compose ensures container writes are owned by the host user; without this, export writes fail or produce root-owned files
5. **Immich unreachable** — album list and photo grid show an error banner with a retry button; the app does not crash or show a Python traceback
6. **Download failure on export** — failed asset downloads are logged per-asset; export continues for remaining assets; post-export summary lists any failed assets
7. **Export idempotency** — if a destination folder already exists, a per-entry overwrite prompt is shown; no silent overwrites
8. **Notes auto-save** — debounced 500ms; "Saved ✓" indicator always visible; no save button needed
9. **No cascade to exported entries**`status: exported` is immutable in state; removing photos upstream or going back never modifies exported entry files or their state record
---
## Test strategy
Playwright (Python API). Tests run against the containerised app. A lightweight mock Immich server (Flask or `pytest-httpserver`) serves pre-canned fixture responses — no real Immich instance required.
One state fixture JSON per phase so individual phase tests do not require clicking through all earlier phases.
**Coverage per phase:**
| Phase | Tests |
|---|---|
| 1 — Album selection | Album list renders; single select initialises state; multi-select merges and deduplicates; resume prompt shown for existing state |
| 2 — Triage | Photos render in day groups; J/S/X keyboard shortcuts apply correct tag; completion gate requires all photos tagged |
| 3 — Curate | Only tagged photos shown; remove reverts to skip; drag reorder updates order in state |
| 4 — Group | Divider inserts create new group; divider removal merges groups; label edit persists |
| 5 — Write | Form auto-saves on change; notes panel saves; journal/story mode switch changes visible fields; skip-for-now defers group |
| 6 — Export | Overwrite prompt shown for existing folder; skipped groups excluded; exported status set in state after write |
| Cross-cutting | Hard refresh at each phase preserves state; back-nav stale banner appears; stale banner dismissal clears flag |
---
## New environment variables
Add to `.env`:
```
IMMICH_URL=http://<nas-ip>:2283
IMMICH_API_KEY=<your-key>
```
---
## File map
| Path | Action |
|---|---|
| `services/travel-memories/` | **Create** — Flask app source |
| `services/travel-memories/Dockerfile` | **Create** |
| `services/travel-memories/requirements.txt` | **Create** |
| `docker-compose.yml` | **Update** — add `travel-memories` service |
| `docs/immich-workflow/` | **Create** — state files per album (host-mounted); add `docs/immich-workflow/*.json` to `.gitignore` |
| `.env` | **Update** — add `IMMICH_URL` and `IMMICH_API_KEY` |
+1 -1
View File
@@ -22,7 +22,7 @@ chmod 600 ~/.netrc
echo "==> Downloading Grav $GRAV_VERSION"
cd "$WEBROOT"
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
wget --no-verbose "https://github.com/getgrav/grav/releases/download/${GRAV_VERSION}/grav-admin-v${GRAV_VERSION}.zip" -O grav-admin.zip
unzip -oq grav-admin.zip
cp -rf grav-admin/. .
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin