Compare commits

..
38 Commits
Author SHA1 Message Date
m038andClaude Opus 4.8 9440bdc29d docs: capture Grav new-user --admin-type gotcha (api.* vs admin.*)
On Admin2-only installs, login new-user auto-detects the admin permission
type to api-only, so accounts lack admin.login and silently fail on
access.admin.login-gated pages like /gpx-manager. Document the root cause,
the --admin-type both fix baked into make test-account, and prevention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:49:35 +02:00
m038andClaude Opus 4.8 fb9a47ea0c docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook
Update CLAUDE.md + architecture.md stack to Grav 2.0.4 stable, admin2 2.0.10,
gpm.releases: stable, and the three-category plugin-management model
(GPM-managed / custom-in-repo / remote-only git-sync). Add execution outcome,
Phase 3 prod fresh-install runbook (documentation-only), and mark plan Status
complete. Note the Form 9.1.10 filepond regression as a known issue owned by
the separate form-to-page rework.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:39:00 +02:00
m038andClaude Opus 4.8 58d2d70c13 test: self-contained local test account + urlencoded login
Add a dedicated, gitignored testrunner account auto-created by the suite
(make test-account, also invoked from global-setup) so `make test` no longer
needs the real account in .env. The target uses --admin-type both so the
account has admin.login for the gpx-manager specs. test-post.sh switches its
login POST to --data-urlencode to survive special chars in credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:36:39 +02:00
m038andClaude Opus 4.8 725131e128 test: retarget form-config and post tests from retired japan-korea-2026 to italy-2026-demo
The japan-korea-2026 trip is retired; italy-2026-demo is the vetted go-forward
fixture (already used by the Playwright suite and matching post-form.md parent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 17:50:05 +02:00
m038andClaude Opus 4.8 0f9a3b86b8 build: drop admin2/api zip-stash from server-install; install via GPM (option B)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:41:50 +02:00
m038andClaude Opus 4.8 a639dc6e41 build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:41:25 +02:00
m038andClaude Opus 4.8 3fffa02bec build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:38:58 +02:00
m038andClaude Opus 4.8 6ad62360c6 docs: apply doc-review fixes to Grav 2.0.4 upgrade plan
- Task 2 Step 8: version-assert Expected output now includes the
  'version:' prefix the grep command actually prints (coherence).
- Rollback: state core self-upgrade is forward-only (no revert) and warn
  that server-install.sh must never be used as a live-server rollback —
  it rm -rf's the untracked git-sync.yaml encrypted token (feasibility +
  adversarial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 16:35:20 +02:00
m038 a28ef8f8d7 Merge branch 'main' into grav-2.0.4-upgrade 2026-07-04 15:20:59 +02:00
m038andClaude Opus 4.8 1710ad8612 docs: document dual-repo submodule workflow and pointer-bump convention
Add docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md
covering the outer-repo + user/ submodule structure, when to bump the pin
(cross-repo feature boundaries, not routine content), the reachable/push-first
rules, and the worktree + per-worktree dev-server flow. Update CLAUDE.md's
folder explanation and add a "Dual-repo submodule structure" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:13:50 +02:00
m038andClaude Opus 4.8 52010c9733 chore: register user/ as a git submodule
Converts the orphaned gitlink (160000 with no .gitmodules) into a proper
submodule pointing at the content remote, and absorbs user/.git into
.git/modules/user so linked worktrees can populate user/. Bumps the pin
from 75da83d to the current pushed origin/main (924cfc1) as a clean
baseline. Enables per-worktree user/ checkouts for parallel dual-repo work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:04:27 +02:00
m038andClaude Opus 4.8 b47b1e9657 docs: add Grav 2.0.4 upgrade implementation plan
6 tasks: Phase 0 edits, local build+validate, Makefile remote targets
(fix broken remote-upgrade-grav; add update/git-sync-toggle/content-status),
server-install cleanup, test-env upgrade sequence, docs+prod runbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:02:59 +02:00
m038andClaude Opus 4.8 3085cede28 docs: correct upgrade spec channel + CLI commands after container verification
- GPM channel is system.yaml gpm.releases (testing->stable), not GRAV_CHANNEL
- core upgrade is 'bin/gpm self-upgrade -y'; existing remote-upgrade-grav
  target ('bin/grav upgrade') is broken and must be fixed
- add server content-pull + cache-clear + preflight steps to Phase 2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:58:27 +02:00
m038andClaude Opus 4.8 2f733f668d chore: register user/ as a git submodule
Converts the orphaned gitlink (160000 with no .gitmodules) into a proper
submodule pointing at the content remote, and absorbs user/.git into
.git/modules/user so linked worktrees can populate user/. Bumps the pin
from 75da83d to the current pushed origin/main (924cfc1) as a clean
baseline. Enables per-worktree user/ checkouts for parallel dual-repo work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:57:13 +02:00
m038andClaude Opus 4.8 99f290fbca docs: add Grav 2.0.4 upgrade + GPM-manage plugins design spec
Coordinated upgrade of core rc.10 -> 2.0.4 and promotion of
admin2/api/flex-objects to GPM management (option B). Documents the
dependency-forced atomicity, the three plugin-management categories,
git-sync handling (disable-during-upgrade, remote-only), and the
local -> test -> prod(documented-only) rollout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:54:28 +02:00
m038andClaude Opus 4.8 b1b7f64996 docs: add journal-post-form implementation plan (ce-plan + reviewed)
Enriched from the requirements-only brainstorm to implementation-ready:
7 units (active-trip parent injection, full field exposure, EasyMDE,
client-side HEIC conversion, Field Notes mobile styling, draft resilience,
tests). Applied headless doc-review fixes plus two owner decisions:
/post bundle built ESM + code-split (defer heic-to for cold-load), and
R20 draft persistence narrowed to text (photos re-picked after re-auth).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:31:07 +02:00
m038andClaude Opus 4.8 2695bce835 docs: compound learning — retiring a consolidated Grav sub-page
Capture the safe procedure + three non-obvious traps from the standalone-
page cleanup: keep the folder as a routable:false data container, repoint
Back-link fallbacks to the grandparent (silent direct-landing regression),
and sync the gitignored demo source + Makefile or the next demo-load undoes it.

Seed CONCEPTS.md (Trip, Entry, Story, Active Trip) and surface docs/solutions/
+ CONCEPTS.md in CLAUDE.md so future sessions discover them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:23:25 +02:00
m038andClaude Opus 4.8 7984b3a75e docs: sync CLAUDE.md + reference/guides with standalone-page cleanup
- Replace 'Shared feed-map partial' section with 'entry-map' partial + the
  single MapUtils.initEntryMap map path
- Trip architecture: two content subfolders (dailies/stories as inert
  routable:false containers); map/stats inline on trip page
- Fix stale references to deleted map/stats/dailies/stories templates in
  architecture.md (template list, page tree, GPX flow) and trip-switching.md
  (scaffold + verify steps)
- Correct GPX rendering: toGeoJSON bundled in map.js, no leaflet CDN
- Pre-launch smoke test now checks the trip page feed, not /dailies

Left docs/working/* (milestones, learnings, QA, plans) as point-in-time history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:06:55 +02:00
m038andClaude Opus 4.8 4dc5bf6812 build: add per-environment (test/prod) Makefile targets
Split env config into local vs remote:
- .env: local/shared config, always loaded (docker compose + make test)
- .env.test / .env.prod: full remote config, loaded on demand via ENV

Remote targets now generate -test/-prod variants (e.g. remote-install-prod);
a guard-env prerequisite blocks bare remote targets with no environment set.
Refresh .env.example to document the two-tier layout and add .env.prod/.env.test
to gitignore and the never-read list in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:02:13 +02:00
m038andClaude Opus 4.8 a1425e851b docs: mark standalone-page-cleanup plan complete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:58:59 +02:00
m038andClaude Opus 4.8 7407129812 test: re-point suite off retired standalone views
Phase-1 fallout: delete tests for deleted pages, re-point moved-behavior
tests to the trip/home page where the content now lives.

- maps.spec: keep M4/M7/M8 (home+trip maps); drop M1-3,5,6,9-11 (/map,
  /dailies & /stories mini-maps)
- map-ux: re-point MUX4/5 sort toggle to #trip-sort-toggle on trip page
- nav: N1->trip page, N2->home, drop N3 (/stats) + N5 (obsolete skip)
- dailies: T1->trip page; T2->trip page with oldest-first assertion;
  fix T3-T6 stale entry-detail selectors (.entry-* -> .journal-post-*,
  pre-existing failures from an earlier entry redesign)
- stories: S1/S5 -> trip feed story cards ([data-type=story])
- gpx-journey: load MapUtils from the trip page instead of /map
- a11y: A4 photo strips -> trip page; drop AX3 (covered by AX2)
- post/helpers: verify posted entry on the trip page (ACTIVE_TRIP_URL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:58:27 +02:00
m038andClaude Opus 4.8 a2457d6402 docs: mark cleanup Phase 1.5 + 2 complete; note test debt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:43:27 +02:00
m038andClaude Opus 4.8 2729a8c14c docs: mark standalone-page-cleanup Phase 1 complete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:23:14 +02:00
m038andClaude Opus 4.8 aeea6744bd chore: drop 02.map/03.stats from demo-load target
Phase 1 cleanup companion — demo-load no longer creates the retired
map/stats sub-pages or copies their now-deleted source .md files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:22:58 +02:00
m038andClaude Opus 4.8 a9ca68d790 docs: add standalone sub-page cleanup plan (map/stats/dailies/stories retire)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:15:55 +02:00
m038andClaude Opus 4.8 9fbc61ee6e docs: mark map-init consolidation plan complete
Status → Complete; document the markLatest opt added during execution,
the window.tripMap/homeMap exposure, and the test outcome (38 passed;
M6/H1 pre-existing and out of scope).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-28 00:11:45 +02:00
m038andClaude Opus 4.8 abab85ca5c docs: add map-init consolidation plan (reviewed)
Plan for extracting duplicated MapLibre init into a shared
MapUtils.initEntryMap(opts). Incorporates ce-doc-review findings:
card-absent fallback invariant scoped (no-op→navigate), exact
isLatest predicate, and the home-map attribution restyle documented
as an intentional change. Filter-hidden-card click left as a noted
known limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 23:43:03 +02:00
m038andClaude Opus 4.8 0427b75b6e chore: update .env.example
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 22:35:43 +02:00
m038andClaude Opus 4.8 4665e014be chore: bump user/ to home/trip view convergence theme work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 22:35:43 +02:00
m038andClaude Opus 4.8 567ea7bb89 docs: add home/trip convergence plan (complete) and update design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 22:35:43 +02:00
m038andClaude Opus 4.8 dd3c89e88a docs(claude): document shared trip-feed-col + home-predeparture partials
Adds a 'Shared trip-feed-col partial' section (callers, param table, the
home-predeparture sibling, and the initTripStats JS glue) and points the
trip-page filter-bar note at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 22:32:11 +02:00
m038andClaude Opus 4.8 301de51add docs: add home/trip view convergence design spec
Converge home-active feed-col chrome (date range, filter bar, stats/
cycling panels) with the trip page via a shared partial + shared
initTripStats() glue. Map convergence deferred to a later spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
2026-06-27 19:37:11 +02:00
m038andClaude Sonnet 4.6 119e8e5a35 chore: restore docs/immich-workflow gitignore entry dropped in previous commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-26 18:35:37 +02:00
m038andClaude Sonnet 4.6 532fbda801 fix(home): wrap map init in DOMContentLoaded to fix broken homepage map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:34:21 +02:00
m038andClaude Sonnet 4.6 fcd8ed13ce chore: ignore services/ dir, remove stale travel-memories gitignore entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-26 18:25:29 +02:00
m038andClaude Sonnet 4.6 a80b0a90fc chore: remove travel-memories service from repo (moved to separate project)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-26 18:23:54 +02:00
m038andClaude Sonnet 4.6 4df191b9f4 docs: clean up research notes, add production backlog and working docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-26 18:22:58 +02:00
m038andClaude Sonnet 4.6 0d3a3451f3 chore: update .env.example and fix remote-fetch-content sparse-checkout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-26 18:22:53 +02:00
85 changed files with 3168 additions and 3546 deletions
+57 -12
View File
@@ -1,26 +1,71 @@
# SSH connection
REMOTE_USER=root
# .env.example — template for the project's environment files.
#
# There are TWO kinds of env file, loaded by the Makefile in this order:
#
# .env → LOCAL / shared config. ALWAYS loaded. NO remote credentials.
# Used by local targets (docker compose ${UID}/${GID} + the
# travel-memories env_file, and `make test-post` / `make test`).
# Copy the LOCAL section below into it.
#
# .env.test → REMOTE config for the test environment.
# .env.prod → REMOTE config for production.
# Loaded only when a remote target sets ENV, e.g.
# `make remote-install-prod`. Copy the REMOTE section below into
# each, with the values for that environment.
#
# .env, .env.test and .env.prod are all gitignored — never commit real values.
# This .example file is the only one that IS committed; keep its values as
# placeholders.
# ─────────────────────────────────────────────────────────────────────────────
# LOCAL → copy into .env
# ─────────────────────────────────────────────────────────────────────────────
# Host user/group id for container file ownership (docker-compose ${UID}:${GID}).
# Match your local user: run `id -u` / `id -g` (usually 1000 on a single-user box).
UID=1000
GID=1000
# Local Grav dev server. GRAV_BASE_URL is used by the Playwright suite and
# scripts/test-post.sh.
GRAV_BASE_URL=http://localhost:8081
# Test login for `make test` — OPTIONAL. If unset, the suite auto-creates and
# uses a dedicated local-only account (testrunner / Testpass1234), gitignored so
# it is never pushed to prod (see `make test-account`). Override only to test as
# a different account; keep the password free of shell/Make/URL-special chars.
# GRAV_TEST_USER=testrunner
# GRAV_TEST_PASS=Testpass1234
GRAV_USER_DIR=/absolute/path/to/travel-blog-intotheeast/user
# travel-memories service (docker-compose `env_file: .env`). Fill in whatever
# that Flask app needs — e.g. its Immich connection. Leave commented until set.
# IMMICH_URL=
# IMMICH_API_KEY=
# ─────────────────────────────────────────────────────────────────────────────
# REMOTE → copy into .env.test AND .env.prod (with per-env values)
# ─────────────────────────────────────────────────────────────────────────────
# SSH connection to the target server.
REMOTE_USER=deploy
REMOTE_HOST=example.com
REMOTE_PORT=22
REMOTE_HOME=/home/example.com
# Server paths (override here if your setup differs from the Makefile defaults)
# Server paths. Optional — default to $(REMOTE_HOME)/public_html and
# $(REMOTE_HOME)/site-config. Set explicitly only if the layout differs
# (e.g. a per-domain webroot like /home/deploy/domains/test.example.com/public_html).
WEBROOT=/home/example.com/public_html
SITE_CONFIG_DIR=/home/example.com/site-config
# Grav
GRAV_VERSION=2.0.0-rc.10
# Grav version installed by scripts/server-install.sh (remote-install).
GRAV_VERSION=2.0.4
# Repos
# Repos cloned/pulled on the server.
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
MAIN_REPO=https://gitea.example.com/org/travel-blog-intotheeast.git
# Gitea credentials — never commit these; only ever in .env (local) or ~/.env-project (server, temporary)
# Gitea credentials used by remote-install / remote-env-setup.
GITEA_HOST=gitea.example.com
GITEA_USER=deploy-user
GITEA_TOKEN=your-gitea-personal-access-token
# Test credentials — used by 'make test-post' (must be a valid Grav site login user)
GRAV_TEST_USER=mischa
GRAV_TEST_PASS=TravelBlog2026!
GRAV_BASE_URL=http://localhost:8081
+5
View File
@@ -1,5 +1,7 @@
# Environment
.env
.env.prod
.env.test
# Grav CMS
/user/
@@ -20,6 +22,9 @@ playwright-report/
tests/.auth/
user/pages/**/ui-test-trip/
# Services (moved to separate projects)
services/
# travel-memories state
docs/immich-workflow/*.json
+4
View File
@@ -0,0 +1,4 @@
[submodule "user"]
path = user
url = ssh://git@m038-nas.tail63ee39.ts.net:222/m038/intotheeast-com-content.git
branch = main
+77 -28
View File
@@ -8,16 +8,22 @@
- **./**: Grav CMS dev environment for intotheeast travel blog
- **scripts/**: Server install and maintenance scripts
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here)
- **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
### Current stack
- **Grav:** 2.0.0-rc.10 (baked into the custom Docker image via `Dockerfile`)
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`)
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta`
- **Grav:** 2.0.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
- **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
> Known issue (2026-07-04): Form 9.1.10 regressed the `filepond` upload field — on the post-submit re-render, `filepond.html.twig` runs `merge` on a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6 `post.spec.js` UI specs. Being fixed separately in the form-to-page/image-upload rework — **do not** work around it here.
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
@@ -27,39 +33,73 @@ The Docker dev server runs at **http://localhost:8081** (mapped from container p
The site is structured around Trip entities. Key facts:
- Active trip is set in `user/config/site.yaml``active_trip: japan-korea-2026`
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has: `01.dailies/`, `02.map/`, `03.stats/`, `04.stories/`
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
- Post form parent (`post-form.md``pageconfig.parent`) **must be kept in sync** with `active_trip`
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories) — do NOT add nav links back to `/dailies`, `/stats`, `/stories` on the trip page
- Stats are shown inline on the trip page via a toggle; the standalone `/stats` sub-page still exists as a URL but is not linked from the trip page
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
### Shared feed-map partial
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial
The mini-map above the feed is shared across two pages via a Twig partial:
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle.
- **Partial:** `user/themes/intotheeast/templates/partials/feed-map.html.twig`
- **Used by:** `dailies.html.twig` and `stories.html.twig`
- **NOT used by:** `trip.html.twig` (uses its own `#trip-map` / `.home-map-col` layout)
The map **markup + invocation** is shared via one partial:
**Parameters (passed via `{% include ... with {...} only %}`):**
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
| Parameter | Type | Description |
|---|---|---|
| `map_entries` | array | `[{lat, lng, title, slug, url, type, force_connect, transport_mode}]` |
| `map_id` | string | HTML id for map div: `'feed-map'` or `'stories-map'` |
| `map_var` | string | JS global variable: `'feedMap'` or `'storiesMap'` |
| `link_href` | string\|null | "View full map" link URL; `null` hides it |
| `card_prefix` | string | Scroll-to ID prefix: `'entry-'` (dailies) or `'story-'` (stories) |
| `trip_page` | Page | Trip page object for autoconnect setting |
| `show_journey` | bool | `true` draws the route connector; `false` skips it |
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
The partial always: starts attribution collapsed, shows the fullscreen button (mobile-only, CSS `display:none` ≥769px), and on marker click scrolls to `#<card_prefix><slug>` + flashes `.is-highlighted`.
**Parameters:**
| Parameter | Type | Trip passes | Home passes |
|---|---|---|---|
| `map_id` | string | `'trip-map'` | `'home-map'` |
| `map_global` | string | `'tripMap'` | `'homeMap'` |
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
| `card_prefix` | string | `'entry-'` | `'entry-'` |
| `story_markers` | bool | `true` (diamond markers) | `false` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them.
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`.
### Shared trip-feed-col partial
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map.
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `home.html.twig` picks it with `{% if all_items|length == 0 %}``home-predeparture` `{% else %}``trip-feed-col`. Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.
**Parameters (`trip-feed-col`):**
| Parameter | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` / `story_count` | int | counts | counts |
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` |
| `show_sort` | bool | `true` | `false` (home keeps its own feed order, no sort button) |
`home-predeparture` takes only `trip_page`.
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js` (rebuild with `make build-assets`; never hand-edit `js/main.js`). It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
### GPX file management
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `trip.html.twig` (and `home.html.twig`) via `trip_page.media.all`, filtered to `.gpx`, and passed to the shared `entry-map` partial.
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
@@ -84,11 +124,11 @@ Two places hardcode the active trip slug. Grav's config and page frontmatter are
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the standard four subfolders.
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the two content subfolders `01.dailies/` and `04.stories/` (each with an inert `routable:false` container `.md`), plus the trip's `trip.md`. Do **not** recreate `02.map/` or `03.stats/` — those standalone views were retired.
### Environment
**Never read `.env`**it contains sensitive credentials. You may pass it to commands (e.g. `docker compose`, `make`) but never read its contents directly. Ask the user if you need environment-specific information.
**Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information.
### Remote operations
@@ -106,6 +146,15 @@ Always use `make` commands for anything on the production server (`make remote-i
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
### Dual-repo submodule structure
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
- **Worktrees for parallel work.** A worktree off `main` gets its own `user/` (`git submodule update --init user`) and can run its own dev server (`docker compose -p itte-<feature> up` — the `./user` mount is relative, so each worktree serves its own content). Tooling worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
## 1. Environment modes
### Rule: do not switch modes during development
@@ -130,7 +179,7 @@ Before going live, change in `user/config/system.yaml`:
|---|---|---|
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/trips/italy-2026-demo/dailies` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
### What the cache-on-save plugin handles
+28
View File
@@ -0,0 +1,28 @@
# Concepts
Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
## Relationships
A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Active Trip** at a time; it is the one surfaced on the home page and the target for new posts. Entries and Stories are always scoped to a Trip — they do not exist independently.
## Trip
### Trip
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
### Active Trip
The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip.
### Entry
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
*Avoid:* daily, journal post
The Trip's journal section is labelled "Journal" and lives in the Trip's `dailies` container, so an Entry is colloquially "a daily"; in templates and page metadata the same thing is called an `entry`. Entries carry a date, optional location and coordinates, weather, and photos, and are ordered by date within a Trip.
### Story
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
## Flagged ambiguities
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM getgrav/grav
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip' \
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
-o /tmp/grav-admin.zip \
&& unzip -q /tmp/grav-admin.zip -d /tmp \
&& cp -rf /tmp/grav-admin/assets /var/www/html/ \
+65 -18
View File
@@ -1,4 +1,13 @@
# Local/shared config — always loaded. Keep remote credentials OUT of here;
# those live in .env.test / .env.prod. (docker compose also reads .env directly
# for ${UID}/${GID} substitution and the travel-memories env_file.)
-include .env
# Remote config — loaded only when targeting an environment. ENV is set
# automatically by the env-suffixed remote targets (e.g. `make remote-install-prod`);
# each .env.<ENV> holds a full, self-contained set of remote vars.
ENV ?=
-include .env.$(ENV)
export
REMOTE_PORT ?= 22
@@ -6,15 +15,44 @@ SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
WEBROOT ?= $(REMOTE_HOME)/public_html
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
# ── Environment guard + generated per-env remote targets ──────────────────────
# Every remote-* target below gains `-test` / `-prod` variants, e.g.
# make remote-install-prod → runs remote-install with ENV=prod
# Calling a bare remote target (no ENV) fails via guard-env.
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
ENVS := test prod
guard-env:
@test -n "$(ENV)" || { echo "ERROR: no environment. Use an env-suffixed target, e.g. 'make remote-install-prod'."; exit 1; }
@test -f ".env.$(ENV)" || { echo "ERROR: missing .env.$(ENV)"; exit 1; }
define make-env-target
$(1)-$(2): ; @$$(MAKE) --no-print-directory $(1) ENV=$(2)
endef
$(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,$(t),$(e)))))
# ── Tests ─────────────────────────────────────────────────────────────────────
# Local test account — auto-created, never committed (see user/.gitignore).
# Keep the password free of shell/Make/URL-special chars so every consumer agrees.
GRAV_TEST_USER ?= testrunner
GRAV_TEST_PASS ?= Testpass1234
test-account:
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
test-config:
@bash scripts/test-form-config.sh
test-post:
test-post: test-account
@bash scripts/test-post.sh
test-ui:
test-ui: test-account
@npx playwright test
test: test-config test-post test-ui
@@ -52,10 +90,8 @@ install-plugins:
demo-load:
# Load italy-2026-demo trip (create pages if absent)
docker exec intotheeast_grav bash -c "\
mkdir -p /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies /var/www/html/user/pages/01.trips/italy-2026-demo/02.map /var/www/html/user/pages/01.trips/italy-2026-demo/03.stats /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories && \
mkdir -p /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories && \
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/trip.md /var/www/html/user/pages/01.trips/italy-2026-demo/trip.md 2>/dev/null || true && \
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/map.md /var/www/html/user/pages/01.trips/italy-2026-demo/02.map/map.md 2>/dev/null || true && \
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/stats.md /var/www/html/user/pages/01.trips/italy-2026-demo/03.stats/stats.md 2>/dev/null || true && \
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/stories.md /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/stories.md 2>/dev/null || true && \
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/04.stories/. /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/ 2>/dev/null || true && \
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/dailies/. /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies/ && \
@@ -82,21 +118,21 @@ content-pull:
# ── Remote credentials ─────────────────────────────────────────────────────────
remote-env-setup:
remote-env-setup: guard-env
@$(SSH) "printf 'GITEA_HOST=%s\nGITEA_USER=%s\nGITEA_TOKEN=%s\n' \
'$(GITEA_HOST)' '$(GITEA_USER)' '$(GITEA_TOKEN)' > ~/.env-intotheeast && chmod 600 ~/.env-intotheeast"
@echo "Credentials written to server. Run 'make remote-env-remove' when done."
remote-env-remove:
remote-env-remove: guard-env
@$(SSH) "rm -f ~/.env-intotheeast"
@echo "Credentials removed from server."
# ── Remote: initial install ────────────────────────────────────────────────────
remote-wipe:
remote-wipe: guard-env
$(SSH) "cd $(WEBROOT) && rm -rf assets backup bin cache images logs system tmp vendor webserver-configs index.php .htaccess CHANGELOG.md LICENSE.txt README.md"
remote-install:
remote-install: guard-env
$(SSH) "WEBROOT=$(WEBROOT) \
SITE_CONFIG_DIR=$(SITE_CONFIG_DIR) \
USER_REPO=$(USER_REPO) \
@@ -110,24 +146,35 @@ remote-install:
# ── Remote: ongoing maintenance ────────────────────────────────────────────────
remote-fetch:
remote-fetch: guard-env
$(SSH) "git -C $(SITE_CONFIG_DIR) checkout main && git -C $(SITE_CONFIG_DIR) pull"
remote-fetch-content:
$(SSH) "git -C $(WEBROOT)/user checkout main && git -C $(WEBROOT)/user pull"
remote-fetch-content: guard-env
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
remote-install-plugins:
remote-install-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
remote-update-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
remote-upgrade-grav:
$(SSH) "cd $(WEBROOT) && php bin/grav upgrade"
remote-upgrade-grav: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
remote-clean:
remote-git-sync-disable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
remote-git-sync-enable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
remote-content-status: guard-env
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
remote-clean: guard-env
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
remote-maintenance-on:
remote-maintenance-on: guard-env
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
remote-maintenance-off:
remote-maintenance-off: guard-env
$(SSH) "bash -s off $(WEBROOT)" < scripts/server-maintenance.sh
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build: .
container_name: intotheeast_grav
environment:
- GRAV_CHANNEL=beta
- GRAV_CHANNEL=production
- APACHE_RUN_USER=#1000
- APACHE_RUN_GROUP=#1000
ports:
+7 -9
View File
@@ -42,23 +42,21 @@ pageconfig:
## Step 3 — Create the new trip page tree
Create the standard four subfolders under `user/pages/01.trips/<new-slug>/`:
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
```
user/pages/01.trips/japan-korea-2026/
├─ trip.md ← title, date_start, date_end, cover_image, album_url
├─ 01.dailies/
│ └─ dailies.md ← template: dailies (list page)
├─ 02.map/
│ └─ map.md ← template: map
├─ 03.stats/
│ └─ stats.md ← template: stats
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
└─ 04.stories/
└─ stories.md ← template: stories
└─ stories.md ← inert container: template: default, routable: false, visible: false
```
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable.
Fields in `trip.md` to update:
| Field | Example | Notes |
@@ -85,5 +83,5 @@ This commits and pushes the `user/` repo to Gitea. The webhook triggers a produc
After pushing, check:
1. Home page shows the new trip (title and date)
2. Submit a test entry via `/post` — verify it lands under `user/pages/01.trips/<new-slug>/01.dailies/`
3. Map at `/trips/<new-slug>/map` shows the correct (empty or GPX-only) state
2. Submit a test entry via `/post` — verify it lands under `user/pages/01.trips/<new-slug>/01.dailies/` and appears in the feed at `/trips/<new-slug>`
3. The inline map on `/trips/<new-slug>` shows the correct (empty or GPX-only) state
+23 -16
View File
@@ -8,13 +8,14 @@ How the intotheeast site hangs together.
| Layer | Technology | Notes |
|---|---|---|
| CMS | Grav 2.0.0-rc.10 | Flat-file PHP CMS; no database |
| Admin | Admin2 v2.0.0-rc.15 | Plugin slug: `admin2` (not `admin`) |
| CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
| Admin | Admin2 v2.0.10 | Plugin slug: `admin2` (not `admin`) |
| GPM channel | `stable` | Authoritative in `user/config/system.yaml``gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
| Maps | MapLibre GL JS | Replaced Leaflet; all 3 map templates use it |
| GPX rendering | maplibre-gl-leaflet-gpx (CDN) | Renders GPX files as route layers |
| Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (`MapUtils.initEntryMap`) on trip + home |
| GPX rendering | toGeoJSON (bundled in `js/map.js`) | Parses GPX → GeoJSON route layers client-side; no CDN |
---
@@ -49,6 +50,14 @@ Other notable plugins:
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
| `admin2` | Admin panel at /admin |
### Plugin management model
Three categories, by how each plugin is installed and maintained:
1. **GPM-managed** (`plugins.txt``make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`. Versioned in the user repo.
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
---
## Template hierarchy
@@ -62,15 +71,13 @@ templates/
├─ home.html.twig ← extends base; context-aware two-column layout
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
├─ dailies.html.twig ← extends base; journal feed list
├─ map.html.twig ← extends base; full-height MapLibre trip map
├─ stats.html.twig ← extends base; trip stats (days, distance, elevation)
├─ stories.html.twig ← extends base; stories grid
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
└─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
```
Partials live in `templates/partials/`. Currently one partial: `base.html.twig` (the site shell extended by all page templates).
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), and `weather-icons.html.twig`.
---
@@ -82,13 +89,13 @@ The site is organized around Trip entities. The active trip is set in `user/conf
user/pages/01.trips/
└─ japan-korea-2026/
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
├─ *.gpx ← GPX route files (served as page media; auto-detected by map.html.twig)
├─ 01.dailies/ ← journal entries (template: dailies list + entry children)
─ 02.map/map.md ← template: map
├─ 03.stats/stats.md ← template: stats
└─ 04.stories/ ← story pages (template: stories list + story children)
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
─ 04.stories/ ← story children (container .md is routable:false)
```
`01.dailies/` and `04.stories/` are inert data containers — the trip page aggregates their children; visiting the container routes directly 404s/redirects. (The former `02.map/` and `03.stats/` folders were removed with their view templates.)
---
## GPX data flow
@@ -100,10 +107,10 @@ GPX file uploaded to trip page media
user/pages/01.trips/<slug>/*.gpx
map.html.twig: trip_page.media.all → filter .gpx files → pass as JS array
trip.html.twig / home.html.twig: trip_page.media.all → filter .gpx → entry-map partial
MapLibre source: each GPX file added as a GeoJSON source via maplibre-gl-leaflet-gpx
MapLibre source: each GPX file parsed by toGeoJSON (bundled in js/map.js) → GeoJSON source
Connector suppression: same-file 10km proximity check prevents spurious inter-track segments
-141
View File
@@ -1,141 +0,0 @@
# FindPenguins — Feature Research
*Researched June 2026. Source: findpenguins.com, App Store, support docs, reviews.*
---
## Overview
FindPenguins is a German travel tracking and community app. Core features are free; premium subscription ($4.99/month or $32.99/year) unlocks more photos per post and ebook exports. Revenue comes from subscriptions and printed photo books ($40240). It leans more social than Polarsteps — discovery, community, and inspiring other travelers are central to its identity.
---
## Core User Flow
1. User creates a **Trip** (title, dates, cover)
2. App runs in background with **automatic GPS + flight detection tracking**
3. User creates **Footprints** — individual journal entries tied to a location and time
4. Each Footprint can contain: location, title, date, text story, photos, video, weather
5. Footprints appear in a **chronological timeline** per trip
6. Trip is shareable; social followers can view, comment, react
7. At the end, optionally order a printed **photo book**
---
## Map Features
- **Automatic route tracking**: GPS + flight detection, works offline
- **Interactive world map**: route lines drawn between footprints
- **3D flyover video**: auto-generated cinematic route visualization, free
- **Countries/continents highlighted**: on personal map
- **Visited places completion**: stats on what % of a country/region visited
- Battery usage: ~4% per day (comparable to Polarsteps)
- Route visualized as path on map, not just pins
---
## Footprints (Journal Entries)
Each "Footprint" is the core content unit:
- **Location**: GPS-detected, shown as city/country; uses reverse geocoding (LocationIQ)
- **Title**: required, user-set
- **Date**: required, defaults to current time
- **Text story**: freeform journal text
- **Photos**: 6 (free) / 10 (premium) per footprint
- **Videos**: 1 (free) / 2 (premium) per footprint
- **Weather**: auto-populated at location + time; manually editable
- **Place name**: auto-detected city/neighborhood/country, editable
- **Selective sharing**: each footprint can be public, friends-only, or private
- **Delayed posting**: option to share location with a time delay (privacy feature)
---
## Photo Handling
- Up to 6 photos per footprint (free), 10 (premium)
- 1 video per footprint (free), 2 (premium)
- Photos displayed in carousel/grid within footprint
- High-res stored for photobook printing
- Cover photo selectable per trip
---
## Statistics
- Countries visited (count + list + % world)
- Continents visited
- Total distance traveled
- Number of footprints / trips
- Days on the road
- World coverage percentage
- Shown on profile and within photo books
---
## Social & Discovery Features
- **Follower system**: follow other travelers, see their public footprints
- **Comments**: friends/followers can comment on individual footprints
- **Reactions**: like/react to footprints
- **Discovery**: browse 10M+ travel experiences from other users by destination
- **Group trips**: invite co-travelers to add footprints to a shared trip (with known bug: co-travelers can delete each other's content)
- **Travel inspiration**: browse community trips to plan your own
- **Explore by destination**: search real traveler experiences for any city/country
---
## Privacy Controls
- Per-footprint visibility: public / friends / private
- **Delayed sharing**: share location with a configurable time delay (safety feature for solo travelers)
- Trip-level privacy: whole trip can be private or public
- Can hide real-time location from followers
---
## Photo Book (Premium)
- Printed book with maps, photos, text, statistics, and friend comments
- €40–€240 depending on size/format (hardcover or layflat)
- Free ebook version for premium subscribers
- 5% discount on books with premium
---
## 3D Flyover Video
- Free feature: auto-generates a cinematic 3D video of your route
- Shareable directly from the app
- No native app required for viewing (shareable link)
---
## Offline Capability
- Tracker works fully offline (GPS, flight detection)
- Footprints can be created and edited offline
- Syncs when connected
---
## What Makes FindPenguins Distinctive
1. **Flight detection**: auto-detects flights and logs them on the route
2. **3D flyover video**: compelling visual output, free
3. **Delayed sharing**: useful for solo travelers worried about broadcasting real-time location
4. **Richer social layer**: comments on individual footprints, community discovery
5. **Destination exploration**: browse real traveler posts for any place (like a user-generated travel guide)
6. **Premium photo books**: more polished physical product with friend comments included
---
## Limitations (relevant to our context)
- Requires native app for GPS/flight tracking — not reproducible in a web CMS
- Social discovery features irrelevant for a solo personal blog
- Group trip feature has a bug (co-travelers can delete your content)
- Premium paywall for basic things like more than 6 photos per post
- Community/social focus means the UX is designed around a social graph we don't have
- 3D flyover video requires proprietary rendering pipeline
- Real-time delayed sharing is a privacy feature for apps broadcasting live location — moot for a blog that posts after the fact
-137
View File
@@ -1,137 +0,0 @@
# Polarsteps — Feature Research
*Researched June 2026. Source: polarsteps.com, App Store, support docs, reviews.*
---
## Overview
Polarsteps is a travel tracking and journaling app used by 20M+ travelers. It is ad-free, primarily free to use, with paid travel books as the main revenue stream. It positions itself as "by travelers, for travelers" — clean, minimal, focused on personal memory-keeping and sharing with close friends/family rather than a social discovery platform.
---
## Core User Flow
1. User creates a **Trip** (name, start/end dates, cover photo)
2. App runs in background and **auto-tracks GPS route** continuously (dots on map)
3. App auto-generates **Step Suggestions** when you stay somewhere — a notification asks "Are you in [City]? Add a step?"
4. User accepts or manually creates a **Step**: a journal entry tied to a location
5. Each Step gets: title, text, photos/videos, date, and auto-populated metadata
6. Steps appear in a **timeline feed** ordered chronologically
7. Trip is shareable via link; friends/family can follow in real time
---
## Map Features
- **Route tracking**: GPS + WiFi + cell towers → white dots plotted on world map as you move
- **Offline tracking**: stores locally, syncs when connected
- **Travel Tracker steps**: actual route taken (not straight lines), with transport mode tagging (car, bus, train, taxi, walk, fly)
- **Route visualization**: colored line on map connecting all steps
- **Countries/continents visited**: highlighted on world map
- **Battery usage**: ~4% per day (very efficient)
- **World completion %**: gamified stat showing % of the globe visited
- Tracks distance, speed, and estimated travel time between steps
---
## Steps (Journal Entries)
Each "Step" is the core content unit:
- **Location**: auto-detected city/country, adjustable
- **Title**: auto-suggested from location, editable
- **Date/time**: auto from GPS
- **Text**: rich freeform journal text
- **Photos**: unlimited (mobile app), displayed in a grid/carousel
- **Videos**: supported on mobile only, excluded from printed books
- **Weather**: auto-populated (temperature, conditions) at time of step
- **Altitude**: recorded from GPS
- **GPS coordinates**: stored and displayed
- **Transport**: mode of travel to reach this step (car/train/fly/etc.)
---
## Photo Handling
- Add photos directly from camera roll per step
- Choose cover photo for the trip
- Photos displayed in gallery within each step
- High-resolution stored for travel book printing
- No hard per-step photo limit mentioned (effectively unlimited)
- Videos supported on mobile, excluded from print
---
## Statistics
Displayed on trip and profile level:
- Total km/miles traveled
- Countries visited (count + list)
- Continents visited
- Number of steps/entries
- Days on the road
- World completion percentage
- Furthest point from home
- Number of followers / following
---
## Sharing & Social Features
- **Privacy**: "Only me", "Followers only", or "Public"
- **Shareable link**: send a URL to anyone to follow the trip live
- **Followers**: people can follow your profile and see all public trips
- **Reactions/comments**: followers can react and comment on steps
- **Social media sharing**: export to Facebook, Instagram, etc.
- **Travel Buddy**: invite friends to join and co-document a trip together
- **Editors' Choice**: curated featured trips for discovery (like a magazine)
- **Trip Reels**: auto-generated short video from photos/videos + visited places, shareable
---
## Planning Features (2025 addition)
- **AI Itinerary Builder**: generates multi-stop travel plan on the map, with transport modes
- **Accommodation import**: forward booking confirmation emails to plan@polarsteps.app → appears on map
- **Activity planning**: add stays, restaurants, activities to itinerary
- **Travel DNA**: personality-based personalization for AI suggestions
---
## Travel Book
- Print a hardback book of your trip (€3080, 24300 pages)
- Each step on its own page: photo, text, map thumbnail, metadata
- Statistics page at the end
- Designed, high-quality output — main revenue for Polarsteps
---
## Offline Capability
- Full offline posting (text, photos)
- GPS route tracking continues offline
- All data syncs when back online
---
## What Makes Polarsteps Distinctive
1. **Simplicity** — minimal UI, auto-everything, almost no friction to log a day
2. **Route tracking** — actually shows where you walked/drove, not just pins
3. **"Step suggestions"** — proactive nudges to journal without opening the app
4. **Printed book** — the premium product, excellent quality
5. **Ad-free** — rare among free travel apps
6. **Battery efficiency** — 4% per day, usable on long trips
---
## Limitations (relevant to our context)
- Requires native mobile app for GPS tracking (cannot do in browser)
- Videos excluded from print
- Social/discovery features add little value for a solo personal blog
- AI itinerary builder overkill for one-person blog
- Travel Buddy / follower system assumes a social graph we don't have
- Reels require the native app video processing pipeline
@@ -0,0 +1,104 @@
---
title: Dual-repo submodule workflow — outer dev-env repo + user/ content submodule
date: 2026-07-04
category: architecture-patterns
module: Repo structure — outer repo + user/ content repo
problem_type: architecture_pattern
component: git
severity: medium
applies_when:
- Starting a feature that touches both the outer repo and user/ (theme, plugins, pages)
- Setting up a git worktree for long-running work while doing other work in parallel
- Deciding when to bump the user/ submodule pointer in the outer repo
- A git worktree of the outer repo shows an empty or broken user/ directory
- Seeing a persistent "M user" / "m user" dirty state in the outer repo
tags: [git, submodule, worktree, dual-repo, user-repo, docker, content-sync, pointer-bump]
---
# Dual-repo submodule workflow
## Context
This project is **two independent git repositories** that happen to be nested:
- **Outer repo** (`intotheeast-com.git`) — the Grav dev environment: `tests/`, `scripts/`, `docs/`, `docker-compose.yml`, `Dockerfile`, `Makefile`, `CLAUDE.md`.
- **`user/` repo** (`intotheeast-com-content.git`) — all site content and the theme: `pages/`, `config/`, `accounts/`, `themes/`. It has its own remote (the Gitea content mirror) and its own release cadence (`make content-push` → webhook → production pull).
As of 2026-07-04, the outer repo tracks `user/` as a **proper git submodule** (`.gitmodules` at the outer root, git dir absorbed into `.git/modules/user`). Before that it was an *orphaned gitlink* — a `160000` tree entry with no `.gitmodules`, so git had no URL to populate or update it. That broke worktrees (a fresh outer worktree got an empty `user/`) and offered no supported sync path.
## Why a submodule (and not untracking)
Two options were weighed: make it a real submodule, or stop tracking `user/` in the outer repo entirely (gitignore it, symlink the real checkout in).
The submodule was chosen deliberately, for one reason that outweighs its ceremony:
- **Routine content churn is benign** — day-to-day entries/stories change `user/` constantly and never break the dev environment. Those changes do **not** need to be reflected in the outer repo.
- **Cross-repo *features* must be tracked together.** A feature like the journal post-form touches both repos (a plugin + theme JS/CSS in `user/`, and tests/docs in the outer repo). The outer repo pinning an exact `user/` commit records *"this dev-env state expects this content/theme state"* — so checking out the outer feature also gets the matching `user/` code. That coupling is real and worth having.
- It enables **per-worktree `user/` checkouts**, which is what makes true parallel work across both repos possible (see below). This was a hard requirement.
The cost accepted: a persistent `M user` dirty signal (intrinsic to submodules under active development) and the possibility of gitlink merge conflicts between outer branches. Neither is removed by the submodule; they are the price of version pinning.
## The pointer-bump convention
The outer repo's `user` gitlink stores an exact `user/` commit SHA. **When to bump it:**
- **Routine content changes → do not bump.** Push content with `make content-push` and leave the outer pin where it is. A stale pin during normal content work is expected and harmless.
- **At the end of a cross-repo feature → bump once.** When the feature's `user/` work is finalized, update the outer pin to the finished `user/` commit, as the final step of the feature (its own `chore: bump user pointer to <sha>` commit, or folded into the final integration commit).
Two rules keep the pin from dangling for other machines/clones:
1. **Pin a commit reachable from `user/`'s published `main`.** Prefer the **merge-to-main commit**. Pinning a feature-branch tip is safe *only* if that exact commit survives onto `main` (fast-forward / no-squash merge); a squashed-away tip becomes an orphaned SHA and `git submodule update` fails elsewhere.
2. **Push `user/` before the outer repo.** The submodule golden rule: the superproject references a child SHA, so the child must already be pushed. `make content-push` handles the `user/` push — just do it before pushing the outer branch.
Production is unaffected either way: prod pulls `user/` directly via the content-remote webhook, independent of the outer repo's pin. The pin is **dev-side coordination only**.
## Parallel work: worktree + its own dev server
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
Set up a feature worktree off `main`:
```bash
# outer worktree on a new feature branch
git worktree add .worktrees/<feature> -b feat/<feature> main
cd .worktrees/<feature>
# populate user/ at the pinned SHA, then branch it for the cross-repo work
git submodule update --init user
git -C user checkout -b feat/<feature>
# its own dev server — separate project name + port from the main checkout's :8081
docker compose -p itte-<feature> up -d
```
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
### Teardown
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step:
```bash
docker compose -p itte-<feature> down
cd "$(git rev-parse --show-toplevel)" # back to the main checkout
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree
git worktree remove .worktrees/<feature> # remove the outer worktree
git branch -d feat/<feature> # if merged
```
### Landing a commit on main without disturbing the main checkout
When the main checkout is mid-work on another branch, add a commit to `main` through a throwaway worktree instead of `git checkout main` (which would yank branches out from under an open IDE):
```bash
git worktree add .worktrees/main-tmp main
git -C .worktrees/main-tmp cherry-pick <sha> # or edit + commit
git worktree remove .worktrees/main-tmp
```
## Gotchas
- **`M user` / `m user` is normal.** Uppercase `M` = the pin differs from `user/` HEAD (bump pending or intentional). Lowercase `m` = the submodule working tree is dirty (e.g. an uncommitted `config/site.yaml` used for local testing). Neither is an error.
- **Gitlink merge conflicts still happen.** If two outer branches pin different `user/` SHAs, merging them conflicts on the `user` entry. Resolve by choosing the correct (usually newer, merged) SHA, then `git add user`.
- **Worktrees need `submodule update --init`.** A fresh outer worktree has an empty `user/` until you run it — it is not automatic.
- **The submodule git dir was absorbed** (`git submodule absorbgitdirs user`) so all worktrees share `.git/modules/user`. `user/.git` is now a gitfile (`gitdir: ../.git/modules/user`), not a directory. `make content-push`/`content-pull` still operate on `user/` normally.
- **Access requires the content remote** (SSH over Tailscale). A machine that cannot reach it cannot `submodule update` — but it could never clone `user/` anyway, so this is not a regression.
@@ -0,0 +1,104 @@
---
title: Retiring a standalone Grav sub-page that was consolidated onto another page
date: 2026-07-04
category: architecture-patterns
module: Grav theme — trip pages / templates
problem_type: architecture_pattern
component: rails_view
severity: medium
applies_when:
- Deleting a standalone Grav view whose content now renders inside another page
- A page folder holds child pages that other templates fetch via grav.pages.find(route).children
- Detail pages have a Back link that falls back to the deleted page
- The affected trip is demo content regenerated by make demo-load
tags: [grav, twig, page-tree, routable, back-link, demo-content, page-retirement]
---
# Retiring a standalone Grav sub-page that was consolidated onto another page
## Context
The site consolidated four standalone trip views — `/map`, `/stats`, `/dailies`, `/stories` — onto a single trip page (inline map + filter bar + inline stats). Removing the now-redundant view pages looks like a simple `git rm`, but a Grav "page" is a **folder + a `.md` that names a template**, and several things quietly depend on both halves. Missing any of them ships a broken site or a broken `make demo-load`. This captures the safe procedure and the three traps that are not obvious from the file listing.
## Guidance
Treat a page as two separable roles: a **routable view** (the `.md`'s template renders a URL) and a **data container** (the folder holds child pages other code reads). Retiring the view must preserve the container.
**1. Keep the folder; neutralise the view — do not delete the container.**
`01.dailies/` and `04.stories/` hold the journal/story children, and `trip.html.twig` / `home.html.twig` reach them via `grav.pages.find(route ~ '/dailies').children`. Deleting the folder (or its `.md`) breaks that lookup and the entries vanish from the feed. Instead, keep the folder and repoint its `.md` to an inert container:
```yaml
# 01.dailies/dailies.md
---
title: Journal
template: default # was: dailies (dailies.html.twig is deleted)
routable: false # the container's own URL 404s
visible: false # not enumerated in nav
---
```
`routable: false` makes the container URL inert **without** unrouting its children — individual entries stay reachable at `/trips/<slug>/dailies/<entry>`, and `find(...).children` still resolves because the page remains in the tree. (Folders that were pure views with no children — `02.map/`, `03.stats/` — can be deleted outright.)
**2. Repoint Back-link fallbacks to the surviving surface (grandparent), not the retired parent.**
Detail templates used `href="{{ page.parent().url }}"` with an `onclick` that runs `history.back()` when history exists. `history.back()` covers in-app navigation, but the `href` is the fallback for **direct-landing visitors** (shared link, new browser tab, search result) — and it pointed at the now-inert container:
```twig
{# entry.html.twig / story.html.twig — BEFORE (breaks on direct landing) #}
<a class="back-pill" href="{{ page.parent().url }}"
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
{# AFTER — fall back to the trip page (grandparent), the surface that survived #}
<a class="back-pill" href="{{ page.parent().parent().url }}"
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
```
This is a **silent** regression: the detail page renders fine (200, no Twig error), so curl/CI-of-the-page all pass. It only breaks when a cold visitor clicks Back — the exact path a shared link takes.
**3. Sync the gitignored demo source AND the Makefile, or the next reload undoes the cleanup.**
The `italy-2026-demo` trip pages are **gitignored**; they are regenerated by `make demo-load` from `user/docs/demo/trips/italy-2026-demo/` (which **is** tracked in the user repo). Deleting pages from the live tree is not enough — you must also:
- delete/repoint the demo **source** files (`map.md`, `stats.md`, and the `dailies/`/`stories` container `.md`s), and
- update the `demo-load` Makefile target so it no longer `mkdir`s `02.map`/`03.stats` or copies the deleted `.md`s.
Otherwise the next `make demo-load` recreates the deleted pages pointing at deleted templates. This matters doubly because Playwright's `global-setup` runs `make demo-load` before the suite — stale demo source breaks tests, not just a manual reload.
**4. Sweep the test suite for the deleted routes.** Delete tests that target the gone pages; re-point tests whose behaviour moved to the consolidation surface (e.g. the feed sort toggle is now `#trip-sort-toggle` on the trip page). Note the consolidation surface may sort differently (the trip page is oldest-first; the old `/dailies` view was newest-first) — re-pointed ordering assertions may need to invert.
## Why This Matters
The two halves of a Grav page (routable view vs. data container) are invisible in a file listing but load-bearing. The failure modes are asymmetric and sneaky: deleting a container **loudly** empties a feed (easy to catch), but the back-link fallback fails **silently** for only a subset of visitors, and the demo-source drift fails **later** — on the next reload or CI run, not during the change. A curl/HTTP smoke test passes all three while two are broken. Getting the procedure right the first time avoids a shipped regression and a red suite that looks unrelated to the change.
## When to Apply
- Deleting any Grav view page whose feed/map/list now renders inside another page.
- Any time a page folder is a parent of child pages that templates fetch via `find(route).children` — keep it as a `routable:false` container.
- Whenever a detail page's Back link (or any `page.parent()` reference) could resolve to the page being retired.
- Whenever the affected trip is `italy-2026-demo` (or any gitignored, `demo-load`-regenerated content).
## Examples
**Verification that proves the container split worked** (children of a `routable:false` parent stay reachable):
```
# keepers
/ → 200
/trips/italy-2026-demo → 200 (feed still populated)
/trips/italy-2026-demo/dailies/<entry> → 200 (child of routable:false container)
/trips/italy-2026-demo/stories/<story> → 200
# retired views
/trips/italy-2026-demo/map → 404
/trips/italy-2026-demo/stats → 404
/trips/italy-2026-demo/dailies → 404 (container inert; children still route)
```
**Grep gate before declaring done** — no `include`/`import`/link references to the deleted templates remain (a lone `macros/stats.html.twig` hit is the shared macro, a keeper — not the deleted `stats.html.twig` page):
```
grep -rnE "include .*(feed-map|dailies|stories|map|stats)\.html|~ '/map'|~ '/stats'" templates/
```
## Related
- `docs/working/plans/2026-07-04-standalone-page-cleanup.md` — the plan this learning came from
- `docs/reference/architecture.md` — page-tree structure and the single `MapUtils.initEntryMap` map path
- `docs/guides/trip-switching.md` — new trips now scaffold only `01.dailies/` + `04.stories/` (inert containers)
- `CLAUDE.md` → "Trip entity architecture" and "One map path" — the current-state contract
@@ -0,0 +1,82 @@
---
title: "Grav login new-user grants api.* but not admin.* on Admin2-only installs"
date: 2026-07-04
category: docs/solutions/test-failures
module: testing / account provisioning
problem_type: test_failure
component: authentication
symptoms:
- "gpx-manager Playwright specs fail (401 / login form shown) after switching the suite onto a dedicated test account"
- "an authenticated account still sees the login form at /gpx-manager instead of the manager UI"
- "the generated accounts/*.yaml has an access.api block but no access.admin block"
root_cause: missing_permission
resolution_type: tooling_addition
severity: medium
related_components:
- testing_framework
- tooling
tags:
- grav
- login-plugin
- admin2
- permissions
- playwright
- test-account
- gpx-manager
---
# Grav login new-user grants api.* but not admin.* on Admin2-only installs
## Problem
When the Playwright suite was moved onto a dedicated local `testrunner` account, every `/gpx-manager` spec started failing — the account could authenticate but was treated as unauthorized for the manager page. The account had been created with `bin/plugin login new-user ... -P b` (Admin + Site access) but **without** `--admin-type`, and on this Admin2-only install that grants `api.*` permissions and no `admin.*` permissions.
## Symptoms
- The `/gpx-manager` Playwright specs fail after switching from the real user to the `testrunner` account (they passed as the real user).
- An authenticated `testrunner` still gets the Login plugin's login form at `/gpx-manager` instead of the manager UI.
- The generated `user/accounts/testrunner.yaml` contains an `access.api` block (`login: true`, `super: true`) but **no** `access.admin` block.
## What Didn't Work
- **Assuming `-P b` was enough.** `-P/--permissions b` selects the *category* of access (Admin + Site), but the *type* of admin permission — classic `admin.*` vs Admin2 `api.*` — is a separate axis controlled by `--admin-type`, which defaults to auto-detect. `-P b` alone does not guarantee `admin.login`.
- **Blaming the wrong specs.** In the same push, the home `H1`/map specs were also red, which looked like it might be the same auth problem. It was not — those were gated by `site.yaml` `travelling: false` hiding the active-trip view, a completely separate cause. Conflating the two delayed pinning the permission root cause.
## Solution
Create the account with an explicit `--admin-type both`, and bake it into the idempotent `make test-account` target so every recreation is faithful:
```make
test-account:
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
```
Verify the resulting permissions actually include `admin.login`:
```bash
docker exec intotheeast_grav rm -f /var/www/html/user/accounts/testrunner.yaml
make test-account
docker exec intotheeast_grav sh -c 'grep -A6 "^access:" /var/www/html/user/accounts/testrunner.yaml'
# access:
# admin:
# login: true
# super: true
# api:
# login: true
# super: true
```
## Why This Works
The `login new-user` help text spells out the axis:
> `--admin-type` — Which admin permission type to grant when permissions include Admin: `admin` (classic Admin plugin, `admin.*`), `api` (Admin2, `api.*`), or `both`. **If omitted, auto-detects from which admin plugin is installed.**
This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto-detect resolves to `api` and emits `api.*` alone. `/gpx-manager` is gated by `access.admin.login: true` in its page frontmatter (enforced by the Login plugin), and that check looks specifically for the `admin.login` permission — `api.login` does not satisfy it. Passing `--admin-type both` forces both namespaces into the account, so the admin-gated page accepts the session.
## Prevention
- **On Admin2-only Grav installs, always pass `--admin-type both` (or `admin`) to `login new-user`** when the account must reach any page gated by `access.admin.login` (e.g. `/gpx-manager`). Auto-detect will otherwise silently give you api-only.
- **Assert the permission, not the exit code.** After provisioning an account for admin-gated pages, check that `access.admin.login` exists in the generated YAML rather than trusting that account creation "succeeded."
- **Keep provisioning in one idempotent place.** The `make test-account` target is the single source of truth; `tests/global-setup.js` calls it, so `make test` and a bare `npx playwright test` both get identical permissions. Don't hand-create the account out-of-band with different flags — that reintroduces the drift this fix removed.
## Related Issues
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
+10
View File
@@ -4,6 +4,16 @@ Ideas and improvements not yet planned or scheduled.
---
## Production — remaining items
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit — breaks local dev)
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
- [ ] Register at carto.com and review terms for production traffic
- [ ] Japan & Korea 2026 trip page: set `date_start`, add `cover_image`, upload GPX route file(s)
---
## GPX Manager (`/gpx-manager`)
- [ ] **Polish the UI** — the current design is functional but bare; align with the Field Notes aesthetic, add better empty states, drag-and-drop upload area
+27
View File
@@ -0,0 +1,27 @@
# Git Sync Plugin — Setup Notes
## Folders YAML bug
The plugin UI always saves the folders field as a single comma-string:
```yaml
folders:
- 'pages,config,themes'
```
But the plugin code iterates the array expecting separate items. This causes `git status pages,config,themes` to be passed as a single path, so git sees nothing to commit and sync silently does nothing.
**Fix:** Edit `user/config/plugins/git-sync.yaml` directly:
```yaml
folders:
- pages
- config
- themes
```
Never use the Admin UI to change folders — it will rewrite the broken format.
## Files to gitignore
`user/config/plugins/git-sync.yaml` contains an encrypted token and is server-specific. `user/config/security.yaml` contains Grav nonces/salts, also server-specific. Both are in `.gitignore` and must never be committed.
@@ -0,0 +1,79 @@
# Milestone 2: Template Refactor — Session Brief
Use this as the starting point for the brainstorm in a new session.
Invoke the brainstorming skill (`/brainstorm`) and hand it this file as context.
---
## What this milestone is about
The asset pipeline (Milestone 1) is done — CDN dependencies eliminated, JS deduplicated into shared bundles. The templates themselves still have structural problems that make them hard to maintain and extend.
## Problems to solve
### 1. `trip.html.twig` mixes three concerns
Currently ~384 lines after Milestone 1 cleanup. Still mixes:
- Twig data-building loops (collecting `map_entries`, building entry lists, GPX URL arrays)
- HTML structure (cards, panels, filter bar)
- Inline JS (map init, GPX stats block)
Goal: split into focused, readable sections or partials.
### 2. `map_entries` loop is duplicated across 4 templates
Near-identical Twig loop that builds `[{lat, lng, title, slug, url, type, ...}]` appears in:
- `trip.html.twig`
- `dailies.html.twig`
- `stories.html.twig`
- `map.html.twig`
Candidate for a Twig macro so a change only needs to happen once.
### 3. Stats computation is slow Twig loops
Country counting, temperature range, days on road — currently computed in Twig on every uncached page load. At 6080 entries this is noticeable.
**Stronger option:** Move to a small PHP Grav plugin that exposes a single `{{ trip_stats }}` Twig variable. PHP loops are significantly faster than Twig loops. This is also the prerequisite for showing stats on other pages (homepage, story pages) in future.
### 4. Date range formatting duplicated
Same date formatting logic in both `story.html.twig` and `stories.html.twig`.
### 5. Latent bugs on inactive pages (fix while touching templates)
While refactoring, fix these two issues on pages not yet in active use:
- `map.html.twig`: inline map init needs `DOMContentLoaded` wrapper; `{% block map_assets %}` nested inside `{% block content %}` (double-registers assets)
- `feed-map.html.twig` (partial): `{% do assets.addCss %}` registers after `{{ assets.css()|raw }}` has rendered; inline map init also needs `DOMContentLoaded`
---
## Key constraint
Mischa wants stats and cycling data (distance, elevation gain/loss, moving time) visible on other pages in future (homepage, story pages). Centralising the computation — whether as Twig macros or a PHP plugin — is the prerequisite for that.
## What NOT to do in this milestone
- Don't touch JS or asset pipeline (that's Milestone 1, done)
- Don't redesign the visual layout
- Don't activate `dailies.html.twig`, `stories.html.twig`, or `map.html.twig` as new features — just fix their structural bugs while you're in the templates
## Relevant files
- `user/themes/intotheeast/templates/trip.html.twig` — main template (~384 lines)
- `user/themes/intotheeast/templates/partials/base.html.twig` — base layout
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — mini-map partial
- `user/themes/intotheeast/templates/map.html.twig` — full-page map (inactive)
- `user/themes/intotheeast/templates/dailies.html.twig` — journal feed (inactive)
- `user/themes/intotheeast/templates/stories.html.twig` — stories grid (inactive)
- `user/themes/intotheeast/templates/story.html.twig` — single story page
- `user/plugins/` — where a new stats plugin would live
## Open question for the brainstorm
The biggest design decision: **PHP plugin vs Twig macro for stats computation.**
- Twig macro: simpler, no new plugin, but still slow Twig loops
- PHP plugin: faster, reusable across pages, but adds a plugin to maintain
Mischa's stated preference leans toward the PHP plugin given the future-reuse goal, but hasn't committed yet.
@@ -0,0 +1,440 @@
# Home / Trip View Convergence Implementation Plan
**Status:** ✅ Complete (2026-06-27)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the home page's active-trip view present the same feed-col chrome (date range, filter bar, stats/cycling panels) as the trip page, by extracting the chrome into one shared Twig partial and the stats computation into one shared JS function.
**Architecture:** A new partial `templates/partials/trip-feed-col.html.twig` holds the entire `.home-feed-col` markup (header, filter bar, panel toggles, stats/cycling macro calls, feed loop) and is included by both `trip.html.twig` and `home.html.twig` (active branch). The inline stats/cycling computation currently in `trip.html.twig` becomes a window-exposed `initTripStats(config)` in `js/src/main.js`; the partial emits a small `DOMContentLoaded` inline script that calls it with page-specific data. The two intended differences (home has no sort button and keeps its own feed order) are driven by partial params, not separate markup.
**Tech Stack:** Grav 2.0 / Twig templates, esbuild-bundled vanilla JS (`js/src/main.js``js/main.js`), MapLibre via `map.js` (`window.MapUtils`).
## Global Constraints
- **Only ever write changes inside `travel-blog-intotheeast/` or subfolders.** The `user/` tree is a standalone git repo synced via `make content-push`; commit there as instructed by the execution skill.
- **Dev mode stays dev** — `twig.cache: false` is already set. Do NOT toggle any dev/prod config flag to work around caching; theme edits take effect on reload.
- **No map convergence.** Both inline map `<script>` blocks and both `.home-map-col` markup blocks stay exactly as they are. Do not touch map markers, fullscreen wiring, or map data-build loops.
- **No visual restyling.** Home reuses the trip's existing CSS classes unchanged. No new CSS class names except the pre-departure divider (`home-predeparture-divider`) and reuse of existing `home-highlights-cta` / `home-highlights-cta-wrap` for the pre-departure button.
- **No new JS for filter/sort/panels** — `initFilterBar()`, `initPanelToggles()`, `initSortButton()` are already global and selector-guarded. Only `initTripStats` is new.
- **Trip page rendered output must be visually and functionally identical** to before for the populated and empty cases — exact bytes may differ (the partial re-indents the feed-col markup, and the stats logic moves into a relocated inline `<script>`). Structural refactor only on that side; verify by behavioral smoke test, not a literal diff.
- **Built JS is generated** — never hand-edit `js/main.js`; edit `js/src/main.js` and rebuild with `make build-assets`.
- Dev server: `http://localhost:8081`. All verification is manual browser smoke testing (no JS test harness exists).
## File Structure
| File | Responsibility |
|---|---|
| `user/themes/intotheeast/js/src/main.js` (edit) | Add `initTripStats(config)`; expose on `window`. Rebuild → `js/main.js`. |
| `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` (new) | The entire shared `.home-feed-col`: header, filter bar (sort button gated), panel toggles, stats/cycling macro calls, feed loop, pre-departure block, and the inline `initTripStats` call. |
| `user/themes/intotheeast/templates/trip.html.twig` (edit) | Replace inline `.home-feed-col` (`:70-120`) with the partial include; remove inline stats script (`:213-249`). Map untouched. |
| `user/themes/intotheeast/templates/home.html.twig` (edit) | Active branch: add `gps_points` build; replace bespoke feed-col (`:60-83`) with the partial include (`show_sort: false`, `pre_departure` gated). Map untouched. Between-trips branch untouched. |
---
## Task 1: Shared stats glue `initTripStats(config)` in main.js
**Files:**
- Modify: `user/themes/intotheeast/js/src/main.js` (add function near the other init functions, ~after `initPanelToggles` at `:239`; expose on `window`)
- Rebuild artifact: `user/themes/intotheeast/js/main.js` (via `make build-assets`)
**Interfaces:**
- Consumes: `window.MapUtils.parseGpxFiles(urls, cb)`, `window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)` (from `map.js`, loaded in the `bottom` asset group).
- Produces: `window.initTripStats(config)` where `config = { gpxUrls: string[], gpsPoints: [number,number][], hasGpx: boolean }`. Selector-guarded: no-op when `#stat-distance` is absent. No-GPX fallback writes `'—'` (not `~0`) and returns when `gpsPoints.length < 2`. This is the exact contract the partial's inline script (Task 2) and both templates (Tasks 34) rely on.
- [ ] **Step 1: Add the `initTripStats` function**
In `user/themes/intotheeast/js/src/main.js`, immediately after the `initPanelToggles` function (after line 239, before the `/* ── Boot ── */` comment), add:
```js
/* ── Trip stats / cycling computation (trip + home-active) ───
config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
No-op if #stat-distance is absent (page rendered no stats panel).
No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0'). */
function initTripStats(config) {
var distEl = document.getElementById('stat-distance');
if (!distEl) return;
var gpxUrls = config.gpxUrls || [];
var gpsPoints = config.gpsPoints || [];
if (config.hasGpx) {
MapUtils.parseGpxFiles(gpxUrls, function (result) {
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
function setText(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = val;
}
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
setText('cyc-moving-time', result.movingTime || '—');
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
});
} else {
if (gpsPoints.length < 2) {
distEl.textContent = '—';
return;
}
var total = 0;
for (var i = 1; i < gpsPoints.length; i++) {
total += MapUtils.haversineKm(
parseFloat(gpsPoints[i-1][0]), parseFloat(gpsPoints[i-1][1]),
parseFloat(gpsPoints[i][0]), parseFloat(gpsPoints[i][1])
);
}
distEl.textContent = '~' + Math.round(total).toLocaleString();
}
}
window.initTripStats = initTripStats;
```
Note: the function is **not** added to the `DOMContentLoaded` boot block — it is called per-page from the partial's inline script (Task 2) with page-specific config. `window.initTripStats =` is required because `main.js` is bundled as an IIFE, so the function is otherwise not reachable from inline template scripts.
- [ ] **Step 2: Rebuild the JS bundle**
Run: `make build-assets`
Expected: completes without esbuild errors; `user/themes/intotheeast/js/main.js` is regenerated.
- [ ] **Step 3: Verify the function is exposed in the built bundle**
Run: `grep -c "initTripStats" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/main.js`
Expected: a non-zero count (the minified bundle contains the symbol).
- [ ] **Step 4: Smoke-test that existing pages still work (no regression from the additive change)**
Load `http://localhost:8081/trips/japan-korea-2026` (or the active trip) in a browser. The trip page still uses its own inline stats script at this point, so stats should populate exactly as before. Open the console and confirm **no errors** and that `typeof window.initTripStats === 'function'`.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add js/src/main.js js/main.js
git commit -m "feat(theme): add shared initTripStats() for trip+home stats panels"
```
---
## Task 2: Shared partial `trip-feed-col.html.twig`
**Files:**
- Create: `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
**Interfaces:**
- Consumes (params, passed via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
| Param | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` | int | count | count |
| `story_count` | int | count | count |
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` (new on home, Task 4) |
| `show_sort` | bool | `true` | `false` |
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
`gpx_urls` and `gps_points` are added to the spec's interface table as the agreed implementation choice: the partial emits the `initTripStats` inline call itself (single place), so it needs the page-specific data.
- Consumes globally: `window.initTripStats` (Task 1), `window.MapUtils` (map.js), CSS classes from the existing theme.
- Produces: the `.home-feed-col` DOM that `initFilterBar` / `initPanelToggles` / `initSortButton('trip-sort-toggle', …)` already key off (`.trip-filter-btn`, `[data-type]`, `.trip-panel-toggle`, `#feed-filter-empty`, `#trip-sort-toggle`).
- [ ] **Step 1: Create the partial file**
Create `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` with exactly:
```twig
{% import 'macros/stats.html.twig' as stats_m %}
{% import 'macros/cycling.html.twig' as cycling_m %}
<div class="home-feed-col">
{% if pre_departure %}
{# ── Pre-departure landing state (home-active only) ──────────── #}
<div class="home-trip-header">
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
{% if trip_page.header.date_start %}
<p class="trip-dates">Departing {{ trip_page.header.date_start|date('d M Y') }}</p>
{% endif %}
<span class="home-trip-counts">Coming soon</span>
</div>
<div class="feed">
<hr class="home-predeparture-divider">
<p class="feed-empty">The journey hasn't begun yet — check back once we're on the road.</p>
<div class="home-highlights-cta-wrap">
<a class="home-highlights-cta" href="/trips">In the meantime, explore my other trips →</a>
</div>
</div>
{% else %}
<div class="home-trip-header">
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
{% if trip_page.header.date_start %}
<p class="trip-dates">
{{ trip_page.header.date_start|date('d M Y') }}
{% if trip_page.header.date_end %}{{ trip_page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}
</p>
{% endif %}
<span class="home-trip-counts">
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
</span>
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
</div>
{% if show_sort %}
<button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
{% endif %}
</div>
<div class="trip-panel-toggles">
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% if has_gpx %}
<button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% endif %}
</div>
</div>
{{ stats_m.stats_panel(journal_entries, trip_page, journal_count, has_gpx) }}
{% if has_gpx %}
{{ cycling_m.cycling_panel() }}
{% endif %}
<div class="feed">
{% if all_items|length > 0 %}
{% for item in all_items %}
{% set entry = item.page %}
{% if item.type == 'journal' %}
{% include 'partials/entry-journal.html.twig' %}
{% else %}
{% include 'partials/entry-story.html.twig' %}
{% endif %}
{% endfor %}
{% else %}
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
{% endif %}
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTripStats({
gpxUrls: {{ gpx_urls|json_encode|raw }},
gpsPoints: {{ gps_points|json_encode|raw }},
hasGpx: {{ has_gpx ? 'true' : 'false' }}
});
});
</script>
{% endif %}
</div>
```
Notes baked into this markup:
- The non-pre-departure feed keeps the `{% else %}` "No entries yet" fallback so the trip page's empty-case output is unchanged (trip always passes `pre_departure: false`). Home never reaches this fallback because home-empty sets `pre_departure: true`.
- The `initTripStats` call is wrapped in `DOMContentLoaded` so `window.initTripStats` and `window.MapUtils` (both in the `bottom` asset group rendered at the end of `<body>`) are defined when it runs.
- The call is **not** nested inside any map-entries condition, so a trip with GPX but zero geocoded journal entries still populates the panels.
- The partial is included with `only`, so it imports the `stats`/`cycling` macros itself.
- [ ] **Step 2: Verify Twig syntax compiles (no include yet, so render via a temporary check)**
The partial isn't referenced anywhere yet, so it can't render on its own. Verify there are no obvious Twig errors by confirming the file is well-formed:
Run: `grep -c "endif\|endfor\|endmacro" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
Expected: non-zero (sanity check the file saved). Real verification happens in Task 3 when the trip page includes it.
- [ ] **Step 3: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/partials/trip-feed-col.html.twig
git commit -m "feat(theme): add shared trip-feed-col partial"
```
---
## Task 3: Refactor `trip.html.twig` to use the partial
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (replace `:70-120`; remove `:213-249`)
**Interfaces:**
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
- Produces: visually and functionally identical trip-page output (regression-critical) — exact bytes may differ (re-indented markup, relocated stats `<script>`); confirm via the behavioral checks in Step 3, not a literal diff. The trip page already builds `all_items` (flag 4), `journal_entries`, `journal_count`, `story_count`, `gps_points`, `gpx_urls`, `has_gpx` — all passed straight through.
- [ ] **Step 1: Replace the inline `.home-feed-col` block with the include**
In `user/themes/intotheeast/templates/trip.html.twig`, replace the entire block from line 70 (` <div class="home-feed-col">`) through line 120 (` </div>`, the closing of `.home-feed-col`) with:
```twig
{% include 'partials/trip-feed-col.html.twig' with {
trip_page: page,
all_items: all_items,
journal_entries: journal_entries,
journal_count: journal_count,
story_count: story_count,
has_gpx: has_gpx,
gpx_urls: gpx_urls,
gps_points: gps_points,
show_sort: true,
pre_departure: false
} only %}
```
Leave the surrounding `<div class="home-layout">` and `.home-map-col` block (lines 5868) and the closing `</div>` of `.home-layout` (line 121) intact.
- [ ] **Step 2: Remove the inline stats script**
In the same file, delete the inline stats block — from line 213 (`var STATS_GPS = …`) through line 249 (the closing `})();` of the stats IIFE), inclusive. Specifically remove:
```twig
var STATS_GPS = {{ gps_points|json_encode|raw }};
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
(function() {
var distEl = document.getElementById('stat-distance');
if (HAS_GPX) {
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
...
});
} else {
var total = 0;
...
}
})();
```
The map `<script>`'s `document.addEventListener('DOMContentLoaded', function() { … });` wrapper and its closing `}); // DOMContentLoaded` (line 251) stay — only the stats portion inside it is removed. The map setup, marker loop, fitBounds, `renderGpxJourney`, and the fullscreen IIFE (`:201-211`) remain untouched.
- [ ] **Step 3: Reload and regression-test the trip page**
Load `http://localhost:8081/trips/japan-korea-2026` (active trip with content). Confirm:
- Header, date range, counts render as before.
- Filter bar **with** the sort button (`↑`) is present.
- Stats panel toggles open; distance populates (GPX → exact number; no GPX → `~`-prefixed estimate).
- If the trip has GPX: Cycling toggle present and its panel populates.
- Feed lists journal + stories, default order oldest→newest (flag 4, unchanged).
- Filter All/Journal/Stories works; sort button flips order.
- Console shows no errors.
- [ ] **Step 4: Verify the map is unaffected**
On the same page, confirm the map renders with markers, fits bounds, draws the GPX/journey route, and the mobile fullscreen button still works (resize on toggle). Marker click still scrolls to and flashes the card.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/trip.html.twig
git commit -m "refactor(theme): trip.html.twig uses shared trip-feed-col partial + initTripStats"
```
---
## Task 4: Wire `home.html.twig` active branch to the partial
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch: add `gps_points` build at `:30-31`; replace `:60-83`)
**Interfaces:**
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
- Produces: home-active now renders date range, filter bar (no sort button), and stats/cycling panels, plus the pre-departure block when no entries exist. The between-trips branch (`{% else %}`) is untouched and does not use the partial.
- [ ] **Step 1: Add the `gps_points` build (no-GPX stats fallback)**
In `user/themes/intotheeast/templates/home.html.twig`, in the active-trip branch, after the counts at line 30 (`{% set story_count = story_entries|length %}`) and before the `map_entries` build (line 32), insert:
```twig
{% set gps_points = [] %}
{% for entry in journal_entries %}
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
{% endif %}
{% endfor %}
```
This mirrors `trip.html.twig:27-32`.
- [ ] **Step 2: Replace the bespoke feed-col with the include**
In the same file, replace the entire `<div class="home-feed-col">` block from line 60 through its closing `</div>` at line 83 with:
```twig
{% include 'partials/trip-feed-col.html.twig' with {
trip_page: trip,
all_items: all_items,
journal_entries: journal_entries,
journal_count: journal_count,
story_count: story_count,
has_gpx: home_gpx_urls|length > 0,
gpx_urls: home_gpx_urls,
gps_points: gps_points,
show_sort: false,
pre_departure: all_items|length == 0
} only %}
```
Leave `<div class="home-layout">` and the `.home-map-col` block (lines 5558) and the closing `</div>` of `.home-layout` (line 84) intact. The map `<script>` block (lines 86139, gated by `map_entries|length > 0`) stays untouched.
- [ ] **Step 3: Reload and test home-active (with content)**
Ensure `config.site.travelling: true` and the active trip has posts. Load `http://localhost:8081/`. Confirm:
- Date range, counts, and filter bar appear — **no** sort button.
- Stats panel toggles open and distance populates (`~` estimate from `gps_points` when no GPX; exact when GPX present); Cycling panel appears and populates only if the trip has GPX.
- Filter All/Journal/Stories works; panel toggles work.
- Feed default order is home's own (flag 3, unchanged from today).
- Console shows no errors; map still renders.
- [ ] **Step 4: Test the pre-departure empty state**
With `travelling: true` and **no posts** in the active trip's `dailies`/`stories` (temporarily, or on a fresh trip), load `/`. Confirm:
- The pre-departure block shows the trip title, "Departing &lt;date&gt;", "Coming soon", a divider, and the "In the meantime, explore my other trips →" button linking to `/trips`.
- The filter bar, panel toggles, and the generic "No entries yet" fallback do **not** appear.
- After posting one entry (or restoring content), the pre-departure block disappears and the normal filter bar + feed render.
- [ ] **Step 5: Regression-test between-trips mode**
Set `config.site.travelling: false`, load `/`. Confirm the highlights grid layout is unchanged (this branch does not use the partial). Restore `travelling: true` afterward if that is the intended dev state.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/home.html.twig
git commit -m "feat(theme): home-active reuses trip-feed-col partial with stats + pre-departure state"
```
---
## Self-Review
**Spec coverage:**
- Date-range header, filter bar, stats/cycling panels on home-active → Tasks 2 + 4. ✅
- Chrome in one place (partial) → Task 2; both pages include it → Tasks 3, 4. ✅
- Home keeps own order, no sort button → `show_sort: false`, `all_items` flag 3 unchanged (Task 4). ✅
- Stats/cycling from single shared JS → Task 1 (`initTripStats`), called via partial. ✅
- No visual/functional change to trip output (exact bytes may differ: re-indented markup, relocated stats `<script>`) → Task 3 passes through existing vars; partial preserves the empty-case `{% else %}` fallback. ✅
- Stats glue runs in `DOMContentLoaded`, not nested in map block, `<2`-points guard writes `—` → Task 1 + partial script. ✅
- Home `gps_points` build added → Task 4 Step 1. ✅
- Pre-departure block (title + start date + "Coming soon" + divider + button; suppresses filter bar/fallback; panels hidden) → Task 2 markup + Task 4 gating. ✅
- Map convergence out of scope; both map blocks untouched → Tasks 3, 4 leave map markup/scripts intact. ✅
- Between-trips branch untouched → Task 4 only edits the active branch. ✅
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every step has concrete code or an exact command. ✅
**Type consistency:** `initTripStats` config keys (`gpxUrls`, `gpsPoints`, `hasGpx`) match between Task 1 (definition), the partial's inline call (Task 2), and the data both pages pass (Tasks 3, 4). Partial param names match the include calls in both templates. `gpx_urls`/`gps_points`/`has_gpx` consistent throughout. ✅
---
**Plan complete and saved to `docs/working/plans/2026-06-27-home-trip-view-convergence.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?
@@ -0,0 +1,270 @@
# Map Init Consolidation — shared `MapUtils.initEntryMap()`
**Status:** ✅ Complete (2026-06-27)
> Plan type: `refactor` · Depth: Standard · Origin: deferred memory `project-map-init-refactor` (re-scoped 2026-06-27 after home/trip convergence)
---
## Summary
The recent home/trip convergence work multiplied an already-duplicated pattern: there are now **five** near-identical MapLibre init blocks, each repeating ~50130 lines of map construction, marker/popup loop, bounds-fitting, journey rendering, and fullscreen wiring. The shared `maplibre-utils.js` already centralizes marker *creation* and GPX/journey rendering, but **not the init orchestration** — that is what is copy-pasted and has since drifted into subtly inconsistent behavior.
This plan extracts the init orchestration into one config-driven function, `MapUtils.initEntryMap(opts)`, in `user/themes/intotheeast/js/maplibre-utils.js` (which esbuild already bundles into `js/map.js`, so every template that loads `map.js` picks it up). The two actively-used surfaces — the **trip page** and the **home active-trip view** — are converted to call it and become behaviorally identical. The **home highlights (between-trips) view** is converted too, with a deliberate small UX change: marker click navigates to the article instead of scrolling to a grid card (hover-title already exists). The dormant `feed-map.html.twig` partial and `map.html.twig` full-page map are **left untouched** this pass.
This is a refactor with two intentional, scoped behavior changes (both on the home page) — not byte-for-byte preservation.
---
## Problem Frame
`maplibre-utils.js` gives every map the same building blocks (`createDotMarker`, `createStoryMarker`, `renderGpxJourney`, `MAP_STYLE`), but each template still hand-writes the *assembly*: `new maplibregl.Map(...)`, attribution control, the `on('load')` marker loop with hover popup + click handler, `fitBounds`/`jumpTo`, and the fullscreen toggle IIFE. Five copies exist:
| Surface | Container | Marker click (today) | In active use? |
|---|---|---|---|
| `trip.html.twig` | `#trip-map` | scroll+flash `entry-` card, fullscreen-aware | ✅ active |
| `home.html.twig` active branch | `#home-map` | set hash to `entry-` card, **no flash, not fullscreen-aware** | ✅ active |
| `home.html.twig` highlights branch | `#home-map` | `scrollIntoView` to `highlight-` card | ✅ active |
| `partials/feed-map.html.twig` | `#feed-map` / `#stories-map` | scroll+flash else navigate, fullscreen-aware | ⚠️ dormant (dailies/stories) |
| `map.html.twig` | `#trip-map` (full page) | always navigate to URL | ⚠️ dormant |
**Consequences of the duplication:**
- The trip page and home active view are meant to be the same component but have already drifted (home active lacks the flash highlight and the fullscreen button trip has).
- Any future map change must be applied in up to five places, by hand, with no shared test surface.
- The click logic carries five subtly different implementations of just **two** real intents: *scroll to the matching card on this page*, or *navigate to the entry's own page*.
**Why now:** The deferral in `project-map-init-refactor` was justified by "dailies/stories/full-map aren't in active use." That still holds for those three — but trip and home are now both active and nearly identical, so the high-value consolidation is unblocked while the dormant surfaces stay out of scope.
---
## Scope Boundaries
**In scope:**
- New `MapUtils.initEntryMap(opts)` in `maplibre-utils.js` + esbuild rebuild.
- Convert `trip.html.twig` to call it (behavior preserved).
- Convert `home.html.twig` active branch to call it + add a fullscreen button so it matches the trip page exactly.
- Convert `home.html.twig` highlights branch to call it (click → navigate to article).
**Intentional behavior changes (both home page only):**
- Home active view **gains** the flash-highlight on card scroll and the fullscreen button/awareness it currently lacks → becomes identical to the trip page.
- Home highlights view marker click **changes** from `scrollIntoView` to the grid card → navigate to the article URL. Hover-title popup is unchanged (already present).
- **Both home maps' attribution restyles.** `initEntryMap` always constructs with `attributionControl: false` + a compact `AttributionControl` bottom-left, collapsed on load (mirroring trip). The home active and home highlights maps currently use MapLibre's default attribution (expanded, bottom-right), so both move to trip's compact collapsed bottom-left. For home active this is part of "identical to trip"; for home highlights — which is otherwise unchanged except for the click behavior — it is an *incidental* restyle. If highlights should keep the default attribution, parameterize attribution in `opts` (e.g. `attribution: { compact, position, collapse }`) rather than baking trip's treatment into every caller.
### Deferred to Follow-Up Work
- Converting `partials/feed-map.html.twig` (dailies/stories) onto `initEntryMap`. It is already a shared partial with low duplication cost and the pages are dormant; retrofit it when those feeds return to active use. The unified click rule already matches feed-map's current behavior, so this will be a near-drop-in later.
- Converting `map.html.twig` (full-page map) onto `initEntryMap`. Dormant; navigate-only behavior is the `cardPrefix: null` path, so it too will be a clean later conversion.
**Out of scope:** no CSS changes, no map-style change, no change to the Twig-side `map_entries`/`gpx_urls` computation, no change to `createDotMarker`/`createStoryMarker`/`renderGpxJourney`.
---
## Key Technical Decisions
**KTD1 — Init orchestration lives in `maplibre-utils.js`, not per-template.**
`maplibre-utils.js` is imported by `js/src/map.js` and bundled by esbuild into the minified `js/map.js` that every map-bearing template loads via `{% block map_assets %}`. Adding `initEntryMap` there + rebuilding makes it available everywhere with a single source of truth. This is the whole point of the refactor.
**KTD2 — One unified click rule, no click-mode enum.**
The function takes an optional `cardPrefix`. Click behavior is a single rule: *if `cardPrefix` is set and `document.getElementById(cardPrefix + slug)` exists, scroll to it (via `location.hash`) and flash `is-highlighted`, fullscreen-aware when a fullscreen target is configured; otherwise navigate to `entry.url`.* This single rule subsumes every behavior the in-scope surfaces need — trip and home active pass `cardPrefix: 'entry-'`; home highlights passes no prefix and gets navigate-on-click for free. It also happens to match the dormant feed-map/map.html behaviors, easing their later conversion. No `clickMode` parameter is introduced.
**Card-absent fallback — note the divergence from trip today.** The current trip handler does `if (!card) return;` (a no-op) when no card matches the slug; the unified rule instead **navigates** to `entry.url`. This is behavior-preserving on every in-scope surface **only under the invariant that every map marker has a matching feed card** (`entry-<slug>`, emitted by both the journal and story entry partials). Document that invariant where it is relied on (U2). If a future map entry can ever lack a feed card (a map-only POI, a new pin type), make the fallback per-surface — trip = no-op, highlights = navigate — rather than letting the shared default retroactively change trip's semantics.
**KTD3 — `map_entries` / `gpx_urls` stay computed in Twig.**
Per the Milestone 2 refactor decision, Twig macros cannot return arrays, so each template keeps its existing Twig loop that builds `map_entries` and serializes it to a JS var. The only change is replacing the inline init `<script>` body with a single `MapUtils.initEntryMap({...})` call inside `DOMContentLoaded`. Each template's map `<script>` shrinks from ~5090 lines to ~10.
**KTD4 — Dormant surfaces excluded.**
`feed-map.html.twig` and `map.html.twig` keep their current inline init this pass (see Deferred). Reduces blast radius to the two active surfaces plus the home highlights view.
**KTD5 — Home active fullscreen button reuses existing CSS.**
The fullscreen button uses the same `.feed-map-fullscreen-btn` markup as the trip page, and the fullscreen target is `.home-map-col`. Both `.feed-map-fullscreen-btn` (`css/style.css:636`) and `.home-map-col.is-fullscreen` (`css/style.css:911`) already exist — no CSS changes required.
---
## High-Level Technical Design
**Who calls the shared function (after this plan):**
```
maplibre-utils.js ── MapUtils.initEntryMap(opts) ──┐
(bundled into js/map.js) │
├─ trip.html.twig → cardPrefix:'entry-', fullscreen, story markers
├─ home.html.twig (active) → cardPrefix:'entry-', fullscreen
└─ home.html.twig (highlights) → no cardPrefix (→ navigate)
feed-map.html.twig / map.html.twig → unchanged (own inline init, deferred)
```
**Unified marker-click rule** (the single behavior the function implements):
```mermaid
flowchart TD
A[Marker clicked] --> B{cardPrefix set AND<br/>card #prefix+slug exists?}
B -- no --> C[navigate to entry.url]
B -- yes --> D{fullscreen target<br/>configured AND open?}
D -- yes --> E[close fullscreen,<br/>then scroll+flash after delay]
D -- no --> F[scroll to hash,<br/>flash is-highlighted]
```
**`initEntryMap(opts)` shape** (directional, not a signature spec):
| Option | Type | Used by | Notes |
|---|---|---|---|
| `container` | string | all | map div id (`'trip-map'`, `'home-map'`) |
| `entries` | array | all | already-parsed `map_entries` from Twig |
| `cardPrefix` | string \| null | trip, home active | `'entry-'`; omit/null → markers navigate to `entry.url` |
| `storyMarkers` | bool | trip | render `createStoryMarker()` for `type === 'story'`; default dot markers |
| `markLatest` | bool | trip, home active | enlarge the final non-story entry's dot (default `true`); home highlights passes `false` so no marker is singled out in the shuffled set (added during execution — preserves highlights' current all-equal dots) |
| `fullscreen` | `{ btnId, colSelector }` \| null | trip, home active | wires the fullscreen toggle; null → no fullscreen |
| `gpx` | `{ urls, use, autoconnect, sourcePrefix, journeyId }` \| null | trip, home active | forwarded to `renderGpxJourney`; null → skip |
| `fit` | `{ padding, maxZoom, singleZoom }` | all | defaults `{60, 11, 10}`; highlights uses `maxZoom: 8`, `singleZoom: 8` |
The function returns the map instance and internally does: construct map (`attributionControl: false`) + compact `AttributionControl` bottom-left; on `load` build bounds, loop entries → marker + hover popup + unified click handler, `fitBounds`/`jumpTo`, collapse the attribution `<details>`, call `renderGpxJourney` when `gpx` is set; wire the fullscreen IIFE when `fullscreen` is set; `setTimeout(map.resize, 100)`.
---
## Implementation Units
### U1. Add `MapUtils.initEntryMap(opts)` to `maplibre-utils.js`
**Goal:** Introduce the config-driven init function and rebuild the bundle. No template consumes it yet.
**Dependencies:** none.
**Files:**
- Modify: `user/themes/intotheeast/js/maplibre-utils.js` (add `initEntryMap`, export it on the `global.MapUtils` object)
- Build artifact (regenerated, do not hand-edit): `user/themes/intotheeast/js/map.js`
**Approach:**
- Add `function initEntryMap(opts) { ... }` near the other public helpers; add `initEntryMap: initEntryMap` to the `global.MapUtils = { ... }` export block.
- Implement the full orchestration described in HTD: map construction, attribution control + collapse, the `on('load')` marker loop (hover popup identical to current `map-tip` popups; marker element via `createStoryMarker()` when `opts.storyMarkers && entry.type === 'story'`, else `createDotMarker(isLatest)` where `isLatest = (entry.type !== 'story') && (i === entries.length - 1)` — the final-indexed entry, and only when it is not a story, mirroring `trip.html.twig:110` exactly; note this enlarges *nothing* when the last entry is a story, which is the current trip behavior and must be preserved — do **not** reinterpret it as "the last non-story entry"), bounds fit (`fitBounds` with `opts.fit` defaults, `jumpTo` for single entry), `renderGpxJourney` when `opts.gpx`, the fullscreen toggle IIFE when `opts.fullscreen`, and the trailing `resize`.
- Implement KTD2's unified click rule exactly: resolve card by `opts.cardPrefix + entry.slug`; if absent → `window.location.href = entry.url`; if present → set `location.hash`, then after 350ms add `is-highlighted` for 700ms; when `opts.fullscreen` is configured and the col is `.is-fullscreen`, click the fullscreen button first and defer the scroll ~450ms (mirror the current trip handler timings).
- Keep the empty-entries behavior cheap: if `entries.length === 0`, still construct the map and return (the trip page's existing "no locations yet" copy is page-specific and stays in the template if needed — do not bake page copy into the util).
- Rebuild: run `make build-assets` so `js/map.js` regenerates from source. Never hand-edit `js/map.js`.
**Patterns to follow:** mirror the existing trip page handler (`trip.html.twig:100-171`) as the canonical behavior, since trip is the surface whose behavior is being preserved; reuse the existing module structure and IIFE export pattern already in `maplibre-utils.js`.
**Execution note:** This is the load-bearing unit. Implement it to faithfully reproduce the trip page's current behavior before any caller is switched, so U2 is behavior-preserving — a no-op under the card-matching invariant noted in KTD2; the card-absent navigate fallback is the one deliberate divergence and is unreachable on trip today.
**Test scenarios:**
- *Happy path (card present):* with a `cardPrefix` and a matching card in the DOM, clicking a marker sets `location.hash` to `prefix+slug` and toggles `is-highlighted` on the card (added after ~350ms, removed ~700ms later).
- *Happy path (no prefix):* with `cardPrefix` null/omitted, clicking a marker sets `window.location.href` to `entry.url`.
- *Fallback:* with a `cardPrefix` set but no matching card in the DOM, clicking navigates to `entry.url`.
- *Fullscreen-aware:* with `fullscreen` configured and the col `.is-fullscreen`, a marker click triggers the fullscreen button click first, then scrolls.
- *Bounds:* one entry → `jumpTo` at `fit.singleZoom`; multiple entries → `fitBounds` with `fit.padding`/`fit.maxZoom`.
- *Story markers:* with `storyMarkers: true` and an entry `type === 'story'`, a story marker is used and that entry is never treated as `isLatest`.
- *GPX:* with `gpx` set, `renderGpxJourney` is called with the forwarded urls/sourcePrefix/journeyId/connectMode; with `gpx` null it is not called.
- *Empty:* `entries: []` constructs the map without throwing and renders no markers.
- *Build:* after `make build-assets`, `js/map.js` is regenerated and `window.MapUtils.initEntryMap` is defined at runtime.
**Verification:** `MapUtils.initEntryMap` is exported and callable; `make build-assets` completes and updates `js/map.js`; no console errors when invoked.
---
### U2. Convert `trip.html.twig` to `initEntryMap` (behavior preserved)
**Goal:** Replace the trip page's inline ~85-line map `<script>` body with a single `initEntryMap` call; behavior is preserved — behaviorally equivalent under the card-matching invariant in KTD2 (not literally byte-for-byte, since the `<script>` body is rewritten and the card-absent fallback changes from no-op to navigate, which is unreachable on trip).
**Dependencies:** U1.
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
**Approach:** Keep the Twig `map_entries`/`gpx_urls` computation and the `TRIP_ENTRIES`/`GPX_URLS`/`USE_GPX`/`AUTOCONNECT` JS var declarations. Replace everything inside `DOMContentLoaded` (the `new maplibregl.Map`, the `on('load')` loop, fit-bounds, `renderGpxJourney`, attribution collapse, the fullscreen IIFE, the trailing resize) with one call: `MapUtils.initEntryMap({ container: 'trip-map', entries: TRIP_ENTRIES, cardPrefix: 'entry-', storyMarkers: true, fullscreen: { btnId: 'trip-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'gpx', journeyId: 'trip-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`. Leave the fullscreen button markup and the `#trip-totop` button as-is.
**Patterns to follow:** existing `trip.html.twig` markup and var names; the include-call style already used for partials.
**Test scenarios:**
- *Markers + hover:* trip page renders one dot per entry plus story markers; hovering shows the `map-tip` title popup (unchanged).
- *Click → scroll+flash:* clicking a marker scrolls to its `entry-<slug>` feed card and flashes it.
- *Fullscreen:* the fullscreen button still expands `.home-map-col` and the marker-click-while-fullscreen path still closes then scrolls.
- *GPX:* GPX tracks + journey segments still render when `use_gpx` is on.
- *Regression:* visual diff against pre-change trip page shows no behavioral difference.
**Verification:** trip page at `localhost:8081/trips/<active_trip>` behaves identically to before — markers, popups, click-scroll-flash, fullscreen, GPX all intact; no console errors.
---
### U3. Convert `home.html.twig` active-trip branch + add fullscreen button (match trip page)
**Goal:** The home active-trip map becomes behaviorally identical to the trip page — it gains the flash-highlight and a working fullscreen button it currently lacks.
**Dependencies:** U1. Independent of U2.
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch markup + script)
**Approach:**
- Markup: add the fullscreen button inside the active branch's `<div class="home-map" id="home-map">` (currently `home.html.twig:64`), reusing the exact `.feed-map-fullscreen-btn` markup from `trip.html.twig:61-67` with `id="home-map-fullscreen"`. No CSS changes (KTD5).
- Script: keep the `HOME_ENTRIES`/`HOME_GPX_URLS`/`USE_GPX`/`AUTOCONNECT` var declarations; replace the inline `new maplibregl.Map` + `on('load')` body with `MapUtils.initEntryMap({ container: 'home-map', entries: HOME_ENTRIES, cardPrefix: 'entry-', fullscreen: { btnId: 'home-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: HOME_GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'home-gpx', journeyId: 'home-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`.
- Note: `storyMarkers` is omitted (home active currently uses dot markers only — preserved).
**Patterns to follow:** the trip page conversion (U2) and the trip fullscreen button markup.
**Test scenarios:**
- *Parity:* home active map renders markers, hover popups, and click-scroll **with flash** (previously no flash) to `entry-<slug>` cards.
- *Fullscreen (new):* the new `home-map-fullscreen` button expands `.home-map-col`, and a marker click while fullscreen closes then scrolls — matching trip.
- *GPX:* home GPX journey still renders (`home-gpx` / `home-journey` source ids preserved).
- *No story markers:* dot markers only, as before.
**Verification:** home page (travelling/active state) map matches the trip page in every interaction; fullscreen button visible and functional on mobile widths; no console errors.
---
### U4. Convert `home.html.twig` highlights branch (click → navigate)
**Goal:** The between-trips highlights map uses the shared init, and marker click opens the article instead of scrolling to a grid card. Hover-title popup unchanged.
**Dependencies:** U1. Lands naturally alongside U3 (same file) but is a distinct behavior change.
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (highlights branch script, `home.html.twig:245-289`)
**Approach:** Keep the `HIGHLIGHTS_ENTRIES` var declaration. Replace the inline `new maplibregl.Map` + `on('load')` body (including the current `scrollIntoView` click handler) with `MapUtils.initEntryMap({ container: 'home-map', entries: HIGHLIGHTS_ENTRIES, fit: { padding: 60, maxZoom: 8, singleZoom: 8 } })` — no `cardPrefix`, no `fullscreen`, no `gpx`. The absent `cardPrefix` yields navigate-on-click per KTD2; the `map-tip` hover popup is provided by the shared loop, so hover-title is preserved with no extra code. Note: this also restyles the highlights map's attribution to trip's compact collapsed bottom-left (see Scope Boundaries → "Both home maps' attribution restyles") — an incidental change; pass an attribution `opts` override if the MapLibre default should be retained here.
**Patterns to follow:** the navigate path of the unified click rule (KTD2).
**Test scenarios:**
- *Hover:* hovering a highlights marker shows the article title popup (preserved).
- *Click → navigate:* clicking a highlights marker navigates to `entry.url` (changed from `scrollIntoView`).
- *Bounds:* highlights map still fits at the wider zoom (`maxZoom`/`singleZoom` 8).
- *No fullscreen / no GPX:* no fullscreen button appears and no GPX journey renders on the highlights map.
**Verification:** between-trips home state shows the highlights map; hovering a pin shows its title, clicking it opens the article; no console errors.
---
## Risks & Dependencies
- **Regression on the two live surfaces.** Trip and home active are the primary UI. Mitigation: U2 is a strict behavior-preserving change verified by visual diff; U1 is built to reproduce the trip handler exactly before any caller switches. Check existing Playwright map coverage (see `docs/working/plans/2026-06-22-align-maps-tests.md` / `2026-06-21-playwright-tests.md`) and run it after U2U4.
- **Stale bundle.** `js/map.js` is generated; forgetting `make build-assets` ships old behavior. Mitigation: U1 explicitly includes the rebuild and a runtime check that `MapUtils.initEntryMap` is defined.
- **Duplicate element id.** The new `home-map-fullscreen` button must exist only in the active branch (highlights branch has no fullscreen). The two `#home-map` containers are already in mutually-exclusive Twig branches, so no real-DOM collision occurs.
- **Sequencing:** U2, U3, U4 all depend only on U1. U3 and U4 touch the same file and will typically land in one commit.
- **Known limitation — filter-hidden card click (accepted).** The unified rule keys on the card *existing* (`getElementById`), not on it being visible. When the feed filter bar (All/Journal/Stories) has hidden the target card (`display:none`), a marker click sets the hash and flashes an off-screen card, so the map appears unresponsive. This is a pre-existing rough edge being carried forward deliberately (not a regression introduced here) — accepted as-is for this pass rather than adding filter-reset or navigate-fallback handling.
---
## Verification Strategy
1. After U1: `make build-assets` succeeds; `js/map.js` updated; `window.MapUtils.initEntryMap` defined.
2. After U2: trip page (`/trips/<active_trip>`) — markers, hover, click-scroll-flash, fullscreen, GPX all unchanged.
3. After U3: home active state — identical to trip, including the new fullscreen button and flash.
4. After U4: home between-trips state — hover-title + click-to-open; wider zoom; no fullscreen/GPX.
5. Run existing Playwright map tests; confirm no new failures.
6. Confirm net line reduction across `trip.html.twig` + `home.html.twig` (the duplication is gone) and that `maplibre-utils.js` is the single source of init truth.
---
## Execution Outcome (2026-06-27)
All four units landed. `MapUtils.initEntryMap(opts)` added to `maplibre-utils.js` and bundled via `make build-assets`; `trip.html.twig`, both `home.html.twig` branches converted. Two notes from execution:
- **`markLatest` opt added** — the highlights branch rendered all dots equal (`createDotMarker(false)`), but the shared `isLatest = (i === length-1)` would have enlarged the last (shuffled) highlight. Added a `markLatest` flag (default `true`; highlights passes `false`) to preserve that.
- **Map instance exposed as `window.tripMap` / `window.homeMap`** — `initEntryMap` returns the map, and the templates assign it to these globals. This is the affordance the existing Playwright specs (M7, M8) already assumed; wiring it up turned two perma-failing tests green, giving real regression coverage on the converted surfaces.
**Test status:** `tests/ui/maps`, `tests/ui/home`, `tests/ui/trip`, `tests/ui/gpx` — 38 passed. Remaining failures are pre-existing and out of scope: **M6** asserts `window.map` on the deferred `map.html.twig` (untouched this pass); **H1** is a parallel-load timing flake (passes 4/4 in isolation). Both fail identically on the pre-refactor baseline.
## Sources & Research
- Origin: memory `project-map-init-refactor` (deferral), re-scoped after `project-homepage-redesign` / home-trip convergence.
- Milestone 2 refactor decision that `map_entries` stays Twig-side: memory `project-template-refactor-milestone2`, plan `docs/working/plans/2026-06-23-template-refactor.md`.
- Current behavior read directly from: `trip.html.twig:83-174`, `home.html.twig:87-139` (active) and `:245-289` (highlights), `partials/feed-map.html.twig`, `map.html.twig`, `js/maplibre-utils.js`, build config `package.json` `build` script.
- No external research — strong local patterns; behavior is fully specified by the existing code.
@@ -0,0 +1,550 @@
# Grav 2.0.4 Upgrade + GPM-Manage Plugins — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Status:** ✅ Complete (2026-07-04) — local (Phase 1) validated and shipped; Task 5 (remote test-env, Phase 2) is user-gated and parked; Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond.
**Goal:** Upgrade Grav core `2.0.0-rc.10``2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
**Architecture:** One dependency-forced atomic upgrade. Local core is baked into the Docker image (rebuild); the server upgrades in place via `bin/gpm self-upgrade` + `bin/gpm update`. The GPM release channel is switched from `testing` to `stable` in `user/config/system.yaml`. `git-sync` is disabled for the duration of the remote upgrade and left off pending user validation.
**Tech Stack:** Grav 2.0 (PHP 8.3), GPM CLI, Docker Compose, Make (env-suffixed remote targets), Gitea content sync.
**Spec:** `docs/working/specs/2026-07-04-grav-2.0.4-upgrade-design.md`
## Global Constraints
- Version floors (GPM enforces): `grav >= 2.0.4`, `api >= 1.0.6`, `admin2 >= 2.0.9`, `flex-objects >= 1.4.3`, `login >= 3.8.11`, `form >= 6.0.0`. Assert with `>=`, not `==` — GPM may serve a newer stable patch at execution time.
- Only write inside `travel-blog-intotheeast/` or its subfolders.
- **Dual git repos.** The project root is one repo; `user/` is a *separate* repo where only `pages/ config/ accounts/ themes/` are tracked (`plugins/` is gitignored except `cache-on-save/` and `story-blocks/`). Changes to `user/config/system.yaml` commit to the **user repo** and reach the server via `make content-push` → server pull; everything else commits to the **root repo**.
- GPM channel authority is `user/config/system.yaml``gpm.releases` (must be `stable` on the server *before* any GPM op). `GRAV_CHANNEL` in docker-compose is cosmetic/consistency only.
- Never read `.env*`. Use `make remote-*` targets for all server ops.
- Do not touch `twig.cache` (stays `false` in dev per CLAUDE.md).
- `git-sync` is remote-only: never add it to `plugins.txt`; disable it during the remote upgrade and leave it disabled until the user re-enables.
- Prod is empty → **Phase 3 is documentation only, never executed.**
- Verified CLI names (against the rc.10 container): `php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins), `php bin/grav cache` (clear cache). `bin/grav upgrade` does **not** exist.
---
## File structure
| File | Repo | Responsibility |
|---|---|---|
| `Dockerfile` | root | Baked local core version (grav-admin zip URL) |
| `plugins.txt` | root | GPM plugin manifest — gains admin2/api/flex-objects |
| `docker-compose.yml` | root | `GRAV_CHANNEL` cosmetic bump |
| `user/config/system.yaml` | **user** | Authoritative GPM channel (`gpm.releases`) |
| `scripts/server-install.sh` | root | Fresh-install script — drop admin2/api special-casing |
| `scripts/git-sync-toggle.sh` | root | New: idempotently set git-sync `enabled:` on the server |
| `Makefile` | root | Fix `remote-upgrade-grav`; add 3 remote targets |
| `docs/working/plans/...` `CLAUDE.md` `docs/reference/architecture.md` | root | Runbook + stack docs |
---
## Task 1: Phase 0 — core, plugin-list, and channel edits
**Files:**
- Modify: `Dockerfile` (the grav-admin zip URL line)
- Modify: `plugins.txt`
- Modify: `docker-compose.yml` (`GRAV_CHANNEL`)
- Modify: `user/config/system.yaml` (`gpm.releases`)
**Interfaces:**
- Produces: local image that installs Grav 2.0.4; `plugins.txt` containing `api`, `admin2`, `flex-objects`; `stable` GPM channel consumed by Task 2 (local) and Task 5 (server).
- [ ] **Step 1: Bump the core version in the Dockerfile**
In `Dockerfile`, change the download URL:
```dockerfile
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
-o /tmp/grav-admin.zip \
```
(Only the URL changes — the zip still extracts to `/tmp/grav-admin/`, so every `cp` line below it is unchanged.)
- [ ] **Step 2: Add the three plugins to `plugins.txt`**
Append these lines to `plugins.txt` (order is not significant; GPM resolves deps):
```
api
admin2
flex-objects
```
- [ ] **Step 3: Switch the GPM channel to stable**
In `user/config/system.yaml`, under the `gpm:` block (currently line ~212):
```yaml
gpm:
releases: stable
official_gpm_only: true
```
(Change `testing``stable`. Leave `official_gpm_only` as-is.)
- [ ] **Step 4: Bump the cosmetic channel env**
In `docker-compose.yml`, under the `grav` service environment:
```yaml
- GRAV_CHANNEL=production
```
- [ ] **Step 5: Commit the user-repo change**
```bash
cd user
git add config/system.yaml
git commit -m "config: switch GPM release channel testing -> stable"
cd ..
```
Expected: commit succeeds in the `user` repo.
- [ ] **Step 6: Commit the root-repo changes**
```bash
git add Dockerfile plugins.txt docker-compose.yml
git commit -m "build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt"
```
Expected: commit succeeds on branch `grav-2.0.4-upgrade`.
---
## Task 2: Phase 1 — local build, clean install, validation
**Files:**
- No file edits. Executes the Task 1 changes locally.
**Interfaces:**
- Consumes: Task 1 (Dockerfile 2.0.4, plugins.txt, stable channel).
- Produces: a proven-working local 2.0.4 stack — the go/no-go gate for the remote phases.
- [ ] **Step 1: Remove the stale manually-extracted plugin folders**
These were hand-extracted from the rc.10 bundle; GPM must install them fresh.
```bash
rm -rf user/plugins/admin2 user/plugins/api user/plugins/flex-objects
```
Expected: the three folders are gone (`ls user/plugins/` no longer lists them). They are gitignored, so `git status` in `user/` is unaffected.
- [ ] **Step 2: Rebuild the image with core 2.0.4**
```bash
make build
```
Expected: build completes; the RUN layer downloads `grav-admin-v2.0.4.zip`.
- [ ] **Step 3: Recreate the container**
```bash
make start
```
Expected: `intotheeast_grav` is up on http://localhost:8081.
- [ ] **Step 4: Confirm the core version is 2.0.4**
```bash
docker exec intotheeast_grav php bin/grav --version
```
Expected output contains: `Grav CLI Application 2.0.4` (or a newer 2.0.x).
- [ ] **Step 5: Update already-installed GPM plugins to stable**
This bumps `login` (3.8.9 → ≥3.8.11, required by `api`) and `form` before the new plugins install. `gpm install` alone would skip them because they already exist.
```bash
docker exec -w /var/www/html intotheeast_grav php bin/gpm update -y
```
Expected: `login`, `form`, `shortcode-core`, etc. report as updated (or already up to date).
- [ ] **Step 6: Install the newly-listed plugins**
```bash
make install-plugins
```
Expected: `admin2`, `api`, `flex-objects` install; their dependencies resolve against the now-current `login`/`form`; no "requires grav >= 2.0.4" errors.
- [ ] **Step 7: Clear the cache**
```bash
docker exec intotheeast_grav php bin/grav cache
```
Expected: "Cache cleared" output.
- [ ] **Step 8: Assert plugin versions meet the floors**
```bash
docker exec intotheeast_grav sh -c 'cd /var/www/html && for p in admin2 api flex-objects login form; do printf "%s: " "$p"; grep -m1 "^version:" user/plugins/$p/blueprints.yaml; done'
```
Expected (at least): `admin2: version: 2.0.9`, `api: version: 1.0.6`, `flex-objects: version: 1.4.3`, `login: version: 3.8.11`, `form: version: 9.1.8` — equal or higher.
- [ ] **Step 9: Run the automated smoke suite**
This exercises the posting pipeline (`test-post` submits via the real form → add-page-by-form → cache-on-save) and renders pages via Playwright — the exact admin2/api-critical path.
```bash
make test
```
Expected: `test-config`, `test-post`, and `test-ui` all pass.
- [ ] **Step 10: Manual browser spot-check**
Visit and confirm each renders without error:
- http://localhost:8081/ (home)
- the active trip page (`/trips/japan-korea-2026`) — filter bar + map load
- one story page — hero + shortcodes render
- http://localhost:8081/admin2 — login page loads; log in
- http://localhost:8081/gpx-manager — list loads; upload a small `.gpx`, then delete it
Expected: all load; no PHP errors in `docker logs intotheeast_grav`.
- [ ] **Step 11: Checkpoint (no commit needed)**
No files changed in this task. If any step failed, stop and diagnose before proceeding — this is the go/no-go gate for remote work.
---
## Task 3: Remote Makefile targets (fix + additions)
**Files:**
- Create: `scripts/git-sync-toggle.sh`
- Modify: `Makefile` (fix `remote-upgrade-grav`; add `remote-update-plugins`, `remote-git-sync-disable`, `remote-git-sync-enable`; register the new targets in `REMOTE_TARGETS`)
**Interfaces:**
- Produces: `make remote-upgrade-grav-<env>`, `make remote-update-plugins-<env>`, `make remote-git-sync-disable-<env>`, `make remote-git-sync-enable-<env>` — consumed by Task 5.
- [ ] **Step 1: Create the git-sync toggle script**
Create `scripts/git-sync-toggle.sh` (piped to the server via `bash -s`, matching the `server-install.sh` pattern). It only ever rewrites the top-level `enabled:` key — never `folders` or the encrypted token.
```bash
#!/bin/bash
set -e
FILE="$1"
STATE="$2"
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
if [ ! -f "$FILE" ]; then
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
exit 1
fi
if grep -qE '^enabled:' "$FILE"; then
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
else
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x scripts/git-sync-toggle.sh
```
- [ ] **Step 3: Fix the broken `remote-upgrade-grav` target**
In `Makefile`, replace the body of `remote-upgrade-grav` (currently `php bin/grav upgrade`, which is not a real command):
```make
remote-upgrade-grav: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
```
- [ ] **Step 4: Add the plugin-update target**
Add below `remote-install-plugins`:
```make
remote-update-plugins: guard-env
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
```
- [ ] **Step 5: Add the git-sync toggle targets**
```make
remote-git-sync-disable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
remote-git-sync-enable: guard-env
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
```
- [ ] **Step 6: Add a server content-status target**
For reviewing config drift after the plugin upgrade without raw SSH:
```make
remote-content-status: guard-env
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
```
- [ ] **Step 7: Register the new targets for env-suffix generation**
In `Makefile`, extend the `REMOTE_TARGETS` list so the `-test`/`-prod` variants get generated:
```make
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
```
- [ ] **Step 8: Verify the targets exist and expand correctly (dry run)**
```bash
make -n remote-update-plugins-test
make -n remote-git-sync-disable-test
make -n remote-upgrade-grav-test
make -n remote-content-status-test
```
Expected: each prints the intended `ssh ...` command with `ENV=test` resolved, and no "No rule to make target" error. (No server is contacted by `-n`.)
- [ ] **Step 9: Commit**
```bash
git add scripts/git-sync-toggle.sh Makefile
git commit -m "build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets"
```
---
## Task 4: Fresh-install script cleanup (option B server-side)
**Files:**
- Modify: `scripts/server-install.sh` (remove admin2/api stash+restore)
**Interfaces:**
- Consumes: `plugins.txt` now containing admin2/api/flex-objects (Task 1).
- Produces: a fresh-install path where admin2/api/flex install purely via `gpm install $PLUGINS`.
- [ ] **Step 1: Remove the zip-stash lines**
In `scripts/server-install.sh`, delete lines 2526 (the admin2/api stash into `/tmp`):
```bash
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
```
- [ ] **Step 2: Remove the restore lines**
Delete lines 4345 (the restore after the user re-clone):
```bash
cp -rf /tmp/admin2-plugin user/plugins/admin2
cp -rf /tmp/api-plugin user/plugins/api
rm -rf /tmp/admin2-plugin /tmp/api-plugin
```
Leave `mkdir -p user/plugins user/accounts user/data` in place. admin2/api/flex now come from `php bin/gpm install $PLUGINS -y` (unchanged line ~48).
- [ ] **Step 3: Syntax-check the script**
```bash
bash -n scripts/server-install.sh
```
Expected: no output (valid syntax).
- [ ] **Step 4: Commit**
```bash
git add scripts/server-install.sh
git commit -m "build: drop admin2/api zip-stash from server-install; install via GPM (option B)"
```
---
## Task 5: Phase 2 — test env upgrade
**Files:**
- No file edits. Executes against the test environment using Task 3 targets.
**Interfaces:**
- Consumes: Tasks 14 (pushed to Gitea), the `-test` make targets.
- Produces: test env on 2.0.4 with GPM-managed plugins, validated; git-sync left disabled.
- [ ] **Step 1: Push all changes to Gitea**
The server pulls `user/` content (incl. the stable-channel `system.yaml`) from Gitea; the root repo pushes normally.
```bash
git push origin grav-2.0.4-upgrade # or merge to the branch the server tracks, per your deploy convention
make content-push # pushes the user repo commit (system.yaml) to Gitea
```
Expected: both remotes updated. (Confirm with the user which branch the test server tracks before pushing.)
- [ ] **Step 2: Disable git-sync on test**
```bash
make remote-git-sync-disable-test
```
Expected: prints `git-sync now: enabled: false`.
- [ ] **Step 3: Pull latest content to the test server**
Brings the `gpm.releases: stable` change onto the server *before* any GPM op.
```bash
make remote-fetch-content-test
```
Expected: server `user/` fast-forwards; `user/config/system.yaml` shows `releases: stable`.
- [ ] **Step 4: Upgrade the core on test**
```bash
make remote-upgrade-grav-test
```
Expected: `bin/gpm self-upgrade` moves core rc.10 → 2.0.4 (stable channel); cache cleared. If it fails on a shared-folder error (see spec Risks), retry is safe — `self-upgrade` supports `-o/--overwrite`; add it to the target temporarily if a retry is needed.
- [ ] **Step 5: Update all plugins on test**
```bash
make remote-update-plugins-test
```
Expected: admin2 → ≥2.0.9, api → ≥1.0.6, flex-objects → ≥1.4.3, login → ≥3.8.11, form, git-sync all update to their stable versions; cache cleared.
- [ ] **Step 6: Review server config drift**
Inspect the server `user/` working tree for unexpected rewrites from the plugin upgrades (do NOT blind-commit):
```bash
make remote-content-status-test
```
Expected: review any `config/` diffs deliberately. Discard server-specific/reformatting churn; keep only intended changes. (git-sync is disabled, so nothing auto-commits while you review.)
- [ ] **Step 7: Smoke-test the test URL**
Against the test site (URL per your test env), confirm:
- home, a trip page, a story render
- admin2 login works
- submit one `/post` → the entry appears in the active trip's dailies
- `/gpx-manager` lists, uploads, and deletes a file
Expected: all pass. (git-sync stays disabled, so the new post will not auto-sync yet — that's expected and verified in Step 9.)
- [ ] **Step 8: Notify the user — validation checkpoint**
Report results and explicitly state that **git-sync remains disabled** on test pending their validation. Do not re-enable automatically.
- [ ] **Step 9: (User-gated) Re-enable git-sync and verify sync**
After the user confirms validation:
```bash
make remote-git-sync-enable-test
make content-push # or trigger a content change; confirm it syncs through
```
Expected: `git-sync now: enabled: true`; a content round-trip syncs between the test server and Gitea.
---
## Task 6: Docs, prod runbook, and memory
**Files:**
- Modify: `CLAUDE.md` (stack versions + plugin-management model)
- Modify: `docs/reference/architecture.md` (versions/channel)
- Modify: `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` (this file — Phase 3 runbook + Status)
- Modify: memory files under the auto-memory dir (project-grav2-upgrade, project-plugin-architecture)
**Interfaces:**
- Consumes: the completed local + test upgrade.
- Produces: current docs; an executable-but-unexecuted prod runbook.
- [ ] **Step 1: Update the stack facts in `CLAUDE.md`**
Change the "Current stack" block: Grav `2.0.4` (not rc.10); Admin2 to the installed stable version; note that admin2/api/flex-objects are now **GPM-managed via `plugins.txt`** (no longer hand-extracted); note `gpm.releases: stable`.
- [ ] **Step 2: Update `docs/reference/architecture.md`**
Reflect core 2.0.4, stable channel, and the three-category plugin model (GPM-managed / former-manual-now-GPM / remote-only git-sync).
- [ ] **Step 3: Write the Phase 3 prod runbook**
Append a "Phase 3 — Production (fresh install, NOT executed)" section to this plan documenting: run `make remote-install-prod` with `GRAV_VERSION=2.0.4`; admin2/api/flex install via GPM from `plugins.txt`; then set up git-sync manually (install, add encrypted token, apply the `folders:` array fix per `docs/working/git-sync-notes.md`), and leave it disabled until first validation.
- [ ] **Step 4: Update memory**
Update `project-grav2-upgrade.md` (now on 2.0.4 stable; GPM serves stable so direct-download-only no longer applies) and `project-plugin-architecture.md` (admin2/api/flex now GPM-managed; git-sync remote-only category). Refresh the `MEMORY.md` pointers if the hooks change.
- [ ] **Step 5: Set the plan Status to complete**
Change the `**Status:**` line at the top of this file to `✅ Complete (YYYY-MM-DD)` (today's date at execution).
- [ ] **Step 6: Commit**
```bash
git add CLAUDE.md docs/reference/architecture.md docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md
git commit -m "docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook"
```
(Memory files live outside the repo; they are written directly, not committed here.)
---
## Rollback
If any phase fails and cannot be fixed forward:
1. `git revert` the relevant commits on `grav-2.0.4-upgrade` (root repo) and the `user` repo `system.yaml` commit.
2. Local: `make build && make start && make install-plugins`.
3. Test server: config, content, and plugin reverts are delivered via `make content-push` + `make remote-fetch-content-test`. **The core is the exception — once `bin/gpm self-upgrade` has completed it cannot downgrade, so treat a completed core upgrade as forward-only and fix forward; there is no revert for it.**
> ⚠️ Do **not** run the fresh-install path (`scripts/server-install.sh`) against a live server as a rollback. It does `rm -rf user; git clone`, which destroys the server-only, gitignored `user/config/plugins/git-sync.yaml` (the encrypted git-sync token a clone never restores). The fresh-install path is for empty/new servers only.
Content, config, and accounts are in git, so no data restore is required — but note the core caveat above: "rollback = git" covers config/content/plugins, **not** a completed server core self-upgrade.
---
## Execution outcome (2026-07-04)
**Installed local versions (all at/above floors):** Grav `2.0.4`, admin2 `2.0.10`, api `1.0.7`, flex-objects `1.4.4`, login `3.8.11`, form `9.1.10`, shortcode-core `6.2.2`.
**Phase 1 (local): validated + shipped.** Image rebuilt on 2.0.4, plugins installed via GPM, cache clears, rendering clean (home, trips, story, `/admin2` login, `/gpx-manager` list/upload/delete all 200). Test suite: **75 passing**. Test-config was re-pointed off the retired `japan-korea-2026` onto the vetted `italy-2026-demo` data. A self-contained, gitignored `testrunner` account (created via `make test-account` with `--admin-type both`) makes `make test` runnable without the real account in `.env`.
**Known issue — Form 9.1.10 filepond regression (blocked elsewhere, not a go/no-go blocker).** The post form's `filepond` photo field 500s on the post-submit re-render (`filepond.html.twig` runs `merge` on a string). The journal entry still saves correctly (curl/on-disk `test-post.sh` passes); only the browser re-render errors, failing 6 `post.spec.js` UI specs. This is a stock-plugin upgrade regression, being fixed independently in the form-to-page/image-upload rework. **Do not** add a theme-override workaround in this upgrade — let that rework own the fix.
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
**Task 5 (Phase 2, remote test-env): parked, user-gated.** Not executed. Requires the user to confirm which branch the test server tracks and that `.env.test` is ready. All `-test` make targets exist and dry-run clean.
---
## Phase 3 — Production (fresh install, NOT executed)
Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan.
When prod is provisioned:
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt``admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
3. **git-sync (remote-only, manual):** it is deliberately absent from `plugins.txt`. Install it on the server, add the encrypted token to `user/config/plugins/git-sync.yaml` (server-only, gitignored — a fresh clone never restores it), and apply the `folders:` array fix per `docs/working/git-sync-notes.md`. Leave it **disabled** (`make remote-git-sync-disable-prod`) until the first content round-trip is validated, then `make remote-git-sync-enable-prod`.
4. **Smoke test** the prod URL as in Task 5 Step 7 (home / trip / story / admin2 login / one `/post` / `/gpx-manager`). Note the Form filepond known-issue above will surface on `/post` until the separate rework lands — the entry still saves.
5. **Never** run `scripts/server-install.sh` against a populated server (it `rm -rf user; git clone`, destroying the server-only git-sync token). Fresh/empty servers only.
@@ -0,0 +1,344 @@
---
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-brainstorm
title: Journal Post Form Improvements - Plan
type: feat
date: 2026-07-04
execution: code
---
# Journal Post Form Improvements — Plan
**Status:** 📋 Not started
> Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
**Product Contract preservation:** Product Contract unchanged. Planning enriches this artifact in place — Requirements R1R20, Key Flows, and Acceptance Examples are carried verbatim from the brainstorm.
---
## Goal Capsule
- **Objective:** Redesign the frontend `/post` journal form so a daily entry can be posted end-to-end from an iPhone — auto-targeting the active trip, exposing every entry field, loading a light markdown editor, converting HEIC photos in the browser, and matching the Field Notes design system.
- **Authority hierarchy:** This plan's Requirements (R1R20) and the three resolved Key Technical Decisions govern. Where an implementation detail is unspecified, follow existing repo conventions (esbuild bundle, Playwright suite, plugin structure). `CLAUDE.md` project rules override everything (only write inside `travel-blog-intotheeast/`; `user/` is a standalone repo; never read `.env`; use `make` for remote ops).
- **Stop conditions:** Stop and surface if (a) intercepting Grav's managed FilePond instance for HEIC conversion proves infeasible without replacing the field type (U4 is the load-bearing risk), or (b) any change would require a server-side image pipeline or Docker rebuild — that path is explicitly deferred.
- **Execution profile:** Frontend-weighted. One PHP handler (U1); the rest is form blueprint, Twig, an esbuild bundle with two new npm deps, and CSS. Dev server at `http://localhost:8081`; rebuild JS with `make build-assets` (never hand-edit `js/main.js`).
- **Tail ownership:** Verify with `make test` (config + post + Playwright UI) and manual dev-server walkthrough on a narrow viewport.
---
## Product Contract
### Summary
Redesign the frontend `/post` journal form to auto-target the active trip, expose every entry-blueprint field (core visible, advanced behind "More options"), load a light markdown editor, convert iPhone HEIC photos to JPEG in the browser, and match the site's Field Notes design system — all behind the existing site login, optimised for posting from an iPhone during a trip.
### Problem Frame
Posting a daily entry today has five rough edges. The parent trip is hardcoded in `user/pages/02.post/post-form.md` (`pageconfig.parent`) and must be kept in sync by hand with `site.active_trip`; forgetting on a trip switch silently files entries under the wrong trip. The form exposes only a subset of the entry blueprint, so `transport_mode`, `hero_image`, `force_connect`, `featured`, and a proper weather-condition picker are unreachable without opening Admin2. The content field is a bare `<textarea>` — the bundled SimpleMDE never loads because `add-page-by-form` keys its editor on a form named `add_page*`, and this form is `new-entry`. Photos come straight off an iPhone, most often as HEIC, which Grav's GD pipeline cannot read at all, so thumbnails break. The form also uses generic Grav markup rather than the site's visual language, and isn't tuned for one-handed mobile use — which is the only way it will be used during the trip.
### Key Decisions
- **Active trip is resolved dynamically, not hardcoded.** The parent is derived from `site.active_trip` at submit time instead of from a static `pageconfig.parent`. `add-page-by-form` already honours a submitted `parent` value (`add-page-by-form.php` ~L521), so a small server-side hook injects `<active_trip>/dailies`. This removes the manual two-file sync and its silent-misfile failure mode.
- **HEIC is handled client-side only.** Desktop story work sources images from Immich, which already yields JPEG, so HEIC only ever originates from this mobile form — a single path. A browser converter covers it without adding a libheif-enabled ImageMagick to the baked Docker image. Server-side conversion would maintain infrastructure for a case that, by the owner's workflow, never occurs.
- **Advanced fields sit behind a "More options" disclosure.** Core fields stay visible for fast phone posting; `hero_image`, `force_connect`, and `featured` are collapsed by default but reachable — nothing is Admin-only anymore.
- **Editor is EasyMDE.** The maintained SimpleMDE successor, with a minimal toolbar (bold, italic, list, link) plus a preview toggle — enough affordance without the mobile clutter of a full toolbar.
- **The footprint is mostly frontend, not PHP.** Despite the original framing, the only PHP change is the active-trip parent injection. Fields, editor, HEIC conversion, styling, and mobile layout all live in the form blueprint, the Twig template, and its JS/CSS.
### Requirements
**Active-trip targeting**
- R1. Submitting the form stores the new entry under the currently active trip's `dailies` folder, resolved from `site.active_trip` at submit time.
- R2. Switching trips (changing `active_trip`) requires no edit to the post form; the hardcoded `pageconfig.parent` coupling is removed.
**Entry fields**
- R3. The form can set the following entry fields: title, date, content, photos, location (city, country, lat, lng), weather (condition, temperature), `transport_mode`, `hero_image`, `force_connect`, `featured`. (title/date/content/photos are page-level and media fields supplied by the form; the remaining fields live in `entry.yaml`.)
- R4. Core fields are always visible: title, date, content, photos, location, weather condition, weather temperature, transport mode.
- R5. `hero_image`, `force_connect`, and `featured` sit behind a "More options" disclosure that is collapsed by default.
- R6. Weather condition is a labelled picker matching the blueprint's options; the existing "Get Weather" action pre-fills it, and it stays manually overridable.
**Editor**
- R7. The content field uses EasyMDE with a minimal toolbar (bold, italic, list, link) and a preview toggle, bound to the underlying content field so both submission and validation read its value.
- R15. EasyMDE syncs its content back to the underlying textarea before the form's custom required-field validation runs (e.g. `editor.codemirror.save()` on submit, or bound on change), so a valid entry is never rejected as empty and an empty one never slips past.
**Image handling**
- R8. HEIC/HEIF photos selected on the device are converted to JPEG in the browser before upload, so only web-renderable images reach the server.
- R9. Conversion is a no-op for photos already in a web format (JPEG/PNG), including HEIC that iOS Safari has already transcoded on file-pick.
- R10. No server-side or Docker image change is required; image handling is entirely client-side. (Client-side conversion is a UX convenience; the trust boundary at the upload endpoint is an accepted, deferred gap — see Open Questions.)
- R16. HEIC/HEIF is detected by content sniffing, not filename or MIME alone. If a photo is HEIC/HEIF and conversion fails, times out, or the file is corrupt/ambiguous, that photo is blocked from upload with an inline error while other selected photos and Submit remain usable; the original HEIC is never posted.
- R17. Each photo still converting shows a per-thumbnail "converting…" indicator, and Submit is disabled until every selected photo has finished converting.
**Styling and mobile UX**
- R11. The form is styled to the Field Notes design system (teal accent, DM Serif Display + DM Sans, warm paper background), consistent with the rest of the site.
- R12. The form is single-column and mobile-first: large tap targets, native-keyboard-friendly inputs, a comfortable writing area, and smooth "Get Location" / "Get Weather" / photo-capture actions on iPhone.
- R13. Photo input supports selecting or capturing images from an iPhone, up to 4.
- R18. "Get Location" and "Get Weather" each expose idle, loading (spinner on the button), success (fields filled), and error/permission-denied states; on failure an inline message appears and the fields stay manually editable.
- R19. Submit runs blocking inline validation with per-field messages for missing required fields (at minimum title and content, matching the form's current validation), and on a failed save it preserves all entered input and surfaces a retry.
**Access**
- R14. `/post` remains gated by the existing frontend site login; one login persists for the session. No public or unauthenticated posting.
- R20. If the site-login session expires while an entry is being composed, submitting does not lose the in-progress **text**: the entered title, content, location, weather, and other field values are preserved so the owner can re-authenticate and resubmit. **Scope limit:** selected/converted photos are *not* preserved across a reload or re-auth — `localStorage` cannot hold `File`/`Blob` objects — so photos must be re-picked after re-authenticating. The form surfaces an inline hint to that effect rather than silently dropping them.
### Key Flows
- F1. **Post a daily entry from an iPhone.**
- **Trigger:** owner opens `/post` on their phone (already logged into the site).
- Fills title, date, content (EasyMDE), taps "Get Location" then "Get Weather" to auto-fill coords + weather, sets transport mode, optionally expands "More options".
- Picks up to 4 photos. Any HEIC is converted to JPEG in the browser before upload; already-web-format photos pass through untouched.
- On submit, the entry is written to `<site.active_trip>/dailies` (parent injected server-side), media attached, and the page cache cleared so it appears immediately in the feed.
### Acceptance Examples
- AE1. **Covers R8, R9.** A HEIC photo is selected → converted to JPEG client-side → the posted entry renders with a working thumbnail and hero. A JPEG photo is selected → uploaded unchanged.
- AE2. **Covers R1, R2.** With `active_trip: /trips/japan-korea-2026`, a new post lands in `/trips/japan-korea-2026/dailies` without any edit to the form definition.
- AE3. **Covers R4, R5.** On load, title/date/content/photos/location/weather/transport are visible; `hero_image`, `force_connect`, and `featured` are hidden until "More options" is expanded.
- AE4. **Covers R16, R17.** While a photo converts it shows a "converting…" indicator and Submit is disabled. A HEIC photo whose conversion fails (or a corrupt/ambiguous file) is blocked with an inline error while other photos and Submit stay usable; the original HEIC is never posted.
### Scope Boundaries
- **Deferred:** server-side HEIC conversion and a custom libheif-enabled ImageMagick Docker image — revisit only if HEIC begins arriving through a non-Immich path.
- **Deferred:** server-side upload validation (accept-list + size cap on `/post`). The authenticated SVG/HEIC/oversized-payload gap is real but login-gated and low-risk for a solo owner; left in Open Questions rather than pulled into this plan. Client-side conversion is UX, not the security boundary.
- **Separate brainstorm:** moving story authoring into a frontend add-page flow ("capture a story from the road"). Stories remain desktop-authored for now.
- **Unchanged:** the auth model (no PIN/magic-link), the travel-memories / Immich pipeline, and Admin2 authoring.
### Dependencies / Assumptions
- Desktop story images come from Immich as JPEG — this is what makes client-side-only HEIC handling sufficient.
- `add-page-by-form` continues to honour a submitted `parent` value that overrides `pageconfig.parent`.
- A browser HEIC→JPEG library ([heic-to](https://github.com/hoppergee/heic-to)) integrates into the filepond upload step.
- EasyMDE can be bound to the content field so its value syncs to the submitted form data.
- The frontend Login-plugin session persists on iOS for the trip's duration.
---
## Planning Contract
### Key Technical Decisions
- KTD1. **Parent injection lives in `cache-on-save`, as a second handler on `onFormValidationProcessed`, and is server-authoritative.** The plugin gains an `onFormValidationProcessed` handler that, for form `new-entry`, reads `site.active_trip` and sets the form's `parent` value (via `$form->value()`) to `<active_trip>/dailies` before `add-page-by-form`'s `add_page` action reads `$form->value()->toArray()['parent']` on its own `onFormProcessed` handler (`add-page-by-form.php:521`). `onFormValidationProcessed` is chosen deliberately over a higher-priority `onFormProcessed`: it is the only pre-write event that can **abort** the submit by failing validation. The existing cache-clear handler is untouched. No `parent` field is added to the form blueprint — injecting server-side (not via a client-submitted hidden field) keeps the write target out of the client's control. `pageconfig.parent` is removed from `post-form.md` so nothing can drift out of sync. **Empty-`active_trip` fail-closed:** if `active_trip` is missing/empty the handler must fail validation (raise an `onFormValidationError` / throw) so `add_page` never runs — merely leaving `parent` unset is not enough, because with `pageconfig.parent` gone `getParentPage('')` resolves to the `/post` page itself and the entry would silently land under `/post` (not the site root). Failing validation is what guarantees no misfile.
- KTD2. **EasyMDE + heic-to ship in a `/post`-scoped, code-split bundle, not the global `main.js`.** A new entry `js/src/post-form.js` is bundled to `js/post-form.js` and loaded only by `post-form.html.twig` — keeping ~1.5 MB of converter + editor off every other page. Unlike the site's other bundles (`--format=iife`, no splitting), the `/post` entry is built with `--format=esm --splitting` so the dynamic `import('heic-to')` (KTD4) becomes a **separately-fetched chunk** rather than being inlined — the HEIC converter's weight stays out of the initial `/post` download and is fetched only when a HEIC is actually picked. This matters because `/post` is the cold-load-on-cellular surface. Consequences to carry through: the template must load the entry as `<script type="module" src="js/post-form.js">` (not a classic `<script>`), esbuild emits shared/dynamic chunks alongside the entry (the whole emitted set must ship, so the build's `outdir`/chunk output is committed, not just the single file), and this is the only ESM/split entry in `package.json`'s `build` script — the existing IIFE entries are untouched. CSS is still extracted to `css-compiled/post-form.css`.
- KTD3. **EasyMDE flushes to the textarea before validation.** Init EasyMDE on the content `<textarea>`, and call `editor.codemirror.save()` on `change` and at the top of the existing `submit` handler, so the custom `novalidate` validator (which reads `[name="data[content]"]`, `post-form.html.twig:3945`) sees the live value. This preserves the current validation approach rather than replacing it.
- KTD4. **HEIC is detected by magic-byte sniffing and the converter is lazy-loaded.** Sniff the first bytes for the ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands rather than trusting extension or MIME. Only when a HEIC is detected is heic-to dynamically imported (keeps the initial `/post` payload small). On success the file is replaced with a JPEG blob (slugified `.jpg` name); on failure/timeout/corrupt the file is rejected fail-closed with an inline error. Submit is gated on a "conversions in flight" counter.
- KTD5. **"More options" is an accessible native `<details>`/disclosure.** Advanced fields (`hero_image`, `force_connect`, `featured`) render inside a `<details>` collapsed by default, auto-expanded if any advanced field is non-empty on load. Native `<details>` gives keyboard/AT support without custom ARIA wiring.
- KTD6. **Submit resilience via a `localStorage` draft.** Field values (content especially) are mirrored to `localStorage` on input and restored on load; the draft is cleared on a confirmed successful post. On a failed submit — validation, save error, or a session-expiry response that renders the login form instead of the success message — the draft survives so the owner re-authenticates and resubmits without loss.
### High-Level Technical Design
The submit pipeline spans client (conversion, editor sync, validation) and server (parent injection, page write, cache clear). The load-bearing ordering is that parent injection must run *before* `add-page-by-form`'s `add_page` action.
```mermaid
flowchart TB
subgraph Client
A[Pick photos] --> B{HEIC?<br/>magic-byte sniff}
B -->|yes| C[Lazy-load heic-to<br/>convert to JPEG]
B -->|no| D[Pass through]
C -->|fail| E[Block photo,<br/>inline error]
C -->|ok| F[Replace with JPEG blob]
D --> F
G[EasyMDE] -->|codemirror.save| H[textarea value]
F --> I{Submit}
H --> I
I -->|conversions in flight| J[Submit disabled]
I -->|required missing| K[Inline validation, preserve draft]
I -->|ok| L[POST /post]
end
subgraph Server
L --> M[onFormValidationProcessed<br/>cache-on-save injects parent<br/>= active_trip + /dailies]
M --> N[add-page-by-form add_page<br/>reads form_data.parent L521]
N --> O[Page written under active trip]
O --> P[cache-on-save clears cache]
P --> Q[Entry appears in feed]
end
```
### Sequencing
U1 (parent injection) and U2 (fields) are independent and can land first in either order. U3 introduces the `/post` bundle. U4's HEIC logic is independent of U3's editor logic, but U4 depends on that bundle scaffolding — build U3 first so the bundle exists, then U4 adds to it. U5 (styling/disclosure/feedback) depends on U2's field definitions and U3's bundle. U6 (draft resilience) depends on U3 and U5. U7 (tests) comes last and verifies the whole.
### Assumptions / Execution-time unknowns
- The exact hook for injecting into Grav's managed FilePond instance (U4) is unresolved and is the plan's chief risk — see Risks. Resolve during implementation by inspecting the rendered filepond field and FilePond's `beforeAddFile` / `server.process` options; a fallback is documented in U4.
- Whether `onFormValidationProcessed` exposes a settable `parent` on the form in this Grav/add-page-by-form version, or whether a higher-priority `onFormProcessed` is needed, is confirmed at implementation time against a live submit.
---
## Implementation Units
### U1. Server-authoritative active-trip parent injection
- **Goal:** New entries land under `<site.active_trip>/dailies` automatically; the hardcoded parent sync is removed (R1, R2).
- **Requirements:** R1, R2. Covers AE2.
- **Dependencies:** none.
- **Files:**
- `user/plugins/cache-on-save/cache-on-save.php` — add a second subscribed event + handler for parent injection.
- `user/pages/02.post/post-form.md` — remove `pageconfig.parent`; drop the "keep in sync" comment.
- **Approach:** Subscribe to `onFormValidationProcessed` (keep the existing `onFormProcessed` cache-clear). In the new handler, guard on `$form->getName() === 'new-entry'`, read `active_trip` from `$this->grav['config']->get('site.active_trip')`, and set the form's `parent` value to `<active_trip>/dailies` so `add-page-by-form` picks it up at `add-page-by-form.php:521`. Do not add a `parent` form field. If `active_trip` is empty, fail validation (raise an `onFormValidationError` / throw) so the `add_page` action never runs — do not just leave `parent` unset, which would misfile under `/post` (see KTD1). Optionally tighten the existing `deleteAll()` to run once (minor; only if trivially safe).
- **Patterns to follow:** existing `cache-on-save.php` handler shape and `getSubscribedEvents()`.
- **Test scenarios:**
- Covers AE2. With `active_trip: /trips/italy-2026-demo`, a form post creates the page under `/trips/italy-2026-demo/dailies`.
- Change `active_trip` to another trip → next post lands there with no edit to `post-form.md`.
- `active_trip` empty/unset → validation fails and the `add_page` action never runs; no page is written under `/post` or anywhere.
- Existing cache-clear behavior still fires (new entry appears immediately in the feed).
- **Verification:** `make test-post` and `make test-config` pass; a manual post at `http://localhost:8081/post` lands in the active trip and appears in its dailies feed immediately.
### U2. Full entry-field exposure + weather picker
- **Goal:** The form can set every entry field, with a proper weather-condition picker; core fields visible, advanced fields defined for the U5 disclosure (R3, R4, R6).
- **Requirements:** R3, R4, R6. Supports R5 (disclosure UI in U5).
- **Dependencies:** none.
- **Files:** `user/pages/02.post/post-form.md` — field definitions.
- **Approach:** Change `weather_desc` from `hidden` to a `select` mirroring `entry.yaml`'s options (the emoji-labelled conditions). Add `transport_mode` (select, options from `entry.yaml`), `hero_image` (text), `force_connect` (toggle), `featured` (toggle). Keep `weather_temp_c` (populated by Get Weather; a `number` input so it stays user-editable). Order fields so core (title, date, content, photos, location, weather condition, weather temp, transport) precede the advanced trio; the visual grouping/disclosure is U5. Field names must match the `entry.yaml` header keys so `pagefrontmatter` serialization lands them correctly. **Caution:** turning `weather_desc` into a `<select>` breaks the existing Get Weather handler's `getField('weather_desc')` lookup, which queries `input[name="data[weather_desc]"]` (`post-form.html.twig:65-67`) and will return `null` for a select — U5 must generalize that selector when it migrates the handler, or Get Weather's condition pre-fill silently no-ops.
- **Patterns to follow:** `entry.yaml` field types and option lists; existing field blocks in `post-form.md`.
- **Test scenarios:**
- Covers AE3 (field presence half). A logged-in `/post` render shows title, date, content, photos, location, weather condition (as a select with emoji options), weather temp, and transport mode.
- Posting with `transport_mode`, `hero_image`, `force_connect`, `featured` set writes those keys into the entry frontmatter.
- Weather condition select round-trips a manually chosen value (not overwritten unless Get Weather runs).
- **Verification:** `make test-config` passes; posted entry frontmatter contains the new fields; entry renders with transport/weather on the trip feed.
### U3. EasyMDE editor + validation sync + `/post` bundle scaffolding
- **Goal:** Content uses EasyMDE with a minimal toolbar + preview, synced to the textarea before validation; establish the `/post`-scoped bundle (R7, R15).
- **Requirements:** R7, R15.
- **Dependencies:** none (introduces the bundle U4/U5/U6 extend).
- **Files:**
- `user/themes/intotheeast/package.json` — add `easymde` dep; add a `js/src/post-form.js` esbuild entry to the `build` script built with `--format=esm --splitting` (per KTD2, so KTD4's `import('heic-to')` is a real deferred chunk), CSS extracted to `css-compiled/post-form.css`. The existing IIFE entries stay as-is.
- `user/themes/intotheeast/js/src/post-form.js` — new bundle entry: init EasyMDE, wire sync.
- `user/themes/intotheeast/templates/post-form.html.twig` — load the bundle as `<script type="module" src="js/post-form.js">` + `css-compiled/post-form.css` (page-scoped); migrate the inline validation script's content read to use the synced textarea. (Module scripts defer by default — ensure any inline init that depends on globals accounts for that.)
- **Approach:** In `post-form.js`, guard on the presence of the content textarea (no-op otherwise, mirroring `initTripStats`). Init EasyMDE with `toolbar: ['bold','italic','unordered-list','link','preview']`. On `editor.codemirror` `change` and at the start of the existing submit handler, call `editor.codemirror.save()` so `[name="data[content]"]` holds the live value for validation and submission. Rebuild with `make build-assets` (never hand-edit `js/post-form.js`).
- **Patterns to follow:** `js/src/main.js` `initTripStats` presence-guard pattern; `package.json` `build` script esbuild invocation; `base.html.twig` `assets.addJs(..., {group:'bottom'})` for the page-scoped adds in the template.
- **Test scenarios:**
- Typing content in EasyMDE, then submitting, posts the entered markdown (content persists).
- Submitting with an empty editor triggers the required-field error (sync makes the empty value visible to the validator).
- Content with markdown (bold, list, link) round-trips into the entry body.
- Preview toggle renders markdown without breaking submit.
- **Verification:** `make build-assets` completes clean; `make test-ui` post spec (U7) passes; manual check that a valid entry is never wrongly rejected as empty.
### U4. Client-side HEIC→JPEG conversion with progress + failure states
- **Goal:** HEIC photos are detected and converted before upload with a converting indicator and fail-closed handling; web-format photos pass through (R8, R9, R16, R17).
- **Requirements:** R8, R9, R16, R17. Covers AE1, AE4.
- **Dependencies:** U3 (the `/post` bundle).
- **Files:**
- `user/themes/intotheeast/package.json` — add `heic-to` dep (dynamically imported).
- `user/themes/intotheeast/js/src/post-form.js` — HEIC detection, conversion, progress/failure UI, Submit gating.
- `user/themes/intotheeast/css/style.css` (or `post-form.css` bundle) — converting indicator + inline photo error styles.
- **Approach:** Hook the filepond field's file intake. Sniff the first bytes for an ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands. On a HEIC, dynamically `import('heic-to')`, convert to a JPEG blob, and substitute it (slugified `.jpg` name) before it uploads; show a per-thumbnail "converting…" state and increment an in-flight counter that disables Submit. On success decrement; on failure/timeout/corrupt, reject that file with an inline error, leave other files + Submit usable, and never upload the original. Non-HEIC files pass through untouched (R9), including HEIC already transcoded to JPEG by iOS on pick.
- **Execution note:** This is the plan's highest-risk unit — Grav's `filepond` field manages its own FilePond instance. Resolve the exact interception point at implementation time (FilePond `beforeAddFile` / `server.process`, or converting the `File` before it enters filepond). **Fallback if the managed instance can't be hooked cleanly:** replace the `filepond` field with a plain multiple `file` input for `/post` and drive conversion + preview directly. Surface this as a blocker (per Goal Capsule stop condition) before adopting the fallback.
- **Patterns to follow:** none local for filepond interception — see Sources; follow `initTripStats` presence-guard for the init.
- **Test scenarios:**
- Covers AE1. A JPEG uploads unchanged; the posted entry renders a working thumbnail + hero.
- Covers AE4. A HEIC file shows a "converting…" indicator, converts, and posts as JPEG; Submit is disabled until conversion completes.
- A corrupt/ambiguous HEIC (or a conversion that throws) is blocked with an inline error; other selected photos and Submit remain usable; the original HEIC is not posted.
- A HEIC renamed to `.jpg` (misleading extension) is still detected by sniffing and converted, not passed through.
- Selecting a 5th photo respects the `limit: 4` cap.
- **Verification:** `make build-assets` clean; `make test-ui` HEIC spec (U7) passes using a real `.heic` fixture; manual iPhone-Safari check that a camera HEIC posts with a working thumbnail.
### U5. Field Notes styling, mobile layout, "More options" disclosure, async feedback
- **Goal:** The form matches the design system and is mobile-first; advanced fields sit behind an accessible disclosure; Get Location / Get Weather / submit validation expose full feedback states (R5, R11, R12, R13, R18, R19).
- **Requirements:** R5, R11, R12, R13, R18, R19. Covers AE3 (disclosure half).
- **Dependencies:** U2 (field definitions), U3 (the `/post` bundle + EasyMDE-synced content value).
- **Files:**
- `user/themes/intotheeast/templates/post-form.html.twig` — wrap advanced fields in a `<details>` "More options"; restructure for single-column mobile; migrate inline scripts into the bundle where practical.
- `user/themes/intotheeast/js/src/post-form.js` — Get Location / Get Weather state machine (idle/loading/success/error), Get Weather disabled until coords present, blocking submit validation with per-field messages.
- `user/themes/intotheeast/css/style.css` and/or `post-form.css` — Field Notes tokens (`tokens.css`), large tap targets, disclosure styling, `.form-status` states, `.field-error`.
- **Approach:** Use `tokens.css` variables (teal accent, DM Serif Display + DM Sans, paper background) for a single-column layout with ≥44px tap targets and native-friendly inputs. Advanced fields render inside `<details>` collapsed by default, auto-`open` when any advanced field is non-empty. Extend the existing Get Location / Get Weather handlers (`post-form.html.twig:69124`) with explicit loading (button spinner), success, and error/permission-denied states; disable Get Weather with a hint until lat/lng exist. Keep the fields manually editable on failure. When migrating the Get Weather handler, generalize the `weather_desc` lookup so it matches the U2 `<select>` (not `input[...]`). Submit validation stays the custom `novalidate` approach (title + content required), now reading the EasyMDE-synced value. Add a **save-failure feedback state** distinct from field validation: on a failed `add_page`/`upload` — including KTD1's empty-`active_trip` validation error — show an inline error with an explicit retry affordance while the draft (U6) is preserved; specify what the empty-`active_trip` case tells the user ("no active trip is set").
- **Patterns to follow:** `css/tokens.css` variables; existing `.form-status--ok` / `.form-status--err` classes; `.journal-post` / site card styling for visual consistency.
- **Test scenarios:**
- Covers AE3 (disclosure). Advanced trio is hidden until "More options" is expanded; expands automatically when an advanced field has a value.
- Get Location denied → inline error, lat/lng stay manually editable.
- Get Weather tapped before coords exist → disabled/hint, no dead tap.
- Get Weather success fills the weather condition select + temp; failure shows an inline message.
- Submit with empty title → per-field inline error, focus moves to the field, no navigation.
- Save failure (e.g. empty `active_trip`) → inline save-error message with a retry affordance; entered content preserved (not reset).
- Narrow viewport (~375px) renders single-column with no horizontal scroll.
- **Verification:** `make test-ui` (incl. `tests/ui/a11y/accessibility.spec.js`) passes; manual dev-server walkthrough at 375px width.
### U6. Submit resilience — draft persistence
- **Goal:** A failed submit or an expired session mid-compose never loses the entry's **text** (R19 preservation, R20); photos are out of scope for persistence and the form says so.
- **Requirements:** R19 (preserve-on-failure), R20.
- **Dependencies:** U3, U5 (bundle + submit handling).
- **Files:** `user/themes/intotheeast/js/src/post-form.js` — draft mirror/restore; `user/themes/intotheeast/templates/post-form.html.twig` — re-auth hint markup if needed.
- **Approach:** Mirror **text** field values (content especially — title, date, content, location, weather, transport, advanced fields) to `localStorage` on input under a `new-entry` key. Photos are explicitly out of scope: `File`/`Blob` objects can't be serialized to `localStorage`, so picked/converted photos are not persisted and must be re-selected after a reload or re-auth — render an inline hint near the photo field on restore ("photos need re-selecting"). On load, restore any text draft into the fields + editor. Clear the draft only after a confirmed successful post (success message present) — and ensure this clear runs before/independently of the form's `process.reset: true`, so the reset doesn't repopulate blank fields back into `localStorage`. **Text-draft survival is guaranteed by this clear-only-on-success invariant**, independent of any failure-type detection. The tailored "session expired — log in and resubmit" hint is best-effort on top: verify at implementation time what a multipart POST under an expired session/nonce actually returns (an inline `#grav-login`, a Grav nonce/validation error, or a 302 redirect) before keying the hint on it — the auth spec's `#grav-login` assumption is GET-scoped and may not hold for the POST.
- **Patterns to follow:** the auth spec's assumption that `/post` renders `#grav-login` inline when unauthenticated (`tests/ui/auth/auth.spec.js` A4) — detect that to distinguish session-expiry from other failures.
- **Test scenarios:**
- Type content, reload the page → content is restored from the draft.
- Successful post → draft is cleared (a fresh `/post` load is empty).
- Failed validation submit → entered values persist (not wiped by reset).
- Simulated session-expiry response (login form) → text draft survives; re-auth + resubmit posts the text without loss.
- After a reload with a photo previously picked → the photo is gone (expected) and the inline "photos need re-selecting" hint is shown; text fields are still restored.
- **Verification:** `make test-ui` draft spec (U7) passes; manual check that a reload mid-compose restores content.
### U7. Post-form test coverage
- **Goal:** Lock the behavior with a Playwright spec and fixtures (verifies AE1AE4 and the new UX).
- **Requirements:** verification for R1R20. Two are preserve/constraint requirements with no new-behavior scenario: R10 (no server-side/Docker change) is enforced by the "Scope discipline" Definition-of-Done line; R14 (login gating, no public posting) is covered by the existing `tests/ui/auth/auth.spec.js` (A4).
- **Dependencies:** U1U6.
- **Files:**
- `tests/ui/post/post.spec.js` and `tests/ui/post/validation.spec.js`**update existing specs**: they (and `tests/ui/helpers.js`) currently fill `textarea[name="data[content]"]`, which EasyMDE hides once U3 lands. Retarget content entry to the CodeMirror instance (type into `.CodeMirror textarea` or call the EasyMDE API) or the Playwright suite goes red.
- `tests/ui/helpers.js` — update the shared content-fill helper for the same reason.
- `tests/ui/post/post.spec.js` — extend with the new coverage (or add a focused sibling spec) for disclosure, HEIC conversion + failure, active-trip landing, feedback states, draft restore.
- `tests/fixtures/test-photo.heic` — real HEIC fixture for the conversion path.
- `scripts/test-post.sh` — extend if the active-trip landing assertion belongs there rather than in Playwright.
- **Approach:** Follow the existing spec style (`tests/ui/post/post.spec.js`, `auth.spec.js`): use the logged-in storage state, drive `/post`, and assert field presence (AE3), disclosure behavior, HEIC conversion + failure (AE1/AE4), active-trip landing (AE2), and draft restore. Add the `.heic` fixture alongside `test-photo.jpg` / `test-nonimage.txt`. Note the filepond-targeting specs (and helpers) also need updating if U4's plain-input fallback is adopted.
- **Patterns to follow:** existing `tests/ui/**` specs; `.env.test` provides `GRAV_TEST_USER` / `GRAV_TEST_PASS` / `GRAV_BASE_URL`.
- **Test scenarios:** the spec *is* the scenarios — AE1, AE2, AE3, AE4, plus disclosure, feedback states, and draft restore.
- **Verification:** `make test` (config + post + UI) is green.
---
## Verification Contract
| Gate | Command | Proves |
|---|---|---|
| Asset build | `make build-assets` | `/post` bundle compiles as ESM with splitting; `js/post-form.js` entry + the `heic-to` dynamic chunk + `css-compiled/post-form.css` all emitted and committed |
| Form config | `make test-config` (`scripts/test-form-config.sh`) | `post-form.md` blueprint is valid; new fields parse |
| Post pipeline | `make test-post` (`scripts/test-post.sh`) | A post lands under the active trip and appears in the feed |
| UI suite | `make test-ui` (`npx playwright test`) | AE1AE4, disclosure, feedback states, draft restore, accessibility |
| Full gate | `make test` | All of the above in sequence |
Manual: on `http://localhost:8081/post` at ~375px width, post a real iPhone HEIC and confirm a working thumbnail; verify the entry lands in the active trip's dailies immediately.
---
## Definition of Done
- **Global:** All of R1R20 satisfied; `make test` green; `make build-assets` clean with no hand-edits to generated `js/*.js`; the form is posted successfully end-to-end from a narrow (mobile) viewport including one real HEIC photo.
- **Per unit:** each unit's Test scenarios pass and its Verification holds.
- **Scope discipline:** no server-side image pipeline, no Docker change, no server-side upload validation added (deferred per decision); `pageconfig.parent` removed and no new client-submittable `parent` field introduced.
- **Cleanup:** any exploratory filepond-interception dead-ends removed; if the U4 fallback (plain file input) was adopted, the managed-filepond attempt is not left commented in the bundle.
- **Docs:** if `active_trip`/post-form coupling notes in `CLAUDE.md` are now stale (the two-file sync is gone), update them.
---
## Open Questions
Both are deferred (security posture), not launch-blocking. They stay in Open Questions by owner decision:
- **HEIC single-path durability.** The client-side-only decision assumes HEIC only ever enters via this mobile form, but Admin2 media edits, Immich-served originals, and the same `/post` form opened in a desktop browser can each introduce an unconverted HEIC that bypasses the converter. Decide whether to add a cheap server-side HEIC rejection backstop or to explicitly accept (and document) that non-`/post` HEIC uploads render broken. Reversal cost of the deferred server-side path is a Docker image rebuild.
- **Server-side upload validation vs. client-only posture.** A direct authenticated POST can bypass the browser conversion and the `accept: image/*` filter — sending still-HEIC, oversized, non-image, or SVG payloads (`media.yaml` serves `svg`, making an uploaded SVG stored XSS). Deferred: login-gated and low-risk for a solo owner. If pulled in later, enforce a server-side accept-list (jpeg/png/webp; reject SVG + HEIC) and per-file size cap in the same `cache-on-save` handler added in U1, treating client-side conversion as UX rather than a security control.
---
## Risks & Dependencies
- **Filepond interception (U4) is the load-bearing risk.** Grav's managed FilePond instance may not expose a clean hook for pre-upload conversion. Mitigation: documented fallback to a plain file input scoped to `/post`; surface as a blocker before adopting it.
- **heic-to browser support.** Relies on WASM/libheif in-browser; verify it works in iOS Safari (the only target). Mitigation: the U7 HEIC fixture test plus a manual real-device check.
- **EasyMDE ↔ custom validation ordering.** If `codemirror.save()` doesn't fire before the validator reads the textarea, valid entries get rejected. Mitigated by KTD3 (save on change *and* at submit-handler top) and a U3 test.
- **`onFormValidationProcessed` parent settability.** The exact event/priority at which `parent` is settable before `add-page-by-form` reads it is confirmed against a live submit in U1; a higher-priority `onFormProcessed` is the fallback.
---
## Sources / Research
- **Code:** `user/pages/02.post/post-form.md`, `user/plugins/add-page-by-form/add-page-by-form.php` (`parent` override L521523), `user/plugins/cache-on-save/cache-on-save.php` (`onFormProcessed` handler), `user/themes/intotheeast/blueprints/entry.yaml` (field types/options), `user/themes/intotheeast/templates/post-form.html.twig` (inline validation + Get Location/Weather), `user/themes/intotheeast/package.json` (esbuild `build` script), `user/themes/intotheeast/templates/partials/base.html.twig` (asset loading), `user/config/media.yaml` (no `heic`; serves `svg`), `user/config/site.yaml` (`active_trip`), `tests/ui/**` (Playwright suite), `tests/fixtures/` (`test-photo.jpg`).
- **External:** [Grav Media docs](https://learn.getgrav.org/17/content/media) (HEIC unsupported; jpg/png/gif/svg) · [Grav forum — image upload preprocessing](https://getgrav.org/forum/forms-blueprints/image-upload-with-preprocessing-t610) · [heic-to](https://github.com/hoppergee/heic-to) · [EasyMDE](https://github.com/Ionaru/easy-markdown-editor).
- **Design:** `docs/reference/design-system.md`, `user/themes/intotheeast/css/tokens.css`.
@@ -0,0 +1,104 @@
---
artifact_contract: ce-unified-plan/v1
artifact_readiness: requirements-only
product_contract_source: ce-brainstorm
---
# Standalone Sub-Page Cleanup — retire redundant map/stats/dailies/stories pages
**Status:** ✅ Complete (2026-07-04). Phase 1 (retire map/stats/dailies/stories views), Phase 1.5 (align dailies title to "Journal"), Phase 2 (shared entry-map partial), plus follow-ups: fixed a live back-button fallback regression (entry/story pills pointed at retired containers → now the trip page) and re-pointed/cleaned the Playwright suite (9 spec files) off the deleted views. All pushed to production. Auth-gated gpx-manager/post specs not run here (no test creds); everything else green.
> Plan type: `refactor` · Depth: Standard · Origin: sequel to `2026-06-27-map-init-consolidation.md` — that plan unified the map *engine* (`MapUtils.initEntryMap()`) onto trip + home but deferred `feed-map`/`map.html`; this plan removes those deferred surfaces entirely.
---
## Summary
Every trip carries four standalone sub-pages — `02.map`, `03.stats`, plus the standalone `dailies` and `stories` list views — that are **fully consolidated onto the trip page** (inline map + filter bar + inline stats via `trip-feed-col`) and are **no longer reachable from navigation**. They are also the last consumers of the *old* map code path: `feed-map.html.twig` hand-rolls an inline MapLibre init that duplicates `MapUtils.initEntryMap()`, and `map.html.twig` is a third variant built on `renderGpxJourney` directly.
This plan removes the dead pages/logic (**Phase 1**) and then finishes the shared-logic arc by de-duplicating the map markup that trip and home still copy-paste (**Phase 2**).
**Net effect after both phases:** the entire site renders maps through exactly one code path (`initEntryMap()`), invoked from exactly one shared partial.
## Goal
- Remove unused pages and view logic so the codebase has no orphaned templates or dead map variants.
- Preserve all content and all currently-linked behavior — this is a pure internal cleanup, no user-visible feature change on the pages that remain.
- Converge on a single map code path.
## Product authority / decisions locked
- **Scope = Option 2** (all four standalone views retired), confirmed by owner.
- **Keepers — must not break:** `home.html.twig`, `trips.html.twig` (trip overview), `trip.html.twig`, `story.html.twig`, and every shared element they use (`trip-feed-col`, `home-predeparture`, `macros/stats`, `macros/cycling`, `macros/date-range`, `map.css`, `map.js`).
- **Containers stay:** `01.dailies/` and `04.stories/` folders remain as data containers (they physically hold entries/stories; trip + home fetch children via `grav.pages.find(route ~ '/dailies').children`).
- **Old URLs are don't-care:** `/map`, `/stats`, `/dailies`, `/stories` direct hits may 404. No redirects required (owner decision). Individual entry/story detail pages underneath remain reachable.
---
## Phase 1 — Cleanup (content + logic + old templates)
**Delete these page templates (old / unreachable views):**
- `user/themes/intotheeast/templates/map.html.twig` — old map variant (`renderGpxJourney` inline)
- `user/themes/intotheeast/templates/stats.html.twig` — orphaned (nothing links to it)
- `user/themes/intotheeast/templates/dailies.html.twig` — standalone list view, consolidated onto trip page
- `user/themes/intotheeast/templates/stories.html.twig` — standalone list view, consolidated onto trip page
**Delete this partial (dies with its only two consumers):**
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — old inline-script map duplicate; included **only** by the two deleted list views.
**Delete these page folders (empty pure-view shells) across every trip:**
- `user/pages/01.trips/<slug>/02.map/`
- `user/pages/01.trips/<slug>/03.stats/`
- (applies to all trips: `central-asia-2023`, `italy-2025`, `italy-2026-demo`, `slovenia-2024`, `us-canada-mex-2024`)
**Repoint the two container pages so nothing errors** (keep the folders, retire the view):
- `01.dailies/dailies.md` and `04.stories/stories.md`: change `template:` off the deleted templates (e.g. to `default`), and mark the container non-routable/non-visible so its own URL is inert while children stay reachable.
**Also update / remove any dangling reference to the deleted pages found during work** (e.g. the `link_href: … ~ '/map'` line lived inside `dailies.html.twig`, which is being deleted — confirm no *other* template links to `/map`, `/stats`, `/dailies`, `/stories` as a destination).
### Phase 1 acceptance criteria
- Site renders with no Twig errors on: home (active-trip + between-trips), `/trips`, every trip page, and an individual entry and story detail page.
- Trip page + home still show the inline map, filter bar, and stats correctly (child-fetch via `find(...).children` still resolves).
- `grep` for `feed-map.html.twig`, `map.html.twig`, `stats.html.twig`, `dailies.html.twig`, `stories.html.twig` returns **no remaining `include`/`import`/link references**.
- No content lost: journal entries and stories still present and reachable at their detail URLs.
- Only one non-`initEntryMap` map path removed — confirm `map.css`/`map.js` and `macros/stats` are untouched.
---
## Phase 2 — Optimization (finish the shared-logic arc)
`trip.html.twig` and `home.html.twig` still hand-write near-identical map markup (`home-map-col``home-map`/`trip-map` div → fullscreen button) plus a thin inline `<script>` calling `MapUtils.initEntryMap(...)`. Extract the shared shape.
- Create `user/themes/intotheeast/templates/partials/entry-map.html.twig` taking parameters for: container id, fullscreen button id, entries array, and gpx config (urls / use / autoconnect / sourcePrefix / journeyId), plus the fit config.
- `trip.html.twig` and `home.html.twig` (active branch) both `{% include … with {…} only %}` the new partial instead of their inline markup + script.
- Keep the JS engine (`initEntryMap`) as the single source of truth — the partial only supplies markup + the thin invocation.
### Phase 2 acceptance criteria
- Trip and home maps render and behave identically to pre-Phase-2 (markers, popups, click-to-scroll-and-highlight, GPX journey, fullscreen toggle).
- The map-div markup + invocation exist in exactly one place (`entry-map.html.twig`); no copy-paste twin remains in trip/home.
- Existing map-alignment tests (that assert `window.tripMap` / `window.homeMap` globals) still pass.
---
## Non-goals / scope boundaries
- **Not** deleting the `01.dailies/` or `04.stories/` container folders or any content inside them.
- **Not** adding redirects for old URLs (owner deferred; 404 is acceptable).
- **Not** touching the keeper pages' behavior or the `trip-feed-col` / `home-predeparture` partials, the stats/cycling macros, or `map.css`/`map.js`.
- **Not** changing the GPX-manager, post form, or trip-switching config.
## Risks & verification
- **Risk:** container repoint leaves child entries unreachable. **Mitigation:** verify `routable: false` on a parent does not unroute children in this Grav version — load an entry and a story detail URL after the change.
- **Risk:** a stray reference to a deleted template elsewhere (e.g. `trips.html.twig` counts, sitemap, feed). **Mitigation:** repo-wide grep before declaring Phase 1 done (acceptance criterion above).
- **Verification path:** dev server at `http://localhost:8081`; walk home (both modes), `/trips`, each trip, one entry, one story; then run the map-alignment test suite for Phase 2.
## Open questions
- None blocking. (Container repoint mechanism — `routable:false` vs a minimal redirect — is an implementation detail for planning; owner has already ruled old-URL behavior don't-care.)
-74
View File
@@ -1,74 +0,0 @@
# Production Todo
Work through Phase 1 first (local fixes and config), then Phase 2 (server deployment and go-live).
---
## Phase 1 — Local fixes before deploy
These are changes made in the local dev environment and committed before anything touches the server.
### 1.1 Fix server-install.sh for Grav 2.0
`server-install.sh` had a gap: it copied the `grav-admin` bundle (which includes `user/plugins/admin2/`) but then immediately did `rm -rf user && git clone ...`, wiping admin2. It never got reinstalled because GPM doesn't carry Admin2.
- [x] Updated `server-install.sh` to stash admin2 before wiping user/, then restore it after
- [x] Removed `admin` from `plugins.txt` — Admin2 replaces it and both conflict on `/admin`
### 1.2 Update config for production
- [x] Cleared `custom_base_url` in `user/config/system.yaml` (was pointing to local dev IP; empty means Grav auto-detects from the request, which works both locally and in production)
### 1.3 Content and metadata
- [ ] Set `date_start` on the Japan & Korea 2026 trip page (`user/pages/01.trips/japan-korea-2026/trip.md`)
- [ ] Add `cover_image` to the trip page (used on the trips listing)
- [ ] Upload actual GPX route file(s) to `/gpx-manager` or drop directly into `user/pages/01.trips/japan-korea-2026/`
- [ ] Run `make content-push` to push all local changes to Gitea
---
## Phase 2 — Server deployment and go-live
### 2.1 Configure .env
- [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
### 2.2 Run the install
```bash
make remote-env-setup # writes Gitea token to server temporarily
make remote-install # downloads Grav, clones repos, installs plugins
make remote-env-remove # removes token from server
```
After install, the script prints the server's SSH public key. Add it as a deploy key to both Gitea repos so `make remote-fetch` works going forward.
### 2.3 Verify post-install config
These are committed to the `user/` repo and should be present after the clone — just confirm:
- [ ] `user/config/system.yaml` has `accounts.type: flex` and `pages.type: flex`
- [ ] `user/accounts/mischa.yaml` has `api.super: true` and `api.access: true`
- [ ] Old admin plugin is absent from `plugins.txt` (not installed)
### 2.4 Switch to production mode
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit this to the repo — it would break local dev)
- [ ] If Grav can't auto-detect the base URL (e.g. behind a reverse proxy), set `custom_base_url` in `user/config/system.yaml` on the server
### 2.5 Smoke test
- [ ] Submit one post via `/post`, confirm entry appears in `/trips/japan-korea-2026/dailies` immediately (verifies cache-on-save plugin works with `twig.cache: true`)
### 2.6 Security
- [ ] Change admin password to a strong production password
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
### 2.7 Map tiles
- [ ] Register at [carto.com](https://carto.com) and review terms for production traffic (CartoDB dark tiles are free but registration is expected for production use)
@@ -0,0 +1,313 @@
# Frontend Polish Design Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
**Goal:** Elevate the visual identity and consistency of the five primary page templates — home, trip, trips listing, individual entry, and story — without touching the map page or dailies index. Improvements fall into three categories: visual identity (header, stats, pills), typographic consistency (emoji removal), and content pages (trip cards, story transitions).
**Architecture:** Mostly CSS changes in `style.css`. Two Twig templates need small additions (`trips.html.twig`, `story.html.twig`). One blueprint gets a new field (`blueprints/trip.yaml`). One partial gets emoji removed (`partials/entry-journal.html.twig`). No new JS libraries.
**Already completed as part of this session:**
- `entry.html.twig` unified with `partials/entry-journal.html.twig` — hero image removed, custom lightbox replaced with PhotoSwipe, dead CSS stripped
- See git log for the entry template rewrite commit
---
## Global Constraints
- All changes in `user/` — commit with `git -C user`
- All new CSS must use token variables — never hardcode hex values
- No new JS libraries or CDN dependencies
- Changes must degrade gracefully if optional data (cover image, location) is absent
- `prefers-reduced-motion` must be respected for any new animations
---
## A — Trip Cards: Cover Image
### Problem
The trips listing (`/trips`) renders a vertical stack of text-only cards: title, date range, entry count. For a travel blog, the archive is the viewer's first encounter with trips they haven't visited — showing no visual context is a significant missed opportunity.
### Design decision
Each `.trip-card` gets a full-width banner image above the existing text. Aspect ratio 3:1 — wide enough to suggest landscape/geography without dominating a card in a list.
```
┌─────────────────────────────────────────┐
│ [cover image — 3:1 aspect ratio] │
├─────────────────────────────────────────┤
│ Japan & South Korea │
│ Apr 2026 — Jun 2026 · 24 entries │
└─────────────────────────────────────────┘
```
### Image resolution
The card is constrained to `--content-width` (720px). Use `cropResize(720, 240)` for the 3:1 crop.
### Image source priority
1. `trip.header.cover_image` — a filename from the trip page's own media (explicit, curated)
2. First image from the first published journal entry in the trip (automatic fallback)
3. No image — card degrades to text-only (existing layout, unchanged)
### Blueprint change
Add a `cover_image` field to `user/themes/intotheeast/blueprints/trip.yaml`:
```yaml
cover_image:
type: filepicker
label: Cover Image
preview_images: true
folder: '@self'
accept:
- image/*
```
### New CSS
```css
.trip-card-cover {
aspect-ratio: 3 / 1;
overflow: hidden;
border-radius: var(--radius-md) var(--radius-md) 0 0;
background: var(--color-border);
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-5);
}
.trip-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.45s ease;
}
.trip-card:hover .trip-card-cover img { transform: scale(1.04); }
```
The negative margin pulls the image flush to the card edges while the card keeps its existing padding for the text below.
---
## B — Replace Emoji Icons
### Problem
`partials/entry-journal.html.twig` (which now also powers `entry.html.twig`) uses `📍` for location and emoji for weather conditions (☀️, 🌧️, etc.). These are OS-rendered, variable in size, and break the typographic consistency of the warm-dark palette.
### Design decision
- **Location:** Replace `📍` with a minimal inline SVG mappin. 16×16, `currentColor`, single path.
- **Weather:** Drop the emoji prefix entirely. The text description ("Sunny", "Rain", "Partly cloudy") is the information — the emoji is decoration. Text-only is cleaner and the muted color already signals it as secondary metadata.
### SVG mappin (inline, replaces `📍`)
```html
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true" style="flex-shrink:0;margin-top:1px">
<path d="M6 0C3.24 0 1 2.24 1 5c0 3.75 5 9 5 9s5-5.25 5-9c0-2.76-2.24-5-5-5zm0 6.75A1.75 1.75 0 1 1 6 3.25a1.75 1.75 0 0 1 0 3.5z"/>
</svg>
```
---
## C — Header Identity
### Problem
The site header reads like a product app: text logo left, two nav links right, 60px tall, 3px teal stripe on top. The brand "into the east" at `--text-lg` with `-0.01em` tracking is timid. The content pages are atmospheric and cinematic; the header is functional and forgettable.
### Design decision
Two targeted CSS-only changes:
1. **Site title tracking:** Increase from `--text-lg` to `--text-xl`, set `letter-spacing: 0.06em`. Wider tracking on a dark background is a deliberate typographic mark — it reads as a designed wordmark rather than placeholder text.
2. **Accent stripe:** Increase from `3px` to `4px`. Apply a two-stop gradient along the 90deg axis: `linear-gradient(90deg, var(--color-accent), var(--color-accent-hover))`. This gives the stripe direction (reads left-to-right like a journey) and signals it was chosen, not defaulted.
No layout change, no template change, no height change.
---
## D — Story Opening Transition
### Problem
After the Ken Burns hero and the 40vh spacer, `story.html.twig` begins the body content immediately with prose. There is no visual breath between the cinematic full-screen image and the reading experience. The reader has no bearing — no confirmation of where they are or when.
### Design decision
Add a `.story-opener` block at the top of `.story-body`, before `{{ page.content|raw }}`. It displays the location and formatted date string centered, separated from the prose below by a thin ruled line.
```
Sorano, Italy · 1416 June 2026
────────────────────────────────
[prose begins here]
```
Data comes from `location` and `date_str`, already computed at the top of `story.html.twig`. If both are empty the opener renders nothing (zero markup visible).
The opener fades in using the existing `storyReveal` keyframe (`filter: blur → 0`, `opacity: 0 → 1`, `translateY(22px → 0)`) with a 0.8s delay so it appears after the hero title animation completes.
### New CSS
```css
.story-opener {
text-align: center;
padding-bottom: var(--space-12);
margin-bottom: var(--space-12);
border-bottom: 1px solid var(--color-border);
opacity: 0;
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.8s both;
}
.story-opener__text {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-ink-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (prefers-reduced-motion: reduce) {
.story-opener { opacity: 1; animation: none; }
}
```
### Template addition (in `story.html.twig`, inside `.story-body`, before `page.content`)
```twig
{% if location or date_str %}
<div class="story-opener">
<span class="story-opener__text">
{{- date_str -}}
{%- if location and date_str %} · {% endif -%}
{{- location -}}
</span>
</div>
{% endif %}
```
---
## E — Reading Progress Bar
### Problem
Story pages are long-form — the longest may scroll for several minutes of reading. There is no visual feedback about progress through the piece. This is a small but meaningful quality signal on an immersive reading experience.
### Design decision
A 2px teal bar fixed to the bottom edge of the site header (`top: var(--site-header-height)`), filling left-to-right as the reader scrolls through `.story-body`. Progress is calculated relative to the story body element (not the full page including the hero), so the bar reads 0% when the hero exits and 100% when the last line of content reaches the viewport bottom.
The bar is invisible before the story body enters view. It does not render at all if `prefers-reduced-motion` is set — there should be no static `width: 0` bar for reduced-motion users.
### New CSS
```css
.story-progress {
position: fixed;
top: var(--site-header-height);
left: 0;
height: 2px;
width: 0%;
background: var(--color-accent);
z-index: 200;
pointer-events: none;
will-change: width;
}
```
### JS logic (no transition — rAF-driven for smoothness)
```javascript
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var bar = document.getElementById('story-progress');
var body = document.querySelector('.story-body');
if (!bar || !body) return;
function update() {
var rect = body.getBoundingClientRect();
var total = body.offsetHeight - window.innerHeight;
var scrolled = -rect.top;
var pct = total > 0 ? Math.min(100, Math.max(0, (scrolled / total) * 100)) : 0;
bar.style.width = pct.toFixed(1) + '%';
}
window.addEventListener('scroll', update, { passive: true });
update();
})();
```
The element `<div class="story-progress" id="story-progress"></div>` is added to `story.html.twig` immediately after the opening `{% block content %}`.
---
## F — Pill Shape Differentiation
### Problem
All interactive pills use `border-radius: 9999px` regardless of their role. Back-navigation pills, filter buttons, panel toggles, sort toggles — they all look identical, which collapses the visual grammar. A reader cannot tell at a glance whether tapping a pill will navigate them away or toggle a filter.
### Design decision
Establish a two-shape grammar:
| Role | Shape | Classes |
|---|---|---|
| Navigation (go somewhere, leave the page) | Full pill `9999px` | `.back-pill`, `.story-escape`, `.story-totop` |
| Controls (toggle, filter, sort in place) | Rounded rect `var(--radius-sm)` = 4px | `.trip-filter-btn`, `.trip-stats-btn` |
| Panel toggles (secondary, in-place) | Full pill (keep — less prominent than controls) | `.trip-panel-toggle` |
CSS-only change. `.back-pill`, `.story-escape`, `.story-totop` are unchanged. Only `.trip-filter-btn` and `.trip-stats-btn` change from `border-radius: var(--radius-full)` to `border-radius: var(--radius-sm)`.
---
## G — Stats: Field Notes Treatment
### Problem
`.stat-block` renders as a bordered card with a `background: var(--color-canvas)` surface, box shadow, and teal accent numbers. This reads as a metrics dashboard — every SaaS product uses this pattern. For a travel journal, numbers like "1,847 km" and "3 countries" should feel earned and written, not computed and charted.
### Design decision
Two changes:
1. **Remove the box.** Drop `background`, `border`, and `box-shadow` from `.stat-block`. Replace with a `border-left: 2px solid var(--color-accent)` and `padding-left: var(--space-4)`. Text left-aligns. Numbers feel like notes in a margin, not cells in a table.
2. **Change number color.** `stat-value` moves from `var(--color-accent)` to `var(--color-ink)`. Teal numbers on dark are a SaaS color decision. Cream numbers on dark with a teal accent stripe are a traveler's notation.
The teal accent is now only the left rule — restrained, singular.
### CSS change
```css
/* Before */
.stat-block {
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6) var(--space-5);
text-align: center;
box-shadow: var(--shadow-sm);
}
.stat-value {
color: var(--color-accent);
...
}
/* After */
.stat-block {
border-left: 2px solid var(--color-accent);
padding: var(--space-2) 0 var(--space-2) var(--space-4);
text-align: left;
}
.stat-value {
color: var(--color-ink);
...
}
```
The `.trip-stats-grid` and `.stats-grid` gap/column settings are unchanged.
---
## Verification Checklist
After full implementation, check each page:
| Page | Check |
|---|---|
| `/trips` | Trip cards show cover image (or degrade to text-only gracefully); hover scales image |
| `/trips/<any-trip>` | Stats panel shows left-rule style, cream numbers; filter buttons are rounded-rect |
| `/trips/<any-trip>/dailies/<any-entry>` (standalone) | Photo strip renders via PhotoSwipe, no broken lightbox; no hero image at top |
| `/trips/<any-trip>/<any-story>` | Opener block shows location + date; progress bar fills while scrolling; no bar if reduced-motion |
| Any page | Header title has wider tracking; accent stripe is slightly thicker with gradient |
| Any page with journal entries | Location shows SVG pin; weather shows text only, no emoji |
@@ -0,0 +1,146 @@
# Home / Trip View Convergence Design
**Date:** 2026-06-27
**Status:** Approved for implementation
## Problem
The home page in active-trip mode (`home.html.twig`, `config.site.travelling` branch) and the trip page (`trip.html.twig`) are meant to present the same experience — the same content, behaving near-identically. Today their feeds already match (both render journal + story entries via the shared `entry-journal`/`entry-story` partials), but the **feed-col chrome diverges**:
| Feature | Trip page | Home-active | Converge? |
|---|---|---|---|
| Feed lists journal + stories | ✅ | ✅ | already matches |
| Date-range header | ✅ | ❌ | **yes** |
| Filter bar (All / Journal / Stories) | ✅ | ❌ | **yes** |
| Stats panel | ✅ | ❌ | **yes** |
| Cycling panel | ✅ (if GPX) | ❌ | **yes** |
| Sort toggle button | ✅ | ❌ | **no — intended difference** |
| Default feed order | oldest→newest (sort flag 4) | its own (sort flag 3) | **no — intended difference** |
The chrome markup is the divergence. The supporting **behavior is already global**: `js/main.js` (loaded for every page via `base.html.twig:10`) runs `initFilterBar()`, `initPanelToggles()`, and `initSortButton('trip-sort-toggle', …)`, each a silent no-op when its markup is absent. The entry partials already emit `data-type`, which the filter relies on. So rendering the same markup on home is enough for the filter bar, panel toggles, and (where present) the sort button to work with **zero new JS**.
The single exception is the **stats/cycling computation glue** (writing distance/elevation values into `#stat-distance`, `#cyc-*`). That code is currently *inline* in `trip.html.twig` and not global, so the stats/cycling panels cannot function on home until it is shared.
## Goals
- Home-active gains the date-range header, filter bar, and stats/cycling panels — matching the trip page.
- The shared feed-col chrome lives in **one** place (a partial), so future header/chrome changes apply to both pages.
- Home-active keeps its own default feed order and has **no** sort button (the two intended differences).
- Stats/cycling computation works on both pages from a single shared JS function.
- No change to the trip page's rendered output (structural refactor only on that side).
## Non-goals
- **Map convergence is out of scope.** The home-active map omitting story markers, lacking a fullscreen button, and its hash-only click behavior are all deferred to a later map-init spec (see `project-map-init-refactor` memory). Both inline map scripts and both map-col markup blocks stay exactly as they are.
- Extracting the `all_items` / `map_entries` build loops to a macro — Twig macros output HTML, not arrays (established constraint; see `2026-06-23-template-refactor-design.md`). Each page keeps its own data-build loops.
- Any visual restyling of the chrome — home reuses the trip's existing CSS classes unchanged.
- Adding a sort button to home, or changing home's default order.
## Architecture
### 1. New shared partial: `templates/partials/trip-feed-col.html.twig`
Holds the entire `.home-feed-col` content currently inline in `trip.html.twig:70-120`:
- header (`.home-trip-header`): title, date range (when `trip_page.header.date_start` set), counts
- filter bar (`.trip-filter-bar`): All / Journal / Stories buttons
- the sort button (`#trip-sort-toggle`) — **rendered only when `show_sort` is true**
- panel toggles (`.trip-panel-toggles`): Stats, and Cycling (when `has_gpx`)
- `stats_panel(...)` and (when `has_gpx`) `cycling_panel(...)` macro calls
- the feed loop over `all_items` with the `#feed-filter-empty` sentinel
**Interface** (called via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
| Param | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` | int | count | count |
| `story_count` | int | count | count |
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
| `show_sort` | bool | `true` | `false` |
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
Because the partial is called with `only`, it must `{% import 'macros/stats.html.twig' %}` and `{% import 'macros/cycling.html.twig' %}` itself.
Both pages already build `all_items`, the counts, and `has_gpx` for their existing map data, so these are passed in rather than rebuilt — no new duplication is introduced.
### 2. Shared stats glue: `initTripStats(config)` in `js/src/main.js`
Extract the inline stats/cycling computation from `trip.html.twig:213-249` into a config-driven function. Current inline logic: if GPX present, `MapUtils.parseGpxFiles(urls, …)` fills `#stat-distance` and all `#cyc-*` fields; otherwise sum `haversineKm` over `gps_points` and write the `~`-prefixed estimate to `#stat-distance` — but when `gps_points` has fewer than 2 points, write `—` (not `~0`) and return, preserving the existing guard at `trip.html.twig:245`. This matters on home-active in the pre-departure / zero-entry state, where dropping the guard would render "~0 km roamed" instead of the macro's `—` placeholder.
```js
function initTripStats(config) {
// config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
// No-op if #stat-distance is absent (page has no stats panel).
// No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0').
}
```
Called from the boot block alongside the other inits. Each page provides the config via a small inline `<script>` that defines the data (the `*_GPX_URLS` / `gps_points` arrays are page-specific Twig output), then calls `initTripStats(...)` — or the boot reads globals the page sets. Implementation detail for the plan; the contract is: the function is selector-guarded and runs on any page that rendered a stats panel. Two placement invariants the current working code relies on must carry forward: the inline call **must run inside a `DOMContentLoaded` handler** (mirroring the current `trip.html.twig` stats IIFE) so that `initTripStats` and `MapUtils` — loaded via the `bottom` asset group rendered at the end of `<body>`, after content-block inline scripts — are defined when it executes; and it **must not be nested inside the `{% if map_entries|length > 0 %}` map block**, or a trip with GPX but zero geocoded journal entries would render the panels yet never populate them.
`js/main.js` is the built artifact; the asset pipeline rebuilds it from `js/src/main.js` (see `2026-06-22-asset-pipeline-design.md`).
### 3. Home-active data additions
Home-active currently builds `map_entries` (journal-only) and `home_gpx_urls`. For the stats panel's no-GPX fallback it must also build `gps_points` (journal entries with lat/lng), mirroring `trip.html.twig:27-32`.
### 4. Template wiring
**`trip.html.twig`**: replace the inline `.home-feed-col` block (`:70-120`) with the partial include; remove the inline stats script (`:213-249`) in favor of `initTripStats(...)`. Map script, fullscreen wiring, and map data-build stay untouched.
**`home.html.twig`** (active branch): replace the bespoke feed-col (`:60-83`) with the same partial include (`show_sort: false`); add `gps_points` build; wire `initTripStats(...)`. Map script and map-col stay untouched.
### 5. Home-active pre-departure empty state
Resolves the review finding that home-active (the landing page) would otherwise show two competing empty states before the first post — the static `{% else %}` "No entries yet" feed fallback *and* the JS `#feed-filter-empty` sentinel — once a filter tab is clicked.
When `all_items` is empty (gated by the new `pre_departure` param), the partial renders a single **pre-departure block** instead of the filter bar, panel toggles, and the generic feed fallback:
- the active trip's title and `trip_page.header.date_start` (e.g. "Departing 17 Jun 2026"), with a "Coming soon" note
- a clear divider
- a short line + button — "In the meantime, explore my other trips →" — linking to the Past Trips page
This block renders **only while `all_items|length == 0`** and disappears entirely once the first entry is posted, at which point the normal filter bar + feed render. It is home-active-only: the trip page passes `pre_departure: false` and is unaffected (it is not reachable before content exists). The block is new home-only markup but reuses existing typography/button classes — no new visual language.
Open sub-decision for implementation: whether the Stats/Cycling panels are also hidden in this state (they would otherwise show "0 days / 0 entries"). Defaulting to hidden, for consistency with the suppressed filter bar.
## Data / behavior flow after change
```
base.html.twig ──loads──> js/main.js (global)
├─ initFilterBar() ← works on both via .trip-filter-btn + [data-type]
├─ initPanelToggles() ← works on both via .trip-panel-toggle
├─ initSortButton('trip-sort-toggle', …) ← trip only (home omits button → no-op)
└─ initTripStats(cfg) ← works on both via #stat-distance guard
trip.html.twig ─include─> partials/trip-feed-col.html.twig (show_sort: true)
home.html.twig ─include─> partials/trip-feed-col.html.twig (show_sort: false)
└─ stats_panel(), cycling_panel(), feed loop
```
## Testing
No JS test harness exists in this project; verification is manual browser smoke testing at `http://localhost:8081`, consistent with prior template work.
**Trip page (regression — must be unchanged):**
1. Load the trip page. Confirm header, date range, filter bar, sort button, stats/cycling panels, and feed render identically to before.
2. Filter bar All/Journal/Stories filters the feed; sort button flips order; Stats/Cycling panels toggle open/closed.
3. Stats panel distance and cycling figures populate (GPX present) or show the `~` estimate (no GPX).
**Home page, active-trip mode (new behavior):**
4. With `config.site.travelling: true`, load `/`. Confirm date range, counts, filter bar (no sort button), and Stats panel (+ Cycling if the trip has GPX) now appear.
5. Filter bar filters the feed; panel toggles work; stats figures populate.
6. Confirm the feed default order is home's own order (unchanged from today) and that no sort button is present.
6b. **Pre-departure state:** with `travelling: true` and no posts yet, confirm home shows the trip title + start date + "Coming soon" and the "explore my other trips" divider/button — and that the filter bar and the "No entries yet" fallback do *not* both appear. Post one entry and confirm the pre-departure block disappears and the normal filter bar + feed render.
**Home page, between-trips mode (regression):**
7. With `config.site.travelling: false`, load `/`. Confirm the highlights layout is unaffected (this branch does not use the partial).
## Files touched
- **New:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
- **Edit:** `user/themes/intotheeast/templates/trip.html.twig` (feed-col → include; remove inline stats script)
- **Edit:** `user/themes/intotheeast/templates/home.html.twig` (active branch feed-col → include; add `gps_points`; wire stats)
- **Edit:** `user/themes/intotheeast/js/src/main.js` (+`initTripStats`); rebuild `js/main.js`
@@ -0,0 +1,209 @@
# Grav 2.0.4 Upgrade + GPM-Manage admin2/api/flex-objects — Design
**Date:** 2026-07-04
**Status:** Design approved — pending implementation plan
## Goal
Perform one coordinated upgrade of the intotheeast stack:
1. Bump Grav core from `2.0.0-rc.10``2.0.4` (stable).
2. Promote `admin2`, `api`, and `flex-objects` from bundle-extracted plugins to
**GPM-managed** plugins (this is "option B").
These two changes are **not separable**. The stable plugins hard-require the
stable core, so GPM enforces an atomic upgrade of the whole chain.
## Background / findings
### Version gaps
The project has been frozen on the final release candidate since the original
Grav 2.0 upgrade. Grav went stable on 2026-06-21; latest patch is `2.0.4`
(2026-06-29). No breaking changes exist within the 2.0.x line — the
2.0.12.0.4 releases are security hardening (XSS re-checks on editor Twig,
ZIP-bomb limits, `.htaccess` case-insensitive bypass fix) plus bugfixes.
Installed vs. bundled-in-2.0.4 versions:
| Plugin | Installed | 2.0.4 stable |
|---|---|---|
| core (grav) | 2.0.0-rc.10 | 2.0.4 |
| admin2 | 2.0.0-rc.15 | 2.0.9 |
| api | 1.0.0-rc.15 | 1.0.6 |
| flex-objects | 1.4.0-rc.7 | 1.4.3 |
| login | 3.8.9 | 3.8.11 |
| form | 9.1.6 | 9.1.8 |
| shortcode-core | 6.0.0 | 6.2.1 |
### Dependency chain (why it's atomic)
From the stable plugin blueprints:
- `api` 1.0.6 requires `grav >= 2.0.4` **and** `login >= 3.8.11`
- `admin2` 2.0.9 requires `api >= 1.0.6`
- `flex-objects` 1.4.3 requires `form >= 6.0.0`, `api >= 1.0.0`
So stable admin2/api cannot run on the rc.10 core — GPM would refuse. This is
the core reason option B is the right approach: `gpm` resolves and enforces the
entire chain automatically, which the previous manual-extract approach never
did.
### Three plugin management categories
The upgrade must account for the fact that plugins reached the servers three
different ways:
| Category | Plugins | In `plugins.txt`? | How installed | Upgrade mechanism |
|---|---|---|---|---|
| GPM-managed | email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus | yes | `gpm install` | `gpm update` |
| Manually-placed → GPM (option B) | admin2, api, flex-objects | **will add** | hand-extracted from grav-admin zip | `plugins.txt` for fresh installs; `gpm update` on existing test env |
| Remote-only | git-sync | **no** (config gitignored, holds encrypted token) | installed directly on the server | documented separately; carried by `gpm update`; **disabled during upgrade** |
### git-sync compatibility
git-sync is version 3.4.4 with an explicit `compatibility: 2.0` flag and is one
of Grav's own reference plugins for the Admin Next / API. It is safe to carry
through the upgrade. Note the documented folders-YAML quirk
(`docs/working/git-sync-notes.md`): its config must list `folders` as an array,
never the UI-written comma-string.
### Local vs. server upgrade mechanisms differ
- **Local** bakes the core into the Docker image (`Dockerfile`) → upgrade by
rebuilding the image.
- **Server** is a native webroot install → core upgrades via
`bin/grav upgrade`, plugins via `bin/gpm update`.
The plan therefore has distinct local and remote steps.
## Decisions
- **Option B (GPM management)** for admin2/api/flex-objects. Add them to
`plugins.txt` so future fresh installs pull them via GPM.
- **Rollout order:** local → test → prod.
- **Prod is currently empty** → the prod phase is written as a *documented
fresh-install runbook only* and is **not executed** in this effort. Fresh prod
install uses the option-B-modified `server-install.sh` with
`GRAV_VERSION=2.0.4`.
- **Rollback = git.** All relevant data (`pages/`, `config/`, `accounts/`,
`themes/`) is committed. No separate backup step. Rollback is `git revert` of
this branch plus a rebuild/redeploy.
- **Upgrade verb on existing installs is `gpm update` (update-all)**, not
`gpm install <plugins.txt>`. `install` skips already-installed plugins and
never touches git-sync (which is not in the list); `update` upgrades every
installed plugin regardless of how it was placed, catching the manual and
remote-only categories in one shot.
- **git-sync stays out of `plugins.txt`** (that list is shared with local; git-sync
is remote-only with a manual encrypted token). Documented as separately
managed.
- **git-sync is disabled before the test upgrade and left disabled**, so the
upgrade cannot auto-commit reformatted/server-specific config back into the
shared Gitea `user` repo. The user validates first, then re-enables it as a
separate deliberate step.
## File changes (Phase 0)
| File | Change |
|---|---|
| `Dockerfile` | grav-admin zip URL `2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip``2.0.4/grav-admin-v2.0.4.zip`. Verified: the 2.0.4 zip still extracts to a `grav-admin/` folder, so the existing `cp` block is unchanged. |
| `plugins.txt` | add `api`, `admin2`, `flex-objects` (`form`, `login` already present as their deps) |
| `user/config/system.yaml` | **`gpm.releases: testing → stable`** — this is the authoritative GPM channel. `testing` is what has been serving RC/pre-release versions. Tracked in the `user` repo, so it applies to both local and server once pushed. |
| `docker-compose.yml` | `GRAV_CHANNEL=beta``production` for consistency only. This env drives the base image's `docker-entrypoint.sh`, **not** `bin/gpm`'s channel — `gpm.releases` above is what governs updates. |
| `scripts/server-install.sh` | remove the admin2/api stash+restore special-casing (lines 2526 and 4345); they now install via `gpm install` from `PLUGINS` |
| `Makefile` | **fix broken `remote-upgrade-grav`:** `php bin/grav upgrade` is not a real command — change to `php bin/gpm self-upgrade -y`. Add the new remote targets (below) to `REMOTE_TARGETS` so each gets `-test`/`-prod` variants. |
| `CLAUDE.md`, `docs/reference/architecture.md`, memory | update stack versions; document the three-category plugin model and the channel change |
### New Makefile targets
Added to the `REMOTE_TARGETS` list (Makefile:2224) so the env-suffix macro
(Makefile:3134) auto-generates `-test` / `-prod` variants:
- `remote-update-plugins``cd $(WEBROOT) && php bin/gpm update -y`
- `remote-git-sync-disable` → set `enabled: false` in
`$(WEBROOT)/user/config/plugins/git-sync.yaml` (touch only the `enabled` key;
never rewrite `folders`)
- `remote-git-sync-enable` → set `enabled: true` in the same file
`remote-upgrade-grav` exists but its command is **broken** (`php bin/grav
upgrade` is not a Grav CLI command) — it is fixed to `php bin/gpm self-upgrade
-y` as part of Phase 0. Core self-upgrade respects the `gpm.releases` channel.
**Verified CLI command names** (against the running rc.10 container):
`php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins),
`php bin/grav cache` (clear cache; aliases `clearcache`/`cache-clear`).
> The exact idempotent shell used to toggle the `enabled` key is finalized in the
> implementation plan; it must not disturb the `folders` array or the encrypted
> token in `git-sync.yaml`.
## Phases
### Phase 0 — branch + edits
New branch off `main`. Apply all file changes above.
### Phase 1 — local
1. Remove the stale manually-extracted `admin2`, `api`, `flex-objects` folders
from `user/plugins/` so GPM does a clean install.
2. `make build` (core → 2.0.4)
3. `make start`
4. Install the newly-listed plugins **and** update the already-installed ones
to their 2.0.4-compatible versions via GPM. Note: `gpm install` skips plugins
that are already present, so `login` (3.8.9 → ≥3.8.11, required by `api`) and
`form` need `gpm update`, not `install`. The exact `gpm update` + `gpm install`
sequencing (run inside the container via `docker exec`) is pinned in the plan.
5. Assert versions: admin2 2.0.9, api 1.0.6, flex-objects 1.4.3, login ≥ 3.8.11.
6. **Smoke test:** admin2 login; submit `/post` → entry appears in the active
trip's dailies; `/gpx-manager` list + upload + delete; a trip page and a
story render; maps load.
Prerequisite: the Phase 0 config changes (esp. `system.yaml`
`gpm.releases: stable`) are committed and pushed to Gitea, or GPM on the server
will still resolve the `testing` channel and pull RCs.
1. `make remote-git-sync-disable-test`
2. `make remote-fetch-content-test` — pull latest `user/` content to the test
server so `system.yaml` `gpm.releases: stable` is in place before any GPM
operation.
3. `make remote-upgrade-grav-test` (core self-upgrade → 2.0.4)
4. `make remote-update-plugins-test` (`gpm update -y` — all plugins incl.
admin2/api/flex/git-sync/login/form)
5. Clear cache on the server (`php bin/grav cache`).
6. Review `git status` in the server's `user/` for unexpected config diffs;
handle any deliberately (do not blind-commit).
7. Smoke test on the test URL (same checklist as Phase 1).
8. **Leave git-sync disabled and notify the user.** After the user validates,
re-enable as a separate deliberate step: `make remote-git-sync-enable-test`,
then a `content-push` round-trip to confirm sync still works.
### Phase 3 — prod (DOCUMENTED, NOT EXECUTED)
Prod is empty, so this is a fresh install, not an upgrade. Documented as a
runbook:
- Run the option-B-modified `server-install.sh` with `GRAV_VERSION=2.0.4`
(`make remote-install-prod`).
- admin2/api/flex-objects now install via GPM from `plugins.txt` — no manual
extraction.
- Set up git-sync manually afterward: install, add the encrypted token, apply
the folders-YAML array fix (`docs/working/git-sync-notes.md`).
## Rollback
`git revert` the branch (Dockerfile + plugins.txt + docker-compose +
server-install.sh + Makefile) and rebuild/redeploy. Content, config, and
accounts are already in git, so no data restore is needed.
## Risks
- **git-sync auto-commit during upgrade** — mitigated by disabling git-sync
before the test upgrade and reviewing `git status` before re-enabling.
- **admin2/api behavioral changes across RC→stable** — these back the `/post`
form and `/gpx-manager`; covered by the smoke tests, which are the
highest-weight validation in this effort.
- **GPM channel**`gpm.releases` must be `stable` on the server *before* any
`gpm update`/`self-upgrade`, or GPM pulls RCs. Enforced by pushing the
`system.yaml` change and running `remote-fetch-content` first (Phase 2 step 2).
- **`bin/gpm self-upgrade` on shared hosting** — Grav 2.0.3 fixed self-upgrade
failures on shared-folder setups. On the native server this can still be
fragile; run `php bin/gpm preflight` first and use `-o/--overwrite` if a retry
is needed.
+3
View File
@@ -5,3 +5,6 @@ login
problems
add-page-by-form
shortcode-gallery-plusplus
api
admin2
flex-objects
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -e
FILE="$1"
STATE="$2"
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
if [ ! -f "$FILE" ]; then
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
exit 1
fi
if grep -qE '^enabled:' "$FILE"; then
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
else
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
fi
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
-5
View File
@@ -22,8 +22,6 @@ cd "$WEBROOT"
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
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
rm -rf grav-admin grav-admin.zip
echo "==> Cloning user repo"
@@ -40,9 +38,6 @@ fi
echo "==> Creating required directories"
mkdir -p user/plugins user/accounts user/data
cp -rf /tmp/admin2-plugin user/plugins/admin2
cp -rf /tmp/api-plugin user/plugins/api
rm -rf /tmp/admin2-plugin /tmp/api-plugin
echo "==> Installing plugins"
php bin/gpm install $PLUGINS -y
+1 -1
View File
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
# Config must be in frontmatter, not in the process block
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'"
check_grep "parent set to /trips/italy-2026-demo/dailies" "parent: '/trips/italy-2026-demo/dailies'"
check_grep "slug_field set (determines entry folder name)" "slug_field:"
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
check_grep "template: entry (creates entry.md filename)" "template: entry"
+5 -2
View File
@@ -7,7 +7,7 @@ set -euo pipefail
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
USER="${GRAV_TEST_USER:-}"
PASS="${GRAV_TEST_PASS:-}"
TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies"
TRACKER="user/pages/01.trips/italy-2026-demo/01.dailies"
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
PASS_COUNT=0
FAIL_COUNT=0
@@ -49,7 +49,10 @@ LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-L \
-d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \
--data-urlencode "username=${USER}" \
--data-urlencode "password=${PASS}" \
--data-urlencode "login-form-nonce=${LOGIN_NONCE}" \
--data-urlencode "task=login.login" \
"$BASE_URL/login")
# After login, fetch /post and verify we see the post form (not the login form)
-4
View File
@@ -1,4 +0,0 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
-10
View File
@@ -1,10 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
playwright install chromium --with-deps
COPY app/ ./app/
ENV FLASK_APP=app
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_RUN_PORT=8082
CMD ["flask", "run"]
-26
View File
@@ -1,26 +0,0 @@
import os
from flask import Flask
def create_app(state_dir=None, pages_dir=None):
app = Flask(__name__)
app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state")
app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages")
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
from .routes import albums, triage, proxy, notes, nav, curate, group, write, export
app.register_blueprint(albums.bp)
app.register_blueprint(triage.bp)
app.register_blueprint(proxy.bp)
app.register_blueprint(notes.bp)
app.register_blueprint(nav.bp)
app.register_blueprint(curate.bp)
app.register_blueprint(group.bp)
app.register_blueprint(write.bp)
app.register_blueprint(export.bp)
@app.get("/health")
def health():
return {"ok": True}
return app
-30
View File
@@ -1,30 +0,0 @@
import requests
class ImmichClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {api_key}"}
def _get(self, path: str, **kwargs):
try:
r = requests.get(f"{self.base_url}{path}",
headers=self.headers, timeout=10, **kwargs)
r.raise_for_status()
return r
except requests.exceptions.ConnectionError as e:
raise ConnectionError(f"Cannot reach Immich: {e}") from e
def list_albums(self) -> list:
return self._get("/api/albums").json()
def get_album(self, album_id: str) -> dict:
return self._get(f"/api/albums/{album_id}",
params={"withoutAssets": "false"}).json()
def get_thumbnail(self, asset_id: str) -> bytes:
return self._get(f"/api/assets/{asset_id}/thumbnail",
params={"size": "preview"}).content
def get_original(self, asset_id: str) -> bytes:
return self._get(f"/api/assets/{asset_id}/original").content
@@ -1,79 +0,0 @@
import re
from pathlib import Path
from flask import Blueprint, current_app, redirect, render_template, request
from app.immich import ImmichClient
from app.state import TripState, Photo, load_state, save_state
bp = Blueprint("albums", __name__)
def _sanitise_slug(s: str) -> str:
s = s.strip().lower()
s = re.sub(r'[^a-z0-9-]+', '-', s)
return s.strip('-')
def _client():
return ImmichClient(current_app.config["IMMICH_URL"],
current_app.config["IMMICH_API_KEY"])
@bp.get("/")
def index():
try:
albums = _client().list_albums()
error = None
except ConnectionError as e:
albums = []
error = str(e)
state_dir = Path(current_app.config["STATE_DIR"])
for album in albums:
album["has_state"] = (state_dir / f"{album['id']}.json").exists()
return render_template("phase1.html", albums=albums, error=error,
current_phase="", album_id=None,
phase_stale=[], notes_content="")
@bp.post("/select")
def select():
album_ids = request.form.getlist("album_ids[]")
grav_trip_slug = _sanitise_slug(request.form["grav_trip_slug"])
start_over = request.form.get("start_over") == "1"
if len(album_ids) == 1:
primary_id = album_ids[0]
else:
primary_id = "__merged__" + "_".join(sorted(album_ids))
existing = load_state(primary_id, current_app)
if existing and not start_over:
return redirect(f"/{existing.phase}?album_id={primary_id}")
# Fetch and merge assets, deduplicating by asset ID
all_assets = {}
album_name_parts = []
for aid in album_ids:
album = _client().get_album(aid)
album_name_parts.append(album["albumName"])
for asset in album["assets"]:
if asset["id"] not in all_assets:
all_assets[asset["id"]] = asset
photos = [
Photo(id=a["id"], original_filename=a["originalFileName"],
local_datetime=a["localDateTime"])
for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"])
]
for i, p in enumerate(photos):
p.order = i
state = TripState(
album_id=primary_id,
album_name=", ".join(album_name_parts),
grav_trip_slug=grav_trip_slug,
photos=photos,
)
save_state(state, current_app)
return redirect(f"/triage?album_id={primary_id}")
@@ -1,71 +0,0 @@
from flask import Blueprint, current_app, jsonify, render_template, request
from app.state import load_state, save_state
bp = Blueprint("curate", __name__)
@bp.get("/curate")
def curate():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
kept = [p for p in state.photos if p.tag in ("journal", "story")]
photos_by_day = {}
for p in kept:
day = p.local_datetime[:10]
photos_by_day.setdefault(day, []).append(p)
return render_template(
"phase3.html",
state=state,
photos_by_day=photos_by_day,
current_phase="curate",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/curate/remove")
def remove():
body = request.get_json()
state = load_state(body["album_id"], current_app)
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
if photo is None:
return jsonify({"ok": False, "error": "photo not found"}), 404
photo.tag = "skip"
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/curate/swap")
def swap():
body = request.get_json()
state = load_state(body["album_id"], current_app)
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
if photo is None:
return jsonify({"ok": False, "error": "photo not found"}), 404
photo.tag = "story" if photo.tag == "journal" else "journal"
save_state(state, current_app)
return jsonify({"ok": True, "new_tag": photo.tag})
@bp.post("/curate/reorder")
def reorder():
body = request.get_json()
state = load_state(body["album_id"], current_app)
order_map = {aid: i for i, aid in enumerate(body["order"])}
for p in state.photos:
if p.id in order_map:
p.order = order_map[p.id]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/curate/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if "curate" not in state.phases_completed:
state.phases_completed.append("curate")
state.phase = "group"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"})
@@ -1,229 +0,0 @@
import re
import shutil
from pathlib import Path
from flask import Blueprint, current_app, jsonify, render_template, request
from app.immich import ImmichClient
from app.state import load_state, save_state
bp = Blueprint("export", __name__)
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
return re.sub(r"[\s_-]+", "-", text).strip("-")
def _yaml_str(s: str) -> str:
return s.replace("'", "''")
def _client():
return ImmichClient(
current_app.config["IMMICH_URL"],
current_app.config["IMMICH_API_KEY"],
)
@bp.get("/export")
def export_view():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
to_export = [g for g in state.groups if g.status == "written"]
skipped = [g for g in state.groups if g.status == "skipped"]
return render_template(
"phase6.html",
state=state,
to_export=to_export,
skipped=skipped,
current_phase="export",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/export/run")
def run_export():
body = request.get_json()
album_id = body["album_id"]
state = load_state(album_id, current_app)
pages_dir = Path(current_app.config["PAGES_DIR"])
client = _client()
photo_map = {p.id: p for p in state.photos}
exported = 0
all_failed = []
for group in state.groups:
if group.status != "written":
continue
title_slug = slugify(group.title or group.date or "entry")
if group.entry_type == "journal":
folder_name = f"{group.date}-{title_slug}.entry"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
md_file = "entry.md"
template = "entry"
else:
folder_name = f"{title_slug}.story"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
md_file = "story.md"
template = "story"
if dest.exists():
save_state(state, current_app)
return jsonify({"conflict": True, "path": str(dest)})
dest.mkdir(parents=True, exist_ok=True)
# Download photos
failed = []
hero_filename = None
photo_num = 1
for pid in group.photo_ids:
photo = photo_map.get(pid)
if not photo:
continue
filename = f"photo-{photo_num}.jpg"
try:
data = client.get_original(pid)
(dest / filename).write_bytes(data)
if pid == group.hero_photo_id or photo_num == 1:
hero_filename = filename
photo_num += 1
except Exception as e:
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
failed.append(pid)
# Build frontmatter
date_str = (group.date + " 12:00") if group.date else ""
if group.entry_type == "journal":
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{_yaml_str(group.location_city)}'\n"
f"location_country: '{_yaml_str(group.location_country)}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
body_text = group.body or ""
if group.shortcode_hints:
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
exported += 1
all_failed.extend(failed)
save_state(state, current_app)
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
@bp.post("/export/overwrite")
def overwrite_export():
body = request.get_json()
album_id = body["album_id"]
conflict_path = Path(body["path"])
state = load_state(album_id, current_app)
pages_dir = Path(current_app.config["PAGES_DIR"])
client = _client()
photo_map = {p.id: p for p in state.photos}
# Remove the conflicting folder so the run loop can proceed past it
if conflict_path.exists():
shutil.rmtree(conflict_path)
exported = 0
all_failed = []
for group in state.groups:
if group.status != "written":
continue
title_slug = slugify(group.title or group.date or "entry")
if group.entry_type == "journal":
folder_name = f"{group.date}-{title_slug}.entry"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
md_file = "entry.md"
template = "entry"
else:
folder_name = f"{title_slug}.story"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
md_file = "story.md"
template = "story"
if dest.exists():
save_state(state, current_app)
return jsonify({"conflict": True, "path": str(dest)})
dest.mkdir(parents=True, exist_ok=True)
failed = []
hero_filename = None
photo_num = 1
for pid in group.photo_ids:
photo = photo_map.get(pid)
if not photo:
continue
filename = f"photo-{photo_num}.jpg"
try:
data = client.get_original(pid)
(dest / filename).write_bytes(data)
if pid == group.hero_photo_id or photo_num == 1:
hero_filename = filename
photo_num += 1
except Exception as e:
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
failed.append(pid)
date_str = (group.date + " 12:00") if group.date else ""
if group.entry_type == "journal":
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{_yaml_str(group.location_city)}'\n"
f"location_country: '{_yaml_str(group.location_country)}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
body_text = group.body or ""
if group.shortcode_hints:
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
exported += 1
all_failed.extend(failed)
save_state(state, current_app)
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
@@ -1,116 +0,0 @@
import uuid
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
from app.state import Group, load_state, save_state
bp = Blueprint("group", __name__)
def _build_groups(state):
"""Compute display groups from kept photos + dividers."""
kept = sorted(
[p for p in state.photos if p.tag in ("journal", "story")],
key=lambda p: p.order,
)
divider_orders = sorted(d["after_order"] for d in state.dividers)
divider_ids = {d["after_order"]: d["id"] for d in state.dividers}
groups = []
current_group = []
for photo in kept:
current_group.append(photo)
if photo.order in divider_orders:
div_id = divider_ids[photo.order]
groups.append({
"photos": current_group,
"divider_id": div_id,
"label": state.group_labels.get(div_id, ""),
})
current_group = []
if current_group:
groups.append({"photos": current_group, "divider_id": None, "label": ""})
return groups, kept
@bp.get("/group")
def group():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
groups, kept = _build_groups(state)
return render_template(
"phase4.html",
state=state,
groups=groups,
kept=kept,
current_phase="group",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/group/divider")
def add_divider():
body = request.get_json()
state = load_state(body["album_id"], current_app)
after_order = int(body["after_order"])
if not any(d["after_order"] == after_order for d in state.dividers):
state.dividers.append({"id": str(uuid.uuid4()), "after_order": after_order})
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/remove-divider")
def remove_divider():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.dividers = [d for d in state.dividers if d["id"] != body["divider_id"]]
state.group_labels.pop(body["divider_id"], None)
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/label")
def set_label():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.group_labels[body["divider_id"]] = body["label"]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
groups, _ = _build_groups(state)
state.groups = []
for g in groups:
first_photo = g["photos"][0]
state.groups.append(Group(
id=str(uuid.uuid4()),
photo_ids=[p.id for p in g["photos"]],
entry_type=first_photo.tag,
date=first_photo.local_datetime[:10],
label=g["label"],
))
if "group" not in state.phases_completed:
state.phases_completed.append("group")
state.phase = "write"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/write?album_id={body['album_id']}"})
@bp.post("/group/from-note")
def from_note():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.groups.append(Group(
id=str(uuid.uuid4()),
photo_ids=[],
entry_type="journal",
body=body.get("text", ""),
))
if "write" in state.phases_completed and "write" not in state.phase_stale:
state.phase_stale.append("write")
save_state(state, current_app)
return jsonify({"ok": True})
@@ -1,51 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, request
from app.state import load_state, save_state
bp = Blueprint("nav", __name__)
STALE_DOWNSTREAM = {
"triage": ["curate", "group", "write"],
"curate": ["group", "write"],
"group": ["write"],
"write": [],
"export": [],
}
@bp.post("/nav/phase")
def goto_phase():
body = request.get_json()
target = body["target_phase"]
state = load_state(body["album_id"], current_app)
if state is None:
return jsonify({"error": "no state"}), 404
# Mark downstream completed phases and the current phase as stale
downstream = STALE_DOWNSTREAM.get(target, [])
candidates = set(downstream) & (set(state.phases_completed) | {state.phase})
newly_stale = [p for p in candidates if p not in state.phase_stale]
state.phase_stale = list(set(state.phase_stale + newly_stale))
state.phase = target
save_state(state, current_app)
return jsonify({"ok": True, "phase": target})
@bp.post("/nav/dismiss-stale")
def dismiss_stale():
album_id = request.form["album_id"]
phase = request.form["phase"]
state = load_state(album_id, current_app)
if state:
state.phase_stale = [p for p in state.phase_stale if p != phase]
save_state(state, current_app)
return redirect(f"/{phase}?album_id={album_id}")
@bp.get("/state/<album_id>")
def get_state(album_id):
"""Debug/test endpoint — returns full state JSON."""
state = load_state(album_id, current_app)
if state is None:
return jsonify({"error": "no state"}), 404
from dataclasses import asdict
return jsonify(asdict(state))
@@ -1,23 +0,0 @@
from flask import Blueprint, current_app, jsonify, request
from app.state import load_state, save_state
bp = Blueprint("notes", __name__)
@bp.post("/notes/save")
def save_notes():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if state is None:
return jsonify({"error": "no state"}), 404
state.notes = body["notes"]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.get("/notes/<album_id>")
def get_notes(album_id):
state = load_state(album_id, current_app)
if state is None:
return jsonify({"error": "no state"}), 404
return jsonify({"notes": state.notes})
@@ -1,29 +0,0 @@
from flask import Blueprint, current_app, Response, abort
from app.immich import ImmichClient
bp = Blueprint("proxy", __name__)
def _client() -> ImmichClient:
return ImmichClient(
base_url=current_app.config["IMMICH_URL"],
api_key=current_app.config["IMMICH_API_KEY"],
)
@bp.get("/proxy/thumb/<asset_id>")
def thumb(asset_id):
try:
data = _client().get_thumbnail(asset_id)
except ConnectionError:
abort(502)
return Response(data, content_type="image/jpeg")
@bp.get("/proxy/original/<asset_id>")
def original(asset_id):
try:
data = _client().get_original(asset_id)
except ConnectionError:
abort(502)
return Response(data, content_type="image/jpeg")
@@ -1,51 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
from app.state import load_state, save_state
bp = Blueprint("triage", __name__)
@bp.get("/triage")
def triage():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
photos_by_day = {}
for p in state.photos:
day = p.local_datetime[:10]
photos_by_day.setdefault(day, []).append(p)
all_tagged = all(p.tag != "untagged" for p in state.photos)
return render_template(
"phase2.html",
state=state,
photos_by_day=photos_by_day,
all_tagged=all_tagged,
current_phase="triage",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/triage/tag")
def tag():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for p in state.photos:
if p.id == body["asset_id"]:
p.tag = body["tag"]
break
save_state(state, current_app)
tagged_count = sum(1 for p in state.photos if p.tag != "untagged")
return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)})
@bp.post("/triage/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if not all(p.tag != "untagged" for p in state.photos):
return jsonify({"error": "not all tagged"}), 400
if "triage" not in state.phases_completed:
state.phases_completed.append("triage")
state.phase = "curate"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"})
@@ -1,103 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
from app.state import load_state, save_state
bp = Blueprint("write", __name__)
@bp.get("/write")
def write():
album_id = request.args["album_id"]
group_idx = int(request.args.get("group_idx", 0))
state = load_state(album_id, current_app)
active_groups = [g for g in state.groups if g.status != "exported"]
total = len(active_groups)
group = active_groups[group_idx] if group_idx < total else None
done_count = sum(1 for g in active_groups if g.status in ("written", "skipped"))
if group is None:
all_done = all(g.status in ("written", "skipped", "exported") for g in active_groups)
if not all_done:
first_incomplete = next(i for i, g in enumerate(active_groups) if g.status == "draft")
return redirect(url_for("write.write", album_id=album_id, group_idx=first_incomplete))
photos = []
if group:
by_id = {p.id: p for p in state.photos}
photos = [by_id[pid] for pid in group.photo_ids if pid in by_id]
return render_template(
"phase5.html",
state=state,
group=group,
photos=photos,
group_idx=group_idx,
total=total,
done_count=done_count,
current_phase="write",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/write/autosave")
def autosave():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.title = body.get("title", g.title)
g.body = body.get("body", g.body)
g.location_city = body.get("location_city", g.location_city)
g.location_country = body.get("location_country", g.location_country)
g.date = body.get("date", g.date)
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
if body.get("entry_type"):
g.entry_type = body["entry_type"]
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/save")
def save():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.title = body.get("title", g.title)
g.body = body.get("body", g.body)
g.location_city = body.get("location_city", g.location_city)
g.location_country = body.get("location_country", g.location_country)
g.date = body.get("date", g.date)
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
if body.get("entry_type"):
g.entry_type = body["entry_type"]
g.status = "written"
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/skip")
def skip():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.status = "skipped"
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/done")
def write_done():
album_id = request.form["album_id"]
state = load_state(album_id, current_app)
if state is None:
return jsonify({"ok": False, "error": "not found"}), 404
if "write" not in state.phases_completed:
state.phases_completed.append("write")
state.phase = "export"
save_state(state, current_app)
return redirect(f"/export?album_id={album_id}")
-71
View File
@@ -1,71 +0,0 @@
import json
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional
from flask import current_app
@dataclass
class Photo:
id: str
original_filename: str
local_datetime: str
tag: str = "untagged" # untagged | journal | story | skip
order: int = 0
@dataclass
class Group:
id: str
photo_ids: list = field(default_factory=list)
entry_type: str = "journal" # journal | story
label: str = ""
title: str = ""
body: str = ""
location_city: str = ""
location_country: str = ""
date: str = ""
hero_photo_id: Optional[str] = None
shortcode_hints: str = ""
status: str = "draft" # draft | written | skipped | exported
@dataclass
class TripState:
album_id: str
album_name: str
grav_trip_slug: str
phase: str = "triage"
phases_completed: list = field(default_factory=list)
phase_stale: list = field(default_factory=list)
photos: list = field(default_factory=list)
groups: list = field(default_factory=list)
notes: str = ""
dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}]
group_labels: dict = field(default_factory=dict) # {divider_id: label}
def _state_path(album_id: str, app) -> Path:
return Path(app.config["STATE_DIR"]) / f"{album_id}.json"
def load_state(album_id: str, app) -> Optional[TripState]:
path = _state_path(album_id, app)
if not path.exists():
return None
with open(path) as f:
data = json.load(f)
photos = [Photo(**p) for p in data.pop("photos", [])]
groups = [Group(**g) for g in data.pop("groups", [])]
return TripState(photos=photos, groups=groups, **data)
def save_state(state: TripState, app) -> None:
path = _state_path(state.album_id, app)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(asdict(state), f, indent=2)
os.rename(tmp, path)
@@ -1,34 +0,0 @@
function notesApp(initialNotes, albumId) {
return {
open: false,
notes: initialNotes,
status: '',
saveTimer: null,
scheduleAutosave() {
clearTimeout(this.saveTimer);
this.status = 'Saving…';
this.saveTimer = setTimeout(() => this.doSave(), 500);
},
async doSave() {
const res = await fetch('/notes/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, notes: this.notes }),
});
this.status = res.ok ? 'Saved ✓' : 'Error';
},
async convertToEntry(text) {
const res = await fetch('/group/from-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, text }),
});
if (res.ok) {
this.status = 'Added as entry ✓';
}
},
};
}
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html data-theme="forest" lang="en">
<head>
<meta charset="UTF-8">
<title>travel-memories</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200" x-data="notesApp({{ notes_content | tojson }}, '{{ album_id }}')">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
<div class="navbar-start px-4 font-bold text-lg">travel-memories</div>
<div class="navbar-center">
<ul class="steps">
{% set phases = [('','Album'),('triage','Triage'),('curate','Curate'),('group','Group'),('write','Write'),('export','Export')] %}
{% for key, label in phases %}
<li class="step {% if current_phase == key %}step-primary{% endif %}
{% if key in phase_stale %}step-warning{% endif %}">
{% if album_id %}
<a hx-post="/nav/phase" hx-vals='{"album_id":"{{ album_id }}","target_phase":"{{ key }}"}' href="/{{ key }}{% if album_id %}?album_id={{ album_id }}{% endif %}">{{ label }}</a>
{% else %}{{ label }}{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="navbar-end px-4">
{% if album_id %}
<button class="btn btn-ghost btn-sm" @click="open = !open">📝 Notes</button>
{% endif %}
</div>
</div>
<!-- Stale warning -->
{% if current_phase in phase_stale %}
<div class="alert alert-warning rounded-none" id="stale-banner">
<span>You changed earlier decisions — review this phase before exporting.</span>
<form method="post" action="/nav/dismiss-stale">
<input type="hidden" name="album_id" value="{{ album_id }}">
<input type="hidden" name="phase" value="{{ current_phase }}">
<button class="btn btn-xs">Dismiss</button>
</form>
</div>
{% endif %}
<!-- Body with notes drawer -->
<div class="flex relative">
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
{% block content %}{% endblock %}
</div>
<!-- Notes panel -->
<div class="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-base-100 shadow-2xl p-4 flex flex-col transition-transform z-30"
:class="open ? 'translate-x-0' : 'translate-x-full'" id="notes-panel">
<h3 class="font-bold text-base mb-2">Notes</h3>
<textarea class="textarea textarea-bordered flex-1 resize-none text-sm"
x-model="notes"
@input="scheduleAutosave()"
placeholder="Jot down memories at any time…"></textarea>
<div class="text-xs text-right mt-1 opacity-60" x-text="status"></div>
</div>
</div>
<script src="/static/app.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-4">Select Album</h1>
{% if error %}
<div class="alert alert-error mb-4">
<span>Cannot reach Immich: {{ error }}</span>
<a href="/" class="btn btn-sm">Retry</a>
</div>
{% endif %}
<form method="post" action="/select">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{% for album in albums %}
<label class="album-card card bg-base-100 shadow cursor-pointer hover:shadow-lg transition"
data-album-id="{{ album.id }}">
<figure class="h-40 overflow-hidden">
<img src="/proxy/thumb/{{ album.albumThumbnailAssetId }}"
class="w-full h-full object-cover" alt="">
</figure>
<div class="card-body p-4">
<div class="flex items-start gap-2">
<input type="checkbox" name="album_ids[]" value="{{ album.id }}"
class="checkbox checkbox-primary mt-1">
<div>
<p class="font-semibold">{{ album.albumName }}</p>
<p class="text-sm opacity-60">{{ album.assetCount }} photos</p>
{% if album.has_state %}
<span class="resume-badge badge badge-warning badge-sm mt-1">In progress</span>
{% endif %}
</div>
</div>
</div>
</label>
{% endfor %}
</div>
<div class="form-control mb-4 max-w-xs">
<label class="label"><span class="label-text">Grav trip slug</span></label>
<input id="grav-slug" type="text" name="grav_trip_slug" required
placeholder="central-asia-2023" class="input input-bordered">
</div>
<input type="hidden" name="start_over" id="start-over-flag" value="0">
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">Start &rarr;</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="document.getElementById('start-over-flag').value='1'; this.closest('form').submit()">
Start over (discard progress)
</button>
</div>
</form>
</div>
{% endblock %}
@@ -1,628 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
@keydown.j.window="tagFocused('journal')"
@keydown.s.window="tagFocused('story')"
@keydown.x.window="tagFocused('skip')"
@keydown.space.prevent.window="tagFocused('skip')"
@keydown.left.prevent.window="navigate(-1)"
@keydown.right.prevent.window="navigate(1)"
@keydown.escape.window="closeLightbox()"
@keydown.enter.window="focused && openLightbox(focused)">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Triage</h1>
<div class="flex items-center gap-3">
<span class="text-sm opacity-60" id="tagged-count">
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
/ {{ state.photos | length }} tagged
</span>
<button class="btn btn-ghost btn-sm" @click="skipUntagged()">
Skip untagged
</button>
<button id="done-btn"
class="btn btn-primary btn-sm"
{% if not all_tagged %}disabled{% endif %}
@click="done()">
Done triaging &rarr;
</button>
</div>
</div>
{# ── Desktop grid (hidden on mobile) ── #}
<div id="desktop-view">
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
{% for photo in photos %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
{% if photo.tag == 'journal' %}border-amber-500
{% elif photo.tag == 'story' %}border-sky-400
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
{% else %}border-transparent{% endif %}"
data-asset-id="{{ photo.id }}"
data-tag="{{ photo.tag }}"
tabindex="0"
@click="openLightbox($el)"
@focus="select($el)">
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full aspect-square object-cover" loading="lazy" alt="">
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
{{ photo.local_datetime[11:16] }}
</div>
{% if photo.tag == 'journal' %}
<div class="absolute top-1 right-1 badge badge-xs bg-amber-500 text-black border-0 font-bold">J</div>
{% elif photo.tag == 'story' %}
<div class="absolute top-1 right-1 badge badge-xs bg-sky-400 text-black border-0 font-bold">S</div>
{% elif photo.tag == 'skip' %}
<div class="absolute top-1 right-1 badge badge-xs badge-ghost opacity-60">X</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{# ── Lightbox overlay (desktop) ── #}
<div id="lb" class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" style="display:none">
<button class="absolute top-4 right-4 btn btn-circle btn-sm btn-ghost text-white opacity-60 hover:opacity-100 text-lg"
@click="closeLightbox()">&#10005;</button>
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(-1)">&#8249;</button>
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(1)">&#8250;</button>
<div class="flex flex-col items-center gap-3 px-16 max-w-full">
<img id="lb-img" src="" class="max-h-[82vh] max-w-[88vw] object-contain rounded-lg shadow-2xl" alt="">
<div class="flex items-center gap-4 text-white/60 text-sm">
<span id="lb-date"></span>
<span id="lb-filename" class="opacity-40"></span>
<span id="lb-tag-badge" class="badge badge-sm"></span>
<span class="opacity-30 text-xs">J journal · S story · X skip · ← → navigate · Esc close</span>
</div>
</div>
</div>
{# ── Mobile card UI (hidden on desktop) ── #}
<div id="mobile-view" style="display:none">
{# Progress bar #}
<div class="mb-3">
<div class="flex justify-between text-xs opacity-60 mb-1">
<span id="m-progress-text">0 / {{ state.photos | length }} tagged</span>
<span id="m-undo-btn-wrap" style="display:none">
<button id="m-undo-btn" class="btn btn-ghost btn-xs">&#8592; Back</button>
</span>
</div>
<div class="w-full bg-base-300 rounded-full h-1.5">
<div id="m-progress-bar" class="bg-primary h-1.5 rounded-full transition-all" style="width:0%"></div>
</div>
</div>
{# Card stack #}
<div id="m-card-area" class="relative w-full" style="height:70vh">
{# Card is injected by JS #}
<div id="m-completion" style="display:none"
class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl">&#10003;</div>
<p class="text-lg font-semibold">All tagged!</p>
<button class="btn btn-primary" onclick="document.getElementById('done-btn').click()">
Done triaging &rarr;
</button>
</div>
</div>
{# Action buttons #}
<div id="m-buttons" class="flex justify-center gap-6 mt-4">
<button id="m-btn-skip"
class="btn btn-circle btn-lg btn-ghost border-2 border-base-300 text-2xl"
onclick="mobileApp && mobileApp.doTag('skip')">&#10005;</button>
<button id="m-btn-journal"
class="btn btn-circle btn-lg btn-ghost border-2 border-success text-2xl"
onclick="mobileApp && mobileApp.doTag('journal')">J</button>
<button id="m-btn-story"
class="btn btn-circle btn-lg btn-ghost border-2 border-info text-2xl"
onclick="mobileApp && mobileApp.doTag('story')">S</button>
</div>
{# Thumbnail strip — all photos, colored dot per tag, tap to jump #}
<div id="m-thumb-strip"
class="mt-3 flex gap-1.5 overflow-x-auto pb-2"
style="scrollbar-width:thin;-webkit-overflow-scrolling:touch"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script>
// ── Shared badge helper ──────────────────────────────────────────────────────
function updateBadge(cardEl, tag) {
let badge = cardEl.querySelector('.badge');
if (!badge) {
badge = document.createElement('div');
cardEl.appendChild(badge);
}
const MAP = {
journal: ['badge-xs bg-amber-500 text-black border-0 font-bold', 'J'],
story: ['badge-xs bg-sky-400 text-black border-0 font-bold', 'S'],
skip: ['badge-xs badge-ghost opacity-60', 'X'],
};
if (MAP[tag]) {
badge.className = `absolute top-1 right-1 badge ${MAP[tag][0]}`;
badge.textContent = MAP[tag][1];
} else {
badge.remove();
}
}
// ── Desktop Alpine app ───────────────────────────────────────────────────────
function triageApp(albumId) {
return {
focused: null,
lightboxOpen: false,
init() {
const first = document.querySelector('.photo-card');
if (first) this.select(first);
},
select(el) {
if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
this.focused = el;
if (el) {
el.classList.add('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (this.lightboxOpen) this.updateLightbox();
},
openLightbox(el) {
this.select(el);
this.lightboxOpen = true;
document.getElementById('lb').style.display = '';
this.updateLightbox();
},
closeLightbox() {
if (!this.lightboxOpen) return;
this.lightboxOpen = false;
document.getElementById('lb').style.display = 'none';
},
updateLightbox() {
const el = this.focused;
if (!el) return;
const assetId = el.dataset.assetId;
const tag = el.dataset.tag;
document.getElementById('lb-img').src = `/proxy/thumb/${assetId}`;
const timeEl = el.querySelector('div');
document.getElementById('lb-date').textContent = timeEl ? timeEl.textContent.trim() : '';
document.getElementById('lb-filename').textContent = el.dataset.filename || '';
const badgeEl = document.getElementById('lb-tag-badge');
const MAP = {
journal: ['bg-amber-500 text-black border-0 font-bold', 'Journal'],
story: ['bg-sky-400 text-black border-0 font-bold', 'Story'],
skip: ['badge-ghost opacity-60', 'Skip'],
};
if (MAP[tag]) {
badgeEl.className = `badge badge-sm ${MAP[tag][0]}`;
badgeEl.textContent = MAP[tag][1];
} else {
badgeEl.className = 'badge badge-sm badge-outline opacity-30';
badgeEl.textContent = 'Untagged';
}
},
navigate(dir) {
const cards = [...document.querySelectorAll('.photo-card')];
if (!cards.length) return;
const idx = this.focused ? cards.indexOf(this.focused) : -1;
const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))];
if (next) this.select(next);
},
async tagFocused(tag) {
const el = this.focused || document.querySelector('.photo-card');
if (!el) return;
const assetId = el.dataset.assetId;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
});
el.dataset.tag = tag;
// Remove any existing border/opacity classes before adding new ones
el.className = el.className
.split(/\s+/)
.filter(c => c && !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
if (tag === 'journal') {
el.classList.add('border-4', 'border-amber-500');
} else if (tag === 'story') {
el.classList.add('border-4', 'border-sky-400');
} else {
el.classList.add('border-4', 'border-base-300', 'opacity-40');
}
updateBadge(el, tag);
this.updateCount();
if (this.lightboxOpen) this.updateLightbox();
},
updateCount() {
const total = document.querySelectorAll('.photo-card').length;
const tagged = document.querySelectorAll('.photo-card:not([data-tag="untagged"])').length;
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
document.getElementById('done-btn').disabled = tagged < total;
},
async skipUntagged() {
await fetch('/triage/skip-untagged', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
document.querySelectorAll('.photo-card[data-tag="untagged"]').forEach(el => {
el.dataset.tag = 'skip';
el.className = el.className
.split(' ')
.filter(c => !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
el.classList.add('border-4', 'border-base-300', 'opacity-40');
updateBadge(el, 'skip');
});
this.updateCount();
},
async done() {
const res = await fetch('/triage/done', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
// ── Mobile swipe triage app ──────────────────────────────────────────────────
let mobileApp = null;
function mobileTriageApp(albumId, photos) {
// Build queue: only untagged photos, in their original order
let queue = photos
.filter(p => p.tag === 'untagged')
.slice(); // shallow copy
const total = photos.length;
let taggedCount = photos.filter(p => p.tag !== 'untagged').length;
// Undo stack: [{asset_id, previous_tag}, ...] (max 10)
const undoStack = [];
// DOM refs
const cardArea = document.getElementById('m-card-area');
const completion = document.getElementById('m-completion');
const progressBar = document.getElementById('m-progress-bar');
const progressText = document.getElementById('m-progress-text');
const undoBtnWrap = document.getElementById('m-undo-btn-wrap');
const undoBtn = document.getElementById('m-undo-btn');
undoBtn.addEventListener('click', undo);
// ── helpers ─────────────────────────────────────────────────────────────
function updateProgress() {
progressText.textContent = `${taggedCount} / ${total} tagged`;
progressBar.style.width = total > 0 ? `${(taggedCount / total) * 100}%` : '0%';
undoBtnWrap.style.display = undoStack.length > 0 ? '' : 'none';
// Sync the shared header counter / done button
document.getElementById('tagged-count').textContent = `${taggedCount} / ${total} tagged`;
document.getElementById('done-btn').disabled = taggedCount < total;
}
function showCompletion() {
completion.style.display = '';
document.getElementById('m-buttons').style.display = 'none';
}
function makeCard(photo) {
const card = document.createElement('div');
card.id = 'm-card';
card.style.cssText = `
position: absolute; inset: 0;
border-radius: 16px; overflow: hidden;
background: #000;
touch-action: none;
user-select: none;
will-change: transform;
cursor: grab;
`;
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%; height:100%; object-fit:cover; display:block;';
img.draggable = false;
card.appendChild(img);
// Date overlay
const dateOverlay = document.createElement('div');
dateOverlay.style.cssText = `
position: absolute; bottom: 0; left: 0; right: 0;
padding: 12px 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
color: #fff; font-size: 14px;
`;
dateOverlay.textContent = photo.local_datetime
? photo.local_datetime.slice(0, 16).replace('T', ' ')
: '';
card.appendChild(dateOverlay);
// Colour overlay (shown during drag)
const colorOverlay = document.createElement('div');
colorOverlay.id = 'm-color-overlay';
colorOverlay.style.cssText = `
position: absolute; inset: 0;
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
border-radius: 16px;
`;
card.appendChild(colorOverlay);
return { card, colorOverlay };
}
function showCard() {
// Remove existing card if any
const old = document.getElementById('m-card');
if (old) old.remove();
if (queue.length === 0) {
showCompletion();
updateProgress();
updateThumbStrip();
return;
}
completion.style.display = 'none';
document.getElementById('m-buttons').style.display = '';
const photo = queue[0];
const { card, colorOverlay } = makeCard(photo);
cardArea.appendChild(card);
// ── HammerJS gestures ─────────────────────────────────────────────
const hammer = new Hammer(card, { recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_ALL, threshold: 5 }]] });
// Also enable swipe (velocity-based)
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
let startX = 0, startY = 0;
hammer.on('panstart', () => {
card.style.transition = 'none';
});
hammer.on('panmove', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const tilt = dx * 0.08; // degrees of rotation
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${tilt}deg)`;
// Determine dominant direction for colour overlay
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy > absDx && dy < -30) {
// swipe up → story (blue)
colorOverlay.style.background = 'rgba(56,189,248,0.35)';
colorOverlay.style.opacity = Math.min(absDy / 150, 0.8);
} else if (dx > 30) {
// swipe right → journal (green)
colorOverlay.style.background = 'rgba(74,222,128,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else if (dx < -30) {
// swipe left → skip (grey)
colorOverlay.style.background = 'rgba(100,116,139,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else {
colorOverlay.style.opacity = 0;
}
});
hammer.on('panend', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const THRESHOLD = 50;
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
if (absDy > absDx && dy < -THRESHOLD) {
// Swipe up → story
flyOut(card, 0, -window.innerHeight, () => doTag('story'));
} else if (dx > THRESHOLD) {
// Swipe right → journal
flyOut(card, window.innerWidth, 0, () => doTag('journal'));
} else if (dx < -THRESHOLD) {
// Swipe left → skip
flyOut(card, -window.innerWidth, 0, () => doTag('skip'));
} else {
// Snap back
card.style.transform = 'translate(0,0) rotate(0deg)';
colorOverlay.style.opacity = 0;
}
});
}
function flyOut(card, toX, toY, callback) {
card.style.transform = `translate(${toX}px, ${toY}px) rotate(${toX * 0.1}deg)`;
card.style.opacity = '0';
setTimeout(() => {
callback();
}, 300);
}
// ── Tag action ───────────────────────────────────────────────────────
async function doTag(tag) {
if (queue.length === 0) return;
const photo = queue.shift();
const previousTag = photo.tag;
// Push to undo stack (max 10)
undoStack.push({ photo, previousTag });
if (undoStack.length > 10) undoStack.shift();
// Update local photo tag
photo.tag = tag;
// Increment tagged count only if previously untagged
if (previousTag === 'untagged') taggedCount++;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag }),
});
updateProgress();
showCard();
updateThumbStrip();
}
// ── Undo ─────────────────────────────────────────────────────────────
async function undo() {
if (undoStack.length === 0) return;
const { photo, previousTag } = undoStack.pop();
// Re-insert at front of queue
queue.unshift(photo);
// Revert tagged count
if (previousTag === 'untagged' && photo.tag !== 'untagged') taggedCount--;
photo.tag = previousTag;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: previousTag }),
});
updateProgress();
showCard();
updateThumbStrip();
}
// ── Thumbnail strip ──────────────────────────────────────────────────
const thumbStrip = document.getElementById('m-thumb-strip');
function buildThumbStrip() {
thumbStrip.innerHTML = '';
photos.forEach(photo => {
const wrap = document.createElement('div');
wrap.className = 'relative flex-none cursor-pointer';
wrap.style.cssText = 'width:44px;height:44px;';
wrap.dataset.thumbId = photo.id;
wrap.addEventListener('click', () => jumpToPhoto(photo));
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:4px;border:2px solid transparent;transition:border-color 0.15s;display:block;';
img.draggable = false;
wrap.appendChild(img);
const dot = document.createElement('div');
dot.style.cssText = 'position:absolute;bottom:2px;left:2px;width:7px;height:7px;border-radius:50%;display:none;border:1px solid rgba(0,0,0,0.3);';
wrap.appendChild(dot);
thumbStrip.appendChild(wrap);
});
updateThumbStrip();
}
function updateThumbStrip() {
const currentId = queue.length > 0 ? queue[0].id : null;
photos.forEach(photo => {
const wrap = thumbStrip.querySelector(`[data-thumb-id="${photo.id}"]`);
if (!wrap) return;
const img = wrap.querySelector('img');
const dot = wrap.querySelector('div');
img.style.borderColor = photo.id === currentId ? '#fff' : 'transparent';
img.style.boxShadow = photo.id === currentId ? '0 0 0 1px rgba(0,0,0,0.4)' : 'none';
if (photo.tag === 'journal') {
dot.style.display = '';
dot.style.background = '#f59e0b';
} else if (photo.tag === 'story') {
dot.style.display = '';
dot.style.background = '#38bdf8';
} else if (photo.tag === 'skip') {
dot.style.display = '';
dot.style.background = '#64748b';
} else {
dot.style.display = 'none';
}
});
if (currentId) {
const currentWrap = thumbStrip.querySelector(`[data-thumb-id="${currentId}"]`);
if (currentWrap) currentWrap.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
async function jumpToPhoto(photo) {
const queueIdx = queue.findIndex(p => p.id === photo.id);
if (queueIdx !== -1) queue.splice(queueIdx, 1);
if (photo.tag !== 'untagged') taggedCount--;
const prevTag = photo.tag;
photo.tag = 'untagged';
queue.unshift(photo);
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: 'untagged' }),
});
updateProgress();
updateThumbStrip();
showCard();
}
// ── Public API ───────────────────────────────────────────────────────
return { doTag, undo, showCard, buildThumbStrip, updateThumbStrip };
}
// ── View switching on load ───────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (window.innerWidth < 768) {
document.getElementById('desktop-view').style.display = 'none';
document.getElementById('mobile-view').style.display = '';
const albumId = '{{ album_id }}';
const photos = {{ state.photos | tojson }};
mobileApp = mobileTriageApp(albumId, photos);
// Seed initial progress
const taggedCount = photos.filter(p => p.tag !== 'untagged').length;
document.getElementById('m-progress-text').textContent = `${taggedCount} / ${photos.length} tagged`;
document.getElementById('m-progress-bar').style.width =
photos.length > 0 ? `${(taggedCount / photos.length) * 100}%` : '0%';
mobileApp.buildThumbStrip();
mobileApp.showCard();
}
// Desktop: nothing extra needed — Alpine handles it
});
</script>
{% endblock %}
@@ -1,91 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Curate</h1>
<button id="done-btn" class="btn btn-primary btn-sm" onclick="done()">
Curate done &rarr;
</button>
</div>
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}">
{% for photo in photos %}
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
data-asset-id="{{ photo.id }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
<div class="absolute top-1 left-1 flex gap-1">
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
onclick="retag('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
{% if photo.tag == 'journal' %}&rarr;S{% else %}&rarr;J{% endif %}
</button>
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
onclick="removeFn('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
&#x2715;
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<script>
document.querySelectorAll('[id^="day-"]').forEach(function(el) {
var albumId = new URLSearchParams(location.search).get('album_id');
Sortable.create(el, {
onEnd: function(e) {
reorder(albumId, e.to);
}
});
});
async function removeFn(albumId, assetId, el) {
await fetch('/curate/remove', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.remove();
}
async function retag(albumId, assetId, el) {
await fetch('/curate/swap', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.classList.toggle('border-info');
el.classList.toggle('border-success');
}
async function reorder(albumId, container) {
var ids = Array.from(container.querySelectorAll('.photo-card')).map(function(e) {
return e.dataset.assetId;
});
var day = container.id.replace('day-', '');
await fetch('/curate/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, date: day, order: ids})
});
}
async function done() {
var albumId = new URLSearchParams(location.search).get('album_id');
var res = await fetch('/curate/done', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
var data = await res.json();
if (data.redirect) window.location = data.redirect;
}
</script>
{% endblock %}
@@ -1,99 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Group</h1>
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done &rarr;</button>
</div>
<div class="space-y-1">
{% for grp in groups %}
<div class="group-block border border-base-300 rounded-lg p-2 space-y-1">
{% if grp.label %}
<div class="text-xs font-semibold opacity-70 px-1">{{ grp.label }}</div>
{% endif %}
{% for photo in grp.photos %}
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
data-order="{{ photo.order }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
{{ photo.tag }}
</span>
</div>
{% if not loop.last %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ photo.order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ photo.order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
{% if grp.divider_id %}
<div class="flex items-center gap-2 my-1 px-1">
<input class="group-label input input-sm input-bordered flex-1"
value="{{ grp.label }}"
placeholder="Label this entry&#x2026;"
@change="setLabel('{{ grp.divider_id }}', $el.value)"
@keydown.enter="$el.blur()">
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
@click="removeDivider('{{ grp.divider_id }}')">&#x2715;</button>
</div>
{% endif %}
{% if not loop.last and not grp.divider_id %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ grp.photos[-1].order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ grp.photos[-1].order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function groupApp(albumId) {
return {
async addDivider(afterOrder) {
await fetch('/group/divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, after_order: afterOrder})
});
window.location.reload();
},
async removeDivider(dividerId) {
await fetch('/group/remove-divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId})
});
window.location.reload();
},
async setLabel(dividerId, label) {
await fetch('/group/label', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label: label})
});
},
async done() {
var res = await fetch('/group/done', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
var data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
</script>
{% endblock %}
@@ -1,202 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Write</h1>
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
</div>
{% if not group %}
<div class="alert alert-success mb-4">All groups written or skipped.</div>
<form method="post" action="/write/done">
<input type="hidden" name="album_id" value="{{ album_id }}">
<button type="submit" class="btn btn-primary">Export →</button>
</form>
{% else %}
<div class="flex gap-4">
<!-- Photos panel -->
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
{% for photo in photos %}
<img src="/proxy/thumb/{{ photo.id }}"
id="photo-{{ photo.id }}"
class="w-full rounded cursor-pointer border-4 border-transparent transition"
onclick="setHero('{{ photo.id }}')"
alt="">
{% endfor %}
</div>
<!-- Form -->
<div class="flex-1 space-y-4">
<!-- Mode switch -->
<div class="tabs">
<button id="mode-journal" class="tab tab-bordered tab-active"
onclick="setMode('journal')">Journal</button>
<button id="mode-story" class="tab tab-bordered"
onclick="setMode('story')">Story</button>
</div>
<div class="form-control">
<label class="label text-sm">Title</label>
<input id="title-field" type="text" class="input input-bordered"
oninput="scheduleAutosave()"
value="{{ group.title | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Date</label>
<input id="date-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.date | e }}">
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-sm">City</label>
<input id="city-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_city | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Country</label>
<input id="country-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_country | e }}">
</div>
</div>
<div class="form-control" id="mode-journal-fields">
<label class="label text-sm">Body</label>
<textarea id="body-field" class="textarea textarea-bordered h-40"
oninput="scheduleAutosave()">{{ group.body | e }}</textarea>
</div>
<!-- Story-only fields (hidden by default if mode is journal) -->
<div id="hero-picker" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Hero photo: <span id="hero-label">{{ group.hero_photo_id or 'none' }}</span></label>
<p class="text-xs opacity-60">Click a photo on the left to set it as the hero.</p>
</div>
<div id="shortcode-field-wrap" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Shortcode hints</label>
<input id="shortcode-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
placeholder="e.g. gallery block, pull quote"
value="{{ group.shortcode_hints | e }}">
</div>
<div class="flex gap-2 mt-4">
{% if group_idx > 0 %}
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
{% endif %}
<button id="skip-btn" class="btn btn-ghost btn-sm" onclick="skipGroup()">Skip for now</button>
<button class="btn btn-primary btn-sm ml-auto" onclick="saveAndNext()">Save &amp; next</button>
</div>
</div>
<!-- Inline notes -->
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
{% if group %}
<script>
(function() {
var albumId = {{ album_id | tojson }};
var groupId = {{ group.id | tojson }};
var mode = {{ group.entry_type | tojson }};
var heroId = {{ group.hero_photo_id | tojson }};
var autosaveTimer = null;
window.setMode = function(m) {
mode = m;
var storyFields = ['hero-picker', 'shortcode-field-wrap'];
storyFields.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.style.display = (m === 'story') ? 'block' : 'none';
});
document.getElementById('mode-journal').classList.toggle('tab-active', m === 'journal');
document.getElementById('mode-story').classList.toggle('tab-active', m === 'story');
scheduleAutosave();
};
window.setHero = function(id) {
heroId = id;
// Update border highlight
document.querySelectorAll('.group-photos img').forEach(function(img) {
img.classList.remove('border-primary');
img.classList.add('border-transparent');
});
var el = document.getElementById('photo-' + id);
if (el) { el.classList.remove('border-transparent'); el.classList.add('border-primary'); }
var label = document.getElementById('hero-label');
if (label) label.textContent = id;
scheduleAutosave();
};
window.scheduleAutosave = function() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(doAutosave, 500);
};
function getFormData() {
return {
album_id: albumId,
group_id: groupId,
entry_type: mode,
hero_photo_id: heroId,
title: document.getElementById('title-field') ? document.getElementById('title-field').value : '',
body: document.getElementById('body-field') ? document.getElementById('body-field').value : '',
location_city: document.getElementById('city-field') ? document.getElementById('city-field').value : '',
location_country: document.getElementById('country-field') ? document.getElementById('country-field').value : '',
date: document.getElementById('date-field') ? document.getElementById('date-field').value : '',
shortcode_hints: document.getElementById('shortcode-field') ? document.getElementById('shortcode-field').value : '',
};
}
function doAutosave() {
fetch('/write/autosave', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
});
}
window.skipGroup = function() {
fetch('/write/skip', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, group_id: groupId}),
}).then(function() {
window.location.reload();
});
};
window.saveAndNext = function() {
fetch('/write/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
}).then(function() {
var url = new URL(window.location.href);
var idx = parseInt(url.searchParams.get('group_idx') || '0');
url.searchParams.set('group_idx', idx + 1);
window.location.href = url.toString();
});
};
// Initialize mode display
if (mode === 'story') {
document.getElementById('mode-story') && document.getElementById('mode-story').classList.add('tab-active');
document.getElementById('mode-journal') && document.getElementById('mode-journal').classList.remove('tab-active');
}
})();
</script>
{% endif %}
{% endblock %}
@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-6 max-w-3xl mx-auto" x-data="exportApp('{{ album_id }}')">
<h1 class="text-2xl font-bold mb-4">Export</h1>
<div class="stats shadow mb-6">
<div class="stat"><div class="stat-title">Ready to export</div>
<div class="stat-value text-primary">{{ to_export | length }}</div></div>
<div class="stat"><div class="stat-title">Skipped</div>
<div class="stat-value opacity-40">{{ skipped | length }}</div></div>
</div>
<div class="space-y-2 mb-6">
{% for group in to_export %}
<div class="export-item card card-compact bg-base-100 shadow">
<div class="card-body">
<p class="font-semibold">{{ group.title }}</p>
<p class="text-xs opacity-60">{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos</p>
</div>
</div>
{% endfor %}
</div>
<button id="export-btn" class="btn btn-primary" @click="runExport()">
Export {{ to_export | length }} entries
</button>
<!-- Overwrite confirmation modal -->
<dialog id="overwrite-modal" class="modal">
<div class="modal-box">
<h3 class="font-bold">Destination exists</h3>
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
<div class="modal-action">
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
<button class="btn btn-ghost btn-sm" @click="cancelExport()">Cancel</button>
</div>
</div>
</dialog>
<!-- Results -->
<div x-show="successMsg !== ''" class="mt-6 alert alert-success text-sm" x-text="successMsg"></div>
<div x-show="failedCount > 0" class="mt-2 alert alert-warning text-sm"
x-text="`${failedCount} photo(s) failed to download`"></div>
<details class="mt-6">
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
Skipped ({{ skipped | length }}) — not exported
</summary>
<ul class="mt-2 space-y-1 text-sm opacity-60">
{% for g in skipped %}<li>{{ g.title or g.date }}</li>{% endfor %}
</ul>
</details>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function exportApp(albumId) {
return {
successMsg: '',
failedCount: 0,
conflictPath: null,
overwriteMsg: '',
async runExport() {
const res = await fetch('/export/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId}),
});
const data = await res.json();
if (data.conflict) {
this.conflictPath = data.path;
this.overwriteMsg = `Destination already exists: ${data.path}`;
document.getElementById('overwrite-modal').showModal();
} else if (data.ok) {
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
this.failedCount = (data.failed || []).length;
}
},
async confirmOverwrite() {
document.getElementById('overwrite-modal').close();
const res = await fetch('/export/overwrite', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, path: this.conflictPath}),
});
const data = await res.json();
if (data.conflict) {
this.conflictPath = data.path;
this.overwriteMsg = `Destination already exists: ${data.path}`;
document.getElementById('overwrite-modal').showModal();
} else if (data.ok) {
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
this.failedCount = (data.failed || []).length;
}
},
cancelExport() {
document.getElementById('overwrite-modal').close();
this.conflictPath = null;
},
};
}
</script>
{% endblock %}
-2
View File
@@ -1,2 +0,0 @@
[pytest]
pythonpath = .
@@ -1,5 +0,0 @@
flask==3.1.0
requests==2.32.3
pytest==8.3.4
pytest-playwright==0.6.2
pytest-httpserver==1.1.0
-101
View File
@@ -1,101 +0,0 @@
import json
import os
import shutil
import threading
import time
from pathlib import Path
import pytest
from werkzeug.serving import make_server
FIXTURES_DIR = Path(__file__).parent / "fixtures"
TINY_PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010806"
"0000001f15c4890000000a4944415478016360000000020001e2"
"21bc330000000049454e44ae426082"
)
MOCK_ALBUMS = [
{
"id": "album-1",
"albumName": "Central Asia 2023",
"assetCount": 3,
"albumThumbnailAssetId": "asset-1",
}
]
MOCK_ALBUM_DETAIL = {
"id": "album-1",
"albumName": "Central Asia 2023",
"assets": [
{"id": "asset-1", "originalFileName": "IMG_001.jpg",
"localDateTime": "2023-09-05T09:03:00"},
{"id": "asset-2", "originalFileName": "IMG_002.jpg",
"localDateTime": "2023-09-05T14:30:00"},
{"id": "asset-3", "originalFileName": "IMG_003.jpg",
"localDateTime": "2023-09-06T10:00:00"},
],
}
@pytest.fixture(scope="session")
def httpserver_listen_address():
return ("127.0.0.1", 8099)
@pytest.fixture(scope="session")
def mock_immich(make_httpserver):
server = make_httpserver
server.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS)
server.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL)
for asset_id in ["asset-1", "asset-2", "asset-3"]:
server.expect_request(
f"/api/assets/{asset_id}/thumbnail"
).respond_with_data(TINY_PNG, content_type="image/png")
server.expect_request(
f"/api/assets/{asset_id}/original"
).respond_with_data(TINY_PNG, content_type="image/jpeg")
return server
@pytest.fixture(scope="session")
def state_dir(tmp_path_factory):
return tmp_path_factory.mktemp("state")
@pytest.fixture(scope="session")
def pages_dir(tmp_path_factory):
return tmp_path_factory.mktemp("pages")
@pytest.fixture(scope="session")
def flask_app(state_dir, pages_dir, mock_immich):
os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099"
os.environ["IMMICH_API_KEY"] = "test-key"
from app import create_app
return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir))
@pytest.fixture(scope="session")
def base_url(flask_app):
server = make_server("127.0.0.1", 8083, flask_app)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
time.sleep(0.2)
yield "http://127.0.0.1:8083"
server.shutdown()
@pytest.fixture()
def seed_state(state_dir):
"""Copy a fixture JSON into the state dir; return the album_id."""
def _seed(fixture_name: str) -> str:
src = FIXTURES_DIR / f"{fixture_name}.json"
with open(src) as f:
data = json.load(f)
dst = Path(state_dir) / f"{data['album_id']}.json"
shutil.copy(src, dst)
return data["album_id"]
return _seed
@@ -1,20 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "triage",
"phases_completed": [],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1},
{"id": "asset-3", "original_filename": "IMG_003.jpg",
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
],
"groups": [],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,20 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "curate",
"phases_completed": ["triage"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1},
{"id": "asset-3", "original_filename": "IMG_003.jpg",
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
],
"groups": [],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,18 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "group",
"phases_completed": ["triage", "curate"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [],
"notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
}
@@ -1,31 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "write",
"phases_completed": ["triage", "curate", "group"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [
{
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft"
},
{
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft"
}
],
"notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
}
@@ -1,33 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "export",
"phases_completed": ["triage", "curate", "group", "write"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [
{
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"label": "", "title": "Arrival in Almaty", "body": "Chaos at the airport.",
"location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
"status": "written"
},
{
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"label": "", "title": "The Market", "body": "Colours everywhere.",
"location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
"status": "skipped"
}
],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,87 +0,0 @@
import json
def test_hard_refresh_preserves_triage_state(base_url, page, seed_state, flask_app):
"""State is server-side — hard refresh must not reset it."""
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("j")
page.wait_for_timeout(400)
page.reload()
first_card = page.locator(".photo-card").first
assert "border-success" in first_card.get_attribute("class")
def test_back_nav_from_group_to_triage_marks_curate_group_stale(base_url, page, seed_state):
album_id = seed_state("phase4_state") # completed=[triage, curate], phase=group
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
resp = page.request.get(f"{base_url}/state/{album_id}")
state = resp.json()
assert "curate" in state["phase_stale"]
assert "group" in state["phase_stale"]
def test_stale_banner_visible_on_stale_phase(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
# Now visit curate (which is stale)
page.goto(f"{base_url}/curate?album_id={album_id}")
assert page.locator("#stale-banner").is_visible()
def test_dismiss_stale_clears_flag(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator("#stale-banner button").click()
page.wait_for_url("**/curate**")
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert "curate" not in state.phase_stale
def test_exported_group_not_affected_by_back_nav(base_url, page, seed_state, flask_app):
"""Exporting then going back to triage must not touch the exported group."""
album_id = seed_state("phase6_state")
# Manually set one group to exported
with flask_app.app_context():
from app.state import load_state, save_state
state = load_state(album_id, flask_app)
state.groups[0].status = "exported"
save_state(state, flask_app)
# Navigate back
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].status == "exported"
def test_notes_autosave_survives_phase_navigation(base_url, page, seed_state, flask_app):
album_id = seed_state("phase2_state")
page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "survives navigation"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/curate?album_id={album_id}")
resp = page.request.get(f"{base_url}/notes/{album_id}")
assert resp.json()["notes"] == "survives navigation"
@@ -1,50 +0,0 @@
import pytest
from app.immich import ImmichClient
@pytest.fixture
def client(mock_immich):
return ImmichClient(
base_url=f"http://127.0.0.1:8099",
api_key="test-key",
)
def test_list_albums(client):
albums = client.list_albums()
assert len(albums) == 1
assert albums[0]["albumName"] == "Central Asia 2023"
def test_get_album(client):
album = client.get_album("album-1")
assert len(album["assets"]) == 3
def test_get_thumbnail_returns_bytes(client):
data = client.get_thumbnail("asset-1")
assert isinstance(data, bytes)
assert len(data) > 0
def test_get_original_returns_bytes(client):
data = client.get_original("asset-1")
assert isinstance(data, bytes)
def test_list_albums_connection_error_raises(monkeypatch):
client = ImmichClient(base_url="http://127.0.0.1:1", api_key="x")
with pytest.raises(ConnectionError):
client.list_albums()
def test_proxy_thumb_route(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(f"{base_url}/proxy/thumb/asset-1")
assert page.evaluate("document.contentType").startswith("image/")
def test_proxy_original_route(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(f"{base_url}/proxy/original/asset-1")
assert page.evaluate("document.contentType").startswith("image/")
@@ -1,40 +0,0 @@
import json
import pytest
def test_notes_save(base_url, page, seed_state):
album_id = seed_state("phase2_state")
resp = page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "hello memory"}),
headers={"Content-Type": "application/json"},
)
assert resp.ok
assert resp.json()["ok"] is True
def test_notes_persist_after_reload(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "persisted note"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator("#notes-panel").inner_text().__contains__("persisted note") or True
# Notes content is loaded from server state — verify via API response
resp = page.request.get(f"{base_url}/notes/{album_id}")
assert resp.json()["notes"] == "persisted note"
def test_nav_back_marks_stale(base_url, page, seed_state):
album_id = seed_state("phase4_state") # phase=group, completed=[triage,curate]
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
resp = page.request.get(f"{base_url}/state/{album_id}")
data = resp.json()
assert "curate" in data["phase_stale"]
assert "group" in data["phase_stale"]
@@ -1,30 +0,0 @@
def test_album_list_renders(base_url, page):
page.goto(base_url)
assert page.locator(".album-card").count() == 1
assert "Central Asia 2023" in page.inner_text(".album-card")
def test_select_single_album_creates_state(base_url, page):
page.goto(base_url)
page.locator(".album-card input[type=checkbox]").first.check()
page.fill("#grav-slug", "central-asia-2023")
page.locator("button[type=submit]").click()
page.wait_for_url("**/triage**")
assert "album_id=album-1" in page.url
def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(base_url)
assert page.locator("[data-album-id=album-1] .resume-badge").is_visible()
def test_immich_unreachable_shows_error(base_url, page, monkeypatch):
import app.routes.albums as a
orig = a.ImmichClient
class BrokenClient:
def __init__(self, *a, **k): pass
def list_albums(self): raise ConnectionError("down")
monkeypatch.setattr(a, "ImmichClient", BrokenClient)
page.goto(base_url)
assert page.locator(".alert-error").is_visible()
@@ -1,40 +0,0 @@
import json
def test_photos_render_in_day_groups(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator(".day-group").count() >= 1
assert page.locator(".photo-card").count() == 3
def test_keyboard_j_tags_journal(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("j")
page.wait_for_timeout(300)
card = page.locator(".photo-card").first
assert "border-success" in card.get_attribute("class")
def test_keyboard_s_tags_story(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("s")
page.wait_for_timeout(300)
assert "border-info" in page.locator(".photo-card").first.get_attribute("class")
def test_done_button_disabled_until_all_tagged(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator("#done-btn").is_disabled()
def test_done_advances_to_curate(base_url, page, seed_state):
album_id = seed_state("phase3_state") # all tagged
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/curate**")
@@ -1,40 +0,0 @@
import json
def test_only_kept_photos_shown(base_url, page, seed_state):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
# phase3_state has 2 kept (journal+story) and 1 skipped
assert page.locator(".photo-card").count() == 2
def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator(".remove-btn").first.click()
page.wait_for_timeout(300)
assert page.locator(".photo-card").count() == 1
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
removed = next(p for p in state.photos if p.id == "asset-1")
assert removed.tag == "skip"
def test_retag_journal_to_story(base_url, page, seed_state, flask_app):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator(".retag-btn").first.click()
page.wait_for_timeout(300)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
p = next(p for p in state.photos if p.id == "asset-1")
assert p.tag == "story"
def test_done_advances_to_group(base_url, page, seed_state):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/group**")
@@ -1,47 +0,0 @@
import json
def test_photos_shown_as_stream(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
assert page.locator(".stream-photo").count() == 2
def test_insert_divider_creates_group_boundary(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(300)
assert page.locator(".group-block").count() == 2
def test_remove_divider_merges_groups(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(200)
page.locator(".remove-divider-btn").first.click()
page.wait_for_timeout(200)
assert page.locator(".group-block").count() == 1
def test_label_edit_persists(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(200)
page.locator(".group-label").first.fill("Morning walk")
page.locator(".group-label").first.press("Enter")
page.wait_for_timeout(300)
page.reload()
assert "Morning walk" in page.locator(".group-label").first.input_value()
def test_done_advances_to_write(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/write**")
@@ -1,44 +0,0 @@
import json
def test_first_group_shown(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
assert page.locator(".group-photos img").count() >= 1
assert page.locator("#title-field").is_visible()
def test_form_autosave_on_input(base_url, page, seed_state, flask_app):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.fill("#title-field", "Arrival in Almaty")
page.wait_for_timeout(700)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].title == "Arrival in Almaty"
def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.locator("#mode-story").click()
assert page.locator("#hero-picker").is_visible()
assert not page.locator("#mode-journal-fields").is_visible() or True
def test_skip_defers_group(base_url, page, seed_state, flask_app):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.locator("#skip-btn").click()
page.wait_for_timeout(400)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].status == "skipped"
def test_notes_shown_inline_in_write_phase(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
assert page.locator("#inline-notes").is_visible()
@@ -1,74 +0,0 @@
import json
import shutil
from pathlib import Path
def test_summary_shows_written_and_skipped(base_url, page, seed_state):
album_id = seed_state("phase6_state")
page.goto(f"{base_url}/export?album_id={album_id}")
assert "1 journal" in page.inner_text("body").lower() or page.locator(".export-item").count() >= 1
assert page.locator(".skipped-list").is_visible()
def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
album_id = seed_state("phase6_state")
page.goto(f"{base_url}/export?album_id={album_id}")
page.locator("#export-btn").click()
page.wait_for_timeout(2000)
dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
def test_export_sets_status_exported(base_url, page, seed_state, flask_app, pages_dir):
album_id = seed_state("phase6_state")
# Ensure dest folder does not exist so export proceeds without conflict
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
if daily_dest.exists():
shutil.rmtree(daily_dest)
res = page.request.post(
f"{base_url}/export/run",
data=json.dumps({"album_id": album_id}),
headers={"Content-Type": "application/json"},
)
data = res.json()
# Must not be a conflict — export should succeed
assert data.get("ok") is True, f"Expected ok response, got: {data}"
# The journal entry.md file must exist on disk
entry_files = list(daily_dest.glob("**/entry.md")) if daily_dest.exists() else []
assert len(entry_files) >= 1, "entry.md not written to disk"
# Status must be exported in state
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
written = [g for g in state.groups if g.status not in ("skipped", "exported")]
assert len(written) == 0
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
album_id = seed_state("phase6_state")
# Clean dest so there's no conflict
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
if daily_dest.exists():
shutil.rmtree(daily_dest)
res = page.request.post(
f"{base_url}/export/run",
data=json.dumps({"album_id": album_id}),
headers={"Content-Type": "application/json"},
)
data = res.json()
# Response shape: {"ok": true, "exported": N, "failed": [...]}
# g2 "The Market" is skipped — it must not appear as an exported folder
stories_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "04.stories"
market_dirs = list(stories_dest.glob("*the-market*")) if stories_dest.exists() else []
assert len(market_dirs) == 0, "Skipped group 'The Market' must not be exported"
# And the response must not include a conflict (only written groups are exported)
assert data.get("ok") is True, f"Expected ok response, got: {data}"
@@ -1,3 +0,0 @@
def test_health(base_url, page):
page.goto(f"{base_url}/health")
assert "ok" in page.content()
@@ -1,52 +0,0 @@
import json
import pytest
from pathlib import Path
from app.state import TripState, Photo, Group, load_state, save_state
@pytest.fixture
def app_ctx(flask_app):
with flask_app.app_context():
yield flask_app
def test_save_and_load_roundtrip(app_ctx, state_dir):
state = TripState(
album_id="test-album",
album_name="Test",
grav_trip_slug="test-trip",
photos=[Photo(id="p1", original_filename="a.jpg",
local_datetime="2023-01-01T10:00:00")],
groups=[],
)
save_state(state, app_ctx)
loaded = load_state("test-album", app_ctx)
assert loaded.album_id == "test-album"
assert loaded.photos[0].id == "p1"
def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch):
written_paths = []
real_rename = __import__("os").rename
def fake_rename(src, dst):
written_paths.append(src)
real_rename(src, dst)
monkeypatch.setattr("app.state.os.rename", fake_rename)
state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x")
save_state(state, app_ctx)
assert any(str(p).endswith(".tmp") for p in written_paths)
def test_load_nonexistent_returns_none(app_ctx):
assert load_state("no-such-album", app_ctx) is None
def test_exported_status_field_preserved(app_ctx):
state = TripState(
album_id="export-test", album_name="E", grav_trip_slug="e",
groups=[Group(id="g1", photo_ids=[], entry_type="journal",
status="exported")]
)
save_state(state, app_ctx)
loaded = load_state("export-test", app_ctx)
assert loaded.groups[0].status == "exported"
+8
View File
@@ -13,6 +13,14 @@ module.exports = async function globalSetup() {
});
}
// Local test-account defaults (mirror the Makefile) so direct `npx playwright
// test` runs are self-contained without needing GRAV_TEST_* in .env.
if (!process.env.GRAV_TEST_USER) process.env.GRAV_TEST_USER = 'testrunner';
if (!process.env.GRAV_TEST_PASS) process.env.GRAV_TEST_PASS = 'Testpass1234';
// Ensure the local test account exists (idempotent; never committed).
execSync('make test-account', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
};
+2 -3
View File
@@ -69,7 +69,7 @@ test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false'
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
await page.goto('/trips/italy-2026-demo');
const strips = page.locator('.journal-photo-strip');
const count = await strips.count();
if (count === 0) return;
@@ -80,7 +80,7 @@ test('A4a: all photo strips have role=region and aria-label', async ({ page }) =
});
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
await page.goto('/trips/italy-2026-demo');
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
);
@@ -133,7 +133,6 @@ function axeScan(id, url) {
axeScan('AX1', '/');
axeScan('AX2', '/trips/italy-2026-demo');
axeScan('AX3', '/trips/italy-2026-demo/dailies');
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
axeScan('AX5', '/trips');
+17 -15
View File
@@ -12,17 +12,19 @@ const KNOWN_COUNTRY = 'Italy';
const NEWER_SLUG = '2023-10-18-hunting-the-mother-of-georgia-from-above.entry'; // newest date in that trip
const OLDER_SLUG = '2023-08-28-welcome-to-my-central-asian-picture-diary.entry'; // oldest date in that trip
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
// ── T1: Trip page journal feed loads ──────────────────────────────────────────
// The journal feed moved onto the trip page when the standalone /dailies view was retired.
test('T1: trip page loads and shows at least one journal entry card', async ({ page }) => {
await page.goto('/trips/italy-2026-demo');
await expect(page.locator('.journal-post').first()).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
// ── T2: Entries are newest-first ──────────────────────────────────────────────
// ── T2: Trip feed default order is oldest-first ───────────────────────────────
// The trip page sorts the feed oldest→newest by default (home uses newest-first).
// Verify using two known real entries from central-asia-2023 (22 entries, stable order).
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
await page.goto('/trips/central-asia-2023/dailies');
test('T2: trip feed shows older entries before newer entries (oldest-first default)', async ({ page }) => {
await page.goto('/trips/central-asia-2023');
// Use attribute selector to handle dots in slug names (CSS dots are class selectors)
const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`);
@@ -31,7 +33,7 @@ test('T2: dailies shows newer entries before older entries', async ({ page }) =>
await expect(newerCard).toBeVisible();
await expect(olderCard).toBeVisible();
// The newer entry should appear higher in the DOM (lower index)
// The older entry should appear higher in the DOM (lower index)
const newerIdx = await newerCard.evaluate(el => {
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
});
@@ -39,36 +41,36 @@ test('T2: dailies shows newer entries before older entries', async ({ page }) =>
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
});
expect(newerIdx).toBeLessThan(olderIdx);
expect(olderIdx).toBeLessThan(newerIdx);
});
// ── T3: Individual entry page loads ───────────────────────────────────────────
test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => {
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
await expect(page.locator('article.entry')).toBeVisible();
await expect(page.locator('article.journal-post')).toBeVisible();
await expect(page.locator('.site-header')).toBeVisible();
});
// ── T4: Entry page shows title, date, and content ─────────────────────────────
test('T4: entry page shows title and body content', async ({ page }) => {
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
await expect(page.locator('.entry-body')).not.toBeEmpty();
await expect(page.locator('time.entry-date')).toBeVisible();
await expect(page.locator('.journal-post-title')).toContainText(KNOWN_TITLE);
await expect(page.locator('.journal-post-body')).not.toBeEmpty();
await expect(page.locator('.journal-post-meta time')).toBeVisible();
});
// ── T5: Entry page shows location when present ────────────────────────────────
test('T5: entry page shows city and country when set', async ({ page }) => {
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
await expect(page.locator('.journal-post-location')).toContainText(KNOWN_CITY);
await expect(page.locator('.journal-post-location')).toContainText(KNOWN_COUNTRY);
});
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
await page.goto(KNOWN_ENTRY);
await expect(page.locator('article.entry')).toBeVisible();
await expect(page.locator('article.journal-post')).toBeVisible();
const topPill = page.locator('.entry-back-fixed');
await expect(topPill).toBeVisible();
await expect(topPill).toHaveText(/← Back/);
+4 -4
View File
@@ -1,13 +1,13 @@
// @ts-check
// Tests: G1G5 — buildJourneySegments algorithm correctness
// These tests load the italy-2026-demo map page (which has GPX) to get MapUtils in scope,
// then call the functions with synthetic data via page.evaluate.
// These tests load the italy-2026-demo trip page (which has GPX + the map bundle) to get
// MapUtils in scope, then call the functions with synthetic data via page.evaluate.
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
async function getMapUtils(page) {
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await page.goto('/trips/italy-2026-demo');
await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
}
// G1: No GPX → all pairs connected in one segment
+10 -9
View File
@@ -62,19 +62,20 @@ const USER_DIR = resolveUserDir();
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
/**
* The Grav route to the active dailies listing page,
* read from the post-form.md pageconfig.parent value.
* Falls back to '/trips/italy-2026-demo/dailies'.
* The Grav route to the active trip page, derived from the post-form.md
* pageconfig.parent value (the dailies container route, minus the trailing
* `/dailies`). Posted entries surface in this page's journal feed.
* Falls back to '/trips/italy-2026-demo'.
*/
function resolveActiveDailiesUrl() {
function resolveActiveTripUrl() {
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo/dailies';
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo';
const content = fs.readFileSync(postFormPath, 'utf-8');
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+\/dailies)['"]?/);
return m ? m[1] : '/trips/italy-2026-demo/dailies';
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+)\/dailies['"]?/);
return m ? m[1] : '/trips/italy-2026-demo';
}
const DAILIES_URL = resolveActiveDailiesUrl();
const ACTIVE_TRIP_URL = resolveActiveTripUrl();
/**
* Wait for all filepond items to finish XHR upload.
@@ -136,4 +137,4 @@ function readEntryMd(entryDir) {
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
}
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL };
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL };
+14 -11
View File
@@ -48,11 +48,13 @@ test('MUX3: trip page map has a fullscreen toggle button', async ({ page }) => {
await expect(fsBtn).toHaveAttribute('aria-label', 'Expand map');
});
// ── MUX4: Dailies sort toggle reverses entry order ───────────────────────────
test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
// ── MUX4: Trip feed sort toggle reverses entry order ─────────────────────────
// The sort toggle moved onto the trip page (#trip-sort-toggle) when the standalone
// /dailies feed view was retired.
test('MUX4: trip feed sort toggle reverses the feed entry order', async ({ page }) => {
await page.goto('/trips/italy-2026-demo');
const sortBtn = page.locator('#feed-sort-toggle');
const sortBtn = page.locator('#trip-sort-toggle');
await expect(sortBtn).toBeVisible();
const firstBefore = await page.locator('[data-type]').first().getAttribute('id');
@@ -67,21 +69,22 @@ test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page })
expect(firstRestored, 'Entry order restored after second toggle').toBe(firstBefore);
});
// ── MUX5: Stories sort toggle reverses story card order ────────────────────
test('MUX5: stories sort toggle reverses the story card order', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/stories');
// ── MUX5: Trip feed sort toggle reverses story card order ────────────────────
// Story cards live in the trip feed as [data-type="story"] (the /stories grid was retired).
test('MUX5: trip feed sort toggle reverses the story card order', async ({ page }) => {
await page.goto('/trips/italy-2026-demo');
const sortBtn = page.locator('#feed-sort-toggle');
const sortBtn = page.locator('#trip-sort-toggle');
await expect(sortBtn).toBeVisible();
const firstBefore = await page.locator('.story-card').first().getAttribute('id');
const firstBefore = await page.locator('[data-type="story"]').first().getAttribute('id');
await sortBtn.click();
const firstAfter = await page.locator('.story-card').first().getAttribute('id');
const firstAfter = await page.locator('[data-type="story"]').first().getAttribute('id');
expect(firstAfter, 'Story order reversed after sort').not.toBe(firstBefore);
await sortBtn.click();
const firstRestored = await page.locator('.story-card').first().getAttribute('id');
const firstRestored = await page.locator('[data-type="story"]').first().getAttribute('id');
expect(firstRestored, 'Story order restored after second toggle').toBe(firstBefore);
});
+5 -104
View File
@@ -1,38 +1,11 @@
// @ts-check
// Tests: M1M4 — MapLibre GL canvas renders on all three map surfaces
// Tests: M4, M7, M8 — MapLibre GL renders on the two live map surfaces (home + trip page).
// The standalone /map, /dailies mini-map and /stories mini-map surfaces were retired
// (see docs/working/plans/2026-07-04-standalone-page-cleanup.md); their coverage now
// lives on the trip and home maps, both driven by MapUtils.initEntryMap().
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
// ── M1: Full map page renders MapLibre canvas ─────────────────────────────────
test('M1: /map page renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on map page').toHaveLength(0);
});
// ── M2: Full map page — dot markers are in the DOM ───────────────────────────
test('M2: /map page has at least one dot marker', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Markers are added in map.on('load') — wait for first to appear in the DOM
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
const markerCount = await page.locator('.maplibregl-marker').count();
expect(markerCount, 'At least one marker present').toBeGreaterThan(0);
});
// ── M3: Dailies mini-map renders MapLibre canvas ─────────────────────────────
test('M3: Dailies mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/dailies');
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on dailies page').toHaveLength(0);
});
// ── M4: Home map renders MapLibre canvas ─────────────────────────────────────
test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
@@ -43,42 +16,6 @@ test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({
expect(errors, 'No JS errors on home page').toHaveLength(0);
});
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Wait for markers to confirm map.on('load') completed
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Give Promise.all time to resolve
await page.waitForTimeout(3000);
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
});
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/map');
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
await page.waitForFunction(function () {
return window.map &&
(window.map.getSource('journey') !== undefined ||
window.map.getSource('journey-0') !== undefined);
}, { timeout: 15000 });
const hasSource = await page.evaluate(function () {
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
});
expect(hasSource).toBe(true);
});
// ── M7: Clicking a trip-page map marker adds is-highlighted to the entry card ──
test('M7: clicking map marker briefly highlights the corresponding entry card', async ({ page }) => {
await page.goto('/trips/italy-2026-demo');
@@ -103,7 +40,7 @@ test('M7: clicking map marker briefly highlights the corresponding entry card',
// ── M8: Home map has GPX journey source on active trip ────────────────────────
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
// Requires travelling: true in user/config/site.yaml (set in Task 1).
// Requires travelling: true in user/config/site.yaml.
// Requires GPX files attached to the active trip (italy-2026-demo has 7).
const errors = [];
page.on('pageerror', e => errors.push(e.message));
@@ -125,39 +62,3 @@ test('M8: home map has a journey source after GPX settles (active trip)', async
expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
expect(errors, 'No JS errors on home page').toHaveLength(0);
});
// ── M9: Stories mini-map renders MapLibre canvas ──────────────────────────────
test('M9: Stories mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/stories');
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
expect(errors, 'No JS errors on stories page').toHaveLength(0);
});
// ── M10: Stories mini-map has at least one story marker ──────────────────────
test('M10: Stories mini-map has at least one story marker', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/stories');
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#stories-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
const markerCount = await page.locator('#stories-map .maplibregl-marker').count();
expect(markerCount, 'At least one story marker').toBeGreaterThan(0);
});
// ── M11: Dailies attribution control starts collapsed ─────────────────────────
test('M11: Dailies mini-map attribution starts collapsed (no open attribute)', async ({ page }) => {
await page.goto('/trips/italy-2026-demo/dailies');
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
// Wait for markers (added in map.on('load')) to ensure the load callback has run,
// which is also where removeAttribute('open') executes.
await expect(page.locator('#feed-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('#feed-map .maplibregl-ctrl-attrib')).toBeVisible({ timeout: 10000 });
const hasOpen = await page.evaluate(function () {
var attrib = document.querySelector('#feed-map .maplibregl-ctrl-attrib');
return attrib ? attrib.hasAttribute('open') : null;
});
expect(hasOpen, 'Attribution is collapsed (no open attribute)').toBe(false);
});
+10 -25
View File
@@ -1,47 +1,32 @@
// @ts-check
// Tests: N1N5 — page loads and navigation links
// Tests: N1N4 — page loads and navigation links.
// The standalone /map, /stats and /dailies views were retired; their content now
// lives on the trip page. N1/N2 are smoke tests for the two live landing surfaces.
const { test, expect } = require('@playwright/test');
// ── N1: /trips/italy-2026-demo/dailies renders ───────────────────────────────
test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => {
// ── N1: Trip page renders with site header ───────────────────────────────────
test('N1: trip page loads with site header and title', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/dailies');
await page.goto('/trips/italy-2026-demo');
await expect(page.locator('.site-header')).toBeVisible();
await expect(page).toHaveTitle(/Into the East/i);
expect(errors).toHaveLength(0);
});
// ── N2: /trips/italy-2026-demo/map renders without JS errors ─────────────────
test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => {
// ── N2: Home page renders without JS errors ──────────────────────────────────
test('N2: home page loads with site header and no JS errors', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/map');
await page.goto('/');
await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0);
});
// ── N3: /trips/italy-2026-demo/stats renders ────────────────────────────────
test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => {
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto('/trips/italy-2026-demo/stats');
await expect(page.locator('.site-header')).toBeVisible();
expect(errors).toHaveLength(0);
});
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
// ── N4: trip page has Journal filter button (replaced the old sub-page nav link)
test('N4: trip page filter bar has Journal button', async ({ page }) => {
await page.goto('/trips/italy-2026-demo');
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
});
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
await page.goto('/');
await page.click('nav a[href*="map"]');
await expect(page).toHaveURL(/\/map/);
});
+5 -5
View File
@@ -4,7 +4,7 @@
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL } = require('../helpers');
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers');
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
@@ -16,7 +16,7 @@ test.afterAll(() => {
});
// ── P1: Post without photo ─────────────────────────────────────────────────────
test('P1: post text-only entry → created on disk and visible on /dailies', async ({ page }) => {
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => {
const tag = `p1-${Date.now()}`;
const title = `UI Test ${tag}`;
@@ -39,12 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /dailies', asy
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
await page.goto(DAILIES_URL);
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('body')).toContainText(tag);
});
// ── P2: Post with photo ────────────────────────────────────────────────────────
test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
const tag = `p2-${Date.now()}`;
const title = `UI Test ${tag}`;
@@ -70,7 +70,7 @@ test.skip('P2: post entry with photo → photo saved in entry folder and visible
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
await page.goto(DAILIES_URL);
await page.goto(ACTIVE_TRIP_URL);
await expect(page.locator('body')).toContainText(tag);
});
+13 -13
View File
@@ -3,15 +3,15 @@
// Requires demo data: run `make demo-load` before this suite.
const { test, expect } = require('@playwright/test');
const STORIES_URL = '/trips/italy-2026-demo/stories';
const TRIP_URL = '/trips/italy-2026-demo'; // stories now surface in the trip feed
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // used for cross-trip hero sanity check
// ── S1: Stories listing shows cards ──────────────────────────────────────────
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
await page.goto(STORIES_URL);
const cards = page.locator('.story-card');
// ── S1: Trip feed shows story cards ──────────────────────────────────────────
test('S1: trip feed renders at least 3 story cards', async ({ page }) => {
await page.goto(TRIP_URL);
const cards = page.locator('[data-type="story"]');
await expect(cards.first()).toBeVisible({ timeout: 5000 });
const count = await cards.count();
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
@@ -55,16 +55,16 @@ test('S4: scrolly story page loads without JS errors', async ({ page }) => {
expect(errors, 'No JS errors on story page').toHaveLength(0);
});
// ── S5: Back button returns to stories listing ────────────────────────────────
test('S5: back button navigates back to stories listing', async ({ page }) => {
// Establish history: listing → story → back
await page.goto(STORIES_URL);
await page.locator('.story-card').first().click();
// ── S5: Back button returns to the originating trip page ─────────────────────
test('S5: story back button navigates back to the trip page', async ({ page }) => {
// Establish history: trip page → story → back
await page.goto(TRIP_URL);
await page.locator('[data-type="story"]').first().click();
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
await page.locator('.story-escape').click();
// After history.back(), URL should be the stories listing
await expect(page).toHaveURL(/italy-2026-demo\/stories$/);
await expect(page.locator('.story-card').first()).toBeVisible();
// story-escape runs history.back() when history exists → back to the trip page
await expect(page).toHaveURL(/\/trips\/italy-2026-demo$/);
await expect(page.locator('.journal-post').first()).toBeVisible();
});
// ── S6: Demo story — hero image sanity check ─────────────────────────────────
+1 -1
Submodule user updated: 31f3c6fb2f...924cfc18e2