Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
209b804423 | ||
|
|
20df900188 | ||
|
|
c4891d8f60 | ||
|
|
793ca10d4d | ||
|
|
2e96106c84 | ||
|
|
ceb0570c86 | ||
|
|
877d29b2b4 | ||
|
|
fa6550a232 | ||
|
|
838f237ef5 | ||
|
|
5276768e12 | ||
|
|
44c3a1c32e | ||
|
|
b752178eb4 | ||
|
|
af07ef403c | ||
|
|
06f4c25631 | ||
|
|
6a73be3e49 | ||
|
|
e10496afe7 | ||
|
|
d576487886 | ||
|
|
86f9018f73 | ||
|
|
7ea90de12b | ||
|
|
f4dbac6fc2 | ||
|
|
d3c17791b7 | ||
|
|
7534d7d178 | ||
|
|
9295914238 | ||
|
|
8441ce392d | ||
|
|
3cb7dfbd8b | ||
|
|
bb7f4a02ef | ||
|
|
1cf2d12bc7 | ||
|
|
10f990e0e7 | ||
|
|
7329852497 | ||
|
|
c56265824b | ||
|
|
06d9629075 | ||
|
|
7d7346305d | ||
|
|
fb7b6db1b1 | ||
|
|
f0a8895b78 | ||
|
|
4428ef6c42 | ||
|
|
b0cb67a079 | ||
|
|
c4bee49fc3 | ||
|
|
c76c16b06d | ||
|
|
45c2d54d2b | ||
|
|
edb1c7659c | ||
|
|
0f88ec4694 | ||
|
|
d19a5802ae | ||
|
|
ab5db71f35 | ||
|
|
b3d3a8e8b8 | ||
|
|
27a35a1db8 | ||
|
|
421c21345e |
@@ -2,6 +2,8 @@
|
||||
.env
|
||||
.env.prod
|
||||
.env.test
|
||||
# Per-worktree dev-server identity, written by `make worktree-new`
|
||||
.worktree-env
|
||||
|
||||
# Grav CMS
|
||||
/user/
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
- **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`).
|
||||
@@ -36,7 +34,7 @@ The site is structured around Trip entities. Key facts:
|
||||
- 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`
|
||||
- New journal entries are written to the active trip's `dailies` — the write target is derived from `site.active_trip` at submit time by the `cache-on-save` plugin (post-form.md no longer hardcodes `pageconfig.parent`)
|
||||
- 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
|
||||
@@ -92,6 +90,9 @@ The home page's active-trip view and the trip page render the **same feed-col ch
|
||||
| `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) |
|
||||
| `trip_header_extras` | bool | `true` | not passed (defaults `false`) |
|
||||
|
||||
`trip_header_extras` gates the trip-page-only header block (one-liner `.home-trip-tagline`, expandable `.trip-header-desc`, and `.trip-header-banner` cover strip) that renders between the counts and the filter bar. `trip.html.twig` passes `true`; `home.html.twig` omits it so those extras never leak onto the home route (the `only` include keeps it off by default).
|
||||
|
||||
`home-predeparture` takes only `trip_page`.
|
||||
|
||||
@@ -115,12 +116,11 @@ To add GPX files without the browser UI, drop them directly into `user/pages/01.
|
||||
|
||||
### Switching to a new trip
|
||||
|
||||
Two places hardcode the active trip slug. Grav's config and page frontmatter are static YAML — no variable substitution is possible, so these cannot read from `site.yaml` automatically. **Both must be updated together** when starting a new trip, or entries will be posted to the wrong folder.
|
||||
The active trip lives in **one** place now: `site.active_trip`. The post form no longer hardcodes a `pageconfig.parent` — the `cache-on-save` plugin derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so there is nothing to keep in sync.
|
||||
|
||||
| File | Key | Example value |
|
||||
|---|---|---|
|
||||
| `user/config/site.yaml` | `active_trip` | `italy-2027` |
|
||||
| `user/pages/02.post/post-form.md` | `pageconfig.parent` | `/trips/italy-2027/dailies` |
|
||||
| File | Key | Example value | How to edit |
|
||||
|---|---|---|---|
|
||||
| `user/config/site.yaml` | `active_trip` | `/trips/italy-2027` | Admin → Configuration → Site → **Active Trip** (page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`) |
|
||||
|
||||
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
|
||||
|
||||
@@ -155,7 +155,7 @@ Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `ac
|
||||
- **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`.
|
||||
- **Worktrees for parallel work — use the make targets, don't do it by hand.** `make worktree-new NAME=<feature>` (from the main checkout) creates the outer worktree off `main`, initialises its own `user/` submodule, branches both, and starts an **isolated** dev server (own container name + auto-assigned port `8090+`, persisted in a git-ignored `.worktree-env` so every `make`/compose command in that worktree targets its own server). `make worktree-rm NAME=<feature>` tears it down cleanly (compose down → `submodule deinit` → `worktree remove` → `prune`) — skipping the deinit is what leaves orphaned `.worktrees/` dirs. Worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). A fresh worktree's `user/` is empty until the submodule init runs, and `M user`/`m user` is normal (see above) — do not "fix" either. 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
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
# for ${UID}/${GID} substitution and the travel-memories env_file.)
|
||||
-include .env
|
||||
|
||||
# Per-worktree dev-server identity, written by `make worktree-new` into the new
|
||||
# worktree only (git-ignored). Absent in the main checkout, so the defaults below
|
||||
# apply there. Loaded here so every local target + compose call in a worktree
|
||||
# targets that worktree's own container and ports.
|
||||
-include .worktree-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.
|
||||
@@ -27,7 +33,9 @@ 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-diag remote-apply-env \
|
||||
remote-gpm-install remote-maintenance-on remote-maintenance-off
|
||||
remote-seed-api-salt remote-secrets-audit \
|
||||
remote-gpm-install remote-maintenance-on remote-maintenance-off \
|
||||
remote-apply-plugin-patches
|
||||
ENVS := test prod
|
||||
|
||||
guard-env:
|
||||
@@ -47,7 +55,7 @@ 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 \
|
||||
@docker exec $(GRAV_CONTAINER) 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'
|
||||
|
||||
@@ -64,6 +72,20 @@ test: test-config test-post test-ui
|
||||
|
||||
# ── Local dev ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Dev-server identity. Defaults are the main checkout's canonical values; a
|
||||
# worktree's .worktree-env (above) overrides them so servers never collide.
|
||||
# Exported (via the top-of-file `export`) so `docker compose` picks them up.
|
||||
GRAV_CONTAINER ?= intotheeast_grav
|
||||
GRAV_PORT ?= 8081
|
||||
TM_PORT ?= 8082
|
||||
|
||||
# The container boots as root (the base image entrypoint needs it to bind :80
|
||||
# and set up cron), so a bare `docker exec` runs as root and any file it writes
|
||||
# into the ./user bind mount is root-owned on the host. Run the file-CREATING
|
||||
# CLI commands as the host user instead, so their output belongs to you.
|
||||
HOST_UID := $(shell id -u)
|
||||
HOST_GID := $(shell id -g)
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
@@ -76,42 +98,111 @@ build-assets:
|
||||
start:
|
||||
docker compose up -d
|
||||
|
||||
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||
# travel-memories service, and this keeps its footprint minimal).
|
||||
start-grav:
|
||||
docker compose up -d grav
|
||||
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
setup: build start install-plugins fix-perms
|
||||
|
||||
fix-perms:
|
||||
docker exec intotheeast_grav bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
|
||||
docker exec intotheeast_grav chown -R 1000:1000 /var/www/html
|
||||
docker exec intotheeast_grav apachectl graceful
|
||||
docker exec $(GRAV_CONTAINER) bash -c "getent passwd 1000 > /dev/null || useradd -u 1000 -M hostuser"
|
||||
docker exec $(GRAV_CONTAINER) chown -R 1000:1000 /var/www/html
|
||||
docker exec $(GRAV_CONTAINER) apachectl graceful
|
||||
|
||||
|
||||
install-plugins:
|
||||
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
# cache/ and tmp/ are root-owned in the image, so make them writable first
|
||||
# (container-internal chown — never touches the host) so gpm can run AS YOU.
|
||||
docker exec $(GRAV_CONTAINER) chown -R $(HOST_UID):$(HOST_GID) /var/www/html/cache /var/www/html/tmp
|
||||
# gpm runs as the host user, so the plugins it writes into ./user/plugins are
|
||||
# owned by you, not root — no post-hoc chown, no root files to clean up later.
|
||||
docker exec -u $(HOST_UID):$(HOST_GID) -w /var/www/html $(GRAV_CONTAINER) php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
$(MAKE) apply-plugin-patches
|
||||
|
||||
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins. Run this
|
||||
# AFTER install-plugins (which overwrites them). See deploy/patches/README.md.
|
||||
apply-plugin-patches:
|
||||
@for p in deploy/patches/*.patch; do \
|
||||
[ -f "$$p" ] || continue; \
|
||||
if git apply --check "$$p" >/dev/null 2>&1; then \
|
||||
git apply "$$p" && echo "applied $$p"; \
|
||||
else \
|
||||
echo "skipped $$p (already applied or does not match)"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# ── Worktrees ─────────────────────────────────────────────────────────────────
|
||||
# Isolated outer-repo worktree + its own user/ submodule checkout + its own dev
|
||||
# server (distinct container name & ports), for long-running feature work that
|
||||
# runs in parallel with the main checkout without collisions. Encodes the full
|
||||
# SOP from docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md
|
||||
# so no step (submodule init, per-server isolation, clean teardown) is skipped.
|
||||
#
|
||||
# make worktree-new NAME=my-feature [PORT=8090] # create branch + start server
|
||||
# make worktree-rm NAME=my-feature # tear down cleanly
|
||||
#
|
||||
# Run both from the MAIN checkout. After worktree-new, `cd .worktrees/<name>`
|
||||
# and use make as normal — it targets that worktree's own server automatically.
|
||||
|
||||
WT_DIR = .worktrees/$(NAME)
|
||||
|
||||
guard-name:
|
||||
@test -n "$(NAME)" || { echo "ERROR: set NAME=, e.g. 'make worktree-new NAME=my-feature'."; exit 1; }
|
||||
|
||||
worktree-new: guard-name
|
||||
@test ! -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) already exists."; exit 1; }
|
||||
git worktree add "$(WT_DIR)" -b feat/$(NAME) main
|
||||
git -C "$(WT_DIR)" submodule update --init user
|
||||
git -C "$(WT_DIR)/user" checkout -b feat/$(NAME)
|
||||
@port=$${PORT:-$$(for p in $$(seq 8090 8099); do \
|
||||
docker ps --format '{{.Ports}}' | grep -q ":$$p->" || { echo $$p; break; }; \
|
||||
done)}; \
|
||||
test -n "$$port" || { echo "ERROR: no free port in 8090-8099; pass PORT= explicitly."; exit 1; }; \
|
||||
printf 'COMPOSE_PROJECT_NAME=itte-%s\nGRAV_CONTAINER=itte_%s_grav\nGRAV_PORT=%s\nTM_PORT=%s\n' \
|
||||
"$(NAME)" "$(NAME)" "$$port" "$$((port + 100))" > "$(WT_DIR)/.worktree-env"; \
|
||||
echo "→ starting this worktree's Grav dev server on http://localhost:$$port"; \
|
||||
$(MAKE) -C "$(WT_DIR)" start-grav
|
||||
@echo "Worktree ready: $(WT_DIR) (outer + user/ on branch feat/$(NAME))"
|
||||
|
||||
worktree-rm: guard-name
|
||||
@test -e "$(WT_DIR)" || { echo "ERROR: $(WT_DIR) does not exist."; exit 1; }
|
||||
-$(MAKE) -C "$(WT_DIR)" stop
|
||||
-git -C "$(WT_DIR)" submodule deinit -f user
|
||||
git worktree remove --force "$(WT_DIR)"
|
||||
git worktree prune
|
||||
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
||||
|
||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||
|
||||
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/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/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/ && \
|
||||
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/*.gpx /var/www/html/user/pages/01.trips/italy-2026-demo/ 2>/dev/null || true && \
|
||||
chown -R 1000:1000 /var/www/html/user/pages/01.trips/italy-2026-demo && \
|
||||
cd /var/www/html && php bin/grav clearcache"
|
||||
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||
cp "$$src/trip.md" "$$dst/trip.md" 2>/dev/null || true; \
|
||||
cp "$$src/stories.md" "$$dst/04.stories/stories.md" 2>/dev/null || true; \
|
||||
cp -r "$$src/04.stories/." "$$dst/04.stories/" 2>/dev/null || true; \
|
||||
cp -r "$$src/dailies/." "$$dst/01.dailies/" 2>/dev/null || true; \
|
||||
cp "$$src"/*.gpx "$$dst/" 2>/dev/null || true; \
|
||||
chown -R 1000:1000 "$$dst"; \
|
||||
done; cd /var/www/html && php bin/grav clearcache'
|
||||
|
||||
demo-reset:
|
||||
docker exec intotheeast_grav bash -c "rm -rf /var/www/html/user/pages/01.trips/italy-2026-demo && cd /var/www/html && php bin/grav clearcache"
|
||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||
rm -rf /var/www/html/user/pages/01.trips/$$(basename "$$src"); \
|
||||
done; cd /var/www/html && php bin/grav clearcache'
|
||||
|
||||
pixelfed-import:
|
||||
docker exec intotheeast_grav bash -c "which python3 || apt-get install -y python3 --no-install-recommends -q"
|
||||
docker cp /home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json intotheeast_grav:/tmp/pixelfed-statuses.json
|
||||
docker cp scripts/pixelfed-import.py intotheeast_grav:/tmp/pixelfed-import.py
|
||||
docker exec -w /var/www/html intotheeast_grav python3 /tmp/pixelfed-import.py
|
||||
docker exec $(GRAV_CONTAINER) bash -c "which python3 || apt-get install -y python3 --no-install-recommends -q"
|
||||
docker cp /home/mischa/Nextcloud/Downloads/pixelfed/pixelfed-statuses.json $(GRAV_CONTAINER):/tmp/pixelfed-statuses.json
|
||||
docker cp scripts/pixelfed-import.py $(GRAV_CONTAINER):/tmp/pixelfed-import.py
|
||||
docker exec -w /var/www/html $(GRAV_CONTAINER) python3 /tmp/pixelfed-import.py
|
||||
|
||||
# ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
|
||||
|
||||
@@ -159,9 +250,24 @@ remote-fetch-content: guard-env
|
||||
|
||||
remote-install-plugins: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
||||
$(MAKE) remote-apply-plugin-patches
|
||||
|
||||
remote-update-plugins: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||
$(MAKE) remote-apply-plugin-patches
|
||||
|
||||
# Re-apply local fixes to git-ignored, GPM-managed third-party plugins on the
|
||||
# remote (pristine after a GPM install/update). Piped over SSH like the git-sync
|
||||
# scripts — no scp. `--forward` makes it a no-op when already applied. Runs
|
||||
# automatically after remote-install-plugins / remote-update-plugins; safe to run
|
||||
# standalone. See deploy/patches/README.md.
|
||||
remote-apply-plugin-patches: guard-env
|
||||
@for p in deploy/patches/*.patch; do \
|
||||
[ -f "$$p" ] || continue; \
|
||||
echo "remote-apply $$p"; \
|
||||
$(SSH) "cd $(WEBROOT) && patch -p1 --forward -r - --no-backup-if-mismatch" < "$$p" || echo " (already applied or no-op)"; \
|
||||
done
|
||||
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||
|
||||
remote-upgrade-grav: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||
@@ -173,7 +279,7 @@ remote-git-sync-enable: guard-env
|
||||
$(SSH) "bash -s -- '$(WEBROOT)' true" < scripts/git-sync-toggle.sh
|
||||
|
||||
remote-content-status: guard-env
|
||||
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
||||
$(SSH) "cd $(WEBROOT)/user && echo '--- HEAD ---' && git log -1 --oneline && echo '--- working tree ---' && git status --short && echo '--- config diff ---' && git diff -- config/ && echo '--- .gitignore diff ---' && git diff -- .gitignore"
|
||||
|
||||
remote-clean: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||
@@ -197,6 +303,31 @@ remote-apply-env: guard-env
|
||||
$(SSH) "mkdir -p $(WEBROOT)/user/env/$$host/config && cat > $(WEBROOT)/user/env/$$host/config/system.yaml && cd $(WEBROOT) && php bin/grav clearcache" < deploy/env/$(ENV)/system.yaml; \
|
||||
echo "Applied deploy/env/$(ENV)/system.yaml -> $(WEBROOT)/user/env/$$host/config/system.yaml"
|
||||
|
||||
# Seed a per-host popularity salt into the env override tree so the api plugin
|
||||
# reads it there instead of appending one to the git-tracked config/plugins/
|
||||
# api.yaml. That appended salt kept the content working tree dirty, which broke
|
||||
# git-sync's auto-merge on webhook. Salt is generated server-side and never
|
||||
# committed (a committed salt would be globally known). Idempotent: an existing
|
||||
# salt is kept, so re-running never rotates it.
|
||||
remote-seed-api-salt: guard-env
|
||||
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
|
||||
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
|
||||
$(SSH) "set -e; \
|
||||
envfile=$(WEBROOT)/user/env/$$host/config/plugins/api.yaml; \
|
||||
mkdir -p \$$(dirname \"\$$envfile\"); \
|
||||
if grep -qE '^[[:space:]]*salt:' \"\$$envfile\" 2>/dev/null; then \
|
||||
echo \"salt already present in \$$envfile — keeping it\"; \
|
||||
else \
|
||||
salt=\$$(openssl rand -hex 32); \
|
||||
printf 'popularity:\n salt: %s\n' \"\$$salt\" > \"\$$envfile\"; \
|
||||
echo \"seeded new per-host salt into \$$envfile\"; \
|
||||
fi; \
|
||||
git -C $(WEBROOT)/user checkout -- config/plugins/api.yaml 2>/dev/null || true; \
|
||||
cd $(WEBROOT) && php bin/grav clearcache >/dev/null 2>&1 || true; \
|
||||
echo '--- base api.yaml status (expect clean) ---'; \
|
||||
git -C $(WEBROOT)/user status --short config/plugins/api.yaml; \
|
||||
echo '(if the line above is empty, the tree is clean)'"
|
||||
|
||||
# Read-only health check: plugin install state, versions, key config, log tail.
|
||||
remote-diag: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && \
|
||||
@@ -209,6 +340,16 @@ remote-diag: guard-env
|
||||
echo '=== git-sync config (secrets redacted) ==='; grep -vaiE 'password|token|secret' user/config/plugins/git-sync.yaml user/env/*/config/plugins/git-sync.yaml 2>/dev/null; \
|
||||
echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null"
|
||||
|
||||
# Secret-safe audit: lists WHERE per-host secret/config files live (config/ vs
|
||||
# env/<host>/config/) and their sizes — never prints contents. Used to decide
|
||||
# whether a `reset --hard` would clobber a live runtime secret.
|
||||
remote-secrets-audit: guard-env
|
||||
$(SSH) "cd $(WEBROOT)/user && \
|
||||
echo '=== tracked in git? (git ls-files) ==='; git ls-files config/security-private.php config/security.yaml config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml; \
|
||||
echo '=== config/ copies (size only) ==='; ls -la config/security.yaml config/security-private.php config/versions.yaml config/plugins/api-private.php config/plugins/git-sync.yaml 2>&1; \
|
||||
echo '=== env/<host>/config copies (size only) ==='; ls -la env/*/config/security.yaml env/*/config/security-private.php env/*/config/plugins/api-private.php env/*/config/plugins/git-sync.yaml 2>&1; \
|
||||
echo '=== does security.yaml reference the private php? (key names only) ==='; grep -aoE '^[a-z_]+:' config/security.yaml 2>/dev/null; for f in env/*/config/security.yaml; do echo \"\$$f:\"; grep -aoE '^[a-z_]+:' \"\$$f\" 2>/dev/null; done; true"
|
||||
|
||||
remote-maintenance-on: guard-env
|
||||
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Local plugin patches
|
||||
|
||||
Patches for **third-party, GPM-managed plugins** that live under
|
||||
`user/plugins/` — which is **git-ignored** (see `user/.gitignore`), so these
|
||||
edits do **not** travel with the content repo and are **overwritten by
|
||||
`make install-plugins`** / a fresh image build. Keep the fix here (tracked) and
|
||||
re-apply it after any plugin (re)install, until the plugin is forked upstream.
|
||||
|
||||
### Local (dev)
|
||||
|
||||
```sh
|
||||
make apply-plugin-patches # git apply, idempotent (skips if applied)
|
||||
```
|
||||
|
||||
`make install-plugins` runs this automatically as its last step.
|
||||
|
||||
### Remote (test / prod)
|
||||
|
||||
```sh
|
||||
make remote-apply-plugin-patches-test
|
||||
make remote-apply-plugin-patches-prod
|
||||
```
|
||||
|
||||
Each patch is piped over SSH into `patch -p1 --forward` at the webroot (no scp),
|
||||
so it is a no-op when already applied. **Runs automatically** as the last step of
|
||||
`remote-install-plugins-*` and `remote-update-plugins-*` — GPM lays down pristine
|
||||
plugins, so the patch must follow every GPM install/update. Content pulls
|
||||
(git-sync / `remote-fetch-content`) do **not** touch `user/plugins/`, so the patch
|
||||
survives ordinary content syncs. Requires the `patch` tool on the server.
|
||||
|
||||
Verify a patch is live on a server:
|
||||
`grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` (≥1 = applied).
|
||||
|
||||
## add-page-by-form-grav2-header.patch
|
||||
|
||||
Fixes a fatal when **adding a new photo while editing an entry** (front-end
|
||||
journal edit, milestone M2 / R9).
|
||||
|
||||
- **Plugin:** `add-page-by-form` 3.3.0 (abandoned upstream — last release Sept 2023).
|
||||
- **Bug:** the edit-mode branch reads existing frontmatter with
|
||||
`(array)$pages->get($folder)->header()`. On Grav 2.0 `header()` returns a
|
||||
`Grav\Common\Page\Header` object whose data sits in a **protected** `items`
|
||||
property, so the `(array)` cast produces mangled keys (`\0*\0items`) and
|
||||
`$original_frontmatter['photos']` is never set → `array_merge(null, …)`
|
||||
throws a `TypeError` (PHP 8) on any edit that uploads a new file.
|
||||
- **Fix:** use `Header::toArray()` (clean keys) with a fallback to the cast for
|
||||
classic stdClass headers, and guard the per-field merge against a
|
||||
missing/non-array original.
|
||||
|
||||
Remove this patch once `add-page-by-form` is forked and the fix lands in the
|
||||
fork (then pin the fork instead of the GPM package).
|
||||
@@ -0,0 +1,38 @@
|
||||
--- a/b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 12:03:55.849015242 +0200
|
||||
+++ b/user/plugins/add-page-by-form/add-page-by-form.php 2026-07-05 11:55:06.175609339 +0200
|
||||
@@ -619,7 +619,19 @@
|
||||
if ($overwrite_mode !== 'false') {
|
||||
if (file_exists($new_page_folder)) {
|
||||
if ($overwrite_mode === 'edit') {
|
||||
- $original_frontmatter = (array)$pages->get($new_page_folder)->header();
|
||||
+ // intotheeast patch (temporary, pending upstream fork):
|
||||
+ // On Grav 2.0 header() returns a Grav\Common\Page\Header
|
||||
+ // object whose data sits in a PROTECTED `items` property,
|
||||
+ // so the original `(array)$header` yields mangled keys
|
||||
+ // (\0*\0items) and every frontmatter lookup below misses —
|
||||
+ // `array_merge($original_frontmatter['photos'], …)` then
|
||||
+ // fatals under PHP 8. Use toArray() (clean keys) when the
|
||||
+ // Header exposes it; fall back to the cast for a plain
|
||||
+ // stdClass (classic pages).
|
||||
+ $__header = $pages->get($new_page_folder)->header();
|
||||
+ $original_frontmatter = (is_object($__header) && method_exists($__header, 'toArray'))
|
||||
+ ? $__header->toArray()
|
||||
+ : (array)$__header;
|
||||
} else {
|
||||
Folder::delete($new_page_folder);
|
||||
}
|
||||
@@ -708,7 +720,13 @@
|
||||
|
||||
$file_fields_updated = array();
|
||||
foreach ($file_fields as $file_field => $uploads) {
|
||||
- $file_fields_updated[$file_field] = array_merge($original_frontmatter[$file_field], $uploads);
|
||||
+ // intotheeast patch: entries that render from folder-scanned
|
||||
+ // media carry no matching frontmatter key, so fall back to []
|
||||
+ // rather than fatal array_merge() on a missing/null original.
|
||||
+ $existing = (isset($original_frontmatter[$file_field]) && is_array($original_frontmatter[$file_field]))
|
||||
+ ? $original_frontmatter[$file_field]
|
||||
+ : array();
|
||||
+ $file_fields_updated[$file_field] = array_merge($existing, $uploads);
|
||||
|
||||
// Get any (uploaded and then) deleted files
|
||||
foreach ($copy_files['deleted'] as $file_to_delete) {
|
||||
+5
-3
@@ -1,13 +1,15 @@
|
||||
services:
|
||||
grav:
|
||||
build: .
|
||||
container_name: intotheeast_grav
|
||||
# Overridable so a git worktree can run its own isolated dev server (see
|
||||
# `make worktree-new`); unset → the canonical main-checkout values below.
|
||||
container_name: ${GRAV_CONTAINER:-intotheeast_grav}
|
||||
environment:
|
||||
- GRAV_CHANNEL=production
|
||||
- APACHE_RUN_USER=#1000
|
||||
- APACHE_RUN_GROUP=#1000
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "${GRAV_PORT:-8081}:80"
|
||||
volumes:
|
||||
- ./user:/var/www/html/user
|
||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||
@@ -16,7 +18,7 @@ services:
|
||||
travel-memories:
|
||||
build: ./services/travel-memories
|
||||
ports:
|
||||
- "8082:8082"
|
||||
- "${TM_PORT:-8082}:8082"
|
||||
volumes:
|
||||
- ./docs/immich-workflow:/app/state
|
||||
- ./user/pages:/app/pages
|
||||
|
||||
@@ -70,18 +70,20 @@ servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebui
|
||||
```
|
||||
make remote-fetch-content-test # 1. clean-reset synced folders to repo state
|
||||
make remote-upgrade-grav-test # 2. gpm self-upgrade (rewrites schema — expect drift)
|
||||
make remote-update-plugins-test # 3. gpm update the plugins.txt set
|
||||
make remote-update-plugins-test # 3. gpm update the plugins.txt set (auto-applies deploy/patches/)
|
||||
make remote-gpm-install-test PKG=git-sync # 4. EXPLICITLY (re)install each remote-only plugin
|
||||
make remote-apply-env-test # 5. re-deploy the env override (not synced; gone after install)
|
||||
```
|
||||
|
||||
Why each matters:
|
||||
- **Step 3** re-applies `deploy/patches/*.patch` automatically (it chains `remote-apply-plugin-patches`). GPM install/update lays down **pristine** third-party plugins, wiping local fixes to git-ignored `user/plugins/` — the patch step restores them. Content pulls (step 1) do **not** touch `plugins/`, so the patch only needs re-applying after a GPM op, not after every sync. Run `make remote-apply-plugin-patches-test` standalone if you ever GPM-install outside this sequence. Requires the `patch` tool on the server. See `deploy/patches/README.md`.
|
||||
- **Step 4** is non-optional even if git-sync "was already there" — remote-only plugins are not in `plugins.txt`, so nothing in steps 1–3 restores them. If the code is missing, the plugin is inert despite valid config.
|
||||
- **Step 5** re-writes `user/env/<host>/config/…` from `deploy/env/<env>/`. The env tree is not synced by anything, so a fresh install loses it until you re-apply.
|
||||
|
||||
### Verify (smoke checklist — this is the payoff)
|
||||
|
||||
- **Code present, not just config:** `ls user/plugins/<name>/` for every expected plugin (especially `git-sync`). An empty/absent dir = reinstall (step 4). *(Do this via an ssh one-liner you run, or `make remote-diag-test`.)*
|
||||
- **Plugin patches applied:** confirm the add-page-by-form fix survived the GPM op — `grep -c toArray user/plugins/add-page-by-form/add-page-by-form.php` should be ≥1 (0 = pristine, re-run `make remote-apply-plugin-patches-test`). Functional check: edit a journal entry and add a photo — a pristine plugin 500s on save.
|
||||
- **HTTP:** `/` → 200, `/admin` → 200, `/api/v1/pages` → 401, `/gpx-manager` → 200. Watch for the double-`Content-Encoding` garbage page (fix: `debugger.shutdown.close_connection: false` in the env override — already in `deploy/env/prod/system.yaml`).
|
||||
- **Post smoke test:** submit one entry via `/post` and confirm it appears in the trip feed immediately. This proves the `cache-on-save` plugin works with prod caching on.
|
||||
- **Config drift:** `make remote-diag-test` — diff server config against the repo. Fold any *intended* schema migration (e.g. the Twig-3 `strict_mode` flags a `self-upgrade` writes) back into `user/config/system.yaml`, or the next `fetch-content` reverts it.
|
||||
|
||||
+76
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)"
|
||||
date: 2026-07-05
|
||||
last_updated: 2026-07-05
|
||||
module: git-sync
|
||||
problem_type: architecture_pattern
|
||||
component: tooling
|
||||
@@ -174,6 +175,69 @@ committing looks clean locally, but Grav re-appends it at runtime and the next
|
||||
sync re-commits and re-pushes it. The only durable fixes are the
|
||||
companion-private-file pattern or disabling the feature.
|
||||
|
||||
### Untracking an already-committed secret under a live sync — freeze every server first
|
||||
|
||||
`.gitignore` (and git-sync's `ignore` field) only affects **untracked** files.
|
||||
Once a secret has actually been *committed* to the shared repo, ignoring it does
|
||||
nothing — removing it means rewriting history. Doing that while a bidirectional
|
||||
sync is live has its own trap.
|
||||
|
||||
**A `direction: both` server silently reverts your force-push.** Concrete
|
||||
incident (2026-07-05, a second occurrence of this doc's trap): `config/security-private.php`
|
||||
and `config/versions.yaml` had been committed to Gitea `main` (auto-commit
|
||||
`d1643a7`, merged at `f8c45fc`). Force-pushing `main` back to the clean commit
|
||||
`32c3d8c` *looked* successful — and within seconds Gitea was back at `f8c45fc`.
|
||||
Cause: prod's git-sync is `direction: both` and its local `HEAD` was still
|
||||
`f8c45fc`; on its next sync it re-pushed the stale, secret-bearing commit and
|
||||
undid the rewrite. The pull-only test server never fought back — **only
|
||||
push-enabled servers do.**
|
||||
|
||||
**The rule: freeze git-sync on _every_ server (push *and* pull) before rewriting
|
||||
shared history.** A pull server mid-rewrite can also resurrect a half-removed
|
||||
state. The safe sequence that worked:
|
||||
|
||||
1. **Freeze all sync.** `make remote-git-sync-disable-{test,prod}` (flips
|
||||
`enabled: false` in the env-path `git-sync.yaml`).
|
||||
2. **Audit where the live secret actually lives — before any `reset --hard`.** A
|
||||
destructive reset *deletes* working-tree files tracked now but absent in the
|
||||
target commit. `config/security-private.php` was such a file — but it was a
|
||||
**stray duplicate**; the authoritative 290-byte copy lives at
|
||||
`env/intotheeast.com/config/security-private.php` (mode 600), which git-sync
|
||||
had copied into `config/`. Because `env/` is gitignored and outside every
|
||||
tracked folder, it survives the reset and *wins* Grav's config merge — so
|
||||
dropping the `config/` copy is safe. **Verify this first** with a secret-safe
|
||||
audit that lists existence + size + `git ls-files` tracking and **never prints
|
||||
contents** (added as `make remote-secrets-audit`; it `ls` / `git ls-files`,
|
||||
never `cat`).
|
||||
3. **Force-push `main` to the clean commit.** It sticks now — no server is pushing.
|
||||
4. **Reset each server** with `make remote-fetch-content-{test,prod}`
|
||||
(`fetch` → `sparse-checkout disable` → `reset --hard origin/main`). This
|
||||
deletes the stray tracked `config/` copies; the `env/` originals remain.
|
||||
5. **Verify** `git ls-files` shows no secret tracked and the `env/` copy is intact
|
||||
on every host.
|
||||
6. **Re-enable sync** (`make remote-git-sync-enable-*`), preserving each server's
|
||||
`direction`. Local `HEAD` now equals Gitea `main`, so there is nothing bad to
|
||||
push.
|
||||
|
||||
Two gotchas inside step 4:
|
||||
|
||||
- **Stale remote-tracking ref.** `reset --hard origin/main` resets to the
|
||||
server's *cached* `refs/remotes/origin/main`, not to Gitea directly. If that ref
|
||||
is stale the reset lands on the wrong commit — confirm the `fetch` force-updated
|
||||
it (`+ f8c45fc...32c3d8c main -> origin/main (forced update)`) before trusting
|
||||
the reset.
|
||||
- **`sparse-checkout disable` before `reset --hard`** — otherwise the reset only
|
||||
touches paths inside the sparse pattern and can skip/wipe directories outside it.
|
||||
|
||||
**Durable exclusion goes in git-sync's `ignore:` config field, never a
|
||||
hand-edited `.gitignore`.** git-sync owns `.gitignore`: on load it regenerates it
|
||||
from `folders` (`/*`, `!/pages`, `!/config`, `!/themes`) and **appends** the
|
||||
`ignore:` entries. Hand edits are clobbered on the next sync; `ignore:` entries
|
||||
persist because git-sync writes them back every time. So the secret paths belong
|
||||
in `ignore:` — but that only prevents *future* tracking. The history rewrite
|
||||
(steps 1–5) is still required *in addition to* the ignore entries to remove a
|
||||
secret that is already committed, not instead of them.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Two quiet, cross-environmental failure modes:
|
||||
@@ -207,6 +271,9 @@ strength of folder scoping alone.
|
||||
- **Reviewing a "stop tracking this secret" cleanup** for whether it will stick:
|
||||
is the secret in its own gitignored path (holds) or a line inside a tracked
|
||||
functional file that something regenerates (boomerangs)?
|
||||
- **Rewriting shared history (force-push, `filter-repo`, `reset --hard`) on a
|
||||
git-sync-managed repo** — freeze sync on every server first, audit where the
|
||||
live secret authoritatively lives before any destructive reset, then re-enable.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -229,6 +296,15 @@ per-install and re-commits under sync. The fix that *sticks* is the
|
||||
companion-private-file pattern or `popularity.enabled: false` — **not** stripping
|
||||
the `salt:` line.
|
||||
|
||||
**Force-push revert example.** With `config/security-private.php` +
|
||||
`config/versions.yaml` already committed to Gitea `main` (`f8c45fc`), a
|
||||
`git push --force origin main` back to the clean `32c3d8c` was undone within
|
||||
seconds — prod's `direction: both` git-sync re-pushed its stale `f8c45fc` `HEAD`.
|
||||
The rewrite only held after `make remote-git-sync-disable-{test,prod}` froze both
|
||||
servers first; then force-push → `make remote-fetch-content-{test,prod}` →
|
||||
re-enable. Verified afterward: `git ls-files` on every host lists no secret, and
|
||||
each host's `env/…/security-private.php` is intact.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "cache.deleteAll() doesn't rebuild the page-tree index — a freshly-posted entry 404s when opened for editing"
|
||||
date: 2026-07-07
|
||||
category: integration-issues
|
||||
module: cache-on-save
|
||||
problem_type: integration_issue
|
||||
component: plugin
|
||||
severity: high
|
||||
symptoms:
|
||||
- "A just-posted journal entry is written to disk but the API 404s on it (GET /api/v1/pages{route})"
|
||||
- "Opening the entry you just created for editing shows 'This entry no longer exists — it may have been deleted'"
|
||||
- "The entry DOES appear in the trip feed, but the edit prefill fetch can't find it until the next unrelated cache bump"
|
||||
- "Intermittent — only bites when the page-tree index survives the create"
|
||||
root_cause: incomplete_setup
|
||||
resolution_type: code_fix
|
||||
related_components:
|
||||
- documentation
|
||||
- development_workflow
|
||||
tags:
|
||||
- grav
|
||||
- cache
|
||||
- forms
|
||||
- page-tree
|
||||
---
|
||||
|
||||
# `cache.deleteAll()` doesn't rebuild the page-tree index
|
||||
|
||||
## Context — this is BUG-001 Part 2
|
||||
|
||||
[BUG-001](../../working/bugs-and-fixes.md) ("new entry not visible after form
|
||||
submission") was fixed by wiring `$this->grav['cache']->deleteAll()` into the
|
||||
`cache-on-save` plugin's `onFormProcessed` hook. That made new entries appear in
|
||||
the trip feed immediately. It was **not the whole story**: `deleteAll()` drops
|
||||
the Doctrine store (rendered-page cache, feed HTML, etc.) but does **not** force
|
||||
Grav to rebuild its **regular-pages index**.
|
||||
|
||||
The gap only surfaced once the shared `/post` form gained an **edit mode**
|
||||
(`?edit=<route>`), whose prefill does `GET /api/v1/pages{route}`. On a fresh
|
||||
create that request would 404 — so the owner opening the entry they had *just*
|
||||
posted saw "This entry no longer exists."
|
||||
|
||||
## Root cause
|
||||
|
||||
Grav's regular-pages index is keyed on:
|
||||
|
||||
```
|
||||
md5(dirs + folderHash + config->checksum() + lang) // Pages::buildRegularPages
|
||||
```
|
||||
|
||||
With `cache.check.method: folder` (our setting), the `folderHash` component does
|
||||
not necessarily change when a new child folder is added inside an existing
|
||||
tree — so the **index key stays the same** and the stale index (missing the new
|
||||
entry) is reused. `deleteAll()` clears cache *stores* but does not change any of
|
||||
the inputs to that key, so the tree is not rebuilt. The new page is on disk and
|
||||
in the feed (which re-reads children), but the **API lookup by route** resolves
|
||||
through the cached index and 404s.
|
||||
|
||||
## Fix
|
||||
|
||||
Add a second invalidation step alongside `deleteAll()`:
|
||||
|
||||
```php
|
||||
use Grav\Common\Cache;
|
||||
// ...
|
||||
$this->grav['cache']->deleteAll();
|
||||
Cache::invalidateCache(); // touch(system.yaml) → bumps config->checksum()
|
||||
```
|
||||
|
||||
`Cache::invalidateCache()` is lightweight and idempotent — it `touch()`es
|
||||
`system.yaml`, calls `clearstatcache()` and `opcache_reset()` (verified in Grav
|
||||
core `Cache.php`). Touching `system.yaml` bumps `config->checksum()`, which
|
||||
changes the index key, so the tree rebuilds on the next request and the new
|
||||
entry becomes resolvable by route.
|
||||
|
||||
### Latch it — the hook fires 4× per submit
|
||||
|
||||
`onFormProcessed` fires once per `process:` action, and `post-form.md` has four
|
||||
(`add_page`, `upload`, `message`, `reset`). Without a guard the
|
||||
`deleteAll()` + `invalidateCache()` pair runs four times per post (a full store
|
||||
wipe + `system.yaml` touch each time). Gate it with a once-per-request latch
|
||||
(`$cacheInvalidated`), the same pattern already used for photo reconciliation
|
||||
(`$photosReconciled`). See `user/plugins/cache-on-save/cache-on-save.php`.
|
||||
|
||||
## How to verify
|
||||
|
||||
1. Post a new entry via `/post`.
|
||||
2. From the trip feed, click the new card's **Edit** link.
|
||||
3. The form prefills with the entry's title/body — no "no longer exists" banner.
|
||||
|
||||
Regression test: `tests/ui/post/edit-mode.spec.js` **ES1** (create → open the
|
||||
feed card's Edit link → change title + body → Save → assert on disk).
|
||||
|
||||
## Residual coverage gap (tracked, not fixed here)
|
||||
|
||||
`tests/ui/home/home.spec.js` **H1** and `tests/ui/maps/maps.spec.js` **M8**
|
||||
require `site.travelling: true` to exercise the active-trip home feed + home GPX
|
||||
map. The committed local `site.yaml` runs `travelling: false` (owner's testing
|
||||
config, intentionally not committed as `true`), so both specs **skip loudly**
|
||||
with a reason rather than fail misleadingly. They validate whenever the site is
|
||||
in travelling mode. This is a known gap in this environment, not a silent hole —
|
||||
provisioning `travelling: true` in a dedicated test config would close it.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images"
|
||||
date: 2026-07-07
|
||||
category: ui-bugs
|
||||
module: intotheeast-theme
|
||||
problem_type: ui_bug
|
||||
component: rails_view
|
||||
symptoms:
|
||||
- "Trip banner/cover renders blurry and badly cropped even though the source photo looks high-res in the post"
|
||||
- "A portrait phone photo appears as a thin, upscaled horizontal sliver in a wide banner strip"
|
||||
- "Cover derivative comes back at the source aspect ratio (e.g. 165x220 from a 1013x1350 portrait) instead of the requested strip"
|
||||
root_cause: wrong_api
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
tags: [grav, twig, medium, cropresize, cropzoom, srcset, retina, cover-image, object-fit]
|
||||
---
|
||||
|
||||
# Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images
|
||||
|
||||
## Problem
|
||||
|
||||
The shared trip-cover macro produced a blurry, badly-composed banner/card image
|
||||
for any trip whose cover fell back to a portrait journal photo. It looked like a
|
||||
low-quality source, but the source was fine — the wrong Grav Medium operation was
|
||||
turning it into a tiny sliver that CSS then upscaled.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Trip banner on `/trips/us-canada-mex-2024` looked "horrendous" — soft and
|
||||
zoomed — while the same photo looked sharp inside the journal post.
|
||||
- The rendered `<img>` derivative came back at the *source* aspect ratio, not the
|
||||
requested strip: `cropResize(720, 220)` on a 1013×1350 portrait produced a
|
||||
**165×220** image (0.75 ratio, matching the source), not a 720×220 strip.
|
||||
- The banner box (`.trip-header-banner img { object-fit: cover; height: 200px }`)
|
||||
then upscaled that ~165px-wide sliver ~4× to fill the column → blur.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Assuming it was source/image quality.** The imported photos are only
|
||||
~700–1200px wide (pixelfed served downscaled web renditions), but that alone
|
||||
did not explain the blur — the same file was sharp in the post.
|
||||
- **Capping the derivative width to avoid upscaling (`min(w, source_width)`), as
|
||||
a first pass.** This stopped Grav from re-encoding an upscaled JPEG, but the
|
||||
derivative was *still* a portrait sliver because `cropResize` was still the
|
||||
wrong operation — it emitted odd intermediate `srcset` widths (`1013w`,
|
||||
`1200w`) without fixing the composition. It was treating a symptom.
|
||||
|
||||
## Solution
|
||||
|
||||
Switch the cover operation from `cropResize` (fit-inside) to `cropZoom`
|
||||
(crop-to-fill / cover), and make retina all-or-nothing so a narrow source is
|
||||
never upscaled.
|
||||
|
||||
```twig
|
||||
{# BEFORE — cropResize fits the source INSIDE the box, preserving its aspect
|
||||
ratio, so a portrait comes back as a narrow sliver #}
|
||||
<img src="{{ cover.cropResize(w, h).url }}"
|
||||
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w,
|
||||
{{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w">
|
||||
|
||||
{# AFTER — cropZoom crops-to-fill, returning an actual w×h cover strip; the 2x
|
||||
descriptor is emitted only when the source is genuinely >= 2w wide #}
|
||||
<img src="{{ cover.cropZoom(w, h).url }}"
|
||||
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}">
|
||||
```
|
||||
|
||||
Verified empirically against the running container (do not trust the method
|
||||
names from memory — Grav's op semantics are non-obvious):
|
||||
|
||||
| op | on a 1013×1350 portrait, target 720×220 | shape |
|
||||
|----|------------------------------------------|-------|
|
||||
| `cropResize(720, 220)` | **165×220** | fit-inside (source aspect kept) |
|
||||
| `cropZoom(720, 220)` | **720×220** | crop-to-fill (cover) ✅ |
|
||||
| `resize(720, 220)` | 720×220 | stretched/distorted ✗ |
|
||||
|
||||
## Why This Works
|
||||
|
||||
Grav's `Medium::cropResize($w, $h)` scales the image to **fit inside** the
|
||||
`$w × $h` box while preserving the source aspect ratio — for a tall portrait it
|
||||
is bound by height, yielding a narrow image far smaller than `$w`. `cropZoom`
|
||||
instead scales to **cover** the box and crops the overflow, so it always returns
|
||||
exactly `$w × $h` with no distortion. A banner/card strip wants cover behavior,
|
||||
so `cropZoom` is correct. Capping widths at `cover.width` prevents Grav from
|
||||
re-encoding an upscaled derivative; combined with `object-fit: cover` on the
|
||||
element, the browser gets a sharp strip at (or below) native resolution.
|
||||
|
||||
Note the imported photos cap at ~1440px wide, so `cover.width >= 2w` is usually
|
||||
false for the wide banner — auto-picked covers render 1x-only (sharp on standard
|
||||
displays; retina only engages for an explicitly-set wide landscape `cover_image`).
|
||||
|
||||
## Prevention
|
||||
|
||||
- **Choose the Grav Medium op by intent, and verify the output dimensions.**
|
||||
For a fixed-shape strip/thumbnail (banner, card, avatar) use `cropZoom`
|
||||
(crop-to-fill). Use `cropResize` only when you actually want the whole image
|
||||
fit inside a bounding box (aspect preserved, letterbox-friendly).
|
||||
- **Confirm Medium API behavior empirically before shipping** rather than trusting
|
||||
method names — a quick `php bin/grav` script that runs the op and calls
|
||||
`getimagesize()` on the derivative catches fit-vs-fill surprises. (auto memory
|
||||
[claude]: this repo's standing guidance is to look up / verify Grav + plugin
|
||||
API behavior, never guess it.)
|
||||
- **Guard retina descriptors against upscaling:** only add the 2x `srcset`
|
||||
candidate when `cover.width >= 2 * targetWidth`; never emit a derivative wider
|
||||
than the source.
|
||||
- **Regression test the composition, not just the URL.** Assert the loaded
|
||||
banner image's natural aspect ratio is the wide strip ratio (e.g. `nw/nh > 3`),
|
||||
which fails if a future edit reverts to a fit-inside sliver. See
|
||||
`tests/ui/trip/trip-header.spec.js` (portrait-source regression on
|
||||
`us-canada-mex-2024`).
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Feature that introduced the macro: `docs/working/plans/2026-07-05-trip-description-and-hero.md`
|
||||
(see the 2026-07-07 follow-up note). Session history shows the retina cover
|
||||
macro was built entirely with `cropResize` across the feature sessions and
|
||||
`cropZoom` was never evaluated, so the bug was latent from inception and only
|
||||
surfaced when real portrait content hit the banner. (session history)
|
||||
- Backlog: full-resolution re-import of pixelfed photos — `docs/working/backlog.md`
|
||||
(Content quality — luxury). The ~1440px source ceiling is why auto covers are
|
||||
1x-only.
|
||||
@@ -15,6 +15,29 @@ Ideas and improvements not yet planned or scheduled.
|
||||
|
||||
---
|
||||
|
||||
## Hero-image cleanup (journal)
|
||||
|
||||
The `hero_image` field was removed from the post form (journal heroes now come
|
||||
from the first uploaded photo). Follow-up: purge the now-unused field from the
|
||||
journal entity end-to-end.
|
||||
|
||||
- [ ] **Remove hero from the journal entity** — drop `hero_image` from the entry blueprint/template so journal entries no longer carry or reference it (journal rendering already uses `entry.media.images|first`)
|
||||
- [ ] **Remove hero from posts + demo content** — strip `hero_image` frontmatter from existing journal entries and the `italy-2026-demo` seed content (`user/docs/demo/`), then re-run `make demo-load`
|
||||
|
||||
---
|
||||
|
||||
## Journal entry detail page
|
||||
|
||||
- [ ] **Retire the journal-entry detail page** — the trip/home feed already renders each entry's full body inline (`entry.content|raw` in `partials/entry-journal.html.twig`), so the standalone `entry.html.twig` route per journal entry is largely redundant. Consider removing the route/permalink for journal entries. **Journal only** — stories are full standalone pages and keep their detail view. (Surfaced during the front-end edit brainstorm; unrelated to edit/delete itself.)
|
||||
|
||||
---
|
||||
|
||||
## Content quality — luxury improvements (much later)
|
||||
|
||||
- [ ] **Re-import pixelfed photos at full resolution** — the current import pulled pixelfed's optimised web renditions, so imported images cap at ~1440px on the long edge (portraits are 700–1200px wide). This is fine for the feed and 1x banners, but the retina cover 2x only kicks in for genuinely wide (≥1440px) sources, so auto-picked trip banners are currently 1x-only. Find the original high-quality versions in the local filesystem and re-import them (or point the pipeline at the originals rather than the pixelfed web renditions). Purely a quality upgrade — no functional gap; future content shot/stored at full res won't have this ceiling.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -9,6 +9,12 @@ Backlog of confirmed bugs with root cause analysis and implementation spec for t
|
||||
**Status:** fixed 2026-06-18
|
||||
**Reported:** 2026-06-18
|
||||
|
||||
> **Follow-up (2026-07-07):** `deleteAll()` alone does not rebuild Grav's
|
||||
> page-tree *index*, so once `/post` gained an edit mode a freshly-posted entry
|
||||
> would 404 on its edit-prefill API lookup. Fixed by also calling
|
||||
> `Cache::invalidateCache()`. See
|
||||
> [`docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`](../solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md).
|
||||
|
||||
### Symptom
|
||||
|
||||
After submitting a new post via `/post`, the entry page file is created correctly on disk but does not appear in the `/trips/<active_trip>/dailies` feed or in the Grav Admin panel until the cache is manually flushed.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Session handover — Playwright coverage for the edit-mode photo editor
|
||||
|
||||
> **✅ COMPLETE (2026-07-07) — SUPERSEDED by `2026-07-05` → `2026-07-07-journal-post-form-review-handover-and-qa.md`.**
|
||||
> The requested coverage landed: `tests/ui/post/photo-editor.spec.js` + `edit-mode.spec.js` now
|
||||
> cover the add/delete/reorder happy **and** failure paths (auth-expiry "sign in again" E5/E7,
|
||||
> retry-able delete failure E4/DEL3, prefill-failure ES2/ES3). Verified green: `39 passed` on
|
||||
> `:8091` (2026-07-07). All remaining work (owner UI QA + landing) is tracked in the 2026-07-07
|
||||
> handover. This file is retained for history only — no further action.
|
||||
|
||||
**Date:** 2026-07-05
|
||||
**Branch:** `feat/journal-post-form` (worktree: `.worktrees/journal-post-form`)
|
||||
**Next session goal:** Add Playwright coverage for the edit-mode photo editor add / delete / reorder paths — **especially the failure paths** just implemented, which currently have zero automated coverage.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — where things stand
|
||||
|
||||
The photo-editor media-API feature is **code-complete and committed** but **not smoke-tested**. Three review follow-ups landed this session (commit `7ffd75e`) on the edit-mode add/delete/reorder **failure** paths. Those paths are exercised by **no** existing test, so nothing proves the behavioral changes work end-to-end. That's the whole reason for the next session.
|
||||
|
||||
**Do not** push, **do not** bump the submodule pin, and **do not** touch the other-session WIP (see Constraints) until the new tests pass and Mischa says go.
|
||||
|
||||
---
|
||||
|
||||
## Git state at handover
|
||||
|
||||
Outer repo (`.worktrees/journal-post-form`):
|
||||
- `HEAD` = `7534d7d test(post-form): expect zero-padded photo-01..NN filenames`
|
||||
- Status: only `M user` — the submodule pin is **intentionally stale** (not bumped mid-feature; per project convention bump once at feature end). **Leave it.**
|
||||
|
||||
`user/` submodule (branch `feat/journal-post-form`):
|
||||
- `HEAD` = `7ffd75e fix(review): surface auth-expiry, harden add-batch rollback, add audit log`
|
||||
- `361a6b4 fix(review): harden photo reorder against data loss + failure-path drift`
|
||||
- `a4432d8 feat(post-form): live photo editor on entry edit (media API + SortableJS)`
|
||||
- **Dirty (DO NOT COMMIT — belongs to a different session):**
|
||||
- `config/plugins/api.yaml`
|
||||
- `config/site.yaml`
|
||||
- `themes/intotheeast/js/src/post-form.css` (a trailing FilePond CSS block)
|
||||
- Nothing pushed on either repo.
|
||||
|
||||
---
|
||||
|
||||
## What commit `7ffd75e` changed (the code under test)
|
||||
|
||||
All in the edit-mode photo editor (the `initPhotoEditor` IIFE in
|
||||
`user/themes/intotheeast/js/src/post-form.js`, bundled to
|
||||
`user/themes/intotheeast/js/post/post-form.js`):
|
||||
|
||||
1. **Surfaced auth-expiry.** Replaced the boolean `apiOk` with `apiSend(url, opts, okStatuses)`, which rejects with an `Error` carrying `.status`. A lapsed owner login mid-edit (**401/403**) now shows *"Your login session expired — sign in again, then retry."* instead of a generic "try again". Applies to reorder, delete, and add paths (`editErrorMsg(err, fallback)` picks the copy).
|
||||
2. **Hardened the add-batch rollback (review item #6).** When a post-upload reorder fails, the cleanup DELETEs no longer swallow individual failures. Each rollback DELETE resolves true/false (204/404 = truly gone); any `false` sets `rollbackIncomplete`, producing *"Couldn't finish adding photos and cleanup was incomplete — reload the page and check your photos."* instead of a false "rolled back cleanly". This closes the window where a surviving stock-named file steals the lexicographic cover slot (`media.images|first`).
|
||||
3. **Audit log** on the two owner-only destructive routes in
|
||||
`user/plugins/entry-actions/classes/EntryActionsApiController.php`
|
||||
(`deleteEntry`, `reorderPhotos`) — behaviorally inert, logs owner + slug. Not worth a Playwright test.
|
||||
|
||||
**User-facing strings to assert against** (stable; survive minification):
|
||||
- `login session expired` / `sign in again`
|
||||
- `cleanup was incomplete`
|
||||
- The N-photos-couldn't-be-added count message
|
||||
|
||||
---
|
||||
|
||||
## The API surface the editor talks to
|
||||
|
||||
- **Add photo:** `POST /api/v1/pages{route}/media` (stock media API, multipart)
|
||||
- **Delete photo:** `DELETE /api/v1/pages{route}/media/{filename}` — editor treats **204 and 404** as success
|
||||
- **Reorder:** `POST /api/v1/entry/{slug}/photos/order`, body `{ "order": ["photo-01.jpg", …] }` — custom scope-guarded route in the `entry-actions` plugin; returns **204**
|
||||
- All requests use `credentials: 'include'` (session-cookie auth).
|
||||
|
||||
Server-side numbering invariant lives in `PhotoRenumberer` (shared by cache-on-save + entry-actions): every on-disk image is renamed `photo-01..NN` zero-padded; the manifest only supplies order, and any unlisted image is appended (never lost).
|
||||
|
||||
---
|
||||
|
||||
## Test harness facts (read before writing specs)
|
||||
|
||||
- **Runner:** Playwright, config at `playwright.config.js`. `testDir: ./tests/ui`. Specs are `*.spec.js`.
|
||||
- **Auth is already solved.** The `setup` project (`tests/ui/auth/auth.setup.js`) logs in with `GRAV_TEST_USER` / `GRAV_TEST_PASS` (from `.env`) and saves `storageState` to `tests/.auth/user.json`; the `chromium` project loads it. **So every test already runs as the authenticated owner** — edit mode is reachable without extra login steps.
|
||||
- **⚠️ Port:** `baseURL` defaults to `http://localhost:8081`, but **this worktree's dev container serves on `:8091`** (`itte_journal_grav`, mapped `8091->80`). Run with `GRAV_BASE_URL=http://localhost:8091` or the specs will hit the wrong container.
|
||||
- **Helpers** (`tests/ui/helpers.js`, exported): `fillEditor`, `waitForPhotoUpload`, `postEntry`, `cleanupEntry`, `findEntry`, `readEntryMd`, `TRACKER_DIR`, `ACTIVE_TRIP_URL`. `findEntry(tag)`/`cleanupEntry(tag)` locate/remove an entry folder on disk — use them to build a fixture entry and to clean up.
|
||||
- **Existing post specs** live in `tests/ui/post/` (`post-form-ux.spec.js`, `post.spec.js`, `validation.spec.js`). They cover the **create** form only — none open `/post?edit=…` or the photo editor. Mirror their style (fixtures at `tests/fixtures/test-photo*.jpg`).
|
||||
- **Global setup/teardown:** `tests/global-setup.js` / `tests/global-teardown.js`.
|
||||
|
||||
---
|
||||
|
||||
## Suggested test plan for the next session
|
||||
|
||||
Edit mode is `GET /post?edit=<slug>` (verify the exact param against the template). Failure paths need **`page.route()` interception** to force API errors — that's the core technique here.
|
||||
|
||||
1. **Fixture:** post one entry via the create form (or drop a folder), capture its slug, open it in edit mode. Clean up with `cleanupEntry` in `afterAll`.
|
||||
2. **Happy paths** (no interception): add a photo → persists (appears on disk / in grid); delete a photo → gone; drag-reorder → files renamed `photo-01..NN` in new order.
|
||||
3. **Auth-expiry (item #1):** `page.route('**/api/v1/**', r => r.fulfill({ status: 401 }))` on a reorder/delete/add → assert the *"login session expired … sign in again"* copy appears.
|
||||
4. **Incomplete rollback (#6):** let the uploads succeed but force the reorder to fail **and** at least one cleanup DELETE to fail (route-match `DELETE **/media/**` → 500). Assert the *"cleanup was incomplete — reload"* message. This is the highest-value, never-before-tested branch.
|
||||
5. **Delete failure:** force a `DELETE` to 500 → assert *"Couldn't delete that photo. Try again."* and the photo stays in the grid.
|
||||
|
||||
Keep assertions on the **user-facing strings** above, not on minified identifiers.
|
||||
|
||||
### Also pending: manual smoke test
|
||||
Independent of automation, the behavioral changes still want one **manual owner-session pass on `:8091`**: log in, open an entry in edit mode, add/delete/reorder and confirm each persists; then simulate a lapsed session and confirm the "sign in again" copy. If Playwright covers 2–5 above, this becomes a quick confidence check rather than the only verification.
|
||||
|
||||
---
|
||||
|
||||
## Constraints (carried from this session — still in force)
|
||||
|
||||
- **Other-session WIP is off-limits.** Do not stage/commit `config/plugins/api.yaml`, `config/site.yaml`, or the FilePond block in `themes/intotheeast/js/src/post-form.css`. If `make build-assets` recompiles `css-compiled/post-form.css` from that dirty source, **revert it**: `git checkout -- themes/intotheeast/css-compiled/post-form.css`.
|
||||
- **Never** read `.env`, `.env.prod`, `.env.test` (pass them to `make`/`compose` only). `GRAV_TEST_USER`/`PASS` live there.
|
||||
- **Only** write inside `travel-blog-intotheeast/` or subfolders.
|
||||
- **Do not** bump the submodule pin or push until the feature is done and Mischa approves.
|
||||
- **Do not** hand-edit the bundle (`js/post/post-form.js`) or `css-compiled/*` — edit `js/src/*` and rebuild with `make build-assets`.
|
||||
- No dev/prod mode switching; fix issues at the app level.
|
||||
- New test files go in the **outer repo** (`tests/` is outer-repo, not the `user/` submodule).
|
||||
|
||||
---
|
||||
|
||||
## Fast start for the next session
|
||||
|
||||
```
|
||||
# worktree root
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/.worktrees/journal-post-form
|
||||
|
||||
# confirm the dev container is up on 8091
|
||||
docker ps --format '{{.Names}}\t{{.Ports}}' | grep itte
|
||||
|
||||
# run existing post specs against THIS worktree's container
|
||||
GRAV_BASE_URL=http://localhost:8091 npx playwright test tests/ui/post
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Journal Post Form — Review Handover & Owner QA
|
||||
|
||||
**Date:** 2026-07-07
|
||||
**Branch:** `feat/journal-post-form` (worktree `.worktrees/journal-post-form`)
|
||||
**State:** Implementation + code-review complete. **Remaining: owner UI QA (Part B) → then landing (Part A §Landing).**
|
||||
|
||||
This doc has two audiences:
|
||||
- **Part A — Handover (Claude → future Claude):** exact branch state, what's committed where, the dual-session/worktree situation, and the landing procedure. Read this first in a fresh session before touching anything.
|
||||
- **Part B — QA checklist (Mischa):** the owner-session UI pass the test harness cannot do (it can't obtain your login). Run on http://localhost:8091.
|
||||
|
||||
---
|
||||
|
||||
## Part A — Handover (Claude → future Claude)
|
||||
|
||||
### What this branch delivers
|
||||
Front-end journal posting + editing, reusing `/post` + `add-page-by-form`:
|
||||
- Create/edit/delete/unpublish entries from the feed (plans `2026-07-04-journal-post-form`, `2026-07-04-frontend-entry-edit`).
|
||||
- In-form photo editor: add (HEIC→JPEG), inline-confirm delete, drag reorder, `photo-01..NN` renumber, first = cover (plan `2026-07-05-photo-editor-media-api`).
|
||||
|
||||
### Commits made in the 2026-07-07 review session (code-review F1–F8)
|
||||
All **local to this worktree's branch** — nothing pushed, no pin bump, no `content-push`.
|
||||
|
||||
**Submodule `user/`** (on `feat/journal-post-form`):
|
||||
- `8db3ffe` — F1/F7: latch cache invalidation (`$cacheInvalidated`) to once-per-submit + info log — `plugins/cache-on-save/cache-on-save.php`
|
||||
- `7f6bf9e` — F4: `initDisclosure` reads each toggle's default from the rendered `[checked]` attribute instead of a `/\[published\]$/` field-name regex; rebuilt bundle — `themes/intotheeast/js/src/post-form.js` + `js/post/post-form.js`
|
||||
|
||||
**Outer repo** (on `feat/journal-post-form`):
|
||||
- `d576487` — F2/F3/F6: shared `createPhotoEntry()` helper; register cleanup **before** the awaited success toast (fixes slow-success entry leak); AE3b disclosure-deviation test — `tests/ui/helpers.js` + 4 specs
|
||||
- `e10496a` — F8/F5: BUG-001 Part 2 solution doc + cross-link — `docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md`, `docs/working/bugs-and-fixes.md`
|
||||
|
||||
Earlier same-branch commits (prior sessions): outer `d3c1779`, `f4dbac6`; submodule `7775a4e`, `a7bda6e` — create→edit stale-cache fix (`Cache::invalidateCache()`) + H1/M8 skip-with-reason.
|
||||
|
||||
### DO NOT commit — off-limits WIP left dirty on purpose
|
||||
- Submodule: `config/site.yaml` (owner's local `travelling:false` / `active_trip` testing config — `m` dirty is normal), `config/plugins/api.yaml`, `themes/intotheeast/js/src/post-form.css`, `themes/intotheeast/css-compiled/post-form.css` (the two CSS files get touched by `make build-assets` rebuilding from the in-progress `post-form.css` source — not part of this work).
|
||||
- Outer: the `user` gitlink (`M user` — pin **intentionally not bumped**).
|
||||
|
||||
### Dual-session / worktree situation (verified 2026-07-07)
|
||||
Two Claude sessions run in parallel. **Local isolation is real and proven:**
|
||||
- This worktree's `user/` git dir: `.git/worktrees/journal-post-form/modules/user`, branch `feat/journal-post-form` — its **own object store**. The other session's branch (`feat/trip-description-hero`) is not visible here and its HEAD commit does not exist in this object store.
|
||||
- Other checkouts: `content-fixes` worktree → `user/` on `feat/trip-description-hero`; main checkout → `user/` on `main`.
|
||||
|
||||
**The only shared resource is Gitea `origin`** (the `intotheeast-com-content.git` content repo) + the single outer pin + outer `main`. Collisions can *only* happen at push / merge-to-main / pin-bump. **Therefore: never push, never `content-push`, never bump the pin from a worktree mid-flight. Landing is a single deliberate step the owner triggers.**
|
||||
|
||||
### Landing procedure (owner-triggered, once QA passes) — do NOT run unprompted
|
||||
1. **Owner UI QA** (Part B) passes.
|
||||
2. **Submodule first.** Reconcile `user/` `feat/journal-post-form` → `user/` `main` (merge; prefer the merge commit, not the branch tip). Push `user/` to Gitea → this triggers the production content pull via webhook.
|
||||
3. **Bump the pin.** In the outer repo, stage the `user` gitlink pointing at that `user/` `main` merge commit (must already be pushed). Commit.
|
||||
4. **Outer.** Merge outer `feat/journal-post-form` → outer `main`, push.
|
||||
5. **Plugin patch.** `add-page-by-form` is GPM-managed/git-ignored; the Grav-2.0 header fix lives at `deploy/patches/add-page-by-form-grav2-header.patch`. Re-apply with `make apply-plugin-patches` after any plugin (re)install on the server — R9 (add photos on edit) breaks without it.
|
||||
6. **Env override.** Re-run `make remote-apply-env-prod` after any fresh install (prod Twig cache settings live only in `user/env/<host>/`, not synced by content).
|
||||
7. **Pre-launch smoke** (CLAUDE.md): submit one post via `/post` on prod, confirm it appears in the trip feed immediately (verifies cache-on-save under `twig.cache:true`).
|
||||
|
||||
### Running the tests
|
||||
- Full post suite: `GRAV_BASE_URL=http://localhost:8091 npx playwright test post/ --reporter=line` (20 pass as of 2026-07-07).
|
||||
- After any `js/src/*` edit: `make build-assets` (never hand-edit `js/post/*` or `css-compiled/*`).
|
||||
- `setup` project logs in → `tests/.auth/user.json`; specs run as the authenticated owner (anon-view clears storageState).
|
||||
|
||||
### Verified vs NOT verified
|
||||
- **Verified (harness):** 20 post specs on :8091 incl. ES1 (create→edit round-trip, the cache fix), AE3b (disclosure deviation), delete flow, anon/draft visibility, HEIC convert, photo renumber; `PhotoRenumberer` unit tests.
|
||||
- **NOT verifiable by harness (needs owner login / real device):** interactive photo add/delete/**drag** reorder in edit mode, on-device **touch**-drag, combined add+delete+reorder in one save. → **This is Part B.**
|
||||
|
||||
---
|
||||
|
||||
## Part B — Owner QA checklist (Mischa)
|
||||
|
||||
Run logged in as the owner on **http://localhost:8091** (worktree dev server). Check each box; if any fails, stop and note it — do not land.
|
||||
|
||||
### Create
|
||||
- [ ] Post an entry with **1 photo** → success toast; entry appears in the active-trip feed **immediately**; that photo is the cover.
|
||||
- [ ] Post an entry with **multiple photos including a HEIC** → HEIC converts to JPEG, all attach, first image is the cover.
|
||||
- [ ] Post with **Published = No** (under "More options") → entry shows a **Draft badge** to you; open the same trip page in a **private/incognito window** → the draft is **absent**.
|
||||
|
||||
### Edit (open an entry's Edit link from the feed)
|
||||
- [ ] Change **title + body**, Save → feed reflects the new title/body.
|
||||
- [ ] Open the entry you *just* created for editing → **no "this entry no longer exists"** banner (the create→edit cache fix).
|
||||
- [ ] **Add** a new photo on edit → attaches and renumbers; regressions don't drop existing photos.
|
||||
- [ ] **Delete** a photo via the inline confirm → removed from disk; if you removed the first, the **cover updates** to the new first.
|
||||
- [ ] **Reorder** photos by **mouse drag** → order persists after Save; first = cover on the feed.
|
||||
- [ ] **Combined** in one save: add + delete + reorder → all three land correctly (cover=first, existing preserved, dropped removed).
|
||||
|
||||
### On-device
|
||||
- [ ] On a **phone or tablet**, edit an entry and **touch-drag** to reorder photos → works and persists.
|
||||
|
||||
### Delete
|
||||
- [ ] Delete an entry from the feed (Delete → Confirm) → card disappears and the folder leaves disk.
|
||||
- [ ] Delete → **Cancel** → nothing removed.
|
||||
|
||||
When every box is checked, hand back to a fresh Claude session and point it at **Part A §Landing procedure**.
|
||||
@@ -0,0 +1,342 @@
|
||||
---
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: implementation-ready
|
||||
execution: code
|
||||
product_contract_source: ce-brainstorm
|
||||
title: Front-End Journal Entry Edit - Plan
|
||||
date: 2026-07-04
|
||||
---
|
||||
|
||||
# Front-End Journal Entry Edit - Plan
|
||||
|
||||
**Status:** 🔄 In progress — M1 (U1–U6) complete & verified (V1–V7). M2 **partially delivered** (2026-07-05): **U7 (load existing photos into FilePond) + remove + reorder** are implemented and verified end-to-end on the :8091 container — V9 (photos load, cover-ordered) and V10 (remove a photo, reorder so a different image is the cover; on-disk `photo-1..N` renumber) both pass; reconcile helpers also covered by a reflection unit test (4 cases). One real bug found & fixed en route: `onFormProcessed` fires once per `process:` action (4×), so photo reconciliation is now latched to run **once** (a 2nd pass deleted the just-renamed `photo-N` files). Changes are in `cache-on-save.php` (edit-aware reconcile) + `post-form.js` (U7 load, D1 disable-sweep excludes the FilePond field). **R9 (add NEW photos on edit) now WORKS (2026-07-05)** via a local patch to add-page-by-form. Root cause: its edit-mode merge read existing frontmatter with `(array)$page->header()`, but Grav 2.0's `Grav\Common\Page\Header` keeps data in a protected `items`, so the cast mangled keys (`\0*\0items`) and `$original_frontmatter['photos']` was never set → `array_merge(null,…)` TypeError on any edit that uploads a file. Fix: use `Header::toArray()` (clean keys) + guard the per-field merge. add-page-by-form is abandoned upstream (last release Sept 2023) and its dir is **git-ignored/GPM-managed**, so the patch is tracked as `deploy/patches/add-page-by-form-grav2-header.patch` and re-applied via `make apply-plugin-patches` after any plugin reinstall — until the plugin is forked. Verified end-to-end on :8091: add a photo, remove one, reorder, and all three combined in one save (cover=first, existing preserved, dropped removed); create-with-photos and edit remove/reorder regressions still pass. (Grav 2.0.7 does **not** fix this on its own — the Header object is unchanged across the patch; only the plugin fix does.) **Code-review complete (2026-07-07)** — the multi-agent review of the branch ran and all findings (F1–F8) were applied & verified (20/20 post specs on :8091); the review's own PERF finding confirmed and hardened the once-per-submit cache latch noted above. **Implementation + review are done; the only remaining items are (1) owner-session UI QA and (2) the deliberate landing step (merge `user/`→main, pin bump, `content-push`, deploy).** Both are captured in `docs/working/handovers/2026-07-07-journal-post-form-review-handover-and-qa.md`. Still **not merged / not deployed** — held for owner QA.
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **Objective:** Let the site owner edit, delete, and unpublish/publish journal entries directly from the front-end feed — reusing the existing `/post` form and the `add-page-by-form` plugin's native edit mode — without touching the Admin2 backend.
|
||||
- **Product authority:** Mischa (site owner, sole author).
|
||||
- **Open blockers:** None blocking. Two planning-time details flagged under Outstanding Questions.
|
||||
|
||||
---
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Actors
|
||||
- **Owner** (authenticated via the existing `site.login` gate) — the only actor who can edit, delete, or change publish state. Everything below is gated to this actor.
|
||||
- **Public visitor** (unauthenticated) — sees only published entries; never sees edit/delete controls or drafts.
|
||||
|
||||
### Problem
|
||||
Correcting a typo, fixing metadata, reordering photos, or shelving a half-written entry currently means logging into Admin2 and navigating the page tree. The owner wants to do all of it inline, from the same feed where the entries already live, on the same phone-friendly form used to post them.
|
||||
|
||||
### What we're building
|
||||
Edit/delete/publish controls that live on the **journal feed cards of the active trip** (its trip page and the home active-trip feed, both rendered by the shared `partials/trip-feed-col.html.twig`). There is **no detail-page route** involved — the feed already renders each entry's full body inline, so the card is the surface. Delivered in two milestones.
|
||||
|
||||
---
|
||||
|
||||
### Milestone 1 — Edit, delete & publish-state from the feed cards
|
||||
|
||||
**Photos are untouched in M1** (the entry keeps its existing images exactly as-is).
|
||||
|
||||
- **R1 — Edit control.** Each journal card shows an **Edit** control when the owner is logged in. The Edit control **navigates to the post form** at `/post?edit=<entry-path>` (a query param carrying the entry's path) — a plain redirect to the existing full-page `/post` surface, not a modal or inline card expansion. The form loads prefilled with the entry's current values: title, date, content, lat, lng, location_city, location_country, weather_desc, weather_temp_c, transport_mode, featured, force_connect, published.
|
||||
- **R2 — Save in place.** Saving writes back to the entry's **existing folder** (via the plugin's `overwrite_mode: edit` + a hidden path field). Editing the title or date does **not** rename the folder or change the URL — identity is stable by design. After saving, the form does a **full page reload** back to the feed (matching the existing post flow — no in-place card update).
|
||||
- **R3 — Delete control.** Each journal card shows a **Delete** control (owner only). Deleting requires an explicit **confirmation step** — an inline button swap on the card (Delete → **Cancel** / **Confirm delete**), no browser dialog or modal — then removes the entry via the Grav API (session-auth `DELETE`, the pattern already used by `/gpx-manager`), clears the page-tree cache, and the card disappears from the feed.
|
||||
- **R4 — Publish/unpublish toggle.** The form carries a publish-state toggle. The owner can unpublish an entry (to shelve it for later rewriting) or re-publish it. This sets the entry's `published` frontmatter. Publishing/unpublishing happens **only through the edit form** — there is no separate card-level publish control.
|
||||
- **R5 — Drafts stay owner-visible.** An unpublished (draft) entry remains visible **to the logged-in owner** in the feed, marked with a **"Draft"** badge, and stays **editable** from its card (opening the edit form, where it can be re-published). It is **hidden from the public** feed entirely. Draft cards appear under **both** the "All content" and "Journal" filter tabs. Drafts are **excluded from the trip map and stats counts** — they render as a feed card only (no map marker, no stat contribution).
|
||||
- **R6 — Server-side guard.** Edit, delete, and publish actions are enforced server-side, not just hidden in the UI: authenticated owner only, and only for entries inside the **active trip's** `dailies` container. The controls render **only on the active trip's** feed cards — past-trip feed pages (which share the same `trip-feed-col` partial) do **not** show them. Neither the `add-page-by-form` save path nor the Grav API delete path enforces trip-scope on its own (the plugin accepts a client-supplied `parent`/`edit_path`, and `PagesController::delete` checks only write-permission), so this guard must be a **custom server-side hook** on both the save and delete paths, validating the target route against `site.active_trip` before proceeding.
|
||||
|
||||
### Milestone 2 — Editable photos in the edit form (FilePond)
|
||||
|
||||
- **R7 — Load existing photos.** Opening an entry for edit loads its current photos into the FilePond field so they can be managed.
|
||||
- **R8 — Remove photos.** The owner can delete any existing photo from the entry.
|
||||
- **R9 — Add photos.** The owner can upload new photos, appended to the set, with the same HEIC→JPEG conversion used when posting.
|
||||
- **R10 — Reorder.** Existing + new photos can be dragged into any order. The **first photo is the cover** — this reuses the live `photo-1..N` ordering convention, *not* the removed `hero_image` field.
|
||||
|
||||
---
|
||||
|
||||
### Scope Boundaries (non-goals)
|
||||
- **Stories are untouched** by all of this — no edit/delete/publish changes to stories; they keep their standalone detail pages and `hero_image`.
|
||||
- **No detail-page edit route** — edit is invoked from feed cards only (the Edit control redirects to `/post?edit=<path>`).
|
||||
- **No editing of past-trip entries** — controls appear only on the active trip's cards; past trips are read-only through this UI (edit them via Admin2 if ever needed).
|
||||
- **Retiring the journal detail page** is out of scope (tracked in `docs/working/backlog.md` → "Journal entry detail page"). It is cleanup unrelated to edit/delete.
|
||||
- **No bulk operations** (multi-select edit/delete/publish).
|
||||
|
||||
### Success criteria
|
||||
- The owner can fix a typo or metadata on an existing entry from the feed and see it update, with the entry's URL unchanged.
|
||||
- The owner can delete an entry from the feed (after confirming) and it disappears.
|
||||
- The owner can unpublish an entry, still see it (badged "Draft") and re-open it later to finish and publish — while the public never sees it.
|
||||
- (M2) The owner can remove, add, and reorder an entry's photos and see the cover change to match the new first photo.
|
||||
|
||||
### Dependencies / Assumptions
|
||||
- **`add-page-by-form` edit mode exists but needs a create-path patch** — `overwrite_mode: edit` saves to the existing folder "respecting any already present uploaded files," targeting it via a hidden **`edit_path`** field (the plugin checks `edit_path` first, then `file_path`, at `add-page-by-form.php:537-545` — standardize on `edit_path`). Note the edit branch does **not** fall through to `slug_field` when `edit_path` is empty, so the shared-form create path requires the plugin patch in KTD1/U1. This is the backbone of M1/M2 save-in-place.
|
||||
- **Post-form field parity** — the `/post` form's fields already map 1:1 to entry frontmatter, so prefill is a matter of loading values, not redesigning the form.
|
||||
- **Cover = first image** is an existing convention (`entry.media.images|first` in `partials/entry-journal.html.twig`); the `hero_image` field was removed and is not reintroduced.
|
||||
- **Feed collection is `.published()`** — both `trip.html.twig` and `home.html.twig` collect dailies via `.children.published()`, which drops unpublished pages unconditionally. R5 (owner-visible drafts) requires replacing this with an **auth-aware collection** in both templates: include unpublished entries only when the owner is authenticated, then gate the Draft badge/controls by auth.
|
||||
- **Delete + cache** — deleting an entry must clear the page-tree cache. Note cache-on-save only clears on the `new-entry` form submit, so it does **not** fire on an API delete; the Grav API's `PagesController::delete` clears the cache itself, so the delete path inherits cache-clearing from the API, not from cache-on-save.
|
||||
- **Auth** reuses the existing `site.login` gate; no new auth system.
|
||||
|
||||
### Outstanding Questions (resolve in planning)
|
||||
- **"Save as draft" on create?** The publish toggle is a shared form field, so it will also appear on the *new-entry* path — confirm whether the create form should let the owner save a brand-new entry directly as a draft (likely yes, near-zero extra cost) or always publish new entries.
|
||||
- **Draft direct-URL access?** Confirm Grav returns a **404 at a draft's direct URL** for anonymous visitors (not merely hiding it from the feed collection) under the current Login plugin config — otherwise draft content is reachable by anyone who guesses the date-slug URL.
|
||||
- **Auth-varying feed vs. output caching?** Once `twig.cache: true` at launch, the feed renders differently for the owner (drafts shown) vs. the public (drafts hidden). Confirm the draft branch is evaluated **per-request** (or the feed bypasses output cache for authenticated sessions) so a cached render can't leak drafts to the public or hide them from the owner. Add a launch smoke test: load the feed as owner, then anonymous, and confirm drafts don't leak.
|
||||
|
||||
**Planning resolutions (2026-07-04):**
|
||||
- *Save as draft on create* → **Yes.** The `published` toggle is a shared field defaulting to Published; flipping it off on the create path saves a brand-new entry as a draft. Near-zero cost, falls out of the shared field (see KTD3).
|
||||
- The *draft direct-URL* and *auth-vs-cache* questions are not planning blockers — they are **launch-time verifications** carried into the Verification Contract (V7, V8). Both are low-risk for a solo-owner blog but must be confirmed before `twig.cache: true` at launch.
|
||||
|
||||
---
|
||||
|
||||
## Product Contract preservation
|
||||
|
||||
Product Contract unchanged. Planning enriches this artifact in place (requirements-only → implementation-ready); all R1–R10 IDs, scope boundaries, and success criteria are preserved verbatim. The only additions are the resolutions above and the Planning Contract below.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD1 — Edit reuses the `new-entry` form via `overwrite_mode: edit` + a hidden `edit_path`; the plugin's edit branch is patched to preserve create.** Set `pageconfig.overwrite_mode: edit` on `post-form.md` unconditionally and add a hidden `edit_path` field that is **empty on create, populated on edit**. **Code check (feasibility + adversarial, confidence 100):** in `add-page-by-form.php` the `slug_field: date,title` computation lives *only* in the `else` (non-edit) branch (~lines 550-602); under `overwrite_mode === 'edit'` the slug is derived solely from `basename(dirname($form_data['edit_path']))` (line 541, guarded by `isset()`, not `!empty()`). So with an empty/absent `edit_path` the create path does **not** fall through to `slug_field` — it either writes into the dailies container itself (`basename(dirname(''))` → `.`) or aborts with a 'slug empty' error. The "one form for both" reuse is therefore **not implementable as written**. **Decision:** patch the plugin's edit branch so that when both `edit_path` and `file_path` are empty it falls through to the existing `slug_field` computation (restoring create behavior). This patch is a **required file of U1**, not a deferred contingency. V1 verifies both branches (empty `edit_path` → fresh dated folder; populated → in-place). *(Alternative considered and rejected for higher carrying cost: a separate edit-form page with its own `overwrite_mode: edit`.)*
|
||||
|
||||
- **KTD2 — Publish is folded into the edit save; no separate publish endpoint.** R4 specifies publish/unpublish happens only through the edit form, so the `published` toggle is a normal form field written to page frontmatter on save. This removes an entire endpoint from the surface — the only new server API is delete (KTD5).
|
||||
|
||||
- **KTD3 — `published` becomes a real form field, replacing the static `pagefrontmatter.published: true`.** Add a `published` toggle to the blueprint (default `1`). Remove the static `pagefrontmatter.published: true` so the field value is authoritative on every submit (create and edit). *Verification:* confirm the field value lands in frontmatter and the static default no longer overrides it (V2).
|
||||
|
||||
- **KTD4 — Prefill is client-side via the Grav API.** The Edit link opens `/post?edit=<entry-route>`; `post-form.js` reads the param, `GET /api/v1/pages<route>` (session-auth, `credentials: 'include'` — the gpx-manager pattern), and populates each field + the hidden `edit_path` + the `published` toggle. Reuses the JS layer we own and the already-configured session API. No server-side Twig form-default plumbing.
|
||||
|
||||
- **KTD5 — Delete is a purpose-built, active-trip-scoped API route in a new `entry-actions` plugin.** The stock `DELETE /api/v1/pages<route>` has no trip-scope guard (`PagesController::delete` checks only write-permission), which violates R6. A thin new plugin registers one route via `onApiRegisterRoutes` that: (a) requires the authenticated **owner** — `grav.user.username == site.owner_username`, **not** merely any login (the super-admin `tester` account also authenticates — see KTD8); (b) resolves the delete target **through the page tree** via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation) and asserts the resolved page is non-null and `->parent()->route()` equals the active trip's dailies route — rejecting any slug containing `/` or `..` at the handler entry with 400; (c) deletes the page folder; (d) clears the page-tree cache. Rejects with 403 otherwise. **Shared guard (FYI A2):** the plugin exports the active-trip→dailies-parent resolution + "is direct child of active dailies" assertion as one helper; `cache-on-save` (KTD6) calls the *same* helper so the two R6 enforcement points cannot diverge. See the `grav-api-integration` skill for the `AbstractApiController` + `onApiRegisterRoutes` contract.
|
||||
|
||||
- **KTD6 — The save-path scope guard lives in `cache-on-save`'s existing `onFormValidationProcessed`.** That handler already runs for `new-entry`, resolves `site.active_trip`, and injects the parent. Extend it: when `edit_path` is present, **normalize it first** — resolve via `$grav['pages']->find($edit_path)` and assert the returned page is non-null and its `->parent()->route()` equals the active dailies route (using the KTD5 shared helper). A raw string-prefix check is insufficient: a value like `/trips/<active>/dailies/../other-slug/entry.md` passes a prefix test while `basename(dirname())` targets a *different* entry (security-lens, confidence 75). Also assert owner identity (KTD8), consistent with the delete route. Throw a `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched. This is R6's enforcement point for edit/publish — no new plugin needed for the save side.
|
||||
|
||||
- **KTD7 — Auth-aware feed collection; map/stats stay published-only.** Replace `.children.published()` with an owner-aware collection: `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()`. The feed (`all_items`) uses the owner-aware list so drafts show to the owner; the **map `entries` array and stats inputs continue to use `.published()` only**, so drafts never get a marker or a stat contribution (R5). The between-trips home grid stays `.published()` (past trips are public-only).
|
||||
|
||||
- **KTD8 — Controls are gated by `owner_can_edit`, computed once per surface and threaded through the feed-col partial.** **Owner identity, not just authentication (security-lens, confidence 100):** `grav.user.authenticated` is true for *any* login, including the super-admin `tester` account, so gating on it alone would grant edit/delete/draft-visibility to every account. Gate on the specific owner: `owner_can_edit = grav.user.authenticated and grav.user.username == site.owner_username and (trip.slug == site.active_trip)`. Add `owner_username` to `site.yaml` (single source of truth) so the same identity check backs the UI gate here **and** the server guards (KTD5/KTD6) — the UI gate is cosmetic; the server is authoritative. `trip.html.twig` and the home active-trip branch compute it and pass it into `trip-feed-col.html.twig`, which passes it into `entry-journal.html.twig`. Past-trip pages compute `false`, so no controls render there — satisfying R6's "active trip only" at the UI layer, matching the server guard.
|
||||
|
||||
- **KTD9 — M1 hides the photos field and relaxes the ≥1-photo rule in edit mode.** Photos are untouched in M1, and the create flow requires ≥1 photo (`post-form.js initValidation`). In edit mode (`?edit=` present) the photos section is hidden and the ≥1-photo check is skipped, so an edit submit with an empty FilePond leaves existing images intact (`overwrite_mode: edit` "respects already present uploaded files"). M2 replaces this by loading the real photos into FilePond.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
**Edit round-trip (M1):**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Owner (browser)
|
||||
participant C as Journal card
|
||||
participant P as /post?edit=route
|
||||
participant JS as post-form.js
|
||||
participant API as Grav API (session auth)
|
||||
participant APBF as add-page-by-form
|
||||
participant COS as cache-on-save guard
|
||||
|
||||
U->>C: click Edit (owner + active trip only)
|
||||
C->>P: navigate /post?edit=<entry-route>
|
||||
P->>JS: page load, ?edit present
|
||||
JS->>API: GET /api/v1/pages<route>
|
||||
API-->>JS: frontmatter + content
|
||||
JS->>P: fill fields, set hidden edit_path,<br/>set published toggle, hide photos, relax photo rule
|
||||
U->>P: edit + Save
|
||||
P->>COS: form submit (new-entry)
|
||||
COS->>COS: assert edit_path ∈ active dailies (else ValidationException/fail closed)
|
||||
COS->>APBF: proceed
|
||||
APBF->>APBF: overwrite_mode:edit → write to existing folder
|
||||
COS->>COS: clear page-tree cache
|
||||
P-->>U: full reload → feed shows updated entry (URL unchanged)
|
||||
```
|
||||
|
||||
**Delete flow (M1):**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Owner (browser)
|
||||
participant C as Journal card
|
||||
participant EA as entry-actions plugin (API route)
|
||||
|
||||
U->>C: click Delete
|
||||
C->>C: swap to Cancel / Confirm delete
|
||||
U->>C: Confirm delete
|
||||
C->>EA: DELETE /api/v1/entry/<slug> (credentials: include)
|
||||
EA->>EA: authenticated? target ∈ active-trip dailies?
|
||||
alt authorized
|
||||
EA->>EA: delete folder + clear cache
|
||||
EA-->>C: 200 → remove card from DOM
|
||||
else rejected
|
||||
EA-->>C: 403 → restore Delete control + inline error
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Blueprint: `published` field + enable edit mode
|
||||
|
||||
- **Goal:** Make the post form capable of editing in place and carrying publish state.
|
||||
- **Requirements:** R1, R2, R4; KTD1, KTD3.
|
||||
- **Dependencies:** none.
|
||||
- **Files:** `user/pages/02.post/post-form.md`; `user/plugins/add-page-by-form/add-page-by-form.php` (create-path patch, KTD1); `user/config/site.yaml` (`owner_username`, KTD8).
|
||||
- **Approach:** Set `pageconfig.overwrite_mode: edit`. Add a hidden `edit_path` field (empty default). Add a `published` toggle field (default `1`, near the advanced fields). Remove the static `pagefrontmatter.published: true` so the field is authoritative (KTD3). **Patch the plugin's edit branch (KTD1):** in the `if ($overwrite_mode === 'edit')` block, when both `edit_path` and `file_path` are empty, fall through to the existing `slug_field: date,title` computation from the `else` branch (factor it into a shared code path or duplicate the slug build) so create still writes a fresh dated folder. Add `owner_username` to `site.yaml`.
|
||||
- **Patterns to follow:** existing `force_connect`/`featured` toggle fields in the same blueprint; hidden field via `type: hidden`; the existing `slug_field` build in `add-page-by-form.php`'s non-edit branch.
|
||||
- **Execution note:** characterization-first on the plugin patch — capture the current create-path slug output before changing the edit branch, so the patch is proven not to alter create.
|
||||
- **Test scenarios:**
|
||||
- Create path preserved under edit mode: submit a new entry with `overwrite_mode: edit` and an empty `edit_path` → a new dated folder is written (not the dailies container, not a 'slug empty' error), `published: true` in frontmatter. *Covers V1.*
|
||||
- Publish field write: submit with `published` off → frontmatter shows `published: false` (assert the on-disk type is a real boolean/int, not the quoted string `'0'`). *Covers V2.*
|
||||
- `Test expectation:` blueprint + plugin patch are behavior-bearing — covered by the two scenarios above plus U2/U5 integration.
|
||||
- **Verification:** posting a brand-new entry still works exactly as before the blueprint flipped to edit mode; `published` value round-trips to frontmatter as a real boolean.
|
||||
|
||||
### U2. Save-path active-trip scope guard (cache-on-save)
|
||||
|
||||
- **Goal:** Enforce R6 on the edit/publish save path.
|
||||
- **Requirements:** R6; KTD6.
|
||||
- **Dependencies:** U1.
|
||||
- **Files:** `user/plugins/cache-on-save/cache-on-save.php`, `tests/` (PHP or UI integration).
|
||||
- **Approach:** In `onFormValidationProcessed` (already gated to `new-entry`), when `edit_path` is present **normalize it via `$grav['pages']->find($edit_path)`** and assert the resolved page is non-null and its `->parent()->route()` equals the active dailies route — using the KTD5 shared helper so save and delete share one scope check. Reject a `null` resolution or any `..`/traversal segment (a raw string-prefix check is insufficient — see KTD6). Also assert `grav.user.username == site.owner_username` (KTD8). Throw `ValidationException` (fail closed) otherwise. Leave create (no `edit_path`) untouched.
|
||||
- **Execution note:** test-first — add failing tests asserting both an out-of-scope `edit_path` **and** a traversal `edit_path` (`/trips/<active>/dailies/../other/entry.md`) are rejected before writing the guard.
|
||||
- **Patterns to follow:** the existing fail-closed `ValidationException` for a missing `active_trip` in the same method; the KTD5 shared scope-guard helper.
|
||||
- **Test scenarios:**
|
||||
- Edit within active trip's dailies → guard passes, save proceeds.
|
||||
- Edit with `edit_path` pointing outside active dailies (e.g. another trip, or `/`) → `ValidationException`, no page write. *Covers V3.*
|
||||
- Traversal `edit_path` that string-prefix-matches the active dailies but resolves elsewhere → `ValidationException`, no page write. *Covers V3 (traversal branch).*
|
||||
- Non-owner authenticated session (e.g. `tester`) → `ValidationException`, no page write.
|
||||
- Create (no `edit_path`) → guard is a no-op, entry posts normally.
|
||||
- **Verification:** a forged out-of-scope or traversal `edit_path`, and a non-owner session, cannot write; in-scope owner edits and normal creates are unaffected.
|
||||
|
||||
### U3. Auth-aware feed collection; drafts excluded from map/stats
|
||||
|
||||
- **Goal:** Owner sees drafts in the feed; public and map/stats do not.
|
||||
- **Requirements:** R5; KTD7, KTD8.
|
||||
- **Dependencies:** none (parallel-safe with U1/U2).
|
||||
- **Files:** `user/themes/intotheeast/templates/trip.html.twig`, `user/themes/intotheeast/templates/home.html.twig`.
|
||||
- **Approach:** Swap `.children.published()` → `grav.user.authenticated ? dailies_page.children : dailies_page.children.published()` for the **feed** list only. Keep the map `entries` array and stats inputs on a `.published()`-only list. Compute `owner_can_edit` (KTD8) and pass it into `trip-feed-col`. Home active-trip branch: `owner_can_edit = grav.user.authenticated`. Between-trips grid stays `.published()`.
|
||||
- **Patterns to follow:** existing `{% set journal_entries = ... %}` blocks at `trip.html.twig:12`, `home.html.twig:17`; the existing `{% include 'partials/trip-feed-col.html.twig' with { ... } only %}` param list.
|
||||
- **Test scenarios:**
|
||||
- Anonymous visitor: draft entry absent from feed, map, and stats. *Covers V4.*
|
||||
- Authenticated owner: draft entry present in feed; still absent from map markers and stat counts.
|
||||
- Published entries: unchanged for both audiences.
|
||||
- **Verification:** draft visibility differs by auth in the feed only; map/stats identical for both.
|
||||
|
||||
### U4. Card UI: Draft badge + Edit/Delete controls
|
||||
|
||||
- **Goal:** Render the badge and the owner controls on the journal card.
|
||||
- **Requirements:** R1, R3, R5, R6; KTD8.
|
||||
- **Dependencies:** U3 (provides `owner_can_edit` and draft flag).
|
||||
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/partials/entry-journal.html.twig`, theme CSS (`user/themes/intotheeast/css/…` or the relevant partial styles).
|
||||
- **Approach:** Thread `owner_can_edit` (owner-username gated per KTD8, not merely authenticated) through `trip-feed-col` into `entry-journal`. In `entry-journal.html.twig`: when `entry.published` is false, render a "Draft" badge in the header. When `owner_can_edit`, render an **Edit** link (`/post?edit={{ entry.route }}&return={{ page.url|url_encode }}` — the `return` param lets a save from the home feed reload back to home, not always the trip page; see U5/D5) and a **Delete** control with the inline Cancel/Confirm button-swap markup (no browser dialog). Carry `data-entry-route` for the delete JS. **Touch targets (D8):** Edit/Delete/Cancel/Confirm controls get a min 44×44px tap area (phone-first, field use) — add the sizing to the card-control CSS.
|
||||
- **Patterns to follow:** the card header structure at `entry-journal.html.twig:3-22`; the filter/`data-*` attribute convention already on the `<article>`.
|
||||
- **Test scenarios:**
|
||||
- Anonymous: no Edit/Delete controls, no Draft badge visible (drafts absent anyway).
|
||||
- Owner on active trip: Edit + Delete present on every journal card; Draft badge on unpublished ones. *Covers V5.*
|
||||
- Non-owner authenticated (e.g. `tester`) on active trip: no controls (owner_can_edit false).
|
||||
- Owner on a past-trip page: no controls (owner_can_edit false).
|
||||
- `Test expectation:` markup/gating — covered by the above UI assertions.
|
||||
- **Verification:** controls appear only for owner+active-trip; badge tracks publish state; controls meet the 44px tap-target minimum.
|
||||
|
||||
### U5. Edit prefill + edit-mode form behavior (post-form.js)
|
||||
|
||||
- **Goal:** Fill the form from the entry and adapt the form for editing.
|
||||
- **Requirements:** R1, R2; KTD1, KTD4, KTD9.
|
||||
- **Dependencies:** U1 (fields exist).
|
||||
- **Files:** `user/themes/intotheeast/js/src/post-form.js` (rebuild via `make build-assets`; never hand-edit `js/post/post-form.js`).
|
||||
- **Approach:** On `?edit=<route>` detection, **before the fetch fires, disable all form fields and swap the submit button to a "Loading entry…" state (D1)** — this prevents the owner typing into empty fields on a slow mobile connection and having that input silently overwritten when the prefill resolves. Then `GET /api/v1/pages<route>` (`credentials: 'include'`), populate title/date/content/lat/lng/city/country/weather/transport/featured/force_connect/published, set the hidden `edit_path`, hide the photos section, and skip the ≥1-photo validation (KTD9); re-enable fields + restore the submit button on success. **Edit-mode chrome (D6):** set the form `h1` to "Edit entry" and the submit button to "Save changes". **On fetch failure (D7):** show an inline error banner between the form heading and the first field, restore empty defaults, keep fields disabled (don't leave a half-filled form). **Save behavior:** keep the existing full-reload-on-success, redirecting to the `return` URL param when present, else the active trip page (D5). **Save-failure state (D3):** if the server guard rejects the submit, the error re-render must preserve the hidden `edit_path`, the `published` toggle, and the prefilled fields so the owner doesn't lose edit context (relevant given the Form 9.1.10 re-render path — see Risks/A3). **Pre-U5 check (A4):** confirm with one `curl` (session cookie) that `GET /api/v1/pages<route>` returns the required frontmatter keys + content and record the exact JSON path (`header.*` vs flat) before wiring field mapping — the gpx-manager reference only covers `/media`.
|
||||
- **Patterns to follow:** the existing API-fetch + `credentials: 'include'` usage in `gpx-manager.html.twig`; the existing `initValidation` and field-setting helpers in `post-form.js`.
|
||||
- **Test scenarios:**
|
||||
- Loading state: on `?edit=`, fields are disabled and the button reads "Loading entry…" until the fetch resolves; typing is impossible before prefill lands. *Covers D1.*
|
||||
- Edit load: `/post?edit=<route>` fills every field with the entry's values, sets `edit_path`, and shows the "Edit entry" heading. *Covers V6.*
|
||||
- Edit save: change the title, submit → same folder/URL, title updated, photos intact; reload lands on the `return` surface. *Covers V1 (edit branch), D5.*
|
||||
- Photos hidden + ≥1-photo rule relaxed in edit mode: submitting with empty FilePond succeeds and keeps existing images.
|
||||
- API fetch failure: inline error banner shown between heading and first field; form not silently broken.
|
||||
- **Verification:** editing round-trips values with a stable URL; the form is never editable before prefill lands; photos survive an M1 edit; a failed save preserves edit context.
|
||||
|
||||
### U6. Delete API route + card delete wiring (entry-actions plugin)
|
||||
|
||||
- **Goal:** Actually delete an entry, scoped to the active trip.
|
||||
- **Requirements:** R3, R6; KTD5.
|
||||
- **Dependencies:** U4 (delete control markup).
|
||||
- **Files:** new plugin `user/plugins/entry-actions/` (`entry-actions.php`, `entry-actions.yaml`, `blueprints.yaml`); **delete JS in a small feed-scoped script** `user/themes/intotheeast/js/src/feed-actions.js` (rebuilt via `make build-assets`) — the delete control lives in `entry-journal.html.twig` (rendered by the feed partial, not the `/post` page), so it does **not** belong in `post-form.js` (C3); `plugins.txt` note only if GPM-managed (this is custom-in-repo, so **not** added to `plugins.txt`).
|
||||
- **Approach:** Register `DELETE /api/v1/entry/<slug>` via `onApiRegisterRoutes`. Handler: require the authenticated **owner** (`grav.user.username == site.owner_username`, KTD8); reject any slug with `/` or `..` at entry (400); resolve the target through the page tree via `$grav['pages']->find($dailiesRoute . '/' . $slug)` (never raw filesystem-path concatenation); assert the resolved page is non-null and a direct child of the active dailies (KTD5 shared helper); delete the page folder; `cache->deleteAll()`. Return 200/400/403/404 as appropriate. **Frontend (feed-actions.js):** Delete → inline swap to Cancel/Confirm. **On Confirm-click (D2): immediately disable both buttons and set Confirm to "Deleting…", and announce via an `aria-live` region** — prevents a mobile double-tap firing two DELETEs (the second 500s on an already-removed folder). On 200: **capture the next-sibling journal card, remove the deleted card, then move focus to that sibling (or the feed heading if it was the last card) and announce "Entry deleted" via `aria-live` (D4)**. On 403/error: re-enable both buttons, restore labels, show a one-line inline message directly below the control, constrained to card width (D7).
|
||||
- **Execution note:** test-first on the scope guard — out-of-scope, traversal, and non-owner deletes must be refused before the happy path is wired.
|
||||
- **Patterns to follow:** `grav-api-integration` skill (`AbstractApiController`, `onApiRegisterRoutes`, response/exception helpers); `api.yaml` session-auth config; the gpx-manager delete fetch shape.
|
||||
- **Test scenarios:**
|
||||
- Owner deletes an active-trip entry → folder gone, cache cleared, card removed, focus moves to the next card. *Covers V5 (delete).*
|
||||
- Delete targeting a non-active-trip / arbitrary page route → 403, nothing deleted. *Covers V3 (delete branch).*
|
||||
- Traversal slug (`../`) or slug containing `/` → 400, nothing deleted.
|
||||
- Non-owner authenticated session (`tester`) → 403, nothing deleted.
|
||||
- Unauthenticated delete request → 401/403, nothing deleted.
|
||||
- In-flight guard: double-tapping Confirm fires exactly one DELETE (buttons disabled after first click).
|
||||
- Confirmation UX: Delete → Cancel restores original control; Delete → Confirm triggers the request.
|
||||
- **Verification:** scoped delete works for the owner only; out-of-scope/traversal/non-owner/unauth requests are refused; no double-submit; focus is preserved after removal.
|
||||
|
||||
### U7. M2: Load existing photos into FilePond on edit
|
||||
|
||||
- **Goal:** Show the entry's current photos in the edit form so they can be managed.
|
||||
- **Requirements:** R7; (M2).
|
||||
- **Dependencies:** U5 (edit mode established). Milestone 2.
|
||||
- **Files:** `user/themes/intotheeast/js/src/post-form.js`; possibly the `entry-actions` plugin or Grav media API for per-photo metadata.
|
||||
- **Approach:** In edit mode, instead of hiding the photos section (KTD9's M1 behavior), pre-populate FilePond with the entry's existing images as remote/local items (FilePond `files` init pointing at the entry media URLs). Re-enable the photos section for edit.
|
||||
- **Patterns to follow:** the existing FilePond init + `GravFilePond` usage in `post-form.js`; entry media URLs as rendered in `entry-journal.html.twig`.
|
||||
- **Test scenarios:**
|
||||
- Edit load: existing photos appear as FilePond items in current order. *Covers V9.*
|
||||
- Entry with a single photo / many photos both render correctly.
|
||||
- **Verification:** the edit form shows the real photos ready to manage.
|
||||
|
||||
### U8. M2: Persist add / remove / reorder (cover = first)
|
||||
|
||||
- **Goal:** Save photo changes back to the entry.
|
||||
- **Requirements:** R8, R9, R10; (M2).
|
||||
- **Dependencies:** U7.
|
||||
- **Files:** `user/themes/intotheeast/js/src/post-form.js`, `user/plugins/cache-on-save/cache-on-save.php` (`reorderPhotos`), `user/plugins/add-page-by-form/add-page-by-form.php` (file-delete path).
|
||||
- **Approach:** On save, reconcile FilePond state to the `photo-1..N` scheme (drag order = cover order, reusing the existing rename convention). Route removals through `add-page-by-form`'s existing deleted-files mechanism (`add-page-by-form.php:121, 715-718`) so dropped images are unlinked. New uploads get the same HEIC→JPEG conversion as create. Verify `reorderPhotos` is reachable from the edit path.
|
||||
- **Execution note:** characterization-first — capture current `reorderPhotos` behavior before extending it to the edit path.
|
||||
- **Patterns to follow:** existing `photo-1..N` rename + `reorderPhotos()` in `cache-on-save`; HEIC→JPEG `beforeAddFile` hook in `post-form.js`.
|
||||
- **Test scenarios:**
|
||||
- Remove a photo → file unlinked on disk; remaining renumbered; feed cover updates. *Covers V10.*
|
||||
- Add a photo (incl. HEIC) → appended, converted, renamed into sequence.
|
||||
- Reorder so a different image is first → that image becomes the feed cover.
|
||||
- Mixed add+remove+reorder in one save → final on-disk set matches the FilePond order exactly.
|
||||
- **Verification:** the on-disk photo set and cover match the FilePond state after save.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
- **V1 — Create not regressed by edit mode.** With `overwrite_mode: edit` and no `edit_path`, posting a new entry writes a fresh dated folder identical to prior behavior — verified by the KTD1 plugin patch (empty `edit_path`/`file_path` falls through to `slug_field`). Assert on the **on-disk folder + feed**, not the re-rendered form (the Form 9.1.10 re-render may 500 — see Risks/A3).
|
||||
- **V2 — Publish field round-trips.** The `published` toggle writes a real boolean `published: true/false` to frontmatter (not the quoted string `'0'`) and the removed static default no longer overrides it.
|
||||
- **V3 — Scope guard rejects out-of-scope, traversal, and non-owner writes/deletes.** A forged out-of-scope `edit_path`/delete route, a traversal path that string-prefix-matches active dailies but resolves elsewhere, and a non-owner authenticated session (e.g. `tester`) are each refused server-side (edit → `ValidationException`; delete → 403/400), with no disk change. Both guards call one shared helper (KTD5).
|
||||
- **V4 — Draft visibility is auth-scoped.** Anonymous: draft absent from feed/map/stats. Owner: draft present in feed only (still absent from map markers and stat counts).
|
||||
- **V5 — Owner (only) can edit and delete from the card.** Active-trip cards expose working Edit and Delete (with confirm) to the owner username only; the edited entry keeps its URL; the deleted entry disappears and focus moves to the next card. Assert on disk/feed, not the re-render (A3).
|
||||
- **V6 — Prefill loads all fields.** `/post?edit=<route>` populates every listed field plus `edit_path` and the publish toggle, and the form is not editable until prefill lands (D1).
|
||||
- **V7 — (interim + launch) Draft direct-URL returns 404 to anonymous.** Confirm a `published: false` entry's URL 404s for anonymous visitors under the current Login config — not merely feed-hidden. **Run this in the dev container during M1** (added to DoD), not only as a launch gate — entry URLs follow a guessable date-slug pattern.
|
||||
- **V8 — (launch) No draft leak under `twig.cache: true`.** With caching on, load the feed as owner then anonymous; drafts never leak to the public nor vanish for the owner.
|
||||
- **V9 — (M2) Existing photos load into FilePond on edit.**
|
||||
- **V10 — (M2) Add/remove/reorder persists; cover = first photo.**
|
||||
|
||||
Existing UI suite to extend: `tests/ui/post/post-form-ux.spec.js` and helpers in `tests/ui/helpers`. Standalone Playwright scripts run against the container per the session norm. **Given the Form 9.1.10 filepond regression (Risks/A3), M1 UI assertions target the on-disk entry and the re-rendered feed, not the post-submit form re-render.**
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All M1 units (U1–U6) implemented; V1–V6 pass, plus **V7 run in the dev container** as an M1 check (draft direct-URL 404s for anonymous). V8 recorded as a launch-gate check (not blocking M1 merge but tracked).
|
||||
- Owner (owner-username, not merely any authenticated account) can edit, delete (with confirm), and unpublish/publish a journal entry entirely from the active-trip feed, with the entry URL stable and the public never seeing drafts.
|
||||
- Server-side scope guard proven on both save and delete paths via the shared helper (V3), including traversal and non-owner rejection.
|
||||
- Empty-`jwt_secret` risk resolved: confirmed the API does not accept empty-signed tokens on the new routes (see Risks/S1).
|
||||
- M2 units (U7–U8) implemented; V9–V10 pass — may land as a separate follow-up PR after M1.
|
||||
- No regression to the create flow (V1) or to stories.
|
||||
- Assets rebuilt via `make build-assets`; no hand-edits to `js/post/post-form.js`.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **`overwrite_mode: edit` create-path behavior (KTD1)** was the load-bearing assumption and it **fails as originally written** (feasibility + adversarial, confidence 100) — the plan now resolves it with a required plugin patch in U1 (fall through to `slug_field` when `edit_path`/`file_path` empty). V1 verifies the patched create path. Contingency if the patch proves unworkable: a dedicated edit-form page.
|
||||
- **Empty `jwt_secret` in `api.yaml` (S1, security-lens).** `jwt_secret: ''` alongside `jwt_enabled: true` — if the API plugin accepts tokens signed with the empty string, the "authenticated owner" guard on both new routes (delete, prefill GET) is forgeable by an unauthenticated attacker. **Pre-M1 check:** verify against the api plugin source (or empirically) that an empty secret means "JWT disabled" and does not accept empty-signed tokens; if it does, set a real secret before shipping. The plugin's own owner-identity assertion (KTD5/KTD8) is the primary control regardless.
|
||||
- **Grav API session permission for the custom delete route** — confirm the `site.login` session carries sufficient permission for the plugin's delete action (page removal may need an elevated check); the plugin owns its own auth assertion regardless (KTD5).
|
||||
- **Form 9.1.10 filepond regression** (flagged in project instructions: the post-submit re-render 500s on the filepond field) affects **M2** photo editing **and also M1's edit-save reload (A3, adversarial)** — every `new-entry` submit, including an M1 edit save, goes through the same re-render. It also already breaks the 6 post UI specs. **Mitigation for M1:** assert V1/V5/V6 on the on-disk entry + re-rendered feed rather than the post-submit form re-render (see Verification). M2 photo editing should land only once the regression is resolved in the form-to-page/image-upload rework; do not work around it here.
|
||||
- **CSRF posture** — the delete route relies on the existing `cors.credentials: false` (blocks cross-origin credentialed fetch). The edit-save POST additionally depends on the PHP session cookie's `SameSite` attribute; confirm it is `Lax`/`Strict`. Document this dependency; revisit if CORS is ever loosened.
|
||||
- **Owner account hygiene** — the super-admin `tester` account authenticates and, under a naive `grav.user.authenticated` gate, would gain full edit/delete rights; the owner-username gate (KTD8) closes this. The `tester` account should not ship to production.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Codebase (grounding for every KTD): `user/plugins/add-page-by-form/add-page-by-form.php` (edit mode 537-545, delete path 121/715-718), `user/plugins/cache-on-save/cache-on-save.php` (parent injection + cache clear), `user/pages/02.post/post-form.md` (blueprint), `user/themes/intotheeast/templates/trip.html.twig` & `home.html.twig` (feed collection), `partials/trip-feed-col.html.twig` & `partials/entry-journal.html.twig` (card), `user/themes/intotheeast/templates/gpx-manager.html.twig` + `user/plugins/api/api.yaml` (session-auth API delete pattern), `user/themes/intotheeast/js/src/main.js` (filter bar).
|
||||
- Skills: `grav-api-integration` (custom API route contract for the `entry-actions` delete endpoint).
|
||||
- Upstream: this artifact's own Product Contract (ce-brainstorm) and the ce-doc-review pass of 2026-07-04.
|
||||
@@ -10,7 +10,7 @@ execution: code
|
||||
|
||||
# Journal Post Form Improvements — Plan
|
||||
|
||||
**Status:** 📋 Not started
|
||||
**Status:** ✅ Complete (2026-07-04) — implemented on `feat/journal-post-form` (U1–U7). U4 changed course during execution: the "plain input + custom uploader" fallback uploaded to Grav's flash but couldn't attach photos to the entry without replicating FilePond's undocumented submit contract, so photos now stay on `type:filepond` with a `beforeAddFile` hook that converts HEIC→JPEG then re-adds via `pond.addFile()` (FilePond owns upload+attach). Verified end-to-end in a browser (HEIC→JPEG attach, corrupt-HEIC fail-closed, disclosure, weather gating, draft restore) and via curl (active-trip parent injection + empty-`active_trip` fail-closed). Merged into `main` on 2026-07-08 (outer-repo `feat/journal-post-form`).
|
||||
|
||||
> 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Photo Editor for Journal Entries (media-API) — Plan
|
||||
date: 2026-07-05
|
||||
---
|
||||
|
||||
# Photo Editor for Journal Entries (media-API) — Plan
|
||||
|
||||
**Status:** 🔄 In progress — implemented & server-logic verified (2026-07-05). Server (shared `PhotoRenumberer`, reorder route, guards) + client (own grid, SortableJS, FilePond decommission) landed; `PhotoRenumberer` unit-verified (pad/normalise/swap/gap/crafted-name-safety/10+/idempotent/ext), PHP lints clean, JS/CSS build clean, `/post` + assets serve on :8091. Server-side SVG block deferred to the R6 add/delete fast-follow (see Deferred). **Pending owner-session UI verification** (add incl. HEIC, inline-confirm delete, mouse reorder, combined; feed cover=first; regressions a/b/c) and **on-device touch-drag** — both need the owner login the harness can't obtain.
|
||||
|
||||
## Why this exists (the honest reason)
|
||||
|
||||
M2 tried to edit an entry's photos by reusing the `/post` **create** form + FilePond + the abandoned `add-page-by-form` plugin. Two distinct failure classes came out of that, and it matters not to blur them into one root cause:
|
||||
|
||||
- **FilePond-widget bugs** — `text/html` previews and broken touch-drag. FilePond is built to upload new files to a fresh entry, not to load/preview/reorder existing server files; these are the widget used against its grain.
|
||||
- **PHP-side bugs** — the header-cast fatal and the rename-reconcile gymnastics live in `add-page-by-form` / `cache-on-save`, **not** in FilePond. This plan **reuses that same rename-reconcile logic** (see the reorder route below), so it must be validated on its own merits — a "different foundation" does not make the carried-forward reconcile code automatically safe.
|
||||
|
||||
This plan replaces the **photo UI** with the **proven `gpx-manager` pattern**: our own UI talking straight to the Grav media API.
|
||||
|
||||
## Foundation status (what's proven vs still assumed)
|
||||
|
||||
**Proven on 2026-07-05 (not assumed):**
|
||||
|
||||
- `POST /api/v1/pages<entry-route>/media` (FormData `file`, owner session) → **201**, file on disk ✓
|
||||
- `DELETE /api/v1/pages<entry-route>/media/<filename>` → **204**, removed ✓
|
||||
- Owner session auth works on entry routes ✓
|
||||
|
||||
**Still assumed (novel, load-bearing, NOT yet proven — this is where the 4-day risk lives):**
|
||||
|
||||
- The custom reorder route (rename to `photo-01..NN`) — no stock endpoint exists.
|
||||
- Live reorder-rename behaviour under real add/delete ops.
|
||||
- Three independent live mutations interacting cleanly with the edit session's text-field Save.
|
||||
- HEIC→JPEG conversion at real photo sizes/counts on a phone.
|
||||
|
||||
## Design decisions
|
||||
|
||||
1. **Live, not on-submit.** Add / delete / reorder each persist **immediately** via the API — decoupled from the `/post` form's text-field Save. No flash, no submit-time reconcile. This sidesteps `add-page-by-form` for the photo path entirely (the text-field save still uses it + our committed patch). *(Edit-then-leave / no-undo behaviour for the destructive delete path is unresolved — see Open Questions.)*
|
||||
2. **Inline on `/post?edit`.** In edit mode, hide the FilePond section and render the photo-editor component from the media list. **Create mode keeps FilePond, untouched** (out of scope). Hiding the section alone is **not** enough — see the FilePond decommission step in the Client section.
|
||||
3. **Own thumbnail grid, SortableJS for drag.** Square `<img>` thumbnails in a grid. Reorder via **SortableJS** — exactly what FilePond couldn't do reliably here. SortableJS is **not yet a theme dependency**: install `sortablejs` and import it into `js/src/post-form.js` so esbuild bundles it into `js/post`. This is a task, not existing foundation.
|
||||
4. **Cover = first.** After any add/delete/reorder, files are renumbered **`photo-01..NN`** (zero-padded, wide enough for the expected max) in display order; the client sorts thumbnails **numerically**, and the feed renders `media.images|first` as cover. Zero-padding is required so lexicographic media order equals numeric order past 10 photos (otherwise photo-1, photo-10, photo-2…). The shared renumber helper must also normalise any pre-existing un-padded `photo-N` files on first reorder. **This helper also runs on create-mode reconcile**, so create-mode entries will now emit `photo-01..NN` too — an intentional, accepted change (see Scope boundaries). Existing published entries keep their un-padded names harmlessly (they have <10 photos and the client sorts numerically).
|
||||
5. **Add/delete via stock media API; reorder via one custom scope-guarded route.** Stock `POST`/`DELETE …/media` are already proven on entry routes, so add + delete use the **stock media API** (client-side, session-auth). Only the missing **reorder** (rename to `photo-01..NN`) is a custom route in the **`entry-actions`** plugin using `EntryScopeGuard` (owner-username + direct-child-of-active-dailies, the R6 guard). **Accepted tradeoff:** server-side scope enforcement on photo **add/delete** is a **known R6 gap** — any account with `api.media.write` can reach the un-scoped stock endpoint directly, and the client UI gate is **not** an access-control boundary. For a solo-owner blog this is accepted for launch and tracked as a **documented fast-follow** (promote add/delete onto scope-guarded custom routes later). HEIC→JPEG happens client-side before upload (reuse the existing converter).
|
||||
|
||||
## Server — `entry-actions` plugin, 1 custom route (+ stock media API for add/delete)
|
||||
|
||||
**Add / delete — stock media API (client-side, session-auth):**
|
||||
|
||||
- `POST /api/v1/pages<entry-route>/media` — upload (stock endpoint). **No SVG support for now:** add `svg` to `security.uploads_dangerous_extensions` (or reject `.svg` in the upload path) so SVGs are **blocked, not sanitized** — this removes the stored-XSS-via-SVG vector without depending on `security.sanitize_svg` staying enabled. Other executable types (html/js/php) are already blocked by Grav's default dangerous-extension denylist, which is the **actual** control on this stock path — there is no positive MIME allowlist or on-disk extension rewrite here. Allowed image types: **jpg/jpeg/png/webp**; the client file input accepts those **plus HEIC** (converted client-side to JPEG before upload) and excludes SVG. If stronger positive-MIME validation is ever wanted, it moves add onto the scope-guarded custom route (the same place the R6 add/delete fast-follow lands).
|
||||
- `DELETE /api/v1/pages<entry-route>/media/<filename>` — remove.
|
||||
- **After every stock add and every stock delete, immediately call the reorder route (below) to re-establish `photo-01..NN`.** Stock upload keeps the file's original (slugified) name — not the next `photo-N` — and stock delete leaves a numbering gap without renumbering; without a follow-up renumber, `cover = first` breaks until the next manual drag. The reorder route is the single owner of the `photo-N` invariant.
|
||||
|
||||
**Reorder — one custom route (owner + scope guarded):**
|
||||
|
||||
- `POST /api/v1/entry/<slug>/photos/order` — body: ordered filenames → two-phase rename to `photo-01..NN` (reuse the proven cache-on-save rename logic; factor it into a shared helper — and validate that helper on its own, per "Why this exists").
|
||||
|
||||
Handler: `EntryScopeGuard::isOwnerUser` + `resolveActiveDailyChild` (reject 403/400 otherwise), then filesystem op, then `cache->deleteAll()`. Reject filenames containing `/` or `..`. **Operate only on filenames that already exist as image media** in the entry folder — any name in the ordered list that isn't a current image file is ignored, so the entry `.md`, a `.gpx`, or a `.meta.yaml` can never be renamed or clobbered by a crafted order body.
|
||||
|
||||
**Deploy note:** the new `/entry/<slug>/photos/order` route only registers after the API route-map cache is rebuilt, so a cache clear must run on deploy. The existing `DELETE /entry/<slug>` route confirms the nested-static-after-param pattern registers fine.
|
||||
|
||||
## Client — new `photo-editor.js` (bundled into the post-form entry)
|
||||
|
||||
In edit mode only:
|
||||
|
||||
- **Decommission the FilePond photo path (hiding it is not enough).** Skip `editLoadPhotos()` and the FilePond `photo_order` submit-handler wiring entirely — do not initialise/populate FilePond. Otherwise the stale `photo_order` manifest posted on text Save drives `cache-on-save.reconcilePhotos()` → `deleteUnlistedImages()`, which **silently deletes any photo added live after page-open**. With an empty manifest the reconcile leaves the live-managed folder untouched.
|
||||
- Hide the FilePond `.photos-collapse`; render `.photo-editor` from `GET …/media` (image files, numeric-sorted). Show a **loading placeholder** during the fetch and an **empty state** for zero-photo entries that keeps the "Add photos" button visible ("No photos yet — add some").
|
||||
- Each cell: `<img>` thumbnail + ✕ delete. **Inline confirm:** ✕ swaps the cell to "Delete? [Confirm] [Cancel]" (Confirm disabled while the DELETE is in flight) → `DELETE …/media/<file>` → renumber → re-render; Cancel reverts.
|
||||
- "Add photos" button → hidden file input → HEIC→JPEG → `POST …/media` (one per file) → renumber → re-render. **Upload progress:** disable the button while a batch is in flight and show "Uploading N of M…", clearing per file.
|
||||
- **Add is a two-write op (stock upload, then reorder).** On a multi-file add, upload each file (stock `POST …/media`) and call the reorder route **once after the whole batch** — not per file — so there is one renumber pass and only the final numbering matters. The client passes the stock-uploaded basenames into that reorder manifest. If an upload succeeds (201) but the follow-up reorder fails, auto-retry the reorder — it is idempotent, since `renumberPhotos` skips files not on disk — or roll back by `DELETE`-ing the just-uploaded file(s), and surface a single inline error. Never leave an orphan stock-named file in the folder: it is a real image, so it would break `cover = first` and the numeric sort until the next successful drag.
|
||||
- `Sortable` on the grid → on drop, `POST …/photos/order` with the new filename order → re-render. First cell = cover.
|
||||
- **Failure path (every op).** On non-2xx / network error: show an inline error near the affected control (reuse gpx-manager's `.gpx-status.error`), keep the item in place — for reorder, **revert the SortableJS move to the last-known-good order** — re-enable the control for retry, and do **not** silently re-render. Displayed order/cover must never disagree with disk without an error shown.
|
||||
- All live; independent of the form's Save button (which continues to handle title/date/content/etc.).
|
||||
|
||||
## Scope boundaries (non-goals)
|
||||
|
||||
- **Create flow (new-entry FilePond) untouched** — *except* that the shared renumber helper is now zero-padded, so create-mode entries also emit `photo-01..NN`. That is the only create-path side effect; the FilePond UI itself is unchanged. Two photo UIs for now (FilePond on create, this on edit); unifying them is a follow-up.
|
||||
- **Text-field editing unchanged** (`/post` form + `add-page-by-form` + our patch).
|
||||
- No captions, no crop/rotate, no bulk ops.
|
||||
|
||||
## Verification
|
||||
|
||||
- **I verify in-harness:** add (incl. HEIC), delete, reorder-by-**mouse**, and combined — each persists to disk + shows in the feed immediately; cover = first after reorder; the reorder route's owner/scope-guard rejects non-owner + out-of-scope.
|
||||
- **Regression checks:** (a) a text-field Save *after* a live photo add does **not** delete the added photo (FilePond decommission); (b) an entry with **10+ photos** keeps arranged order and the correct cover (zero-padding); (c) each op's failure path shows an inline error and leaves UI and disk consistent.
|
||||
- **You verify on-device (the one thing I can't simulate):** touch-drag reorder on a phone.
|
||||
|
||||
## Estimate
|
||||
|
||||
One focused implementation push — 1 custom reorder handler + stock add/delete reuse + one JS component + CSS + the SortableJS dependency (install + import). Not another multi-day cycle. Residual risk concentrated in the "still assumed" list above.
|
||||
|
||||
## Deferred / Open Questions
|
||||
|
||||
### From 2026-07-05 review
|
||||
|
||||
- **No undo / cancel model for destructive live edits (P1).** Add/delete/reorder persist immediately and delete is a destructive `unlink`; the Save button trains the user that leaving without saving discards changes, but live deletes are already gone with no undo and no "permanent" signal. Decide between: (a) accept live-is-permanent + add a "saves immediately" affordance and a real delete confirm (cheapest for the deadline); (b) soft-delete to a trash subfolder purged on Save/leave; (c) stage deletes client-side and commit on Save. Resolve before implementing the delete path.
|
||||
|
||||
### Deferred during implementation (2026-07-05)
|
||||
|
||||
- **Server-side SVG block deferred to the R6 add/delete fast-follow.** The plan
|
||||
called for adding `svg` to `security.uploads_dangerous_extensions`, but
|
||||
`user/config/security.yaml` is **gitignored** (a Grav 1.7-era rule from when the
|
||||
HMAC `salt` lived there; obsolete in 2.0.7 where the secret moved to the
|
||||
still-ignored `security-private.php`). Tracking it would mean un-ignoring a
|
||||
security-namespace file from another work session's era — out of scope for this
|
||||
push. Instead: **SVG is excluded client-side** in the photo-editor file input
|
||||
`accept` (jpg/jpeg/png/webp + HEIC only). The **server-side** block is a
|
||||
documented fast-follow that lands together with promoting photo add/delete onto
|
||||
the scope-guarded custom route (the same R6 gap already accepted above) — both
|
||||
concern the un-scoped stock media endpoint, which only the solo owner can reach.
|
||||
|
||||
### Resolved at review close (2026-07-05) — recorded for the implementer
|
||||
|
||||
- **Reorder-route filename safety** — *resolved:* the handler operates only on filenames already present as image media in the folder, so a crafted order body can't rename/clobber the entry `.md`, a `.gpx`, or a `.meta.yaml`. (Now in the Server reorder-route spec.)
|
||||
- **Multi-photo add — reorder cadence** — *resolved:* call the reorder route **once after the whole batch** of uploads, not once per file. (Now in the Client "Add is a two-write op" spec.)
|
||||
- **New route 404 until cache rebuild** — *resolved:* deploy must clear the API route-map cache so `/entry/<slug>/photos/order` registers; the existing `DELETE /entry/<slug>` proves the nested-route pattern works. (Now a deploy note in the Server section.)
|
||||
- **`.meta.yaml` sidecars not renamed by `renumberPhotos`** — *deferred (genuine future work):* no effect today because per-image captions are deferred. When captions ship, the shared renumber helper must rename each image's `.meta.yaml` sidecar alongside it (and clean up orphans), or per-image metadata will drift on reorder/delete.
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
title: Trip Description, One-liner & Hero Image - Plan
|
||||
type: feat
|
||||
date: 2026-07-05
|
||||
topic: trip-description-and-hero
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: implementation-ready
|
||||
product_contract_source: ce-brainstorm
|
||||
execution: code
|
||||
---
|
||||
|
||||
# Trip Description, One-liner & Hero Image - Plan
|
||||
|
||||
**Status:** ✅ Complete (2026-07-06)
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **Objective:** Give each trip an optional one-liner and description, surface them on the trip list and trip page, and fix the low-resolution trip cover image — all editable from the admin panel.
|
||||
- **Product authority:** Mischa (site owner).
|
||||
- **Open blockers:** None. Ready for planning.
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
Add an optional one-liner and an optional description to trips, and render them where they help: the one-liner on both the trip-list cards and the trip page, the description on the trip page only. On the trip page, extend the **existing in-column header** — `home-trip-header` in the shared `trip-feed-col` partial, which already shows title + dates/counts — with the one-liner (below the title) and the description, plus a thin banner image strip (~180–220px) directly below the header text and above the filter bar. No new header is introduced above the map+journal split, and the split itself is unchanged. Because `trip-feed-col` is shared with the homepage active-trip view, these additions are gated to the trip-page caller so that view is unaffected. Fix the trip cover image so it renders sharp (larger derivative + retina `srcset`) and is chosen via an admin media picker, with the current auto-pick fallback retained.
|
||||
|
||||
### Problem Frame
|
||||
|
||||
Trips currently carry no human-readable summary anywhere the reader sees. The trip-list cards (`user/themes/intotheeast/templates/trips.html.twig`) show only title, dates, and counts; the trip page (`user/themes/intotheeast/templates/trip.html.twig`) shows the title, dates, and counts only *inside* the feed column — via the shared `trip-feed-col` partial's `home-trip-header` block — and no one-liner, description, or banner image. A `header.tagline` field already exists in the trip blueprint but is used only on homepage highlight cards, and a markdown `content` field (labeled "Description" in admin) exists but is never rendered. Separately, the trip-list cover image auto-picks the first journal entry's first photo and crops it to 720×240, which looks soft — especially on high-DPI screens — and the author has no easy way to choose a better shot.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **Reuse `header.tagline` as the single one-liner.** The existing tagline field becomes the one source for the short subtitle across all three surfaces (homepage highlight cards, trip list, trip page). Rejected a separate new field: two fields to keep in sync for one concept.
|
||||
- **Reuse the markdown `content` field as the description.** It is already editable in admin and labeled "Description"; it is simply not rendered on the trip page yet. Rejected adding a new short-text field.
|
||||
- **Extend the existing in-column header; no new header above the split.** The one-liner and description are added to the existing `home-trip-header` block (in the shared `trip-feed-col` partial, which already renders title + dates/counts), with a thin banner strip (~180–220px) directly below the header text and above the filter bar — not a full-bleed hero, and not a separate header above the map+journal split. A slim banner plus text is forgiving of source-photo quality; the full-bleed story-style hero was rejected for pushing primary content below the fold and making the page hostage to photo quality, and a new above-split header was rejected because it would duplicate the title/dates/counts the feed column already shows. Because `trip-feed-col` is shared with the homepage active-trip view, the additions are gated (via a partial parameter) to the trip-page caller so that view is unchanged.
|
||||
- **One-liner on the list, one-liner + description on the page.** The list stays scannable (short subtitle only); the fuller description lives on the trip page.
|
||||
|
||||
### Requirements
|
||||
|
||||
**Trip data & admin**
|
||||
|
||||
- R1. A trip's one-liner is stored in the existing `header.tagline` field and remains editable in the admin trip form.
|
||||
- R2. A trip's description is stored in the existing markdown `content` field and remains editable in the admin trip form.
|
||||
- R3. The admin `header.cover_image` control is a media picker that lets the author select an uploaded image on the trip page, replacing the current type-in-a-filename text field. The picker selects from images uploaded to the trip page's own media; if a trip has none yet, the author uploads one there first, and the R7 auto-pick remains the fallback until a cover is chosen.
|
||||
- R4. The one-liner and description are both optional.
|
||||
|
||||
**Trip list card**
|
||||
|
||||
- R5. When a trip's one-liner is set, the trip-list card displays it (between title and the dates/counts meta line); when unset, no one-liner line renders.
|
||||
- R6. The trip-list card cover image renders sharply on standard and high-DPI displays via a larger derivative (rendered at 1440×480) plus a retina `srcset` (720w and 1440w candidates). Sharpness depends on adequate source resolution — see Dependencies / Assumptions.
|
||||
- R7. The card cover image source is the author-selected `cover_image` when set; when unset, it falls back to the first journal entry's first image (current behavior).
|
||||
|
||||
**Trip page header**
|
||||
|
||||
- R8. On the trip page, the existing in-column header (`home-trip-header` in the shared `trip-feed-col` partial) renders — each only when set — the one-liner (directly below the title) and the description (below the dates/counts), in addition to the title, dates, and counts it already shows.
|
||||
- R9. Directly below the header text and above the filter bar, the trip page renders a thin banner image strip (~180–220px) using the same cover-image source and fallback as the list card (R7), rendered sharply per R6 as a fixed-height center-crop (no focal-point control); when no image is available, the header renders text-only with no banner strip.
|
||||
- R10. No new header is added above the map+journal split, and the map + journal two-column split is unchanged in structure and position.
|
||||
- R11. If a set `cover_image` no longer resolves (file deleted or moved), the trip-list card and the trip-page banner fall back to the R7 auto-pick rather than rendering a broken image.
|
||||
- R12. The one-liner, description, and banner strip are added for the trip-page caller of `trip-feed-col` only (via a partial parameter); the homepage active-trip view's header is unchanged.
|
||||
- R13. The one-liner is plain text (soft cap ~120 characters). The description is markdown; the header shows the first ~2–3 lines with the remainder collapsed behind an expand control, so the map+journal split stays above the fold by default while the full description remains readable on demand.
|
||||
- R14. The cover/banner image's alt text is the trip title.
|
||||
- R15. On narrow/mobile viewports the banner strip and header text reflow without pushing the map+journal split off-screen (e.g. reduced banner height); exact breakpoints are decided during planning.
|
||||
|
||||
### Acceptance Examples
|
||||
|
||||
- AE1. **Covers R4, R5, R8.** Given a trip with neither one-liner nor description set, when a reader views the trip list and the trip page, then no one-liner line and no description block render on either surface, and the in-column header still shows the title (and dates/counts if present).
|
||||
- AE2. **Covers R8.** Given a trip with a one-liner but no description, when a reader views the trip page, then the in-column header shows the title, one-liner, and dates/counts, and renders no description block.
|
||||
- AE3. **Covers R7, R9.** Given a trip with no `cover_image` set but at least one journal entry with an image, when a reader views the list card and the trip-page banner strip, then both show the first entry's first image (sharp per R6).
|
||||
- AE4. **Covers R9.** Given a trip with no `cover_image` and no journal-entry images, when a reader views the trip page, then the header renders text-only with no banner strip.
|
||||
- AE5. **Covers R6.** Given a trip cover image, when a reader views the trip-list card or the trip-page banner strip on a high-DPI (retina) display, then the larger derivative and retina `srcset` apply and the image renders sharply.
|
||||
- AE6. **Covers R10.** Given any trip, when a reader views the trip page, then the map + journal two-column split renders unchanged in structure and position, with no new header inserted above it.
|
||||
- AE7. **Covers R12.** Given the active trip, when a reader views the homepage active-trip view, then its in-column header is unchanged — no description block and no banner strip are added there.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
- The full-bleed, story-style hero banner treatment for trips.
|
||||
- A new header rendered above the map+journal split (the one-liner, description, and banner extend the existing in-column header instead).
|
||||
- Any change to the map/journal two-column split (layout, columns, feed order, filter bar).
|
||||
- Any change to the homepage active-trip view's header (the trip-page additions are gated to the trip-page caller of the shared `trip-feed-col` partial).
|
||||
- A separate one-liner field distinct from `header.tagline`, or a separate description field distinct from the markdown `content`.
|
||||
- Showing the full description on the trip-list cards.
|
||||
- Author-adjustable crop / focal-point control for the banner (fixed center-crop only).
|
||||
|
||||
### Dependencies / Assumptions
|
||||
|
||||
- Confirmed (2026-07-05): `header.tagline` and the markdown `content` field are already present and editable in the admin trip form (`trip.yaml`), so R1/R2 need no new admin fields. `header.cover_image` is currently a plain `text` field.
|
||||
- Resolved (2026-07-05): the media-picker for `cover_image` (R3) uses Grav core's `pagemediaselect` field type. Confirmed present in the Admin2 v2.0.11 compiled field-type registry (`app/_app/immutable/chunks/DzO1nmNX.js`), where `pagemediaselect`, `mediapicker`, and `filepicker` all route to the same picker component. It binds to the page's own media and stores the selected filename — the same value shape `header.cover_image` holds today — so the `trip.media[cover_image]` template lookups need no change and no text-field fallback is required.
|
||||
- Grav's image derivative + `srcset` helpers are available in Twig for producing the larger and retina cover renditions.
|
||||
- Cover source photos are assumed ≥1440px wide. A smaller source cannot be sharpened by a larger derivative (Grav upscales), so the R6 sharpness goal depends on adequate source resolution, not just a bigger render box.
|
||||
- Before enabling description rendering, grep existing `user/pages/01.trips/*/trip.md` for non-empty `content` bodies and confirm each reads as a public description or is intentionally cleared. Verified empty across the four current `trip.md` files as of 2026-07-05; the check guards future/other trips.
|
||||
- Reusing one `header.tagline` across the homepage highlight card, the trip-list card, and the trip-page header assumes the existing per-trip tagline copy reads acceptably on all three; per-surface opt-out is out of scope. Audit current taglines before shipping.
|
||||
- Reusing the markdown `content` body as the description means a future long-form trip article distinct from the short summary would require splitting the field — accepted tradeoff.
|
||||
|
||||
### Follow-up (post-implementation)
|
||||
|
||||
- Backfill one-liners and descriptions for the active and past trips so the reader-facing summary goal is actually realized — the four current `trip.md` files have empty `content` bodies, so shipping the plumbing alone leaves existing trips showing title/dates only.
|
||||
|
||||
### Sources / Research
|
||||
|
||||
- `user/themes/intotheeast/templates/trips.html.twig` — current trip-list card markup and `cropResize(720, 240)` cover logic with first-entry fallback.
|
||||
- `user/themes/intotheeast/templates/trip.html.twig` — current trip page (map+feed via `entry-map` and `trip-feed-col` partials; no dedicated header above the split).
|
||||
- `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` — the shared feed-column header (`home-trip-header`: title, dates, counts, filter bar, panel toggles) that this plan extends with the one-liner, description, and banner; **also included by `home.html.twig`'s active-trip branch**, hence the trip-page gating in R12.
|
||||
- `user/themes/intotheeast/blueprints/trip.yaml` — existing `header.tagline` (homepage-card copy) and markdown `content` ("Description") fields; `header.cover_image` as a text field.
|
||||
- `user/themes/intotheeast/templates/story.html.twig` — existing hero pattern (the rejected full-bleed reference).
|
||||
|
||||
---
|
||||
|
||||
## Planning Contract
|
||||
|
||||
**Product Contract preservation:** changed — R13 (description is now expandable rather than a fixed clamp) and the `cover_image` picker assumption (resolved: `pagemediaselect` confirmed renderable in Admin2 v2.0.11, text-field fallback dropped), both per owner decision on 2026-07-05. All other Product Contract IDs unchanged.
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
- KTD1. **`pagemediaselect` for the cover field, no fallback.** Change `header.cover_image` in `trip.yaml` from `type: text` to `type: pagemediaselect`. Confirmed renderable in Admin2 v2.0.11 (see the resolved dependency note above). Because it stores the selected filename — the value shape `cover_image` already holds — the existing `trip.media[trip.header.cover_image]` lookups in the templates are unchanged. Rejected the text-field fallback: unnecessary once the field type was verified to render.
|
||||
- KTD2. **One shared cover macro, not duplicated resolution.** The trip-list card and the trip-page banner need the same three-step cover resolution (author-selected → first journal entry's first image → none, per R7/R11) and the same retina rendering (R6/R14). Put both in a new `macros/cover.html.twig` so the two surfaces cannot drift. Rejected copy-pasting the current inline `trips.html.twig` logic into the partial: two copies of R7/R11 to keep in sync.
|
||||
- KTD3. **Retina via two explicit `cropResize` derivatives + `srcset`, not Grav's native helper.** Render a 1× and a 2× derivative with `cropResize` and emit an explicit `srcset` (e.g. `720w`, `1440w` for the card). This mirrors the existing working `cropResize(720, 240)` call and gives exact control, with no dependency on Grav's auto-`srcset`/`derivatives` config. The CSS crop (`object-fit: cover`, fixed `aspect-ratio`) is unchanged — only the derivative resolution and the `srcset` attribute change. Rejected `Medium.derivatives()`: adds a config dependency for no gain here.
|
||||
- KTD4. **Gate the header extras with a partial parameter that defaults off.** Add a `trip_header_extras` parameter to `trip-feed-col.html.twig`, defaulted to `false`. `trip.html.twig` passes it `true`; `home.html.twig` is left untouched, so its `include ... only` omits the parameter and the active-trip header renders exactly as today (satisfies R12/AE7 with zero edits to the home template). Rejected a positive flag on the home caller: more edits, more regression surface, on the branch the plan must not change.
|
||||
- KTD5. **Expandable description as inline progressive enhancement.** Render the 2–3-line preview and the full body in markup, and toggle an expanded class with a small inline `<script>` in the partial — the same pattern the partial already uses for `initTripStats`. Avoids touching `js/src/main.js` and the `make build-assets` step. Rejected a fixed CSS-only clamp: it would make the full description unreadable anywhere (owner decision). Rejected a `<details>`/`<summary>` element: harder to style the collapsed state as a clean N-line preview.
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
The trip-page in-column header (`.home-trip-header`), when `trip_header_extras` is true, stacks in this order. Everything from the filter bar down is unchanged; the home active-trip caller renders only the unshaded rows.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
T["h1 title (existing)"]
|
||||
O["one-liner — header.tagline (R8, new)"]
|
||||
D["dates (existing)"]
|
||||
C["counts (existing)"]
|
||||
DESC["description — content, 2-3 line preview + expand (R8/R13, new)"]
|
||||
B["banner strip ~180-220px — cover macro (R9, new)"]
|
||||
F["filter bar (existing, unchanged)"]
|
||||
P["panel toggles (existing, unchanged)"]
|
||||
T --> O --> D --> C --> DESC --> B --> F --> P
|
||||
SPLIT["map + journal two-column split — unchanged, stays above the fold (R10/R15)"]
|
||||
P -.-> SPLIT
|
||||
```
|
||||
|
||||
### Assumptions & Constraints
|
||||
|
||||
- Only `css/style.css` and the `.html.twig` templates are hand-edited; both are loaded directly (`base.html.twig` links `css/style.css`), so no build step is needed for this work. `css-compiled/main.css` is esbuild output and is not touched.
|
||||
- Banner dimensions (1× render box and mobile height) are tunable during implementation within the R9 ~180–220px envelope; the plan fixes the approach, not the exact pixel values.
|
||||
- Source photos are assumed ≥1440px wide (Product Contract dependency); a smaller source cannot be sharpened by a larger derivative.
|
||||
|
||||
### Sequencing
|
||||
|
||||
U1 and U2 are independent and can land first in either order. U3 and U4 both consume the U2 macro. U5 (CSS) supports U3 and U4 and should land with them for meaningful visual verification. Order: U1 → U2 → (U3, U4) → U5.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Cover field → `pagemediaselect`
|
||||
|
||||
- **Goal:** Replace the type-in-a-filename cover control with an Admin2 media picker (R3).
|
||||
- **Requirements:** R3.
|
||||
- **Dependencies:** none.
|
||||
- **Files:** `user/themes/intotheeast/blueprints/trip.yaml`
|
||||
- **Approach:** Change `header.cover_image` from `type: text` to `type: pagemediaselect`. Keep the label, refresh the help text (pick from images uploaded to this trip page). The stored value stays a filename, so no template change is required here.
|
||||
- **Patterns to follow:** existing field definitions in `trip.yaml`; field type verified against the Admin2 v2.0.11 registry.
|
||||
- **Test scenarios:** Test expectation: none — admin-only blueprint config with no automated test surface. Verified manually in U-level verification: the picker renders in the Admin2 trip form, lists the page's uploaded images, and saves the chosen filename into `header.cover_image`.
|
||||
- **Verification:** In Admin2, the trip form shows a media dropdown (not a text box); selecting an image and saving writes its filename to the page header.
|
||||
|
||||
### U2. Shared cover macro
|
||||
|
||||
- **Goal:** Centralize cover resolution + retina rendering for reuse by the list card and the trip-page banner (R6, R7, R11, R14).
|
||||
- **Requirements:** R6, R7, R11, R14.
|
||||
- **Dependencies:** none (U1 not required — resolution reads the same `header.cover_image` filename regardless of how it was set).
|
||||
- **Files:** `user/themes/intotheeast/templates/macros/cover.html.twig` (new)
|
||||
- **Approach:** Two macros.
|
||||
- `resolve(trip_page)` → returns a Medium or null: if `trip_page.header.cover_image` is set and `trip_page.media[...]` resolves, return it; else look up `grav.pages.find(trip_page.route ~ '/dailies')`, take the first published entry's first image if present; else null. This encodes R7 (fallback) and R11 (a set-but-missing `cover_image` falls through to the auto-pick rather than returning a broken reference).
|
||||
- `img(medium, alt, w, h)` → emits `<img src=cropResize(w,h).url srcset="…(w)w, …(2w)w" sizes=… alt=alt loading="lazy">` using `cropResize(w, h)` and `cropResize(w*2, h*2)` (R6, R14).
|
||||
- **Patterns to follow:** the existing inline resolution in `trips.html.twig:16-29`; the existing `cropResize(...).url` calls in the theme; other macros under `user/themes/intotheeast/templates/macros/`.
|
||||
- **Test scenarios:** the macro has no standalone harness; these are asserted through the rendered DOM in U3/U4 specs — `cover_image` set + resolvable returns that image; `cover_image` set but file missing falls back to the first-entry image (R11); no `cover_image` but an entry image exists returns the first-entry image (R7/AE3); no `cover_image` and no entry images returns null (drives AE4); rendered `<img>` carries both `srcset` candidates (R6/AE5) and `alt` equal to the trip title (R14).
|
||||
- **Verification:** both U3 and U4 render covers through this macro with identical fallback behavior; no inline cover-resolution logic remains in either caller.
|
||||
|
||||
### U3. Trip-list card: one-liner + retina cover
|
||||
|
||||
- **Goal:** Show the one-liner on list cards and render the cover sharply, via the shared macro (R5, R6, R7).
|
||||
- **Requirements:** R5, R6, R7, R11, R14.
|
||||
- **Dependencies:** U2.
|
||||
- **Files:** `user/themes/intotheeast/templates/trips.html.twig`, `tests/ui/trip/trips-list.spec.js` (new)
|
||||
- **Approach:** Import `macros/cover.html.twig`. Replace the inline cover block (`trips.html.twig:16-29`) with `cover.resolve(trip)` + `cover.img(cover, trip.title, 720, 240)` inside the existing `.trip-card-cover` wrapper (keeps the 3:1 aspect + `object-fit: cover`). Add a one-liner line rendering `trip.header.tagline`, between `.trip-card-title` and `.trip-card-meta`, only when the tagline is set (R5).
|
||||
- **Patterns to follow:** existing card markup and classes in `trips.html.twig`; `.trip-card-cover` CSS at `css/style.css:1089`.
|
||||
- **Test scenarios:**
|
||||
- Covers R5. A trip with a tagline renders a one-liner element between the title and the meta line.
|
||||
- Covers R5/AE1. A trip with no tagline renders no one-liner element.
|
||||
- Covers R6/AE5. The card cover `<img>` exposes a `srcset` with 720w and 1440w candidates.
|
||||
- Covers R7/AE3. With no `cover_image` set, the card cover uses the first journal entry's first image.
|
||||
- Covers R11. With a `cover_image` pointing at a missing file, the card falls back to the auto-pick and renders no broken image.
|
||||
- Covers R14. The cover `alt` equals the trip title.
|
||||
- **Verification:** the past-trips list shows one-liners where set and sharp covers on a 2× DPR emulation.
|
||||
|
||||
### U4. Trip-page header extras (gated)
|
||||
|
||||
- **Goal:** Extend the in-column header with the one-liner, expandable description, and banner strip — for the trip-page caller only (R8, R9, R10, R12, R13).
|
||||
- **Requirements:** R8, R9, R10, R12, R13.
|
||||
- **Dependencies:** U2.
|
||||
- **Files:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`, `user/themes/intotheeast/templates/trip.html.twig`, `tests/ui/trip/trip-header.spec.js` (new), `tests/ui/home/home.spec.js` (extend for AE7)
|
||||
- **Approach:** Add a `trip_header_extras` parameter to the partial, `|default(false)`. In `trip.html.twig`'s `include`, pass `trip_header_extras: true`; leave `home.html.twig` untouched (its `include ... only` omits the parameter → default false → unchanged, per KTD4/R12). Inside `.home-trip-header`, gated on the flag and on each value's presence, render in the HTD order: one-liner (`trip_page.header.tagline`) directly below the title (R8); description (`trip_page.content|raw`) below the counts as a 2–3-line preview plus an expand control (R8/R13); banner strip below the description and above the filter bar using `cover.resolve(trip_page)` + `cover.img(...)` at banner dimensions, omitted entirely when resolve returns null (R9/AE4). Add a small inline `<script>` (alongside the existing `initTripStats` script) that toggles the expanded class on the description. The map+journal split and everything from the filter bar down are not touched (R10).
|
||||
- **Patterns to follow:** the existing `.home-trip-header` block and inline `<script>` in `trip-feed-col.html.twig`; the `include ... with {...} only` calls in `trip.html.twig` and `home.html.twig`.
|
||||
- **Test scenarios:**
|
||||
- Covers R8/AE2. Trip page with a tagline and no description shows the one-liner below the title and no description block.
|
||||
- Covers R8/R13. Trip page with a description shows a clamped preview plus an expand control that reveals the full text.
|
||||
- Covers R8/R9. Trip page with tagline + description + cover shows one-liner, description, and a banner strip positioned above the filter bar.
|
||||
- Covers R9/AE3. Trip with no `cover_image` but an entry image shows the banner using the first-entry image.
|
||||
- Covers R9/AE4. Trip with no cover and no entry images renders a text-only header with no banner element.
|
||||
- Covers R10/AE6. The map + journal two-column split renders unchanged with no new header inserted above it.
|
||||
- Covers R12/AE7. The homepage active-trip view renders no description block and no banner strip (assertion added to `home.spec.js`).
|
||||
- **Verification:** trip page shows the extras in HTD order and expands the description; the homepage active-trip header is visually identical to before.
|
||||
|
||||
### U5. Header + banner CSS
|
||||
|
||||
- **Goal:** Style the one-liner, expandable description, and banner strip, and keep the split above the fold on narrow viewports (R6 display, R9, R13, R15).
|
||||
- **Requirements:** R9, R13, R15.
|
||||
- **Dependencies:** U3, U4 (styles the markup they add).
|
||||
- **Files:** `user/themes/intotheeast/css/style.css`
|
||||
- **Approach:** Add rules for the trip-card one-liner, the header one-liner, the description preview/expanded states, the expand control, and `.trip-header-banner` (full width, fixed height in the ~180–220px envelope, `object-fit: cover`, matching radius/spacing of the header). Collapse the description preview with a fixed `max-height` + `overflow: hidden` (the expanded state lifts the cap), **not** `-webkit-line-clamp`: `content|raw` renders multi-paragraph markdown (multiple `<p>`), and line-clamp reliably clamps only a single block box, so it would not hold the 2–3-line preview across paragraphs. Add a mobile `@media` block that reduces banner height and reflows the header text so the map+journal split is not pushed off-screen (R15). The existing `.trip-card-cover` needs no change — `object-fit: cover` + `aspect-ratio: 3/1` already crop the larger derivative.
|
||||
- **Patterns to follow:** existing `.home-trip-header`, `.trip-dates`, `.home-trip-counts` (`css/style.css:926-951`) and `.trip-card-cover` (`css/style.css:1089`); the theme's CSS custom properties (`--space-*`, `--text-*`, `--color-*`).
|
||||
- **Test scenarios:** Test expectation: none — presentational CSS; structural correctness (element presence, expand toggle) is asserted by U3/U4 specs, and appearance/reflow is verified visually including a narrow-viewport check.
|
||||
- **Verification:** on desktop and a mobile viewport, the banner and header text render cleanly and the map+journal split remains visible without scrolling past a wall of header content.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
Dev server: the worktree's Docker dev server at `http://localhost:8081` (`docker compose ... up`). Playwright specs live in the outer repo under `tests/ui/` and run against that server.
|
||||
|
||||
| Gate | Command / action | Applies to |
|
||||
|---|---|---|
|
||||
| New + extended UI specs pass | `npx playwright test tests/ui/trip/trips-list.spec.js tests/ui/trip/trip-header.spec.js tests/ui/home/home.spec.js` | U3, U4 |
|
||||
| No regression in related suites | `npx playwright test tests/ui/trip tests/ui/home tests/ui/maps` | U4 (shared partial), U5 |
|
||||
| Admin picker renders + saves | Manual: Admin2 → trip form → cover field is a media picker → select → save → confirm filename stored | U1 |
|
||||
| Retina sharpness | Manual: DevTools at 2× DPR on `/trips` and a trip page → cover/banner load the 1440w derivative | U2, U3, U4 |
|
||||
| Acceptance examples | Manual walkthrough of AE1–AE7 against a trip with/without tagline, description, and cover | all |
|
||||
|
||||
No lint/build step applies — the edited `css/style.css` and templates are served directly.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
**Global**
|
||||
|
||||
- AE1–AE7 all verified against real trip content (with and without tagline, description, and cover).
|
||||
- New specs (`trips-list.spec.js`, `trip-header.spec.js`) and the `home.spec.js` AE7 assertion pass; existing `tests/ui/trip`, `tests/ui/home`, and `tests/ui/maps` suites still pass.
|
||||
- Admin2 renders the `pagemediaselect` cover field and persists the selected filename.
|
||||
- The homepage active-trip view is visually unchanged (no description block, no banner).
|
||||
- No abandoned/experimental markup, CSS, or scripts left in the diff.
|
||||
- Content backfill of one-liners and descriptions for existing trips remains a post-implementation follow-up (per the Product Contract) and is **not** required for done.
|
||||
|
||||
**Per unit**
|
||||
|
||||
| Unit | Done when |
|
||||
|---|---|
|
||||
| U1 | Cover field is a working Admin2 media picker storing a filename. |
|
||||
| U2 | Both callers resolve and render covers through the macro; no inline cover logic remains. |
|
||||
| U3 | List cards show one-liners where set and sharp retina covers with correct fallback; U3 specs pass. |
|
||||
| U4 | Trip-page header shows one-liner, expandable description, and gated banner in HTD order; home view unchanged; U4 specs pass. |
|
||||
| U5 | Header/banner styled; description expands; split stays above the fold on mobile. |
|
||||
|
||||
---
|
||||
|
||||
## Post-review follow-up (2026-07-07)
|
||||
|
||||
A structured code review of the finished diff produced fixes and two
|
||||
intentionally-deferred findings.
|
||||
|
||||
**Applied**
|
||||
|
||||
- Cover picker restricted to images (`accept:` on the `cover_image`
|
||||
`pagemediaselect` field) + macro resolves against `media.images`, so a
|
||||
non-image selection (e.g. a `.gpx` from the trip page media) can no longer
|
||||
route a non-image Medium into `cropResize`. Also hardens R11.
|
||||
- Test quality: replaced a vacuous `toContainText` in the description-clamp
|
||||
spec with real clamp/un-clamp assertions; corrected an R11 over-claim in the
|
||||
trips-list spec header comment.
|
||||
|
||||
**Follow-up (2026-07-07)**
|
||||
|
||||
- **Banner/card cover quality fix.** The macro used `cropResize`, which
|
||||
*fits-inside* preserving aspect ratio — so a portrait fallback source was
|
||||
handed back as a ~165px sliver that the `object-fit:cover` box then upscaled
|
||||
into a blur (reported on `us-canada-mex-2024`). Switched to **`cropZoom`**
|
||||
(crop-to-fill → a real w×h cover strip). Retina is now **all-or-nothing**: the
|
||||
2x `srcset` descriptor is emitted only when the source is genuinely ≥2×w
|
||||
(`cover.width >= 2w`), else 1x-only — no upscaling, no intermediate widths.
|
||||
Note: imported pixelfed photos cap at ~1440px wide, so auto-picked covers are
|
||||
usually 1x-only; see `docs/working/backlog.md` (full-res re-import, luxury).
|
||||
- **AE4 fixture removed.** The `no-photos-demo` fixture (and its browser test)
|
||||
was deleted at the user's request — it surfaced as stray demo content in the
|
||||
trip list. AE4 (no cover + no images → no banner) is a trivial else-branch of
|
||||
the shared macro's `{% if cover %}` guard, covered by construction alongside
|
||||
the R7/AE3 fallback tests. A regression test for the reported portrait-blur
|
||||
bug now lives in `trip-header.spec.js` against `us-canada-mex-2024`.
|
||||
|
||||
**Intentionally deferred — explicit plan override (do not re-flag)**
|
||||
|
||||
- **Macro re-queries dailies/first-entry (reviewer: efficiency/maintainability).**
|
||||
Deferred by design: **KTD2** puts cover resolution *inside* the shared macro
|
||||
precisely so the list card and trip banner cannot drift. Moving resolution
|
||||
out to callers reopens that drift; the extra `grav.pages.find()` is cached and
|
||||
negligible.
|
||||
- **Inline `<script>` for the description toggle should be bundled into
|
||||
`js/src/main.js` (reviewer: convention).** Deferred by design: **U4's
|
||||
Approach** explicitly specifies "a small inline `<script>` (alongside the
|
||||
existing `initTripStats` script)." The inline placement is the plan's chosen
|
||||
approach for a self-contained ~15-line toggle, not an oversight.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Trip publish/unpublish toggle — design
|
||||
|
||||
**Date:** 2026-07-08
|
||||
**Status:** 📋 Design — awaiting plan
|
||||
|
||||
## Goal
|
||||
|
||||
Let the logged-in **owner** publish/unpublish any trip directly from the UI, on
|
||||
two surfaces: the **Past Trips listing** (`/trips`) and each **trip detail page**
|
||||
(`/trips/<slug>`). Anonymous/non-owner visitors see no change. Toggling must
|
||||
correctly invalidate Grav's page-tree cache so the change is reflected
|
||||
everywhere on the next load.
|
||||
|
||||
## Owner gate
|
||||
|
||||
A single rule, mirroring the post feed's owner logic:
|
||||
|
||||
```twig
|
||||
{% set is_owner = grav.user.authenticated and grav.user.username == grav.config.site.owner_username %}
|
||||
```
|
||||
|
||||
This is **broader** than `owner_can_edit` in `trip.html.twig` (which also
|
||||
requires the page to be the active trip). Publishing must work on *any* trip, so
|
||||
it gets its own `is_owner` flag. `is_owner` is computed in both `trips.html.twig`
|
||||
and `trip.html.twig`. The backend enforces the same owner check independently
|
||||
(defense in depth) — the UI gate is not the security boundary.
|
||||
|
||||
## Backend — extend the `entry-actions` plugin
|
||||
|
||||
Reuses the plugin's existing owner-gate, API-key scope cap, and the
|
||||
`deleteAll()` + `Cache::invalidateCache()` caching pattern.
|
||||
|
||||
### Route
|
||||
|
||||
`POST /api/v1/trip/{slug}/publish` — registered in `entry-actions.php`
|
||||
`onApiRegisterRoutes`. Body: `{ "published": true | false }`.
|
||||
|
||||
### Controller: `setTripPublished(ServerRequestInterface): ResponseInterface`
|
||||
|
||||
In `EntryActionsApiController`, mirroring `deleteEntry`:
|
||||
|
||||
1. `$user = $this->getUser($request)` — 401 for anonymous.
|
||||
2. `$this->requirePermission($request, 'api.pages.write')` — same scope cap as the
|
||||
stock media/page-write endpoints (owner already holds it).
|
||||
3. `EntryScopeGuard::isOwnerUser($this->grav, $user)` — else `ForbiddenException`.
|
||||
4. Validate `slug` via `EntryScopeGuard::isSafeSegment` — else 400.
|
||||
5. Resolve the page via a **new** guard `EntryScopeGuard::resolveTripChild($grav, $slug)`:
|
||||
`$pages->find('/trips/' ~ slug)`, assert the resolved page's parent route is
|
||||
exactly `/trips` (no raw path concatenation — same style as
|
||||
`resolveActiveDailyChild`). Return `null` → `NotFoundException`.
|
||||
6. Read desired state: `$published = (bool) ($body['published'] ?? …)`; reject a
|
||||
missing/non-bool value with 400.
|
||||
7. Set published + persist frontmatter. Use Grav's page API (verify exact call
|
||||
against `add-page-by-form` / `cache-on-save` savers before coding — likely
|
||||
`$page->published($published); $page->save();`). The write must land in
|
||||
`trip.md` frontmatter as `published: true|false`.
|
||||
8. **Caching:** `$this->grav['cache']->deleteAll(); Cache::invalidateCache();` —
|
||||
publish state feeds `.published()` collections and routability, both keyed
|
||||
through the page-tree index; without `invalidateCache()` the listing/nav/home
|
||||
render stale (the exact bug fixed in `deleteEntry`).
|
||||
9. Audit log: `owner "%s" set trip "%s" published=%s`.
|
||||
10. Return `ApiResponse::noContent()` (204).
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared toggle partial
|
||||
|
||||
`partials/trip-publish-toggle.html.twig` — renders a sliding on/off **switch**
|
||||
(a styled checkbox that moves left↔right) plus a `Draft` badge when unpublished.
|
||||
Params: `trip` (the trip Page), `is_active` (bool, whether this trip is
|
||||
`site.active_trip`). Emits `data-trip-slug`, `data-trip-route`,
|
||||
`data-published`, and `data-active` for the JS to read. Rendered only when
|
||||
`is_owner`.
|
||||
|
||||
### Surface 1 — `/trips` listing (`trips.html.twig`)
|
||||
|
||||
- Make the collection owner-aware:
|
||||
```twig
|
||||
{% set trips = (is_owner ? page.children : page.children.published())
|
||||
|sort((a, b) => a.date < b.date ? 1 : -1) %}
|
||||
```
|
||||
Owner sees unpublished trips too; anon unchanged.
|
||||
- The trip card is currently a single `<a>` wrapping the cover + title. The
|
||||
toggle must **not** be inside the anchor (a click would navigate). Restructure
|
||||
the card so the cover image is in a positioned wrapper and the toggle sits as
|
||||
an overlay sibling. Toggle placement: **absolutely positioned over the cover
|
||||
image, top-right corner.** `Draft` badge on unpublished cards.
|
||||
|
||||
### Surface 2 — trip detail page (`trip.html.twig`)
|
||||
|
||||
- Compute `is_owner` (separate from `owner_can_edit`).
|
||||
- Render the shared toggle in the header area, **top-right corner near the trip
|
||||
header**, with the `Draft` badge when unpublished.
|
||||
|
||||
### JS — `js/src/trip-publish.js` → built to `js/trip-publish.js`
|
||||
|
||||
Loaded in the `bottom` group **only when `is_owner`** (like `feed-actions.js`).
|
||||
|
||||
- Binds each `.trip-publish-toggle` control.
|
||||
- On change:
|
||||
- If turning **off** (unpublish) AND `data-active` is true → `window.confirm(
|
||||
'This is your active trip — unpublish it anyway?')`; if cancelled, revert the
|
||||
switch and stop.
|
||||
- `POST /api/v1/trip/<slug>/publish` with `{ published }`,
|
||||
`credentials: 'include'`.
|
||||
- **Success:** optimistic UI — flip `data-published`, toggle the `Draft` badge,
|
||||
update the switch position/label. No full reload needed (server state is
|
||||
persisted + cache invalidated for other surfaces).
|
||||
- **Failure:** revert the switch to its prior state and show an inline,
|
||||
`aria-live` error (reuse the copy style from `feed-actions.js`:
|
||||
401/403 → "sign in again"; other → "Couldn't update — try again.").
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Active trip unpublish** → JS `confirm()` (above). Allowed on confirm.
|
||||
- **Anon / non-owner** → no toggle rendered; listing shows `.published()` only;
|
||||
backend rejects with 401/403.
|
||||
- **Unpublished trip visibility** → drops from the public `/trips` listing; its
|
||||
detail page 404s for anon (Grav default for unpublished/unroutable). Owner
|
||||
still sees it in the listing (Draft badge) and can re-publish.
|
||||
- **Child dailies/stories cascade** → out of scope for v1; unpublishing a trip
|
||||
does not change its children's published state.
|
||||
|
||||
## Testing (Playwright, `tests/ui/trip/`)
|
||||
|
||||
Tests run as the owner (`testrunner` via `owner_username` override), mirroring
|
||||
the post specs. Use a throwaway fixture trip folder (create/cleanup on disk).
|
||||
|
||||
1. **TP1 — owner sees the toggle; anon does not.** Owner load of `/trips` shows
|
||||
`.trip-publish-toggle`; an anon (cleared storageState) load does not, and an
|
||||
unpublished fixture trip is absent for anon.
|
||||
2. **TP2 — unpublish hides it (caching).** Owner toggles a published fixture trip
|
||||
off → **reload** `/trips` as anon → the trip is absent; owner reload → Draft
|
||||
badge present. This is the page-tree-index assertion (mirrors DEL4).
|
||||
3. **TP3 — republish restores it.** Toggle back on → anon reload sees it again.
|
||||
4. **TP4 — active-trip confirm.** Unpublishing the active trip prompts a confirm;
|
||||
dismissing leaves it published.
|
||||
5. **TP5 — authz.** `POST /api/v1/trip/<slug>/publish` as anon → 401; as a
|
||||
non-owner authenticated user → 403; frontmatter unchanged on disk.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bulk publish/unpublish.
|
||||
- Scheduling / publish dates.
|
||||
- Cascading child publish state.
|
||||
- Reordering trips by publish state (order stays by date desc).
|
||||
+39
-15
@@ -4,6 +4,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
FORM="user/pages/02.post/post-form.md"
|
||||
SITE="user/config/site.yaml"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ERRORS=()
|
||||
@@ -12,8 +13,13 @@ ok() { echo " ✓ $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ✗ $1"; FAIL=$((FAIL+1)); ERRORS+=("$1"); }
|
||||
|
||||
check_grep() {
|
||||
local desc="$1"; local pattern="$2"
|
||||
if grep -q "$pattern" "$FORM"; then ok "$desc"; else fail "$desc"; fi
|
||||
local desc="$1"; local pattern="$2"; local file="${3:-$FORM}"
|
||||
if grep -q "$pattern" "$file"; then ok "$desc"; else fail "$desc"; fi
|
||||
}
|
||||
|
||||
check_absent() {
|
||||
local desc="$1"; local pattern="$2"; local file="${3:-$FORM}"
|
||||
if grep -q "$pattern" "$file"; then fail "$desc"; else ok "$desc"; fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
@@ -24,25 +30,43 @@ echo "────────────────────────
|
||||
grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugin trigger)" \
|
||||
|| fail "Process action must be 'add_page: true' — 'add-page-by-form' is not handled by the plugin"
|
||||
|
||||
# Config must be in frontmatter, not in the process block
|
||||
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
|
||||
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"
|
||||
# Parent is now injected server-side from site.active_trip by the cache-on-save
|
||||
# plugin (U1). The form must NOT hardcode pageconfig.parent — that coupling was
|
||||
# the silent-misfile bug this whole change removes.
|
||||
check_absent "pageconfig.parent is NOT hardcoded (injected server-side from active_trip)" "^\s*parent:"
|
||||
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
|
||||
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"
|
||||
|
||||
# The active trip — the server-side injection source — must be set in site.yaml.
|
||||
check_grep "active_trip set in site.yaml (injection source)" "^active_trip:\s*\S" "$SITE"
|
||||
|
||||
# Form name must stay 'new-entry' — cache-on-save plugin checks this exact string
|
||||
check_grep "form name is 'new-entry' (required by cache-on-save plugin)" "name: new-entry"
|
||||
|
||||
# Required form fields
|
||||
check_grep "title field present" "name: title"
|
||||
check_grep "date field present" "name: date"
|
||||
check_grep "content field present" "name: content"
|
||||
check_grep "lat field present" "name: lat"
|
||||
check_grep "lng field present" "name: lng"
|
||||
check_grep "location_city field present" "name: location_city"
|
||||
# Core form fields
|
||||
check_grep "title field present" "name: title"
|
||||
check_grep "date field present" "name: date"
|
||||
check_grep "content field present" "name: content"
|
||||
check_grep "photos field present" "name: photos"
|
||||
check_grep "lat field present" "name: lat"
|
||||
check_grep "lng field present" "name: lng"
|
||||
check_grep "location_city field present" "name: location_city"
|
||||
check_grep "location_country field present" "name: location_country"
|
||||
|
||||
# Fields exposed by U2 (weather picker + transport + advanced trio)
|
||||
check_grep "weather_desc field present" "name: weather_desc"
|
||||
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
||||
check_grep "transport_mode field present" "name: transport_mode"
|
||||
check_grep "hero_image field present" "name: hero_image"
|
||||
check_grep "force_connect field present" "name: force_connect"
|
||||
check_grep "featured field present" "name: featured"
|
||||
|
||||
# Photos use Grav's filepond field; post-form.js hooks its beforeAddFile to
|
||||
# convert HEIC->JPEG before FilePond uploads (U4).
|
||||
check_grep "photos field uses the filepond type" "type: filepond"
|
||||
|
||||
echo "────────────────────────────────────────"
|
||||
echo " $PASS passed, $FAIL failed"
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ set -euo pipefail
|
||||
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
|
||||
USER="${GRAV_TEST_USER:-}"
|
||||
PASS="${GRAV_TEST_PASS:-}"
|
||||
TRACKER="user/pages/01.trips/italy-2026-demo/01.dailies"
|
||||
# Parent is injected server-side from site.active_trip (U1), so resolve the
|
||||
# dailies dir from site.yaml rather than hardcoding a trip slug.
|
||||
ACTIVE_TRIP=$(grep -E '^active_trip:' user/config/site.yaml | head -1 | sed -E "s/^active_trip:[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*\$//")
|
||||
TRIP_SLUG=$(basename "${ACTIVE_TRIP%/}")
|
||||
TRACKER="user/pages/01.trips/${TRIP_SLUG:-italy-2026-demo}/01.dailies"
|
||||
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 B |
Vendored
BIN
Binary file not shown.
+99
-34
@@ -2,19 +2,34 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
// The shared photo fixture every create goes through (the post form gates submit
|
||||
// on at least one uploaded photo).
|
||||
const TEST_PHOTO = path.join(__dirname, '../fixtures/test-photo.jpg');
|
||||
|
||||
/**
|
||||
* Resolve the Grav user directory.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. GRAV_USER_DIR env var (set in .env or shell)
|
||||
* 2. docker inspect the running intotheeast_grav container
|
||||
* 3. Sibling `user/` directory (worktree fallback)
|
||||
* 2. Sibling `user/` directory — authoritative for this repo's layout, where
|
||||
* docker-compose always bind-mounts `./user` relative to the checkout. This
|
||||
* is correct for BOTH the main checkout and a git worktree (each worktree
|
||||
* serves its own `./user`), so it must be preferred over docker inspect.
|
||||
* 3. `docker inspect intotheeast_grav` — last-resort fallback for running the
|
||||
* specs detached from the served checkout. NOTE: from a worktree this points
|
||||
* at the MAIN checkout's container (a different `user/`), so it must never
|
||||
* win over the sibling dir above, or disk assertions look in the wrong tree.
|
||||
*/
|
||||
function resolveUserDir() {
|
||||
if (process.env.GRAV_USER_DIR) {
|
||||
return process.env.GRAV_USER_DIR;
|
||||
}
|
||||
const sibling = path.join(__dirname, '../../user');
|
||||
if (fs.existsSync(path.join(sibling, 'config/site.yaml'))) {
|
||||
return sibling;
|
||||
}
|
||||
try {
|
||||
const raw = execSync(
|
||||
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
||||
@@ -24,26 +39,35 @@ function resolveUserDir() {
|
||||
} catch (_) {
|
||||
// docker not available or container not running
|
||||
}
|
||||
return path.join(__dirname, '../../user');
|
||||
return sibling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active dailies directory from the post-form.md pageconfig.
|
||||
* Resolve the active trip slug from site.yaml `active_trip`.
|
||||
*
|
||||
* The post form stores `pageconfig.parent` as a Grav route such as
|
||||
* `/trips/italy-2026-demo/dailies`. We map that to the filesystem by
|
||||
* scanning for a folder whose name ends with the trip slug.
|
||||
* The post form no longer hardcodes `pageconfig.parent` — the write target is
|
||||
* injected server-side from `site.active_trip` (see the cache-on-save plugin).
|
||||
* `active_trip` is a full route ("/trips/italy-2026-demo") or a bare slug; both
|
||||
* reduce to the trip slug here.
|
||||
*/
|
||||
function resolveActiveTripSlug(userDir) {
|
||||
const sitePath = path.join(userDir, 'config/site.yaml');
|
||||
if (!fs.existsSync(sitePath)) return null;
|
||||
const content = fs.readFileSync(sitePath, 'utf-8');
|
||||
const m = content.match(/^active_trip:\s*['"]?(\S+?)['"]?\s*$/m);
|
||||
if (!m) return null;
|
||||
return m[1]
|
||||
.replace(/^\/?trips\//, '') // strip a leading /trips/
|
||||
.replace(/^\//, '')
|
||||
.replace(/\/.*$/, ''); // keep only the slug segment
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active dailies directory on disk from the active trip slug.
|
||||
*/
|
||||
function resolveDailiesDir(userDir) {
|
||||
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
||||
if (!fs.existsSync(postFormPath)) {
|
||||
// fallback: search all trips for a dailies dir
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
||||
if (!m) return null;
|
||||
const tripSlug = m[1];
|
||||
const tripSlug = resolveActiveTripSlug(userDir);
|
||||
if (!tripSlug) return null;
|
||||
|
||||
const tripsBase = path.join(userDir, 'pages/01.trips');
|
||||
if (!fs.existsSync(tripsBase)) return null;
|
||||
@@ -62,31 +86,41 @@ 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 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.
|
||||
* The Grav route to the active trip page, derived from site.yaml `active_trip`.
|
||||
* Posted entries surface in this page's journal feed.
|
||||
* Falls back to '/trips/italy-2026-demo'.
|
||||
*/
|
||||
function resolveActiveTripUrl() {
|
||||
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
|
||||
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';
|
||||
const slug = resolveActiveTripSlug(USER_DIR);
|
||||
return slug ? '/trips/' + slug : '/trips/italy-2026-demo';
|
||||
}
|
||||
|
||||
const ACTIVE_TRIP_URL = resolveActiveTripUrl();
|
||||
|
||||
/**
|
||||
* Wait for all filepond items to finish XHR upload.
|
||||
* Type content into the EasyMDE editor. The underlying <textarea> is hidden by
|
||||
* EasyMDE, so we set the value through the instance the bundle exposes on
|
||||
* window.postFormEditor (which also syncs the textarea for submission).
|
||||
*/
|
||||
async function waitForFilePondUpload(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
|
||||
return items.length > 0 && [...items].every(
|
||||
el => el.getAttribute('data-filepond-item-state') === 'processing-complete'
|
||||
);
|
||||
}, { timeout: 20_000 });
|
||||
async function fillEditor(page, text) {
|
||||
await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 });
|
||||
await page.evaluate((t) => window.postFormEditor.value(t), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for photos to finish uploading. post-form.js converts HEIC->JPEG and
|
||||
* hands files to FilePond via pond.addFile(); FilePond then uploads each, and a
|
||||
* finished item reaches data-filepond-item-state="processing-complete".
|
||||
*/
|
||||
async function waitForPhotoUpload(page, count = 1) {
|
||||
await page.waitForFunction(
|
||||
(n) => {
|
||||
const items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
|
||||
return [...items].filter(el => el.getAttribute('data-filepond-item-state') === 'processing-complete').length >= n;
|
||||
},
|
||||
count,
|
||||
{ timeout: 40_000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +131,7 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
|
||||
const title = `UI Test ${titleTag} ${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', title);
|
||||
await page.fill('textarea[name="data[content]"]', content);
|
||||
await fillEditor(page, content);
|
||||
if (city) await page.fill('input[name="data[location_city]"]', city);
|
||||
if (country) await page.fill('input[name="data[location_country]"]', country);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
@@ -105,6 +139,37 @@ async function postEntry(page, { titleTag, content = 'Automated test. Safe to de
|
||||
return titleTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh journal entry through the /post create form, with a photo
|
||||
* attached so the submit gate is satisfied. Shared by the specs that need a
|
||||
* disposable feed card to act on (delete-flow, edit-mode, anon-view draft).
|
||||
*
|
||||
* Pass the spec's `created` array so the tag is registered for cleanup BEFORE
|
||||
* the (slow, 15s) success-toast assertion — a create that lands on disk but
|
||||
* whose toast assertion times out would otherwise leak an entry the afterAll
|
||||
* hook never sees. `publish:false` flips the Published toggle off to make a
|
||||
* draft (the toggle is a visually-hidden radio pair behind "More options", so
|
||||
* set state + fire `change` rather than fighting the visibility gate).
|
||||
*/
|
||||
async function createPhotoEntry(page, tag, { content, publish = true, created } = {}) {
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, content || `Fixture for ${tag}. Safe to delete.`);
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
if (!publish) {
|
||||
await page.evaluate(() => {
|
||||
const off = document.querySelector('input[name="data[published]"][value="0"]');
|
||||
off.checked = true;
|
||||
off.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
if (created) created.push(tag);
|
||||
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||
'Entry posted successfully!', { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
||||
*/
|
||||
@@ -137,4 +202,4 @@ function readEntryMd(entryDir) {
|
||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||
}
|
||||
|
||||
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
// @ts-check
|
||||
// Tests: H1 — home page journal feed
|
||||
// Tests: H1 — home page journal feed; AE7 — active-trip header gating
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// ── H1: Home page renders inline journal posts ─────────────────────────────────
|
||||
// Only meaningful when the site is in "travelling" mode: home.html.twig gates the
|
||||
// active-trip feed on `config.site.travelling`. When it's false the home renders
|
||||
// the between-trips highlights grid instead (no journal feed), so this test would
|
||||
// fail misleadingly. We skip loudly with a reason rather than assert against the
|
||||
// wrong view — the test still runs and validates whenever travelling is on.
|
||||
test('H1: home page shows at least one inline journal-post block', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const betweenTrips = await page.locator('.home-highlights-title').count();
|
||||
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); H1 requires travelling:true');
|
||||
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── AE7: the trip-page header extras never leak onto the home route ────────────
|
||||
// The trip-page one-liner/description/banner are gated to the trip.html.twig
|
||||
// caller of the shared trip-feed-col partial (trip_header_extras, default off);
|
||||
// home's include omits the flag, so its header is unchanged (R12/KTD4). Asserted
|
||||
// as an absence on `/` so it holds whether home is in active-trip or
|
||||
// between-trips mode — the sibling home-highlights suite toggles that mode in a
|
||||
// parallel worker, so this test must not depend on it.
|
||||
test('AE7: home never renders the trip-page header extras', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.home-trip-tagline')).toHaveCount(0);
|
||||
await expect(page.locator('.trip-header-desc')).toHaveCount(0);
|
||||
await expect(page.locator('.trip-header-banner')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -40,12 +40,17 @@ 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.
|
||||
// Requires GPX files attached to the active trip (italy-2026-demo has 7).
|
||||
// Requires travelling: true in user/config/site.yaml — home.html.twig only
|
||||
// renders the active-trip journey map (home-journey / home-gpx-0 sources) in
|
||||
// that mode. With travelling:false the home shows the between-trips highlights
|
||||
// map, which has neither source, so we skip loudly rather than fail misleadingly.
|
||||
// Also requires GPX files attached to the active trip (italy-2026-demo has 7).
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/');
|
||||
const betweenTrips = await page.locator('.home-highlights-title').count();
|
||||
test.skip(betweenTrips > 0, 'home is in between-trips mode (site.travelling:false); M8 requires travelling:true');
|
||||
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('#home-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @ts-check
|
||||
// Tests: AN1–AN2 — the anonymous (logged-out) visitor's view of the active trip.
|
||||
//
|
||||
// Every other spec runs as the authenticated owner, so nothing guards the
|
||||
// owner/anon boundary. These assert the two things that boundary must enforce
|
||||
// (R5, KTD7):
|
||||
// - AN1: owner-only controls (Edit/Delete, data-entry-route) never render for
|
||||
// an anonymous visitor, even though published entries are visible.
|
||||
// - AN2: an unpublished DRAFT is shown to the owner (with a badge) but is
|
||||
// completely absent for an anonymous visitor.
|
||||
//
|
||||
// The whole file runs UNauthenticated by clearing storageState. AN2 spins up a
|
||||
// short-lived authenticated context to create the draft fixture and confirm the
|
||||
// owner-visible side.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL } = require('../helpers');
|
||||
|
||||
const AUTH_STATE = 'tests/.auth/user.json';
|
||||
const BASE = process.env.GRAV_BASE_URL || 'http://localhost:8081';
|
||||
|
||||
// Run this file with NO owner session.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
const created = [];
|
||||
test.afterAll(() => created.forEach(cleanupEntry));
|
||||
|
||||
// ── AN1: anonymous visitor sees content but no owner controls ─────────────────
|
||||
test('AN1: an anonymous visitor sees published entries but no owner controls', async ({ page }) => {
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
|
||||
// The demo trip has published journal cards — content is public.
|
||||
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||
|
||||
// …but none of the owner-only affordances are present in the markup.
|
||||
await expect(page.locator('.journal-post-actions')).toHaveCount(0);
|
||||
await expect(page.locator('.entry-action--edit')).toHaveCount(0);
|
||||
await expect(page.locator('.entry-action--delete')).toHaveCount(0);
|
||||
await expect(page.locator('[data-entry-route]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── AN2: a draft is owner-only ────────────────────────────────────────────────
|
||||
test('AN2: a draft entry is shown to the owner but hidden from an anonymous visitor', async ({ page, browser }) => {
|
||||
const tag = `draft-${Date.now()}`;
|
||||
|
||||
// Create an UNPUBLISHED entry as the owner, in a separate authed context.
|
||||
const owner = await browser.newContext({ storageState: AUTH_STATE, baseURL: BASE });
|
||||
const op = await owner.newPage();
|
||||
await createPhotoEntry(op, tag, {
|
||||
created,
|
||||
publish: false,
|
||||
content: `Draft body ${tag}. Safe to delete.`,
|
||||
});
|
||||
expect(findEntry(tag), 'draft fixture should exist on disk').not.toBeNull();
|
||||
|
||||
// Owner side: the draft appears in the feed WITH a Draft badge.
|
||||
await op.goto(ACTIVE_TRIP_URL);
|
||||
const ownerCard = op.locator('.journal-post', { hasText: tag });
|
||||
await expect(ownerCard).toHaveCount(1);
|
||||
await expect(ownerCard.locator('.journal-draft-badge')).toBeVisible();
|
||||
await owner.close();
|
||||
|
||||
// Anonymous side (the default page fixture): the draft is nowhere to be seen.
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
|
||||
await expect(page.locator('body')).not.toContainText(tag);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-check
|
||||
// Tests: DEL1–DEL3 — the owner entry-delete flow (feed-actions.js + the
|
||||
// entry-actions `deleteEntry` route). Delete is a two-step inline confirm on a
|
||||
// feed card: Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>.
|
||||
//
|
||||
// This flow — a destructive, owner-only action — had zero automated coverage.
|
||||
// - DEL1: full happy path — the card vanishes AND the folder leaves disk.
|
||||
// - DEL2: Cancel is a real escape hatch — nothing is deleted.
|
||||
// - DEL3: a failed DELETE keeps the card and surfaces the inline error.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
createPhotoEntry, cleanupEntry, findEntry, ACTIVE_TRIP_URL,
|
||||
} = require('../helpers');
|
||||
|
||||
const created = [];
|
||||
// cleanupEntry is a no-op when the entry was already deleted by the test.
|
||||
test.afterAll(() => created.forEach(cleanupEntry));
|
||||
|
||||
// ── DEL1: happy delete removes the card and the folder ────────────────────────
|
||||
test('DEL1: owner deletes an entry — the card disappears and the folder is removed', async ({ page }) => {
|
||||
const tag = `del1-${Date.now()}`;
|
||||
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
|
||||
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
const card = page.locator('.journal-post', { hasText: tag });
|
||||
await expect(card).toHaveCount(1);
|
||||
// The owner-only controls must be present — this also asserts the auth gate.
|
||||
await card.locator('[data-delete-start]').click();
|
||||
await card.locator('[data-delete-confirm]').click();
|
||||
|
||||
await expect(page.locator('.journal-post', { hasText: tag }))
|
||||
.toHaveCount(0, { timeout: 15_000 });
|
||||
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
|
||||
});
|
||||
|
||||
// ── DEL4: a deleted entry stays gone after a full page reload ─────────────────
|
||||
// Regression for the page-tree-index staleness bug: deleteEntry did
|
||||
// cache.deleteAll() but not Cache::invalidateCache(), so with
|
||||
// cache.check.method: folder the deleted child lingered in the pages index and
|
||||
// the SERVER re-rendered the (now image-less) card on the next load — even
|
||||
// though its folder was gone from disk. DEL1 only checks the optimistic DOM
|
||||
// removal + disk, so it missed this. Here we reload and assert the server no
|
||||
// longer emits the card.
|
||||
test('DEL4: a deleted entry is absent from the feed after a fresh page load', async ({ page }) => {
|
||||
const tag = `del4-${Date.now()}`;
|
||||
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
|
||||
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
const card = page.locator('.journal-post', { hasText: tag });
|
||||
await expect(card).toHaveCount(1);
|
||||
await card.locator('[data-delete-start]').click();
|
||||
await card.locator('[data-delete-confirm]').click();
|
||||
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect.poll(() => findEntry(tag), { timeout: 15_000 }).toBeNull();
|
||||
|
||||
// The real test: a fresh server render must not resurrect the entry.
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
await expect(page.locator('.journal-post', { hasText: tag })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── DEL2: Cancel keeps the entry ──────────────────────────────────────────────
|
||||
test('DEL2: cancelling the confirm step keeps the entry on the page and on disk', async ({ page }) => {
|
||||
const tag = `del2-${Date.now()}`;
|
||||
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
|
||||
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
const card = page.locator('.journal-post', { hasText: tag });
|
||||
await expect(card).toHaveCount(1);
|
||||
|
||||
await card.locator('[data-delete-start]').click();
|
||||
await expect(card.locator('.entry-delete-confirm')).toBeVisible();
|
||||
await card.locator('[data-delete-cancel]').click();
|
||||
await expect(card.locator('.entry-delete-confirm')).toBeHidden();
|
||||
|
||||
await expect(card).toHaveCount(1);
|
||||
expect(findEntry(tag), 'a cancelled delete must not remove the folder').not.toBeNull();
|
||||
});
|
||||
|
||||
// ── DEL3: a failed DELETE keeps the card and shows the inline error ───────────
|
||||
test('DEL3: a failed delete keeps the card and surfaces the inline error', async ({ page }) => {
|
||||
const tag = `del3-${Date.now()}`;
|
||||
await createPhotoEntry(page, tag, { created, content: `Delete-flow fixture ${tag}. Safe to delete.` });
|
||||
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
// Force the delete request to fail after the confirm.
|
||||
await page.route('**/api/v1/entry/**', (route) => {
|
||||
if (route.request().method() === 'DELETE') return route.fulfill({ status: 500, body: '' });
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
const card = page.locator('.journal-post', { hasText: tag });
|
||||
await expect(card).toHaveCount(1);
|
||||
await card.locator('[data-delete-start]').click();
|
||||
await card.locator('[data-delete-confirm]').click();
|
||||
|
||||
await expect(card.locator('.entry-delete-msg'))
|
||||
.toContainText('Could not delete', { timeout: 15_000 });
|
||||
await expect(card).toHaveCount(1); // the card survives a failed delete
|
||||
expect(findEntry(tag), 'a failed delete must not remove the folder').not.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
// Tests: ES1–ES3 — edit mode field SAVE round-trip + prefill error states.
|
||||
//
|
||||
// Complements photo-editor.spec.js (which covers the live photo add/delete/
|
||||
// reorder inside edit mode). Here we cover the *text* side of edit mode:
|
||||
// - ES1 drives a real end-to-end save: create → open the feed card's Edit link
|
||||
// → change title + body → Save → assert the new values land back on disk.
|
||||
// - ES2/ES3 mock the prefill fetch to force the two D7 failure branches
|
||||
// (404 "no longer exists" vs a transient "couldn't be loaded") — the copy
|
||||
// that tells the owner whether a retry is worthwhile. These had zero coverage.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
fillEditor, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, ACTIVE_TRIP_URL,
|
||||
} = require('../helpers');
|
||||
|
||||
// Synthetic route for the mocked error tests — never has to exist on disk.
|
||||
const MISSING_ROUTE = '/trips/italy-2026-demo/dailies/does-not-exist';
|
||||
|
||||
const created = [];
|
||||
test.afterAll(() => created.forEach(cleanupEntry));
|
||||
|
||||
// ── ES1: edit-mode save writes the changed title + body back to disk ──────────
|
||||
test('ES1: editing an entry saves the changed title and body back in place', async ({ page }) => {
|
||||
const tag = `es1-${Date.now()}`;
|
||||
await createPhotoEntry(page, tag, { created });
|
||||
|
||||
// Reach edit mode the way the owner does: via the feed card's Edit link.
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
const card = page.locator('.journal-post', { hasText: tag });
|
||||
await expect(card).toHaveCount(1);
|
||||
const editHref = await card.locator('.entry-action--edit').getAttribute('href');
|
||||
expect(editHref).toContain('/post?edit=');
|
||||
|
||||
await page.goto(editHref);
|
||||
// Prefill is async (GET /api/v1/pages{route}); wait until it populates.
|
||||
await expect(page.locator('input[name="data[title]"]'))
|
||||
.toHaveValue(`UI Test ${tag}`, { timeout: 15_000 });
|
||||
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag} EDITED`);
|
||||
await fillEditor(page, `Edited body for ${tag}.`);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
|
||||
// overwrite_mode:edit writes back in place — assert both changes on disk.
|
||||
await expect.poll(() => {
|
||||
const dir = findEntry(tag);
|
||||
return dir ? (readEntryMd(dir) || '') : '';
|
||||
}, { timeout: 15_000 }).toContain('EDITED');
|
||||
|
||||
const md = readEntryMd(findEntry(tag));
|
||||
expect(md, 'edited body should persist').toContain(`Edited body for ${tag}.`);
|
||||
});
|
||||
|
||||
// ── ES2: prefill 404 → the "no longer exists" (deleted) branch ────────────────
|
||||
test('ES2: opening a deleted entry for editing shows the "no longer exists" notice', async ({ page }) => {
|
||||
await page.route('**/api/v1/pages/**', (route) => {
|
||||
if (route.request().method() === 'GET') return route.fulfill({ status: 404, body: '' });
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
|
||||
|
||||
const banner = page.locator('.post-edit-error');
|
||||
await expect(banner).toContainText('no longer exists', { timeout: 15_000 });
|
||||
await expect(banner).toHaveAttribute('role', 'alert');
|
||||
// D7: the form is left empty rather than half-filled.
|
||||
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
|
||||
});
|
||||
|
||||
// ── ES3: prefill 500 → the transient "couldn't be loaded" (retry) branch ──────
|
||||
test('ES3: a transient prefill failure shows the retry-able "be loaded" notice', async ({ page }) => {
|
||||
await page.route('**/api/v1/pages/**', (route) => {
|
||||
if (route.request().method() === 'GET') return route.fulfill({ status: 500, body: '' });
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/post?edit=' + encodeURIComponent(MISSING_ROUTE));
|
||||
|
||||
// Copy differs from the 404 case so the owner knows a retry is worthwhile.
|
||||
await expect(page.locator('.post-edit-error'))
|
||||
.toContainText('be loaded for editing', { timeout: 15_000 });
|
||||
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
// Tests: E1–E7 — the edit-mode live photo editor (initPhotoEditor).
|
||||
//
|
||||
// These cover the add / delete / reorder paths of the photo editor reached at
|
||||
// `/post?edit=<route>`, with an emphasis on the FAILURE branches added in commit
|
||||
// 7ffd75e (auth-expiry copy + incomplete-rollback warning) which had zero
|
||||
// automated coverage.
|
||||
//
|
||||
// Strategy: the media API is fully mocked with page.route(). This is deliberate:
|
||||
// - the create form is now photo-gated (≥1 photo required), so a text-only
|
||||
// fixture entry can't be posted programmatically; and
|
||||
// - mutating a real demo entry on disk would be destructive.
|
||||
// Mocking lets us drive every add/delete/reorder branch — including the ones
|
||||
// that only fire on server errors — deterministically and non-destructively.
|
||||
// Real end-to-end persistence stays covered by the manual owner-session smoke
|
||||
// test in the handover.
|
||||
//
|
||||
// Assertions target the stable USER-FACING strings, never minified identifiers,
|
||||
// so they survive the bundle build.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
|
||||
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||
|
||||
// A synthetic entry route. It never has to exist on disk — every API call the
|
||||
// editor makes against it is intercepted below. The reorder route keys on the
|
||||
// last path segment ("mock-entry").
|
||||
const EDIT_ROUTE = '/trips/italy-2026-demo/dailies/mock-entry';
|
||||
const EDIT_URL = '/post?edit=' + encodeURIComponent(EDIT_ROUTE);
|
||||
|
||||
/**
|
||||
* Install a stateful mock of the Grav media API + entry-actions reorder route.
|
||||
*
|
||||
* cfg:
|
||||
* photos: string[] initial filenames in the grid (default [])
|
||||
* addName: string filename a successful POST /media "creates" (default 'stock-upload.jpg')
|
||||
* status: { prefill, list, add, delete, reorder } — HTTP status per op.
|
||||
* Any value >= 400 makes that op fail. Success defaults:
|
||||
* prefill 200, list 200, add 200, delete 204, reorder 204.
|
||||
*
|
||||
* Returns a `state` object the test can inspect: `state.photos` (current set)
|
||||
* and `state.reorders` (array of the `order` arrays received by the reorder
|
||||
* route, newest last).
|
||||
*/
|
||||
async function installMockApi(page, cfg = {}) {
|
||||
const state = {
|
||||
photos: (cfg.photos || []).slice(),
|
||||
reorders: [],
|
||||
};
|
||||
const s = Object.assign(
|
||||
{ prefill: 200, list: 200, add: 200, delete: 204, reorder: 204 },
|
||||
cfg.status || {}
|
||||
);
|
||||
const addName = cfg.addName || 'stock-upload.jpg';
|
||||
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const req = route.request();
|
||||
const method = req.method();
|
||||
const p = new URL(req.url()).pathname;
|
||||
|
||||
const json = (status, obj) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(obj) });
|
||||
const empty = (status) => route.fulfill({ status, body: '' });
|
||||
|
||||
// Reorder: POST /api/v1/entry/{slug}/photos/order
|
||||
if (/\/api\/v1\/entry\/[^/]+\/photos\/order$/.test(p)) {
|
||||
if (s.reorder >= 400) return empty(s.reorder);
|
||||
try { state.reorders.push(JSON.parse(req.postData() || '{}').order); } catch (_) {}
|
||||
return empty(204);
|
||||
}
|
||||
|
||||
// Delete: DELETE /api/v1/pages{route}/media/{filename}
|
||||
const delMatch = p.match(/\/media\/([^/]+)$/);
|
||||
if (delMatch && method === 'DELETE') {
|
||||
if (s.delete >= 400) return empty(s.delete);
|
||||
const fn = decodeURIComponent(delMatch[1]);
|
||||
state.photos = state.photos.filter((x) => x !== fn);
|
||||
return empty(204);
|
||||
}
|
||||
|
||||
// List (GET) or Add (POST): /api/v1/pages{route}/media
|
||||
if (/\/media$/.test(p)) {
|
||||
if (method === 'POST') {
|
||||
if (s.add >= 400) return empty(s.add);
|
||||
state.photos.push(addName);
|
||||
return json(200, { data: { filename: addName } });
|
||||
}
|
||||
if (s.list >= 400) return empty(s.list);
|
||||
return json(200, { data: state.photos.map((f) => ({ filename: f })) });
|
||||
}
|
||||
|
||||
// Prefill: GET /api/v1/pages{route}
|
||||
if (/\/api\/v1\/pages\//.test(p)) {
|
||||
if (s.prefill >= 400) return empty(s.prefill);
|
||||
return json(200, {
|
||||
data: {
|
||||
header: {
|
||||
title: 'Mock Entry',
|
||||
date: '2026-09-01 07:00',
|
||||
published: true,
|
||||
lat: 43.5,
|
||||
lng: 11.3,
|
||||
},
|
||||
content: 'Mock content for the editor test.',
|
||||
published: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Open the editor and wait for its first render to settle. */
|
||||
async function openEditor(page) {
|
||||
await page.goto(EDIT_URL);
|
||||
await page.waitForSelector('.photo-editor__grid', { timeout: 15_000 });
|
||||
// The grid starts on a "Loading photos…" placeholder; wait for the initial
|
||||
// mediaList() to resolve into either cells or the empty-state message.
|
||||
await page.waitForFunction(() => {
|
||||
const g = document.querySelector('.photo-editor__grid');
|
||||
return g && !g.querySelector('.photo-editor__loading');
|
||||
}, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
const status = (page) => page.locator('.photo-editor__status');
|
||||
const cells = (page) => page.locator('.photo-editor__cell');
|
||||
|
||||
/** Delete the Nth photo cell through the inline confirm dialog. */
|
||||
async function deleteCell(page, index) {
|
||||
await cells(page).nth(index).locator('.photo-editor__del').click();
|
||||
await cells(page).nth(index).locator('.photo-editor__confirm-yes').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag cell at `from` onto cell at `to` using stepped mouse moves so SortableJS
|
||||
* (which listens to native pointer events) picks it up.
|
||||
*/
|
||||
async function dragCell(page, from, to) {
|
||||
const src = await cells(page).nth(from).boundingBox();
|
||||
const dst = await cells(page).nth(to).boundingBox();
|
||||
if (!src || !dst) throw new Error('cell not found for drag');
|
||||
await page.mouse.move(src.x + src.width / 2, src.y + src.height / 2);
|
||||
await page.mouse.down();
|
||||
// A few intermediate steps are needed or SortableJS treats it as a click.
|
||||
await page.mouse.move(src.x + src.width / 2 + 10, src.y + src.height / 2, { steps: 5 });
|
||||
await page.mouse.move(dst.x + dst.width / 2, dst.y + dst.height / 2, { steps: 10 });
|
||||
await page.mouse.move(dst.x + dst.width / 2 + 1, dst.y + dst.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// ── E1: happy add — a photo added through the editor renders in the grid ──────
|
||||
test('E1: adding a photo renders it in the grid and clears the status', async ({ page }) => {
|
||||
await installMockApi(page, { photos: [], addName: 'photo-01.jpg' });
|
||||
await openEditor(page);
|
||||
await expect(page.locator('.photo-editor__empty')).toBeVisible();
|
||||
|
||||
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
|
||||
|
||||
await expect(cells(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
|
||||
await expect(status(page)).toHaveText('');
|
||||
});
|
||||
|
||||
// ── E2: auth-expiry on add (commit 7ffd75e #1) ────────────────────────────────
|
||||
test('E2: a 401 while adding surfaces the "sign in again" copy', async ({ page }) => {
|
||||
await installMockApi(page, { photos: [], status: { add: 401 } });
|
||||
await openEditor(page);
|
||||
|
||||
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
|
||||
|
||||
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
|
||||
await expect(status(page)).toContainText('Sign in again');
|
||||
await expect(status(page)).toHaveClass(/error/);
|
||||
});
|
||||
|
||||
// ── E3: incomplete-rollback warning (commit 7ffd75e #6) — highest value ───────
|
||||
// Upload succeeds, the post-upload reorder fails (twice), and the rollback
|
||||
// DELETE also fails, so cleanup is incomplete and a stray file may remain.
|
||||
test('E3: failed reorder + failed cleanup after add warns "cleanup was incomplete"', async ({ page }) => {
|
||||
await installMockApi(page, {
|
||||
photos: [],
|
||||
addName: 'stray-stock.jpg',
|
||||
status: { reorder: 500, delete: 500 },
|
||||
});
|
||||
await openEditor(page);
|
||||
|
||||
await page.locator('.photo-editor__input').setInputFiles(TEST_PHOTO);
|
||||
|
||||
await expect(status(page)).toContainText('cleanup was incomplete', { timeout: 20_000 });
|
||||
await expect(status(page)).toContainText('reload the page');
|
||||
await expect(status(page)).toHaveClass(/error/);
|
||||
});
|
||||
|
||||
// ── E4: delete failure (500) leaves the photo in place ────────────────────────
|
||||
test('E4: a 500 on delete keeps the photo and shows a retry-able error', async ({ page }) => {
|
||||
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 500 } });
|
||||
await openEditor(page);
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
|
||||
await deleteCell(page, 0);
|
||||
|
||||
await expect(status(page)).toContainText('Couldn’t delete that photo', { timeout: 15_000 });
|
||||
await expect(status(page)).toHaveClass(/error/);
|
||||
// The photo must survive a failed delete.
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
// ── E5: auth-expiry on delete (commit 7ffd75e #1) ─────────────────────────────
|
||||
test('E5: a 401 on delete surfaces the "sign in again" copy and keeps the photo', async ({ page }) => {
|
||||
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { delete: 401 } });
|
||||
await openEditor(page);
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
|
||||
await deleteCell(page, 0);
|
||||
|
||||
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
|
||||
await expect(status(page)).toContainText('sign in again');
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
// ── E6: happy reorder — a drag persists the new order via the reorder route ────
|
||||
test('E6: dragging a photo saves the new order', async ({ page }) => {
|
||||
const state = await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'] });
|
||||
await openEditor(page);
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
|
||||
await dragCell(page, 0, 1);
|
||||
|
||||
// The reorder route must have been called with the swapped order.
|
||||
await expect.poll(() => state.reorders.length, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
expect(state.reorders[state.reorders.length - 1]).toEqual(['photo-02.jpg', 'photo-01.jpg']);
|
||||
await expect(status(page)).toHaveText('');
|
||||
});
|
||||
|
||||
// ── E7: auth-expiry on reorder (commit 7ffd75e #1) reverts the drag ───────────
|
||||
test('E7: a 401 on reorder surfaces the "sign in again" copy and reverts', async ({ page }) => {
|
||||
await installMockApi(page, { photos: ['photo-01.jpg', 'photo-02.jpg'], status: { reorder: 401 } });
|
||||
await openEditor(page);
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
|
||||
await dragCell(page, 0, 1);
|
||||
|
||||
await expect(status(page)).toContainText('login session expired', { timeout: 15_000 });
|
||||
await expect(status(page)).toContainText('sign in again');
|
||||
// Reverted to the last-known-good order.
|
||||
await expect(cells(page)).toHaveCount(2);
|
||||
await expect(cells(page).first()).toHaveAttribute('data-filename', 'photo-01.jpg');
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// @ts-check
|
||||
// Tests: post-form redesign UX — disclosure, HEIC conversion + failure,
|
||||
// weather-button gating, draft restore. Covers AE1, AE3, AE4 and R18/R20.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry } = require('../helpers');
|
||||
|
||||
const TEST_HEIC = path.join(__dirname, '../../fixtures/test-photo.heic');
|
||||
const TEST_CORRUPT_HEIC = path.join(__dirname, '../../fixtures/test-corrupt.heic');
|
||||
const TEST_JPG = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||
const TEST_JPG_B = path.join(__dirname, '../../fixtures/test-photo-b.jpg');
|
||||
const DRAFT_KEY = 'intotheeast:new-entry-draft';
|
||||
|
||||
const created = [];
|
||||
test.afterAll(() => { created.forEach(cleanupEntry); });
|
||||
|
||||
// ── AE3: advanced fields sit behind "More options" ────────────────────────────
|
||||
test('AE3: advanced fields are hidden until "More options" is expanded', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
// The hero-image field was removed (journal heroes come from the first
|
||||
// uploaded photo); force_connect/featured remain the advanced trio's members.
|
||||
const details = page.locator('details.more-options');
|
||||
|
||||
await expect(details).toBeAttached();
|
||||
await expect(details).toHaveJSProperty('open', false); // collapsed by default
|
||||
await page.locator('.more-options__summary').click();
|
||||
await expect(details).toHaveJSProperty('open', true);
|
||||
});
|
||||
|
||||
// ── AE3b: a toggle that deviates from its default auto-opens "More options" ────
|
||||
// AE3 covers the common non-deviating case (collapsed on a plain create). This
|
||||
// covers the OTHER branch of initDisclosure: an advanced toggle whose value
|
||||
// differs from its blueprint default force-opens the panel so a non-default
|
||||
// setting is never hidden. It also guards the data-driven default detection —
|
||||
// initDisclosure reads each toggle's default from the rendered `[checked]`
|
||||
// attribute rather than a hardcoded field name, so this must hold for a
|
||||
// default-OFF toggle (featured) flipped ON just as it does for published.
|
||||
test('AE3b: a non-default advanced toggle auto-expands "More options" on load', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
// Seed a create draft whose `featured` toggle deviates from its OFF default,
|
||||
// then reload so initDraft restores it before initDisclosure's auto-open check.
|
||||
await page.evaluate((k) => {
|
||||
localStorage.setItem(k, JSON.stringify({ 'data[featured]': '1' }));
|
||||
}, DRAFT_KEY);
|
||||
await page.reload();
|
||||
|
||||
const details = page.locator('details.more-options');
|
||||
await expect(details).toBeAttached();
|
||||
await expect(details).toHaveJSProperty('open', true);
|
||||
// The restored deviation is reflected in the live toggle state.
|
||||
await expect(page.locator('input[name="data[featured]"][value="1"]')).toBeChecked();
|
||||
|
||||
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
|
||||
});
|
||||
|
||||
// ── AE1: HEIC → JPEG conversion, posted with a working thumbnail ───────────────
|
||||
test('AE1: a HEIC photo is converted to JPEG client-side and posted', async ({ page }) => {
|
||||
const tag = `heic-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, 'HEIC conversion test. Safe to delete.');
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_HEIC);
|
||||
await waitForPhotoUpload(page, 1); // converted (beforeAddFile) + uploaded via FilePond
|
||||
// The photo section auto-collapses to a summary bar once the upload settles.
|
||||
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
|
||||
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
const dir = findEntry(tag);
|
||||
expect(dir, 'Entry folder should exist on disk').toBeTruthy();
|
||||
const files = fs.readdirSync(dir);
|
||||
expect(files.some(f => /\.jpe?g$/i.test(f)), 'a JPEG should be posted').toBe(true);
|
||||
expect(files.some(f => /\.heic$/i.test(f)), 'the original HEIC must NOT be posted').toBe(false);
|
||||
});
|
||||
|
||||
// ── AE4: corrupt HEIC fails closed — it is the only "photo", so submit blocks ──
|
||||
test('AE4: a corrupt HEIC is blocked (fail-closed) and cannot be posted alone', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test heicfail-${Date.now()}`);
|
||||
await fillEditor(page, 'HEIC failure test. Safe to delete.');
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_CORRUPT_HEIC);
|
||||
// Conversion fails → inline error status, original HEIC never added to FilePond.
|
||||
await expect(page.locator('.photo-convert-status.form-status--err')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('.btn-post')).toBeEnabled(); // Submit still usable
|
||||
|
||||
// The corrupt file was never added, so there are zero photos — the ≥1-photo
|
||||
// requirement blocks submit, which is exactly what keeps the corrupt HEIC
|
||||
// (fail-closed) from ever being posted. No entry is created.
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.photos-collapse .field-error')).toBeVisible();
|
||||
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── Photo count: at least one is required; the picker caps at 6 ────────────────
|
||||
test('a post requires at least one photo and the picker allows at most 6', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
|
||||
// FilePond is configured from the blueprint limit (6).
|
||||
await page.waitForFunction(
|
||||
() => window.GravFilePond && window.GravFilePond.getInstances().length > 0,
|
||||
{ timeout: 10_000 });
|
||||
expect(await page.evaluate(() => window.GravFilePond.getInstances()[0].maxFiles)).toBe(6);
|
||||
|
||||
// Title + content filled, date prefilled, but no photo → submit is blocked
|
||||
// with an error on the photo section and no success notice.
|
||||
await page.fill('input[name="data[title]"]', `UI photoreq-${Date.now()}`);
|
||||
await fillEditor(page, 'photo-required test');
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
|
||||
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── Photo section collapses to a summary after upload, re-expands on tap ──────
|
||||
test('photo section auto-collapses to a summary after upload and re-expands on tap', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
const details = page.locator('details.photos-collapse');
|
||||
await expect(details).toHaveJSProperty('open', true); // open while empty
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_HEIC);
|
||||
await waitForPhotoUpload(page, 1);
|
||||
|
||||
// Settled → auto-collapsed, summary reflects the ready count.
|
||||
await expect(details).toHaveJSProperty('open', false);
|
||||
await expect(page.locator('.photos-collapse__summary')).toContainText('1 photo ready');
|
||||
|
||||
// Native <details>: clicking the summary re-expands for review.
|
||||
await page.locator('.photos-collapse__summary').click();
|
||||
await expect(details).toHaveJSProperty('open', true);
|
||||
});
|
||||
|
||||
// ── Reorder: uploaded photos are renamed photo-01..NN; no order field leaks ────
|
||||
test('uploaded photos are renamed photo-01..NN and the order field never hits frontmatter', async ({ page }) => {
|
||||
const tag = `rename-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, `photo rename test ${tag}`);
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles([TEST_JPG, TEST_JPG_B]);
|
||||
await waitForPhotoUpload(page, 2);
|
||||
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
const dir = findEntry(tag);
|
||||
expect(dir, 'Entry folder should exist').toBeTruthy();
|
||||
const files = fs.readdirSync(dir);
|
||||
// Server renamed both uploads to the deterministic zero-padded photo-NN scheme (drag order).
|
||||
expect(files).toContain('photo-01.jpg');
|
||||
expect(files).toContain('photo-02.jpg');
|
||||
|
||||
// The order is sent as a top-level POST key, so it must not appear in frontmatter.
|
||||
const mdName = files.find(f => /\.md$/.test(f));
|
||||
const md = fs.readFileSync(path.join(dir, mdName), 'utf-8');
|
||||
expect(md).not.toContain('photo_order');
|
||||
});
|
||||
|
||||
// ── Date field is a native datetime-local picker, prefilled, and required ─────
|
||||
test('date field renders as a datetime-local picker, prefilled with now and required', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
const date = page.locator('input[name="data[date]"]');
|
||||
|
||||
// Grav's deprecated datetime field used to fall back to a plain text box;
|
||||
// the theme override renders a real picker instead.
|
||||
await expect(date).toHaveAttribute('type', 'datetime-local');
|
||||
// post-form.js prefills the current local time in the value format the
|
||||
// native input expects (YYYY-MM-DDTHH:MM).
|
||||
await expect(date).toHaveValue(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
|
||||
// Clearing it and submitting must be blocked client-side (this is what keeps
|
||||
// an invalid/empty date from round-tripping to the server and wiping the
|
||||
// FilePond photo list on a re-render).
|
||||
await date.fill('');
|
||||
await page.fill('input[name="data[title]"]', `UI date-${Date.now()}`);
|
||||
await fillEditor(page, 'datetime picker validation test');
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(date).toHaveClass(/field-invalid/);
|
||||
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── R18: Get Weather is gated on coordinates ──────────────────────────────────
|
||||
test('R18: Get Weather is disabled until Get Location provides coordinates', async ({ page, context }) => {
|
||||
await context.grantPermissions(['geolocation']);
|
||||
await context.setGeolocation({ latitude: 35.6812, longitude: 139.7671 });
|
||||
|
||||
await page.goto('/post');
|
||||
await expect(page.locator('#get-weather')).toBeDisabled();
|
||||
|
||||
await page.click('#get-location');
|
||||
await expect(page.locator('input[name="data[lat]"]')).toHaveValue(/35\.68/, { timeout: 5_000 });
|
||||
await expect(page.locator('#get-weather')).toBeEnabled();
|
||||
});
|
||||
|
||||
// ── R20: text draft survives a reload; photos need re-selecting ────────────────
|
||||
test('R20: in-progress text is restored after a reload, with a photos hint', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
|
||||
|
||||
const marker = `draft-${Date.now()}`;
|
||||
await page.fill('input[name="data[title]"]', marker);
|
||||
await fillEditor(page, `Draft body ${marker}`);
|
||||
// Nudge an input event so the draft is written, then let it flush.
|
||||
await page.locator('input[name="data[title]"]').press('End');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator('input[name="data[title]"]')).toHaveValue(marker);
|
||||
expect(await page.evaluate(() => window.postFormEditor.value())).toContain(marker);
|
||||
await expect(page.locator('.photo-reauth-hint')).toBeVisible();
|
||||
|
||||
await page.evaluate((k) => localStorage.removeItem(k), DRAFT_KEY);
|
||||
});
|
||||
|
||||
// ── Success confirmation: after a post, show a clear CTA the owner can act on ──
|
||||
test('post success shows a confirmation with a working "View your journal" link', async ({ page }) => {
|
||||
const tag = `success-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await fillEditor(page, `success cta test ${tag}`);
|
||||
// A photo is required to post.
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_JPG);
|
||||
await waitForPhotoUpload(page, 1);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||
created.push(tag);
|
||||
|
||||
const panel = page.locator('.post-success');
|
||||
await expect(panel).toBeVisible();
|
||||
const view = panel.locator('.post-success__view');
|
||||
await expect(view).toBeVisible();
|
||||
// links into the active trip's journal (resolved from site.active_trip)
|
||||
await expect(view).toHaveAttribute('href', /\/trips\//);
|
||||
await expect(panel.locator('.post-success__again')).toBeVisible();
|
||||
});
|
||||
+34
-27
@@ -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, ACTIVE_TRIP_URL } = require('../helpers');
|
||||
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers');
|
||||
|
||||
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||
|
||||
@@ -15,47 +15,42 @@ test.afterAll(() => {
|
||||
created.forEach(cleanupEntry);
|
||||
});
|
||||
|
||||
// ── P1: Post without photo ─────────────────────────────────────────────────────
|
||||
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => {
|
||||
// ── P1: A photo is required — a text-only submit is blocked, writing nothing ───
|
||||
// Create mode requires ≥1 photo (post-form.js gate). The UX suite asserts the
|
||||
// inline error + suppressed notice; P1 is the complementary DISK-level guarantee
|
||||
// that a blocked submit never lands an entry on disk.
|
||||
test('P1: text-only submit is blocked by the photo gate and creates no entry', async ({ page }) => {
|
||||
const tag = `p1-${Date.now()}`;
|
||||
const title = `UI Test ${tag}`;
|
||||
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', title);
|
||||
await page.fill('textarea[name="data[content]"]', 'Text-only test entry. Safe to delete.');
|
||||
await fillEditor(page, 'Text-only entry. Should be rejected — no photo.');
|
||||
await page.fill('input[name="data[location_city]"]', 'Testville');
|
||||
await page.fill('input[name="data[location_country]"]', 'Testland');
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
|
||||
|
||||
const entryDir = findEntry(tag);
|
||||
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||
created.push(tag);
|
||||
// The photo gate fires an inline error and suppresses the success notice.
|
||||
await expect(page.locator('.photos-collapse .field-error')).toContainText('at least one photo');
|
||||
await expect(page.locator('.notices.success, .notices.green')).toHaveCount(0);
|
||||
|
||||
const md = readEntryMd(entryDir);
|
||||
expect(md).toContain(tag);
|
||||
|
||||
// No photo expected
|
||||
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(ACTIVE_TRIP_URL);
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
// The crucial guarantee: nothing was written to disk.
|
||||
expect(findEntry(tag), 'a blocked submit must not create an entry').toBeNull();
|
||||
});
|
||||
|
||||
// ── P2: Post with photo ────────────────────────────────────────────────────────
|
||||
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
|
||||
test('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}`;
|
||||
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', title);
|
||||
await page.fill('textarea[name="data[content]"]', 'Photo test entry. Safe to delete.');
|
||||
await fillEditor(page, 'Photo test entry. Safe to delete.');
|
||||
await page.fill('input[name="data[location_city]"]', 'Testville');
|
||||
await page.fill('input[name="data[location_country]"]', 'Testland');
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForFilePondUpload(page);
|
||||
await waitForPhotoUpload(page);
|
||||
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
|
||||
@@ -81,9 +76,11 @@ test('P3: post entry with city/country → frontmatter contains location', async
|
||||
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', title);
|
||||
await page.fill('textarea[name="data[content]"]', 'Location test. Safe to delete.');
|
||||
await fillEditor(page, 'Location test. Safe to delete.');
|
||||
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||
await page.fill('input[name="data[location_country]"]', 'Japan');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
|
||||
|
||||
@@ -103,13 +100,15 @@ test('P4: post entry with lat/lng → coordinates saved in frontmatter', async (
|
||||
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', title);
|
||||
await page.fill('textarea[name="data[content]"]', 'GPS test. Safe to delete.');
|
||||
await fillEditor(page, 'GPS test. Safe to delete.');
|
||||
// lat/lng fields are CSS-hidden (designed to be filled by the Get Location button);
|
||||
// set values directly via JS to simulate what the button would do.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('input[name="data[lat]"]').value = '35.6762';
|
||||
document.querySelector('input[name="data[lng]"]').value = '139.6503';
|
||||
});
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await page.waitForSelector('.form-messages, .notices', { timeout: 15_000 });
|
||||
|
||||
@@ -142,7 +141,9 @@ test('P6: successful submit shows "Entry posted successfully!" message', async (
|
||||
const tag = `p6-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await page.fill('textarea[name="data[content]"]', 'P6 test. Safe to delete.');
|
||||
await fillEditor(page, 'P6 test. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||
'Entry posted successfully!', { timeout: 15_000 }
|
||||
@@ -158,7 +159,9 @@ test('P7: submitted entry is saved with a date within 5 minutes of now', async (
|
||||
const tag = `p7-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await page.fill('textarea[name="data[content]"]', 'P7 date test. Safe to delete.');
|
||||
await fillEditor(page, 'P7 date test. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||
'Entry posted successfully!', { timeout: 15_000 }
|
||||
@@ -184,13 +187,17 @@ test('P8: title and content fields are empty after a successful submit', async (
|
||||
const tag = `p8-${Date.now()}`;
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||
await page.fill('textarea[name="data[content]"]', 'P8 reset test. Safe to delete.');
|
||||
await fillEditor(page, 'P8 reset test. Safe to delete.');
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||
await waitForPhotoUpload(page);
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
await expect(page.locator('.form-messages, .notices')).toContainText(
|
||||
'Entry posted successfully!', { timeout: 15_000 }
|
||||
);
|
||||
// After reset, the form fields should be empty
|
||||
// After reset, the form fields should be empty. The content textarea is
|
||||
// hidden by EasyMDE, so check the editor value via the exposed instance.
|
||||
await expect(page.locator('input[name="data[title]"]')).toHaveValue('');
|
||||
await expect(page.locator('textarea[name="data[content]"]')).toHaveValue('');
|
||||
await page.waitForFunction(() => window.postFormEditor != null, { timeout: 10_000 });
|
||||
expect(await page.evaluate(() => window.postFormEditor.value())).toBe('');
|
||||
created.push(tag);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Tests: V1–V4 — form validation and input constraints
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const { fillEditor } = require('../helpers');
|
||||
|
||||
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||
const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
|
||||
@@ -10,7 +11,7 @@ const TEST_NONIMAGE = path.join(__dirname, '../../fixtures/test-nonimage.txt');
|
||||
test('V1: submit without title shows a validation error or stays on /post', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
// Leave title empty, fill only content
|
||||
await page.fill('textarea[name="data[content]"]', 'Content without a title.');
|
||||
await fillEditor(page, 'Content without a title.');
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
|
||||
// Grav either shows an error message OR re-renders the form (stays on /post).
|
||||
@@ -24,7 +25,7 @@ test('V1: submit without title shows a validation error or stays on /post', asyn
|
||||
test('V2: submit without content shows a validation error or stays on /post', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
await page.fill('input[name="data[title]"]', 'V2 title no content');
|
||||
// Leave content (textarea) empty
|
||||
// Leave content (editor) empty
|
||||
await page.locator('.btn-post').evaluate(el => el.click());
|
||||
|
||||
await page.waitForTimeout(2_000);
|
||||
@@ -32,30 +33,27 @@ test('V2: submit without content shows a validation error or stays on /post', as
|
||||
expect(bodyText).not.toContain('Entry posted successfully');
|
||||
});
|
||||
|
||||
// ── V3: Photo limit (max 4) ───────────────────────────────────────────────────
|
||||
test('V3: filepond rejects a 5th photo when limit is 4', async ({ page }) => {
|
||||
// ── V3: Photo limit (max 6) ───────────────────────────────────────────────────
|
||||
// The blueprint limit is 6 (asserted directly as maxFiles===6 in the UX suite);
|
||||
// this is the behavioral counterpart — the picker must refuse a 7th attachment.
|
||||
test('V3: FilePond caps attachments at the limit (6)', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
const browser = page.locator('input.filepond--browser');
|
||||
|
||||
// Upload 4 photos (all the same fixture — we just need 4 items)
|
||||
const fourPhotos = [TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO];
|
||||
await page.locator('input.filepond--browser').setInputFiles(fourPhotos);
|
||||
|
||||
// Wait for all 4 items to appear
|
||||
// Attach 6 photos (same fixture — we only need six items).
|
||||
await browser.setInputFiles([TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO, TEST_PHOTO]);
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelectorAll('.filepond--item').length === 4,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
document.querySelectorAll('.filepond--item').length === 6, { timeout: 10_000 });
|
||||
|
||||
// Attempt a 5th — filepond should ignore it once the limit is reached
|
||||
await page.locator('input.filepond--browser').setInputFiles([TEST_PHOTO]);
|
||||
// A 7th is ignored once the limit is reached.
|
||||
await browser.setInputFiles([TEST_PHOTO]);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const itemCount = await page.locator('.filepond--item').count();
|
||||
expect(itemCount).toBe(4);
|
||||
expect(await page.locator('.filepond--item').count()).toBe(6);
|
||||
});
|
||||
|
||||
// ── V4: Non-image file rejected ───────────────────────────────────────────────
|
||||
test('V4: filepond rejects non-image files', async ({ page }) => {
|
||||
test('V4: FilePond rejects a non-image file', async ({ page }) => {
|
||||
await page.goto('/post');
|
||||
|
||||
await page.locator('input.filepond--browser').setInputFiles(TEST_NONIMAGE);
|
||||
@@ -63,13 +61,12 @@ test('V4: filepond rejects non-image files', async ({ page }) => {
|
||||
|
||||
const items = page.locator('.filepond--item');
|
||||
const count = await items.count();
|
||||
|
||||
if (count > 0) {
|
||||
// If filepond added it, it must show an error state — not processing-complete
|
||||
// If added, it must not reach processing-complete.
|
||||
const state = await items.first().getAttribute('data-filepond-item-state');
|
||||
expect(state).not.toBe('processing-complete');
|
||||
} else {
|
||||
// Silently rejected before adding — also a pass
|
||||
// Silently rejected before adding — also a pass.
|
||||
expect(count).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @ts-check
|
||||
// Tests: U4 — trip-page in-column header extras (R8, R9, R10, R13)
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const TRIP_URL = '/trips/italy-2026-demo';
|
||||
|
||||
const topOf = async (locator) => (await locator.boundingBox()).y;
|
||||
|
||||
// ── R8/R9: extras render in HTD order, banner sits above the filter bar ────────
|
||||
test('U4/R8+R9: one-liner, description and banner stack in order above the filter bar', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const header = page.locator('.home-trip-header');
|
||||
await expect(header.locator('.home-trip-tagline')).toHaveText(/southern Tuscany by bike/);
|
||||
|
||||
const yTitle = await topOf(page.locator('.home-trip-name'));
|
||||
const yTag = await topOf(page.locator('.home-trip-tagline'));
|
||||
const yCounts = await topOf(page.locator('.home-trip-counts'));
|
||||
const yDesc = await topOf(page.locator('.trip-header-desc'));
|
||||
const yBanner = await topOf(page.locator('.trip-header-banner'));
|
||||
const yFilter = await topOf(page.locator('.trip-filter-bar'));
|
||||
|
||||
expect(yTitle).toBeLessThan(yTag); // one-liner directly below the title
|
||||
expect(yTag).toBeLessThan(yCounts);
|
||||
expect(yCounts).toBeLessThan(yDesc); // description below the counts
|
||||
expect(yDesc).toBeLessThan(yBanner); // banner below the description
|
||||
expect(yBanner).toBeLessThan(yFilter); // ...and above the filter bar (R9)
|
||||
});
|
||||
|
||||
// ── R8/R13: description shows a collapsed preview and expands on demand ────────
|
||||
test('U4/R13: description is clamped to a preview and expands to full text', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const desc = page.locator('.trip-header-desc');
|
||||
const body = page.locator('.trip-header-desc-body');
|
||||
const btn = page.locator('.trip-header-desc-toggle');
|
||||
|
||||
await expect(desc).toHaveAttribute('data-collapsed', 'true');
|
||||
await expect(btn).toBeVisible();
|
||||
|
||||
// Collapsed: the body is genuinely clamped — visible height is shorter than
|
||||
// its full content (the max-height:4.8em preview actually hides overflow).
|
||||
const clampedWhenCollapsed = await body.evaluate((el) => el.clientHeight < el.scrollHeight);
|
||||
expect(clampedWhenCollapsed).toBe(true);
|
||||
|
||||
const collapsedH = (await body.boundingBox()).height;
|
||||
await btn.click();
|
||||
|
||||
await expect(desc).toHaveAttribute('data-collapsed', 'false');
|
||||
await expect(btn).toHaveText('Show less');
|
||||
const expandedH = (await body.boundingBox()).height;
|
||||
expect(expandedH).toBeGreaterThan(collapsedH);
|
||||
// Expanded: the clamp is gone — the full text is now actually visible, not
|
||||
// merely present in the DOM (which it was even while collapsed).
|
||||
const unclampedWhenExpanded = await body.evaluate((el) => el.clientHeight >= el.scrollHeight - 1);
|
||||
expect(unclampedWhenExpanded).toBe(true);
|
||||
});
|
||||
|
||||
// ── R9/AE3: banner uses the first journal image (no cover_image set) ───────────
|
||||
test('U4/R9/AE3: banner falls back to the first journal entry image', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const img = page.locator('.trip-header-banner img');
|
||||
await expect(img).toBeVisible();
|
||||
const srcset = await img.getAttribute('srcset');
|
||||
expect(srcset).toContain('720w');
|
||||
// The 1200px landscape source can't supply a non-upscaled 2x (needs ≥1440),
|
||||
// so the retina descriptor is omitted entirely — 1x only, no blurry upscale
|
||||
// and no odd intermediate width.
|
||||
expect(srcset).not.toContain('1440w');
|
||||
expect(srcset).not.toContain('1200w');
|
||||
// cropZoom hands over a wide cover strip (~3.27:1); the old cropResize gave
|
||||
// a fit-inside image the object-fit:cover box then upscaled into a blur.
|
||||
const ratio = await img.evaluate((el) => new Promise((res) => {
|
||||
const done = () => res(el.naturalWidth / el.naturalHeight);
|
||||
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
|
||||
}));
|
||||
expect(ratio).toBeGreaterThan(3);
|
||||
await expect(img).toHaveAttribute('alt', 'Tuscany 2026');
|
||||
});
|
||||
|
||||
// ── Regression: a portrait fallback source (the reported us-canada-mex-2024
|
||||
// banner) must render a sharp wide cover strip, not a blurry upscaled sliver ─
|
||||
test('U4/R9: a portrait fallback source renders a wide cover strip, not a sliver', async ({ page }) => {
|
||||
await page.goto('/trips/us-canada-mex-2024');
|
||||
const img = page.locator('.trip-header-banner img');
|
||||
await expect(img).toBeVisible();
|
||||
const srcset = await img.getAttribute('srcset');
|
||||
expect(srcset).toContain('720w');
|
||||
// 1013px portrait source — nowhere near ≥1440 — so no upscaled 2x candidate.
|
||||
expect(srcset).not.toContain('1440w');
|
||||
// cropZoom crops the tall portrait into a wide cover strip (~3.27:1); the old
|
||||
// cropResize fit it inside as a ~165px sliver the box then upscaled to a blur.
|
||||
const ratio = await img.evaluate((el) => new Promise((res) => {
|
||||
const done = () => res(el.naturalWidth / el.naturalHeight);
|
||||
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
|
||||
}));
|
||||
expect(ratio).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
// ── R10/AE6: the map+journal split is intact with no header above it ───────────
|
||||
test('U4/R10/AE6: map+journal split renders with the extras inside the feed column', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
await expect(page.locator('.home-layout')).toBeVisible();
|
||||
await expect(page.locator('.home-layout > .home-map-col')).toBeVisible();
|
||||
await expect(page.locator('.home-layout > .home-feed-col')).toBeVisible();
|
||||
// extras live inside the feed column, not as a new header above the split
|
||||
await expect(page.locator('.home-feed-col .trip-header-banner')).toHaveCount(1);
|
||||
await expect(page.locator('.home-feed-col .home-trip-tagline')).toHaveCount(1);
|
||||
// the header extras never appear outside the two-column layout
|
||||
await expect(page.locator('body > .trip-header-banner, .home-layout ~ .trip-header-banner')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-check
|
||||
// Tests: U3 — trip-list card one-liner + retina cover (R5, R6, R7, R14)
|
||||
// R11 (set-but-unresolvable cover_image falls back) shares the exact else-branch
|
||||
// exercised by the R7/AE3 fallback test below; it is covered by construction in
|
||||
// the shared cover macro rather than by a dedicated fixture here.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const DEMO_HREF = '/trips/italy-2026-demo'; // has a tagline, no cover_image (entry-image fallback)
|
||||
const NO_TAGLINE_HREF = '/trips/slovenia-2024'; // a real trip with no tagline
|
||||
|
||||
const demoCard = (page) => page.locator(`.trip-card[href="${DEMO_HREF}"]`);
|
||||
|
||||
// ── R5: one-liner renders between the title and the meta line ──────────────────
|
||||
test('U3/R5: card with a tagline shows a one-liner between title and meta', async ({ page }) => {
|
||||
await page.goto('/trips');
|
||||
const card = demoCard(page);
|
||||
const tagline = card.locator('.trip-card-tagline');
|
||||
await expect(tagline).toBeVisible();
|
||||
await expect(tagline).toHaveText(/southern Tuscany by bike/);
|
||||
|
||||
const order = await card.evaluate((el) =>
|
||||
Array.from(el.children).map((c) => c.className.split(' ')[0])
|
||||
);
|
||||
expect(order.indexOf('trip-card-title')).toBeLessThan(order.indexOf('trip-card-tagline'));
|
||||
expect(order.indexOf('trip-card-tagline')).toBeLessThan(order.indexOf('trip-card-meta'));
|
||||
});
|
||||
|
||||
// ── R5/AE1: a card with no tagline renders no one-liner element ────────────────
|
||||
test('U3/R5/AE1: card without a tagline renders no one-liner element', async ({ page }) => {
|
||||
await page.goto('/trips');
|
||||
const card = page.locator(`.trip-card[href="${NO_TAGLINE_HREF}"]`);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.trip-card-tagline')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── R6/AE5: card cover exposes a 1x srcset; 2x only when the source is ≥2×w ────
|
||||
test('U3/R6/AE5: card cover img carries a 720w srcset, 2x omitted for a narrow source', async ({ page }) => {
|
||||
await page.goto('/trips');
|
||||
const img = demoCard(page).locator('.trip-card-cover img');
|
||||
const srcset = await img.getAttribute('srcset');
|
||||
expect(srcset).toContain('720w');
|
||||
// The 1200px source can't supply a non-upscaled 2x (needs ≥1440), so the
|
||||
// retina descriptor is omitted — 1x only, no upscale, no intermediate width.
|
||||
expect(srcset).not.toContain('1440w');
|
||||
expect(srcset).not.toContain('1200w');
|
||||
// cropZoom cover strip (~3:1), not a cropResize fit-inside sliver.
|
||||
const ratio = await img.evaluate((el) => new Promise((res) => {
|
||||
const done = () => res(el.naturalWidth / el.naturalHeight);
|
||||
el.complete && el.naturalWidth ? done() : el.addEventListener('load', done, { once: true });
|
||||
}));
|
||||
expect(ratio).toBeGreaterThan(2.5);
|
||||
});
|
||||
|
||||
// ── R7/AE3: with no cover_image set, the card falls back to a journal image ────
|
||||
test('U3/R7/AE3: card with no cover_image uses the first journal entry image', async ({ page }) => {
|
||||
await page.goto('/trips');
|
||||
// The demo trip sets cover_image: '' so the cover comes from the fallback.
|
||||
const img = demoCard(page).locator('.trip-card-cover img');
|
||||
await expect(img).toBeVisible();
|
||||
const src = await img.getAttribute('src');
|
||||
expect(src).toMatch(/\/images\/.+\.(jpg|jpeg|png|webp)/i);
|
||||
});
|
||||
|
||||
// ── R14: cover alt text equals the trip title ─────────────────────────────────
|
||||
test('U3/R14: card cover alt equals the trip title', async ({ page }) => {
|
||||
await page.goto('/trips');
|
||||
const img = demoCard(page).locator('.trip-card-cover img');
|
||||
await expect(img).toHaveAttribute('alt', 'Tuscany 2026');
|
||||
});
|
||||
+1
-1
Submodule user updated: 4aa9ae9b23...55da834396
Reference in New Issue
Block a user