Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d20e0fedc | |||
| 832e135e3a | |||
| 0b49f90206 | |||
| 5a52b8ff18 | |||
| 2efdfbebb7 | |||
| dfdb4d5ac3 | |||
| 50b64fbcb3 | |||
| 640016c54f | |||
| 64dbcefd9b | |||
| 3fbba7672d | |||
| 6c378d77ca | |||
| 7602b135f8 | |||
| 46c33837ba | |||
| b1ec642d60 | |||
| 28dc6c1f6c | |||
| 3c35176b90 | |||
| 5e864b0c03 | |||
| c9ce336b18 | |||
| e329cd4ad2 | |||
| abd953e1f6 | |||
| dc8b7f58d2 | |||
| 6d54092413 | |||
| ab92f3b469 | |||
| ae17483ca4 | |||
| e032292c97 | |||
| e6eb93cd2c | |||
| 2835d876cc | |||
| 2ff31f311b | |||
| 0cb109b2a3 | |||
| 5e954d8adf | |||
| 6926b4084a | |||
| 943026658b | |||
| 6702b5d9b6 | |||
| b2f6cb1977 | |||
| 8824f79c64 | |||
| 9fd349e5ec | |||
| df55917347 | |||
| 6fe066e77d | |||
| b98ae50f30 | |||
| 9beb22f4c2 |
+3
-1
@@ -3,10 +3,12 @@
|
||||
|
||||
# Grav CMS
|
||||
/user/
|
||||
!/user/
|
||||
!/user/plugins/
|
||||
!/user/plugins/cache-on-save/
|
||||
user/accounts/
|
||||
user/data/
|
||||
user/cache/
|
||||
user/plugins/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
@@ -9,6 +9,60 @@
|
||||
- **./**: Grav CMS dev environment for intotheeast travel blog
|
||||
- **scripts/**: Server install and maintenance scripts
|
||||
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here)
|
||||
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
|
||||
|
||||
### Current stack
|
||||
|
||||
- **Grav:** 2.0.0-rc.9 (installed manually — see §3 below)
|
||||
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`)
|
||||
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta`
|
||||
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
|
||||
|
||||
### Dev server
|
||||
|
||||
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
|
||||
|
||||
### Trip entity architecture
|
||||
|
||||
The site is structured around Trip entities. Key facts:
|
||||
- Active trip is set in `user/config/site.yaml` → `active_trip: japan-korea-2026`
|
||||
- Trip pages live at `user/pages/01.trips/<slug>/`
|
||||
- Each trip has: `01.dailies/`, `02.map/`, `03.stats/`, `04.stories/`
|
||||
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
|
||||
- Post form parent (`post-form.md` → `pageconfig.parent`) **must be kept in sync** with `active_trip`
|
||||
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories) — do NOT add nav links back to `/dailies`, `/stats`, `/stories` on the trip page
|
||||
- Stats are shown inline on the trip page via a toggle; the standalone `/stats` sub-page still exists as a URL but is not linked from the trip page
|
||||
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
|
||||
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
||||
|
||||
### GPX file management
|
||||
|
||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
|
||||
|
||||
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
|
||||
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
|
||||
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
|
||||
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
|
||||
- List: `GET /api/v1/pages{route}/media`
|
||||
- Upload: `POST /api/v1/pages{route}/media` (multipart)
|
||||
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
|
||||
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
|
||||
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
|
||||
|
||||
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
|
||||
|
||||
### 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.
|
||||
|
||||
| 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` |
|
||||
|
||||
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
|
||||
|
||||
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the standard four subfolders.
|
||||
|
||||
### Environment
|
||||
|
||||
@@ -23,6 +77,8 @@ Always use `make` commands for anything on the production server (`make remote-i
|
||||
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
|
||||
- `make content-pull` — pull latest from Gitea to local
|
||||
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
|
||||
- `make demo-load` — load demo entries for both trips (Japan/Korea 2026 + Italy 2025 with real GPX)
|
||||
- `make demo-reset` — remove demo entries (keeps trip page structure, removes entries only)
|
||||
|
||||
### User repo gitignore
|
||||
|
||||
@@ -52,7 +108,7 @@ Before going live, change in `user/config/system.yaml`:
|
||||
|---|---|---|
|
||||
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
|
||||
|
||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/tracker` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/trips/japan-korea-2026/dailies` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
||||
|
||||
### What the cache-on-save plugin handles
|
||||
|
||||
@@ -72,12 +128,63 @@ Then run `make setup` (starts Docker + installs plugins).
|
||||
|
||||
### After make install-plugins: fix cache permissions
|
||||
|
||||
If the site returns a 500 error after plugin installation, the cache/logs/tmp directories may have wrong ownership (gpm runs as root inside the container). Fix with:
|
||||
If the site returns a 500 error after plugin installation or after recreating the container,
|
||||
run `make fix-perms`. This creates uid 1000 in the container, chowns `/var/www/html` to 1000:1000,
|
||||
and reloads Apache. Always run `make setup` (not just `make start`) after `docker compose down && up`
|
||||
to ensure permissions are correct.
|
||||
|
||||
### Grav 2.0 upgrade (local)
|
||||
|
||||
GPM (`php bin/gpm selfupgrade`) does **not** serve Grav 2.0 RC — it still reports 1.7.x as latest even on the `testing` channel. To upgrade locally:
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav chown -R abc:users /app/www/public/cache /app/www/public/logs /app/www/public/tmp
|
||||
# Download grav-admin bundle (includes Grav core + admin2 plugin)
|
||||
docker exec -w /tmp intotheeast_grav bash -c "
|
||||
curl -sL 'https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing' -o grav-admin.zip && \
|
||||
unzip -q grav-admin.zip
|
||||
"
|
||||
# Copy core files only (not user/)
|
||||
docker exec -w /tmp intotheeast_grav bash -c "
|
||||
cp -rf grav-admin/{assets,bin,system,vendor,webserver-configs,index.php,composer.json,composer.lock,robots.txt,CHANGELOG.md,LICENSE.txt} /var/www/html/
|
||||
"
|
||||
# Install Admin2 from the bundle (it's named admin2, not admin)
|
||||
docker exec -w /tmp intotheeast_grav bash -c "
|
||||
cp -rf grav-admin/user/plugins/admin2 /var/www/html/user/plugins/admin2
|
||||
"
|
||||
make fix-perms
|
||||
docker exec -w /var/www/html intotheeast_grav php bin/grav cache --all
|
||||
# Cleanup
|
||||
docker exec intotheeast_grav rm -rf /tmp/grav-admin /tmp/grav-admin.zip
|
||||
```
|
||||
|
||||
After upgrading, ensure these settings in `user/config/system.yaml`:
|
||||
```yaml
|
||||
accounts:
|
||||
type: flex # required for Admin2 API
|
||||
pages:
|
||||
type: flex # required for Admin2 pages API
|
||||
```
|
||||
|
||||
And ensure the admin user account has `api.*` permissions (Admin2 uses a new permission namespace):
|
||||
```yaml
|
||||
# user/accounts/<username>.yaml
|
||||
access:
|
||||
admin:
|
||||
login: true
|
||||
super: true
|
||||
api:
|
||||
super: true
|
||||
access: true
|
||||
```
|
||||
|
||||
**Disable the old `admin` plugin** once `admin2` is installed — both route to `/admin` and conflict:
|
||||
```bash
|
||||
# In user/plugins/admin/admin.yaml:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**JWT secret:** Leave `jwt_secret: ''` in `user/plugins/api/api.yaml` — it works for local dev and production installs generate a secure secret automatically.
|
||||
|
||||
### Language URL prefix
|
||||
|
||||
If Grav redirects to `/en/...` URLs, ensure `user/config/system.yaml` contains:
|
||||
|
||||
@@ -27,23 +27,41 @@ start:
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
setup: start install-plugins
|
||||
setup: 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
|
||||
|
||||
install-plugins:
|
||||
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
|
||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||
|
||||
demo-load:
|
||||
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
|
||||
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
|
||||
# Load japan-korea-2026 dailies
|
||||
cp -r user/docs/demo/trips/japan-korea-2026/dailies/. user/pages/01.trips/japan-korea-2026/01.dailies/
|
||||
cp -r user/docs/demo/trips/japan-korea-2026/04.stories user/pages/01.trips/japan-korea-2026/ 2>/dev/null || true
|
||||
# Load italy-2025 trip (create pages if absent)
|
||||
mkdir -p user/pages/01.trips/italy-2025/01.dailies user/pages/01.trips/italy-2025/02.map user/pages/01.trips/italy-2025/03.stats user/pages/01.trips/italy-2025/04.stories
|
||||
cp user/docs/demo/trips/italy-2025/trip.md user/pages/01.trips/italy-2025/trip.md 2>/dev/null || true
|
||||
cp user/docs/demo/trips/italy-2025/map.md user/pages/01.trips/italy-2025/02.map/map.md 2>/dev/null || true
|
||||
cp user/docs/demo/trips/italy-2025/stats.md user/pages/01.trips/italy-2025/03.stats/stats.md 2>/dev/null || true
|
||||
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
|
||||
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
|
||||
cp -r user/docs/demo/trips/italy-2025/dailies/. user/pages/01.trips/italy-2025/01.dailies/
|
||||
cp user/docs/demo/trips/italy-2025/*.gpx user/pages/01.trips/italy-2025/ 2>/dev/null || true
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
|
||||
demo-reset:
|
||||
@for dir in user/docs/demo/tracker/*/; do \
|
||||
@for dir in user/docs/demo/trips/japan-korea-2026/dailies/*/; do \
|
||||
folder=$$(basename "$$dir"); \
|
||||
rm -rf "user/pages/01.tracker/$$folder"; \
|
||||
rm -rf "user/pages/01.trips/japan-korea-2026/01.dailies/$$folder"; \
|
||||
done
|
||||
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
|
||||
rm -rf user/pages/01.trips/japan-korea-2026/04.stories/01.the-thousand-gates
|
||||
rm -rf user/pages/01.trips/italy-2025
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
|
||||
# ── Content sync (user repo ↔ Gitea) ──────────────────────────────────────────
|
||||
|
||||
|
||||
+6
-5
@@ -1,13 +1,14 @@
|
||||
services:
|
||||
grav:
|
||||
image: lscr.io/linuxserver/grav:latest
|
||||
image: getgrav/grav
|
||||
container_name: intotheeast_grav
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- GRAV_CHANNEL=beta
|
||||
- APACHE_RUN_USER=#1000
|
||||
- APACHE_RUN_GROUP=#1000
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./user:/config/www/user
|
||||
- ./php/php-local.ini:/config/php/php-local.ini
|
||||
- ./user:/var/www/html/user
|
||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Bugs & Fixes
|
||||
|
||||
Backlog of confirmed bugs with root cause analysis and implementation spec for the fix.
|
||||
|
||||
---
|
||||
|
||||
## BUG-001 — New entry not visible after form submission
|
||||
|
||||
**Status:** fixed 2026-06-18
|
||||
**Reported:** 2026-06-18
|
||||
|
||||
### 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.
|
||||
|
||||
### Root cause
|
||||
|
||||
Grav's page-tree cache (`cache/doctrine/`) is not invalidated when `add-page-by-form` writes a new page to disk. The tracker template uses `page.children`, which Grav serves from cache — so the new child page is invisible until the cache is cleared.
|
||||
|
||||
### Workaround (manual)
|
||||
|
||||
Run in terminal after each submission:
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
### Fix spec
|
||||
|
||||
Wire cache-clear into the form process so it happens automatically on every successful submission.
|
||||
|
||||
**Approach — custom Grav plugin event hook:**
|
||||
|
||||
1. Create a small plugin `user/plugins/cache-on-save/` with one event listener:
|
||||
- Listen on `onFormProcessed`
|
||||
- When the form name is `new-entry`, call `$this->grav['cache']->deleteAll()` (note: `clear()` does not exist on `Grav\Common\Cache` in Grav 1.7)
|
||||
2. Enable the plugin in `user/config/plugins/cache-on-save.yaml`
|
||||
|
||||
This is the cleanest approach: it fires exactly once per successful submission, requires no changes to `post-form.md`, and works for any future forms too.
|
||||
|
||||
**Alternative — disable page cache entirely:**
|
||||
|
||||
Set `cache: { enabled: false }` in `system.yaml`. Simpler but degrades frontend performance; not recommended for production.
|
||||
|
||||
### Files to create/modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `user/plugins/cache-on-save/cache-on-save.php` | New plugin, ~30 lines |
|
||||
| `user/plugins/cache-on-save/cache-on-save.yaml` | Plugin manifest, enabled: true |
|
||||
| `user/config/plugins/cache-on-save.yaml` | Runtime config, enabled: true |
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. Submit a new post via `/post`
|
||||
2. Navigate to `/trips/<active_trip>/dailies` — the new entry is visible immediately, no manual cache flush needed
|
||||
3. Grav Admin also shows the new page immediately
|
||||
|
||||
---
|
||||
|
||||
## BUG-002 — Stale Twig cache after theme file changes
|
||||
|
||||
**Status:** fixed 2026-06-18
|
||||
**Reported:** 2026-06-18
|
||||
|
||||
### Symptom
|
||||
|
||||
After theme template files are added or modified (e.g., creating `partials/base.html.twig`), Grav's Twig compiled-template cache still holds the old compiled version. Pages that extend the changed file throw 500 errors like "Template partials/base.html.twig is not defined" even though the file exists on disk.
|
||||
|
||||
### Root cause
|
||||
|
||||
Grav caches compiled Twig templates in `cache/twig/`. When a new file is added, existing templates that reference it don't know to recompile — their cache entries are still valid from their own mtime perspective.
|
||||
|
||||
### Workaround (manual)
|
||||
|
||||
Run after any theme file is added or changed:
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
### Fix spec
|
||||
|
||||
Disable Twig template caching in development via `user/config/system.yaml`:
|
||||
|
||||
```yaml
|
||||
twig:
|
||||
cache: false
|
||||
```
|
||||
|
||||
Acceptable for a single-user dev setup — eliminates both BUG-001's side-effect and this bug entirely. Performance cost is negligible at one-user scale. On production, leave Twig cache enabled (it's fine there because template files don't change at runtime).
|
||||
|
||||
**Files to change:**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `user/config/system.yaml` | Add `twig: { cache: false }` under development section |
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. Add a new theme template file
|
||||
2. Reload any page — no 500 error, template works immediately without manual cache flush
|
||||
|
||||
---
|
||||
|
||||
## BUG-003 — One post per day limit; silent failure on duplicate date
|
||||
|
||||
**Status:** fixed 2026-06-18
|
||||
**Reported:** 2026-06-18
|
||||
|
||||
### Symptom
|
||||
|
||||
Submitting a second post with the same date as an existing entry shows "Entry posted successfully!" but creates no file. The user's post is silently discarded.
|
||||
|
||||
### Root cause
|
||||
|
||||
The `add-page-by-form` plugin built the page slug from date only (`Y-m-d`), producing folder names like `2026-06-18.entry`. With `overwrite_mode: false`, if that folder already exists the plugin skips page creation but does not abort — the `message` process step runs regardless, showing a false success.
|
||||
|
||||
### Fix
|
||||
|
||||
Change the slug template in `user/pages/02.post/post-form.md` to include time and title:
|
||||
|
||||
```twig
|
||||
{{ form.value.date|date('Y-m-d-Hi') }}-{{ form.value.title|lower|regex_replace('/[^a-z0-9]+/', '-')|trim('-') }}
|
||||
```
|
||||
|
||||
Example: title "Arrived in Tokyo" at 14:30 on 2026-06-18 → `2026-06-18-1430-arrived-in-tokyo`
|
||||
|
||||
The slug is locked at creation time. Renaming the title afterwards does not change the URL.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. Submit two posts on the same day with different times or titles — both appear in `/trips/<active_trip>/dailies` as separate entries
|
||||
2. Renaming a post's title in the frontmatter does not break its URL
|
||||
|
||||
---
|
||||
|
||||
## BUG-004 — Admin2 shows empty dashboard after Grav 2.0 upgrade
|
||||
|
||||
**Status:** fixed 2026-06-19
|
||||
|
||||
### Symptom
|
||||
|
||||
After installing Grav 2.0 + Admin2, logging in shows an empty dashboard with no sidebar navigation (only a Settings item visible). Pages and content are not accessible.
|
||||
|
||||
### Root causes (three separate issues)
|
||||
|
||||
**A) Wrong user account type.** `system.yaml` had `accounts.type: regular` (old file-based system). Admin2's API plugin uses the Flex user collection to look up accounts. With `regular`, the API saw zero users and entered setup-wizard mode.
|
||||
|
||||
**B) Wrong pages type.** `system.yaml` had `pages.type: regular`. Admin2's pages API requires `pages.type: flex` to serve the page tree.
|
||||
|
||||
**C) Missing `api.*` permissions on user account.** Grav 2.0 Admin2 uses a new `api.*` permission namespace (`api.super`, `api.access`, etc.) instead of the old `admin.super`. A user with only `access.admin.super: true` appears as a non-admin to Admin2.
|
||||
|
||||
### Fix
|
||||
|
||||
In `user/config/system.yaml`:
|
||||
```yaml
|
||||
accounts:
|
||||
type: flex
|
||||
pages:
|
||||
type: flex
|
||||
```
|
||||
|
||||
In `user/accounts/<username>.yaml`:
|
||||
```yaml
|
||||
access:
|
||||
admin:
|
||||
login: true
|
||||
super: true # keep for backward compat
|
||||
api:
|
||||
super: true # required by Admin2
|
||||
access: true # required by Admin2
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `api.super: true` causes Admin2 to grant all sub-permissions automatically (`api.pages`, `api.config`, etc.)
|
||||
- JWT secret in `user/plugins/api/api.yaml` can stay empty — HMAC-SHA256 works with an empty key locally; production generates its own secure secret
|
||||
- The old `admin` plugin must be disabled (`enabled: false`) to avoid route conflict with `admin2`
|
||||
|
||||
---
|
||||
|
||||
## BUG-005 — PHP session fails after Grav 2.0 container upgrade
|
||||
|
||||
**Status:** fixed 2026-06-19
|
||||
|
||||
### Symptom
|
||||
|
||||
After replacing Grav core files inside the container, all pages return a CRITICAL error in `logs/grav.log`: `Failed to start session: session_start(): Failed to read session data: files (path: )`. Site is inaccessible.
|
||||
|
||||
### Root cause
|
||||
|
||||
The `getgrav/grav` Docker image's PHP configuration does not set `session.save_path`. Grav 1.7 worked because the image's default PHP config included it; the updated image layer did not.
|
||||
|
||||
### Fix
|
||||
|
||||
Add to `php/php-local.ini`:
|
||||
```ini
|
||||
session.save_path = /tmp
|
||||
```
|
||||
|
||||
Restart the container to pick up the change. This file is bind-mounted into the container so no image rebuild is needed.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,132 @@
|
||||
# Daily Entry Posting Pipeline
|
||||
|
||||
Two ways to create a daily entry: the mobile frontend form at `/post`, or directly from the Grav Admin2 panel. Both produce the same page structure under `user/pages/01.trips/<active_trip>/01.dailies/`.
|
||||
|
||||
The active trip is set in `user/config/site.yaml` → `active_trip`. The post form's `pageconfig.parent` in `post-form.md` must be kept in sync with this value.
|
||||
|
||||
---
|
||||
|
||||
## Frontmatter Reference
|
||||
|
||||
Every entry page (`template: entry`) supports these frontmatter fields:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `title` | string | ✅ | Entry headline |
|
||||
| `date` | datetime | ✅ | Format: `Y-m-d H:i` (e.g. `2026-06-17 10:00`) |
|
||||
| `template` | string | ✅ | Always `entry` |
|
||||
| `published` | bool | ✅ | `true` to show in tracker feed |
|
||||
| `lat` | string | — | Latitude decimal degrees (e.g. `52.3676`) |
|
||||
| `lng` | string | — | Longitude decimal degrees (e.g. `4.9041`) |
|
||||
| `location_city` | string | — | City name shown under the title (e.g. `Kyoto`) |
|
||||
| `location_country` | string | — | Country name shown under the title (e.g. `Japan`) |
|
||||
| `weather_desc` | string | — | Condition label — must be one of the values below |
|
||||
| `weather_temp_c` | number | — | Temperature in Celsius (displayed rounded, e.g. `19`) |
|
||||
| `hero_image` | string | — | Filename of the hero image (e.g. `photo.jpg`). Leave blank to auto-select the first uploaded image. |
|
||||
|
||||
**`weather_desc` allowed values** (matched to emoji icons in `entry.html.twig`):
|
||||
`Sunny` · `Partly cloudy` · `Cloudy` · `Foggy` · `Drizzle` · `Rain` · `Snow` · `Thunderstorm`
|
||||
|
||||
**Page media (photos):** images are stored as files in the page folder (`user/pages/01.tracker/<slug>/`). All images in the folder are shown in the gallery. `hero_image` pins one as the full-width header.
|
||||
|
||||
**Example complete frontmatter:**
|
||||
```yaml
|
||||
---
|
||||
title: 'First Day in Kyoto'
|
||||
date: '2026-07-20 09:30'
|
||||
template: entry
|
||||
published: true
|
||||
lat: '35.0116'
|
||||
lng: '135.7681'
|
||||
location_city: 'Kyoto'
|
||||
location_country: 'Japan'
|
||||
weather_desc: 'Sunny'
|
||||
weather_temp_c: 28
|
||||
hero_image: 'temple.jpg'
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 1 — Mobile Frontend Form (`/post`)
|
||||
|
||||
This is the primary posting flow, designed for one-handed phone use.
|
||||
|
||||
```
|
||||
Browser → /post (post-form.md)
|
||||
└─ Grav Form plugin validates fields
|
||||
└─ add-page-by-form plugin (onFormProcessed)
|
||||
├─ reads pageconfig.parent (/trips/japan-korea-2026/dailies) and pageconfig.slug_field (date + title)
|
||||
├─ reads pagefrontmatter (template: entry, published: true)
|
||||
├─ merges form field values into new page frontmatter
|
||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
||||
└─ moves uploaded photos into the page folder
|
||||
└─ cache-on-save plugin (onFormProcessed)
|
||||
└─ calls $grav['cache']->deleteAll() so tracker feed shows the entry immediately
|
||||
└─ form shows success message, resets fields
|
||||
```
|
||||
|
||||
**The form fields and their mapping to frontmatter:**
|
||||
|
||||
| Form field | Frontmatter key | Notes |
|
||||
|---|---|---|
|
||||
| `title` | `title` | Required |
|
||||
| `date` | `date` | Defaults to current datetime |
|
||||
| `content` | page body (markdown) | Required |
|
||||
| `photos` | page media files | Uploaded to page folder |
|
||||
| `lat` | `lat` | Filled via "Get Location" button |
|
||||
| `lng` | `lng` | Filled via "Get Location" button |
|
||||
| `location_city` | `location_city` | Manual text entry |
|
||||
| `location_country` | `location_country` | Manual text entry |
|
||||
| `weather_temp_c` | `weather_temp_c` | Hidden — set by weather JS widget |
|
||||
| `weather_desc` | `weather_desc` | Hidden — set by weather JS widget |
|
||||
|
||||
**Slug format:** `<YYYY-MM-DD>.<slugified-title>` (controlled by `slug_field: 'date,title'` in `post-form.md`).
|
||||
|
||||
**Security:** the `/post` page requires `access: site.login: true` — anonymous visitors get redirected to login.
|
||||
|
||||
---
|
||||
|
||||
## Flow 2 — Admin Panel (sit-down workflow)
|
||||
|
||||
Use this for drafts, scheduled posts, or editing existing entries.
|
||||
|
||||
1. Log in at `/admin`
|
||||
2. Go to **Pages** → **Add Page**
|
||||
3. Set:
|
||||
- **Page Title:** your entry title
|
||||
- **Parent Page:** `/trips/japan-korea-2026/dailies` (adjust to active trip)
|
||||
- **Page Template:** `entry`
|
||||
4. Fill in the **Entry** tab fields (city, country, lat/lng, weather)
|
||||
5. Write content in the **Content** tab
|
||||
6. Upload photos via the **Media** tab
|
||||
7. Set `published: true` (or leave `false` for a draft)
|
||||
8. For scheduling: set `publish_date` in **Options** → **Scheduling**
|
||||
9. Save
|
||||
|
||||
The Admin form fields are defined by `user/themes/intotheeast/blueprints/entry.yaml`.
|
||||
|
||||
**Drafts:** set `published: false` — the entry won't appear in the tracker feed until you flip it to `true`. Useful for writing ahead of time on the road.
|
||||
|
||||
**Scheduling:** Grav supports `publish_date` and `unpublish_date` in page frontmatter. Set them in the Admin Options tab. Requires `pages.publish_dates: true` in `system.yaml` (already enabled).
|
||||
|
||||
---
|
||||
|
||||
## Page folder structure
|
||||
|
||||
```
|
||||
user/pages/01.trips/
|
||||
└─ japan-korea-2026/ ← trip entity (active_trip in site.yaml)
|
||||
├─ trip.md ← trip page (title, date_start, date_end, cover_image, album_url)
|
||||
├─ *.gpx ← GPX route files (served as media, rendered on map)
|
||||
├─ 01.dailies/
|
||||
│ └─ 2026-07-20-1430-first-day-in-kyoto.entry/
|
||||
│ ├─ entry.md ← frontmatter + markdown body
|
||||
│ ├─ temple.jpg ← hero image (referenced by hero_image)
|
||||
│ └─ market.jpg ← additional gallery image
|
||||
├─ 02.map/map.md
|
||||
├─ 03.stats/stats.md
|
||||
└─ 04.stories/stories.md
|
||||
```
|
||||
|
||||
The entry folder name follows `<YYYY-MM-DD-HHmm>-<slug>.entry`. Grav uses this for ordering and routing. The `.entry` suffix enables the `entry` template.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Production Todo
|
||||
|
||||
Fresh server — no Grav installed yet. Work through these sections in order.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-install: fix server-install.sh for Grav 2.0
|
||||
|
||||
`server-install.sh` has a gap: it copies the `grav-admin` bundle (which includes `user/plugins/admin2/`) but then immediately does `rm -rf user && git clone ...`, which wipes admin2. It never gets reinstalled because GPM doesn't carry Admin2.
|
||||
|
||||
- [ ] Update `server-install.sh` to stash admin2 before wiping user/, then restore it after:
|
||||
|
||||
```bash
|
||||
# After "cp -rf grav-admin/. ." and before "rm -rf user":
|
||||
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
|
||||
|
||||
# After "git clone $USER_REPO user" and "mkdir -p user/plugins ...":
|
||||
cp -rf /tmp/admin2-plugin user/plugins/admin2
|
||||
rm -rf /tmp/admin2-plugin
|
||||
```
|
||||
|
||||
- [ ] Remove `admin` from `plugins.txt` if it's there — Admin2 replaces it and both conflict on `/admin`
|
||||
|
||||
## 2. Pre-install: configure .env
|
||||
|
||||
- [ ] Set `GRAV_VERSION=2.0.0-rc.9` in `.env`
|
||||
- [ ] Set `GRAV_CHANNEL_SUFFIX=?testing` in `.env` (makes the download URL resolve to the RC)
|
||||
- [ ] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
|
||||
- [ ] Set `USER_REPO` and `MAIN_REPO` (Gitea URLs)
|
||||
- [ ] Set `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` for the install-time clone
|
||||
|
||||
## 3. Run the install
|
||||
|
||||
```bash
|
||||
make remote-env-setup # writes Gitea token to server temporarily
|
||||
make remote-install # downloads Grav, clones repos, installs plugins
|
||||
make remote-env-remove # removes token from server
|
||||
```
|
||||
|
||||
After install, the script prints the server's SSH public key. Add it as a deploy key to both Gitea repos so `make remote-fetch` works going forward.
|
||||
|
||||
## 4. Post-install: config
|
||||
|
||||
These are already committed to the `user/` repo so they'll be present after the clone — just verify:
|
||||
|
||||
- [ ] `user/config/system.yaml` has `accounts.type: flex` and `pages.type: flex`
|
||||
- [ ] `user/config/system.yaml` `custom_base_url` is set to the production domain (currently set to the local dev IP — update before deploy)
|
||||
- [ ] `user/accounts/mischa.yaml` has `api.super: true` and `api.access: true`
|
||||
- [ ] Disable old admin plugin: set `enabled: false` in `user/plugins/admin/admin.yaml` on production (or ensure it's not in `plugins.txt`)
|
||||
|
||||
## 5. Post-install: switch to production mode
|
||||
|
||||
- [ ] Set `twig.cache: true` in `user/config/system.yaml`
|
||||
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in `/trips/japan-korea-2026/dailies` immediately (verifies cache-on-save plugin works with Twig cache on)
|
||||
|
||||
## 6. Security
|
||||
|
||||
- [ ] Change admin password to a strong production password
|
||||
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
|
||||
|
||||
## 7. Map tiles
|
||||
|
||||
- [ ] Register at [carto.com](https://carto.com) and review terms for production traffic (CartoDB dark tiles are free but registration is expected for production use)
|
||||
|
||||
## 8. Content
|
||||
|
||||
- [ ] Set `date_start` on the Japan & Korea 2026 trip page (`user/pages/01.trips/japan-korea-2026/trip.md`)
|
||||
- [ ] Upload actual GPX route file(s) to the trip page media — currently no GPX files, so the map shows no route
|
||||
- [ ] Add `cover_image` to the trip page (used on the trips listing)
|
||||
- [ ] Run `make content-push` to push any local content changes to Gitea before going live
|
||||
@@ -0,0 +1,508 @@
|
||||
# Grav 2.0 Upgrade Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade the local dev Docker environment from linuxserver/grav 1.7 to getgrav/grav 2.0 RC, validate the full Milestone 1 posting workflow, and update the production install script for a fresh Grav 2.0 deploy.
|
||||
|
||||
**Architecture:** Two tracks in sequence — (1) swap the Docker image and update all dependent config/paths, boot the site with `make setup`, run the existing test suite; (2) update `server-install.sh` so `make remote-install` deploys Grav 2.0 fresh on the production PHP 8.4 server. The `user/` directory (content, config, theme, custom plugins) is already isolated as a git repo and requires only a small compatibility addition to `cache-on-save`.
|
||||
|
||||
**Tech Stack:** Grav CMS 2.0.0-rc.9, PHP 8.4 (production) / Docker `getgrav/grav` with PHP 8.3 (dev), Apache, Twig 3, Symfony 7, Playwright (UI tests).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All work on branch `update-to-2.0` (already created)
|
||||
- Never read `.env` — contains sensitive credentials
|
||||
- Only modify files in the project root or `user/` subfolders
|
||||
- `user/config/system.yaml` is tracked in the **user/ git repo** — commit it with `git -C user add config/system.yaml && git -C user commit ...`, NOT from the main repo
|
||||
- `user/plugins/cache-on-save/` is tracked in the **main repo** (after adding `.gitignore` exception) — commit blueprints.yaml with `git add user/plugins/cache-on-save/blueprints.yaml` from the project root
|
||||
- Container name stays `intotheeast_grav`; local port stays `8081`
|
||||
- `make` commands are the only way to interact with the remote server
|
||||
- Grav 2.0 requires PHP ≥ 8.3 (dev container uses 8.3 default; production uses 8.4 — both compliant)
|
||||
- Production download URL format: `https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}`
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | Reason |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | Modify | Switch image, update volume + PHP ini path, add env var |
|
||||
| `Makefile` | Modify | Three `docker exec` targets hardcode linuxserver's `/app/www/public` path |
|
||||
| `.gitignore` | Modify | Add `!user/plugins/cache-on-save/` exception to track the custom plugin in the main repo |
|
||||
| `user/plugins/cache-on-save/blueprints.yaml` | Create | Grav 2.0 compat flag (required by GPM) — committed to main repo |
|
||||
| `user/config/system.yaml` | Modify | Switch GPM channel from `stable` to `testing` |
|
||||
| `scripts/server-install.sh` | Modify | Support `GRAV_CHANNEL_SUFFIX` for `?testing` query param on 2.0 RC download |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Swap Docker image and fix container paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `Makefile`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: A running Grav 2.0 container reachable at `http://localhost:8081` with `user/` mounted at `/var/www/html/user` and PHP upload limits applied via `/usr/local/etc/php/conf.d/php-local.ini`
|
||||
|
||||
- [ ] **Step 1: Stop and remove the current container**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Expected: container `intotheeast_grav` stops and is removed.
|
||||
|
||||
- [ ] **Step 2: Update `docker-compose.yml`**
|
||||
|
||||
Replace the entire contents of `docker-compose.yml` with:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
grav:
|
||||
image: getgrav/grav
|
||||
container_name: intotheeast_grav
|
||||
environment:
|
||||
- GRAV_CHANNEL=beta
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./user:/var/www/html/user
|
||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Key changes from old file:
|
||||
- `image`: `lscr.io/linuxserver/grav:latest` → `getgrav/grav`
|
||||
- `environment`: removed `PUID`/`PGID` (linuxserver-specific), added `GRAV_CHANNEL=beta`
|
||||
- `volumes[0]`: `/config/www/user` → `/var/www/html/user`
|
||||
- `volumes[1]`: `/config/php/php-local.ini` → `/usr/local/etc/php/conf.d/php-local.ini`
|
||||
|
||||
- [ ] **Step 3: Update Makefile — three targets use the old container path**
|
||||
|
||||
In `Makefile`, make these three targeted replacements:
|
||||
|
||||
**`install-plugins` target** — change working directory flag:
|
||||
|
||||
Old:
|
||||
```makefile
|
||||
install-plugins:
|
||||
docker exec -w /app/www/public intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
```
|
||||
|
||||
New:
|
||||
```makefile
|
||||
install-plugins:
|
||||
docker exec -w /var/www/html intotheeast_grav php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y
|
||||
```
|
||||
|
||||
**`demo-load` target** — change cache clear path:
|
||||
|
||||
Old:
|
||||
```makefile
|
||||
demo-load:
|
||||
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
|
||||
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
New:
|
||||
```makefile
|
||||
demo-load:
|
||||
cp -r user/docs/demo/tracker/. user/pages/01.tracker/
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
**`demo-reset` target** — change cache clear path:
|
||||
|
||||
Old:
|
||||
```makefile
|
||||
demo-reset:
|
||||
@for dir in user/docs/demo/tracker/*/; do \
|
||||
folder=$$(basename "$$dir"); \
|
||||
rm -rf "user/pages/01.tracker/$$folder"; \
|
||||
done
|
||||
docker exec intotheeast_grav bash -c "cd /app/www/public && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
New:
|
||||
```makefile
|
||||
demo-reset:
|
||||
@for dir in user/docs/demo/tracker/*/; do \
|
||||
folder=$$(basename "$$dir"); \
|
||||
rm -rf "user/pages/01.tracker/$$folder"; \
|
||||
done
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate docker-compose syntax**
|
||||
|
||||
```bash
|
||||
docker compose config
|
||||
```
|
||||
|
||||
Expected: prints merged compose config with no errors. If you see `Error`, re-check the YAML indentation in `docker-compose.yml`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml Makefile
|
||||
git commit -m "feat: switch to getgrav/grav 2.0 RC docker image
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Grav 2.0 compat flag and switch GPM to testing channel
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore` (add exception for `user/plugins/cache-on-save/`)
|
||||
- Create: `user/plugins/cache-on-save/blueprints.yaml` (committed to main repo)
|
||||
- Modify: `user/config/system.yaml` (committed to user/ git repo, not main repo)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Running container from Task 1
|
||||
- Produces: GPM resolves 2.0-compatible plugin versions on install; `cache-on-save` is recognized as 2.0-compatible by Grav's plugin registry
|
||||
|
||||
- [ ] **Step 1: Create `user/plugins/cache-on-save/blueprints.yaml`**
|
||||
|
||||
Create the file with this exact content:
|
||||
|
||||
```yaml
|
||||
name: Cache On Save
|
||||
version: 1.0.0
|
||||
description: Clears Grav cache on new-entry form submission
|
||||
author:
|
||||
name: Mischa
|
||||
email: mischa@gorinskat.nl
|
||||
license: MIT
|
||||
|
||||
dependencies:
|
||||
- { name: grav, version: '>=1.6.0' }
|
||||
|
||||
grav:
|
||||
version: ['1.7', '2.0']
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update GPM channel in `user/config/system.yaml`**
|
||||
|
||||
Find the `gpm:` section (around line 200 in the file) and change `releases: stable` to `releases: testing`:
|
||||
|
||||
Old:
|
||||
```yaml
|
||||
gpm:
|
||||
releases: stable
|
||||
official_gpm_only: true
|
||||
```
|
||||
|
||||
New:
|
||||
```yaml
|
||||
gpm:
|
||||
releases: testing
|
||||
official_gpm_only: true
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add gitignore exception and commit blueprints.yaml to main repo**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
|
||||
# Add exception so cache-on-save is tracked in the main repo
|
||||
# Insert after the existing "user/plugins/" line in .gitignore:
|
||||
# !user/plugins/cache-on-save/
|
||||
|
||||
# Then commit to the main repo:
|
||||
git add .gitignore user/plugins/cache-on-save/blueprints.yaml
|
||||
git commit -m "feat: track cache-on-save plugin in main repo; add Grav 2.0 compat flag
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit system.yaml to the user/ git repo**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
git -C user add config/system.yaml
|
||||
git -C user commit -m "feat: switch GPM to testing channel for Grav 2.0
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Boot Grav 2.0 and install plugins
|
||||
|
||||
**Files:** None (runtime only)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: docker-compose.yml from Task 1, GPM config from Task 2
|
||||
- Produces: Running Grav 2.0 instance at `http://localhost:8081` with all plugins installed
|
||||
|
||||
- [ ] **Step 1: Run setup**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
make setup
|
||||
```
|
||||
|
||||
This starts the container and installs all plugins from `plugins.txt`. First run may take 1-2 minutes as `getgrav/grav` downloads and extracts Grav 2.0 RC.
|
||||
|
||||
Expected output ends with something like:
|
||||
```
|
||||
GPM Packages Installed: admin, email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus
|
||||
```
|
||||
|
||||
If `make setup` fails on plugin install with a permission error, fix with:
|
||||
```bash
|
||||
docker exec intotheeast_grav chown -R www-data:www-data /var/www/html/cache /var/www/html/logs /var/www/html/tmp
|
||||
make install-plugins
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify PHP upload limits are applied**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav php -r "echo ini_get('upload_max_filesize') . ' / ' . ini_get('post_max_size');"
|
||||
```
|
||||
|
||||
Expected: `100M / 500M`
|
||||
|
||||
If you see `2M / 8M` (PHP defaults), the ini mount path is wrong. Verify with:
|
||||
```bash
|
||||
docker exec intotheeast_grav php -r "echo php_ini_scanned_files();"
|
||||
```
|
||||
It should include `/usr/local/etc/php/conf.d/php-local.ini`.
|
||||
|
||||
- [ ] **Step 3: Verify site loads**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/
|
||||
```
|
||||
|
||||
Expected: `200`
|
||||
|
||||
If you get `500`, check container logs:
|
||||
```bash
|
||||
docker logs intotheeast_grav --tail 50
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify Admin2 loads**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/admin
|
||||
```
|
||||
|
||||
Expected: `200` (Admin2 SPA login page, not the old Twig admin)
|
||||
|
||||
- [ ] **Step 5: Run config and HTTP tests**
|
||||
|
||||
```bash
|
||||
make test-config
|
||||
make test-post
|
||||
```
|
||||
|
||||
`test-config` validates the form YAML config. `test-post` submits the posting form via HTTP and checks an entry is created.
|
||||
|
||||
Expected: both exit 0.
|
||||
|
||||
If `test-post` fails, check the output of:
|
||||
```bash
|
||||
bash scripts/test-post.sh
|
||||
```
|
||||
This is the critical `add-page-by-form` go/no-go test. If it fails with a 500 or the entry isn't created, see the **If add-page-by-form fails** section at the bottom of this plan.
|
||||
|
||||
- [ ] **Step 6: Commit task completion note**
|
||||
|
||||
No new files to commit. Move to Task 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Run Playwright test suite and fix any Admin2 regressions
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/*.spec.js` (only if tests fail due to Admin2 DOM changes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Running Grav 2.0 from Task 3
|
||||
- Produces: All Playwright tests passing (or updated for Admin2's new DOM)
|
||||
|
||||
- [ ] **Step 1: Run the full UI test suite**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
make test-ui
|
||||
```
|
||||
|
||||
Expected: 25 tests pass.
|
||||
|
||||
- [ ] **Step 2: If any tests fail, classify the failure**
|
||||
|
||||
For each failing test, determine whether it is:
|
||||
|
||||
**A) A genuine regression** (e.g., posting form broken, tracker page missing entries, gallery not rendering) — these are blockers. Stop, investigate the root cause, and fix the underlying Grav/plugin issue before updating the test.
|
||||
|
||||
**B) An Admin2 DOM change** (e.g., selectors targeting old admin HTML structure like `.admin-menu`, `.grav-nav`, admin-specific CSS classes) — these are acceptable test updates. Update the selector in the test file to match Admin2's new HTML.
|
||||
|
||||
To inspect the current Admin2 DOM for a failing selector:
|
||||
```bash
|
||||
# Check what the admin page actually renders
|
||||
curl -s http://localhost:8081/admin | grep -o '<[^>]*class="[^"]*admin[^"]*"[^>]*>' | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update any Admin2 selector regressions**
|
||||
|
||||
For each type-(B) failure, open the relevant test file in `tests/` and update the selector. Example pattern for updating an admin navigation selector:
|
||||
|
||||
Old (targeting classic admin):
|
||||
```js
|
||||
await page.click('.grav-nav-toggle')
|
||||
```
|
||||
|
||||
New (targeting Admin2 SPA — find actual selector from step 2's output):
|
||||
```js
|
||||
await page.click('[data-testid="nav-toggle"]') // replace with actual Admin2 selector
|
||||
```
|
||||
|
||||
After each fix, re-run just that test:
|
||||
```bash
|
||||
npx playwright test tests/<filename>.spec.js --headed
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run full suite to confirm all pass**
|
||||
|
||||
```bash
|
||||
make test-ui
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit any test updates**
|
||||
|
||||
If any test files were modified:
|
||||
```bash
|
||||
git add tests/
|
||||
git commit -m "test: update Playwright selectors for Admin2 DOM
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
If no test files changed, no commit needed.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update production install script for Grav 2.0
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/server-install.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Nothing from prior tasks (independent of Docker)
|
||||
- Produces: `make remote-install` deploys a fresh Grav 2.0 on the production PHP 8.4 server when `GRAV_VERSION=2.0.0-rc.9` and `GRAV_CHANNEL_SUFFIX=?testing` are set in `.env`
|
||||
|
||||
- [ ] **Step 1: Update the wget download line in `scripts/server-install.sh`**
|
||||
|
||||
The script currently downloads Grav with:
|
||||
```bash
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
|
||||
```
|
||||
|
||||
Grav 2.0 RC requires `?testing` appended to the URL. Add `GRAV_CHANNEL_SUFFIX` support:
|
||||
|
||||
Old (line ~15 in the file):
|
||||
```bash
|
||||
echo "==> Downloading Grav $GRAV_VERSION"
|
||||
cd "$WEBROOT"
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
|
||||
```
|
||||
|
||||
New:
|
||||
```bash
|
||||
echo "==> Downloading Grav $GRAV_VERSION"
|
||||
cd "$WEBROOT"
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
|
||||
```
|
||||
|
||||
The `${GRAV_CHANNEL_SUFFIX:-}` expands to empty string if unset, keeping stable releases working without any changes to `.env`.
|
||||
|
||||
- [ ] **Step 2: Add GRAV_CHANNEL_SUFFIX to the env var validation block**
|
||||
|
||||
At the top of the script the required vars are validated. `GRAV_CHANNEL_SUFFIX` is optional, so do NOT add it to the `:?` required list. Instead, add a comment above the download step:
|
||||
|
||||
After the `set -e` and required var block, add a comment before the download line:
|
||||
|
||||
```bash
|
||||
# GRAV_CHANNEL_SUFFIX: optional, set to '?testing' for RC/beta releases (e.g. 2.0.0-rc.9)
|
||||
# Leave unset or empty for stable releases.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the script logic looks correct**
|
||||
|
||||
```bash
|
||||
# Dry-run: simulate what the URL would be with 2.0 RC vars
|
||||
GRAV_VERSION=2.0.0-rc.9 GRAV_CHANNEL_SUFFIX='?testing' bash -c \
|
||||
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
https://getgrav.org/download/core/grav-admin/2.0.0-rc.9?testing
|
||||
```
|
||||
|
||||
```bash
|
||||
# Dry-run: simulate stable release (no suffix)
|
||||
GRAV_VERSION=1.7.53 bash -c \
|
||||
'echo "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}"'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
https://getgrav.org/download/core/grav-admin/1.7.53
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/server-install.sh
|
||||
git commit -m "feat: support GRAV_CHANNEL_SUFFIX for Grav 2.0 RC production install
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## If `add-page-by-form` fails (contingency)
|
||||
|
||||
If `make test-post` in Task 3 step 5 returns a non-zero exit code or the entry is not created, `add-page-by-form` is incompatible with Grav 2.0. The fallback is to write a custom replacement plugin.
|
||||
|
||||
**Do not proceed to Task 4 if the posting workflow is broken.** Instead:
|
||||
|
||||
1. Check the container logs for the specific error:
|
||||
```bash
|
||||
docker logs intotheeast_grav --tail 100 | grep -i "error\|exception\|warning"
|
||||
```
|
||||
|
||||
2. Note the error, stop work, and report back. The custom replacement plugin is a separate task requiring design input from the project owner before implementation.
|
||||
|
||||
The custom plugin would:
|
||||
- Hook `onFormProcessed` (same as `cache-on-save`)
|
||||
- Read form field values (`title`, `content`, `photo`)
|
||||
- Build the page path under `user/pages/01.tracker/`
|
||||
- Write the page file to disk using `Grav\Common\Page\Page`
|
||||
- Merge `cache-on-save` functionality (call `$this->grav['cache']->deleteAll()`)
|
||||
- Replace both `add-page-by-form` and `cache-on-save` with a single plugin
|
||||
|
||||
This is ~200 lines of PHP and ~1 day of work. It should be planned separately.
|
||||
|
||||
---
|
||||
|
||||
## Final smoke test (after all tasks complete)
|
||||
|
||||
Run the full test suite one last time:
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all three suites (`test-config`, `test-post`, `test-ui`) exit 0.
|
||||
|
||||
Then verify the go/no-go criteria from the spec are all met before merging to `main` or deploying to production.
|
||||
@@ -0,0 +1,317 @@
|
||||
# Dark Mode & Visual Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the warm-paper light theme with a warm-dark "notebook at night" aesthetic — dark-only, no toggle, paper grain texture, dark terrain map tiles, typography polish.
|
||||
|
||||
**Architecture:** Pure CSS token swap in `tokens.css` (all components update automatically), grain overlay via `body::after` SVG data URI in `style.css`, map tile URL swap in two Twig templates. No new dependencies, no JS changes, no structural changes.
|
||||
|
||||
**Tech Stack:** CSS custom properties, inline SVG noise filter, Stadia Maps Alidade Smooth Dark tile CDN, Leaflet.js (already present)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All changes in `user/` — commit with `git -C user`, not main-repo git
|
||||
- Dark-only — no `prefers-color-scheme` media query, no light-mode fallback, no toggle
|
||||
- Existing token names in `tokens.css` must not change — only values swap
|
||||
- No new npm/JS dependencies
|
||||
- `make test-ui` must pass after every task (pre-existing P2 FilePond failure is acceptable)
|
||||
- Map tile URL: `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (CartoDB — no API key required)
|
||||
- CartoDB attribution (exact): `© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>`
|
||||
- Note: Stadia Maps requires an API key even for local dev — CartoDB dark_all is the keyless alternative
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dark color tokens
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/css/tokens.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: CSS custom properties consumed by every component in `style.css` and Twig templates
|
||||
|
||||
- [ ] **Step 1: Read the current tokens file**
|
||||
|
||||
```bash
|
||||
cat user/themes/intotheeast/css/tokens.css
|
||||
```
|
||||
|
||||
Confirm these token names exist before editing: `--color-paper`, `--color-canvas`, `--color-ink`, `--color-ink-2`, `--color-ink-muted`, `--color-border`, `--color-border-soft`, `--color-accent`, `--color-accent-hover`, `--color-accent-light`, `--color-accent-on`.
|
||||
|
||||
- [ ] **Step 2: Replace the color block in tokens.css**
|
||||
|
||||
Replace the entire `:root` color block (from `--color-paper` through `--color-accent-on`) with:
|
||||
|
||||
```css
|
||||
/* ── Dark palette (warm notebook) ──────────────────────────────────────── */
|
||||
--color-paper: #1A1814; /* page background — warm near-black */
|
||||
--color-canvas: #22201B; /* card surfaces, form backgrounds */
|
||||
--color-ink: #EDE8DF; /* primary text — warm cream */
|
||||
--color-ink-2: #B8B0A4; /* body text — muted warm */
|
||||
--color-ink-muted: #7A7268; /* labels, timestamps, captions */
|
||||
--color-border: #2E2B25; /* standard dividers */
|
||||
--color-border-soft: #252219; /* subtle dividers */
|
||||
--color-accent: #2A8C73; /* teal — lightened for dark contrast */
|
||||
--color-accent-hover: #236655; /* hover/pressed teal */
|
||||
--color-accent-light: #1A2E29; /* pale teal tint backgrounds */
|
||||
--color-accent-on: #FFFFFF; /* text on accent surfaces */
|
||||
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
|
||||
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
|
||||
```
|
||||
|
||||
Keep all non-color tokens (`--text-*`, `--leading-*`, `--space-*`, font variables, etc.) unchanged.
|
||||
|
||||
- [ ] **Step 3: Verify no syntax errors**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
||||
```
|
||||
|
||||
Expected: `200`
|
||||
|
||||
- [ ] **Step 4: Visual smoke check**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'color: var(--color-paper)' | head -3
|
||||
```
|
||||
|
||||
Not a definitive check — just confirm the page renders. Open a browser and verify the background is dark and text is cream.
|
||||
|
||||
- [ ] **Step 5: Run test suite**
|
||||
|
||||
```bash
|
||||
make test-ui
|
||||
```
|
||||
|
||||
Expected: 24/25 pass (P2 FilePond is pre-existing failure, all others pass).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/css/tokens.css
|
||||
git -C user commit -m "feat: switch to warm-dark color tokens"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Paper grain texture + hardcoded color fixes + typography
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/css/style.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: dark color tokens from Task 1
|
||||
|
||||
- [ ] **Step 1: Find all hardcoded color literals in style.css**
|
||||
|
||||
```bash
|
||||
grep -n '#[0-9a-fA-F]\{3,6\}\|background: white\|background:#fff\|color: #\|background-color: #' user/themes/intotheeast/css/style.css
|
||||
```
|
||||
|
||||
Make note of every hit — each one is a candidate to replace with a token. Exceptions: the CSS SVG data URI you are about to add (the noise filter hex values are part of the graphic, not UI colors).
|
||||
|
||||
- [ ] **Step 2: Add paper grain texture to body**
|
||||
|
||||
Find the `body` rule in `style.css`. It will look something like:
|
||||
|
||||
```css
|
||||
body {
|
||||
background-color: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Add a `body::after` rule immediately after the `body` rule:
|
||||
|
||||
```css
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fix hardcoded login form colors**
|
||||
|
||||
Find this rule (around line 497):
|
||||
|
||||
```css
|
||||
.login-form .button.secondary { background: #f0f0f0; color: #333; text-decoration: none; line-height: 44px; padding: 0 1rem; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```css
|
||||
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); text-decoration: none; line-height: 44px; padding: 0 1rem; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix any other hardcoded colors found in Step 1**
|
||||
|
||||
For each hardcoded literal found in Step 1 (excluding the data URI you added):
|
||||
- `#fff` / `white` → `var(--color-canvas)` (if a surface) or `var(--color-paper)` (if a page background)
|
||||
- `#333` / dark grays → `var(--color-ink)` or `var(--color-ink-2)`
|
||||
- `#eee` / light grays → `var(--color-border)` or `var(--color-border-soft)`
|
||||
- `#f0f0f0` / near-white → `var(--color-canvas)`
|
||||
|
||||
Use judgment: if a hex is inside a gradient or SVG path data, leave it alone.
|
||||
|
||||
- [ ] **Step 5: Typography — increase entry body paragraph spacing**
|
||||
|
||||
Find:
|
||||
|
||||
```css
|
||||
.entry-body p { margin-bottom: 1.1em; font-size: var(--text-md); line-height: var(--leading-normal); color: var(--color-ink-2); }
|
||||
```
|
||||
|
||||
Change `margin-bottom: 1.1em` to `margin-bottom: 1.4em`.
|
||||
|
||||
- [ ] **Step 6: Typography — tighten h1/h2 tracking**
|
||||
|
||||
Find the `h1` and `h2` rules. Any rule that applies `letter-spacing: -0.01em` to an `h1` or `h2` — change it to `-0.02em`. Do not touch h3/h4/h5/h6.
|
||||
|
||||
- [ ] **Step 7: Stats page — tabular numbers**
|
||||
|
||||
Find any CSS rule targeting stats numbers (look for `.stat-value`, `.stats-number`, or similar). Add `font-variant-numeric: tabular-nums` to it. If no such specific rule exists, search the template:
|
||||
|
||||
```bash
|
||||
grep -n 'stat\|number\|count' user/themes/intotheeast/templates/stats.html.twig | head -20
|
||||
```
|
||||
|
||||
Then add a targeted rule in style.css for whatever class wraps the numeric values.
|
||||
|
||||
- [ ] **Step 8: Verify no syntax errors and visual check**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache" && curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/dailies
|
||||
```
|
||||
|
||||
Expected: `200`. Open browser — grain should be subtly visible on the dark background.
|
||||
|
||||
- [ ] **Step 9: Run test suite**
|
||||
|
||||
```bash
|
||||
make test-ui
|
||||
```
|
||||
|
||||
Expected: 24/25 (P2 pre-existing).
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/css/style.css
|
||||
git -C user commit -m "feat: add paper grain texture, fix hardcoded colors, improve typography"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Dark terrain map tiles
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/map.html.twig`
|
||||
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Leaflet.js already loaded in both templates
|
||||
- Produces: Stadia Alidade Smooth Dark tiles replacing OpenStreetMap tiles in both map views
|
||||
|
||||
- [ ] **Step 1: Read current tile setup in both templates**
|
||||
|
||||
```bash
|
||||
grep -n "tileLayer\|openstreetmap\|attribution\|stadia" user/themes/intotheeast/templates/map.html.twig user/themes/intotheeast/templates/dailies.html.twig
|
||||
```
|
||||
|
||||
Confirm the current tile URL pattern (`{s}.tile.openstreetmap.org`) in both files.
|
||||
|
||||
- [ ] **Step 2: Replace tile layer in map.html.twig**
|
||||
|
||||
Find:
|
||||
|
||||
```javascript
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```javascript
|
||||
// TODO: add Stadia API key before launch — free dev use requires no key, production does
|
||||
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 20,
|
||||
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace tile layer in dailies.html.twig (mini-map)**
|
||||
|
||||
Apply the identical tile swap to the mini-map `L.tileLayer` call in `dailies.html.twig`. Find the OpenStreetMap tile URL and replace it with the Stadia dark URL (same as Step 2, same attribution, same TODO comment).
|
||||
|
||||
- [ ] **Step 4: Verify tiles load**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
|
||||
```
|
||||
|
||||
Expected: `200`.
|
||||
|
||||
Check the tile URL is in the HTML:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/japan-korea-2026/map | grep -o 'stadiamaps'
|
||||
```
|
||||
|
||||
Expected: `stadiamaps` (appears in the tile URL).
|
||||
|
||||
Open the map in a browser and confirm:
|
||||
- Dark terrain tiles render (not the default light OSM tiles)
|
||||
- GPX polyline is visible in teal on the dark background
|
||||
- Entry pins render correctly on top
|
||||
- Attribution footer is present
|
||||
|
||||
- [ ] **Step 5: Check mini-map on dailies page**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/japan-korea-2026/dailies | grep -o 'stadiamaps'
|
||||
```
|
||||
|
||||
Expected: `stadiamaps`.
|
||||
|
||||
- [ ] **Step 6: Run test suite**
|
||||
|
||||
```bash
|
||||
make test-ui
|
||||
```
|
||||
|
||||
Expected: 24/25 (P2 pre-existing).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/dailies.html.twig
|
||||
git -C user commit -m "feat: switch to Stadia Alidade Smooth Dark map tiles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
After all 3 tasks:
|
||||
|
||||
1. `make test-config && make test-post && make test-ui` — all pass
|
||||
2. Visual check list (browser, not curl):
|
||||
- `/trips/japan-korea-2026/dailies` — dark warm background, cream text, grain visible, teal accents
|
||||
- `/trips/japan-korea-2026/map` — dark terrain tiles, teal GPX polyline, entry pins
|
||||
- `/trips/japan-korea-2026/dailies/<any-entry>` — dark canvas card, no white boxes
|
||||
- `/post` — form fields readable, no black-on-black inputs
|
||||
- `/trips/japan-korea-2026/stats` — numbers align (tabular-nums)
|
||||
3. Final hardcoded-literal check:
|
||||
```bash
|
||||
grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css | grep -v 'data:image'
|
||||
```
|
||||
All remaining hits should be either intentional (e.g. SVG path data) or documented.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
# Trip Entity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Restructure the site around a Trip entity — tracker/map/stats/stories become children of `/trips/japan-korea-2026/`, GPX route files live as media on the trip page, and `site.yaml` holds an `active_trip` slug so the nav can switch trips via config.
|
||||
|
||||
**Architecture:** Trip = a Grav page (`trip.html.twig`) at `/trips/<slug>/`. Map/stats templates find the tracker via `page.parent().route ~ '/tracker'` instead of the hardcoded `/tracker` path. Leaflet-gpx (CDN) loads all `*.gpx` media files from the trip page. A `trips.html.twig` listing page provides the multi-trip root. Stories is stubbed with a placeholder template.
|
||||
|
||||
**Tech Stack:** Grav CMS 1.7/2.0, Twig, Leaflet.js, leaflet-gpx (CDN, vanilla JS — consistent with existing inline JS pattern)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All content/theme edits go in `user/` — commit with `git -C user`, not main-repo git
|
||||
- Entry URLs change: `/tracker/<slug>` → `/trips/japan-korea-2026/tracker/<slug>` — acceptable pre-launch
|
||||
- `make test-post` (6/6) and `make test-ui` (25/25) must pass after every task
|
||||
- No new JS framework dependencies; leaflet-gpx is 3KB vanilla JS
|
||||
- `user/config/media.yaml` must whitelist `.gpx` so Grav serves it as a file
|
||||
- The `02.post/post-form.md` `pageconfig.parent` must stay in sync with the tracker path
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Restructure pages under `/trips/`
|
||||
|
||||
**Files:**
|
||||
- Create: `user/pages/01.trips/trips.md`
|
||||
- Create: `user/pages/01.trips/japan-korea-2026/trip.md`
|
||||
- Create: `user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md` (copy from `user/pages/01.tracker/tracker.md`, no content change)
|
||||
- Move: all `*.entry/` folders from `user/pages/01.tracker/` → `user/pages/01.trips/japan-korea-2026/01.tracker/`
|
||||
- Create: `user/pages/01.trips/japan-korea-2026/02.map/map.md` (copy from `user/pages/03.map/map.md`)
|
||||
- Create: `user/pages/01.trips/japan-korea-2026/03.stats/stats.md` (copy from `user/pages/04.stats/stats.md`)
|
||||
- Create: `user/pages/01.trips/japan-korea-2026/04.stories/stories.md`
|
||||
- Delete: `user/pages/01.tracker/`, `user/pages/03.map/`, `user/pages/04.stats/`
|
||||
- Modify: `user/config/site.yaml` — add `active_trip: japan-korea-2026`
|
||||
- Modify (create if absent): `user/config/media.yaml` — whitelist GPX
|
||||
|
||||
- [ ] **Step 1: Verify current structure before touching anything**
|
||||
|
||||
```bash
|
||||
find user/pages -name "*.md" | sort
|
||||
```
|
||||
Expected: entries under `01.tracker/`, map at `03.map/map.md`, stats at `04.stats/stats.md`.
|
||||
|
||||
- [ ] **Step 2: Create trips hierarchy**
|
||||
|
||||
```bash
|
||||
mkdir -p user/pages/01.trips/japan-korea-2026/01.tracker
|
||||
mkdir -p user/pages/01.trips/japan-korea-2026/02.map
|
||||
mkdir -p user/pages/01.trips/japan-korea-2026/03.stats
|
||||
mkdir -p user/pages/01.trips/japan-korea-2026/04.stories
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write `trips.md`**
|
||||
|
||||
`user/pages/01.trips/trips.md`:
|
||||
```yaml
|
||||
---
|
||||
title: Trips
|
||||
template: trips
|
||||
content:
|
||||
items: '@self.children'
|
||||
order:
|
||||
by: date
|
||||
dir: desc
|
||||
---
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `trip.md`**
|
||||
|
||||
`user/pages/01.trips/japan-korea-2026/trip.md`:
|
||||
```yaml
|
||||
---
|
||||
title: 'Japan & Korea 2026'
|
||||
template: trip
|
||||
date: '2026-06-17'
|
||||
date_start: '2026-06-17'
|
||||
date_end: ''
|
||||
cover_image: ''
|
||||
content:
|
||||
items: '@self.children'
|
||||
---
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Copy tracker.md, move entries**
|
||||
|
||||
```bash
|
||||
cp user/pages/01.tracker/tracker.md user/pages/01.trips/japan-korea-2026/01.tracker/tracker.md
|
||||
mv user/pages/01.tracker/*.entry user/pages/01.trips/japan-korea-2026/01.tracker/
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Copy map.md and stats.md**
|
||||
|
||||
```bash
|
||||
cp user/pages/03.map/map.md user/pages/01.trips/japan-korea-2026/02.map/map.md
|
||||
cp user/pages/04.stats/stats.md user/pages/01.trips/japan-korea-2026/03.stats/stats.md
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Write stories stub**
|
||||
|
||||
`user/pages/01.trips/japan-korea-2026/04.stories/stories.md`:
|
||||
```yaml
|
||||
---
|
||||
title: Stories
|
||||
template: stories
|
||||
published: true
|
||||
---
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Delete old top-level pages**
|
||||
|
||||
```bash
|
||||
rm -rf user/pages/01.tracker user/pages/03.map user/pages/04.stats
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Add `active_trip` to site.yaml**
|
||||
|
||||
Add to `user/config/site.yaml`:
|
||||
```yaml
|
||||
active_trip: japan-korea-2026
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Whitelist GPX in media.yaml**
|
||||
|
||||
`user/config/media.yaml` (create if absent):
|
||||
```yaml
|
||||
gpx:
|
||||
type: file
|
||||
extensions: ['gpx']
|
||||
mime: application/gpx+xml
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Verify pages load at new URLs**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/tracker
|
||||
# Expected: 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/map
|
||||
# Expected: 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stats
|
||||
# Expected: 200
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add pages/01.trips config/site.yaml config/media.yaml
|
||||
git -C user rm -r --cached pages/01.tracker pages/03.map pages/04.stats
|
||||
git -C user commit -m "feat: restructure pages under trips/japan-korea-2026 entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update templates for trip-relative paths + new trip/trips/stories templates
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/map.html.twig` — change hardcoded `/tracker` path
|
||||
- Modify: `user/themes/intotheeast/templates/stats.html.twig` — same
|
||||
- Modify: `user/themes/intotheeast/templates/partials/base.html.twig` — nav uses `active_trip`
|
||||
- Create: `user/themes/intotheeast/templates/trip.html.twig`
|
||||
- Create: `user/themes/intotheeast/templates/trips.html.twig`
|
||||
- Create: `user/themes/intotheeast/templates/stories.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `config.site.active_trip` from site.yaml (set in Task 1)
|
||||
- Produces: map/stats find entries via `page.parent().route ~ '/tracker'`
|
||||
|
||||
- [ ] **Step 1: Fix `map.html.twig` — tracker path**
|
||||
|
||||
Replace:
|
||||
```twig
|
||||
{% set tracker_page = grav.pages.find('/tracker') %}
|
||||
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
|
||||
```
|
||||
With:
|
||||
```twig
|
||||
{% set tracker_page = grav.pages.find(page.parent().route ~ '/tracker') %}
|
||||
{% set all_entries = tracker_page ? tracker_page.children.published() : [] %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix `stats.html.twig` — tracker path**
|
||||
|
||||
Same replacement as Step 1 (identical pattern in stats.html.twig).
|
||||
|
||||
- [ ] **Step 3: Update `base.html.twig` nav**
|
||||
|
||||
Replace hardcoded nav href values with `active_trip`-driven paths. The pattern in base.html.twig currently sets hrefs to `/tracker`, `/map`, `/stats`. Replace with:
|
||||
|
||||
```twig
|
||||
{% set active_trip = config.site.active_trip %}
|
||||
{% set trip_base = '/trips/' ~ active_trip %}
|
||||
```
|
||||
|
||||
Nav links become:
|
||||
- Journal: `{{ trip_base }}/tracker`
|
||||
- Map: `{{ trip_base }}/map`
|
||||
- Stats: `{{ trip_base }}/stats`
|
||||
|
||||
Active state detection: replace `page.url starts with '/tracker'` checks with `page.url starts with trip_base ~ '/tracker'` (and similarly for map/stats).
|
||||
|
||||
- [ ] **Step 4: Create `trip.html.twig`**
|
||||
|
||||
`user/themes/intotheeast/templates/trip.html.twig`:
|
||||
```twig
|
||||
{% extends 'partials/base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{% set tracker_page = grav.pages.find(page.route ~ '/tracker') %}
|
||||
{% set entries = tracker_page ? tracker_page.children.published() : [] %}
|
||||
|
||||
<div class="trip-hero">
|
||||
<h1>{{ page.title }}</h1>
|
||||
{% if page.header.date_start %}
|
||||
<p class="trip-dates">
|
||||
{{ page.header.date_start|date('d M Y') }}
|
||||
{% if page.header.date_end %} — {{ page.header.date_end|date('d M Y') }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<nav class="trip-nav">
|
||||
<a href="{{ page.route }}/tracker">Journal</a>
|
||||
<a href="{{ page.route }}/map">Map</a>
|
||||
<a href="{{ page.route }}/stats">Stats</a>
|
||||
<a href="{{ page.route }}/stories">Stories</a>
|
||||
</nav>
|
||||
|
||||
{% if entries|length > 0 %}
|
||||
<section class="trip-recent">
|
||||
<h2>Recent entries</h2>
|
||||
{% for entry in entries|slice(0, 3) %}
|
||||
<a href="{{ entry.url }}">
|
||||
<span>{{ entry.date|date('d M Y') }}</span>
|
||||
{{ entry.title }}
|
||||
{% if entry.header.location_city %} · {{ entry.header.location_city }}{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `trips.html.twig`**
|
||||
|
||||
`user/themes/intotheeast/templates/trips.html.twig`:
|
||||
```twig
|
||||
{% extends 'partials/base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ page.title }}</h1>
|
||||
{% set trips = page.children.published() %}
|
||||
{% if trips|length == 0 %}
|
||||
<p>No trips yet.</p>
|
||||
{% else %}
|
||||
<ul class="trips-list">
|
||||
{% for trip in trips %}
|
||||
<li>
|
||||
<a href="{{ trip.url }}">
|
||||
<strong>{{ trip.title }}</strong>
|
||||
{% if trip.header.date_start %}
|
||||
<span>{{ trip.header.date_start|date('d M Y') }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create `stories.html.twig` stub**
|
||||
|
||||
`user/themes/intotheeast/templates/stories.html.twig`:
|
||||
```twig
|
||||
{% extends 'partials/base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p>Stories coming soon.</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify templates render**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026
|
||||
# Expected: 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips
|
||||
# Expected: 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/trips/japan-korea-2026/stories
|
||||
# Expected: 200
|
||||
```
|
||||
|
||||
Check nav links resolve correctly on tracker/map/stats pages.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/templates/
|
||||
git -C user commit -m "feat: add trip/trips/stories templates, update nav and map/stats to use trip-relative paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add GPX route support to map template
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/map.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `*.gpx` files uploaded as media to the trip page (`page.parent()`)
|
||||
- Produces: GPX tracks rendered as colored polylines on the Leaflet map, underneath entry pins
|
||||
|
||||
- [ ] **Step 1: Add leaflet-gpx script tag**
|
||||
|
||||
In `map.html.twig`, after the existing Leaflet script tag, add:
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.1.2/gpx.min.js"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Collect GPX URLs from trip media**
|
||||
|
||||
After the `{% set trip_page = page.parent() %}` line (add this at the top of the template, alongside the tracker_page lookup), add:
|
||||
|
||||
```twig
|
||||
{% set gpx_urls = [] %}
|
||||
{% for name, media in trip_page.media.all %}
|
||||
{% if name|split('.')|last == 'gpx' %}
|
||||
{% set gpx_urls = gpx_urls|merge([media.url]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass GPX URLs to JavaScript**
|
||||
|
||||
In the `<script>` block, after the map is initialized and before the entry markers loop, add:
|
||||
|
||||
```javascript
|
||||
// GPX route tracks
|
||||
const gpxUrls = {{ gpx_urls|json_encode|raw }};
|
||||
gpxUrls.forEach(url => {
|
||||
new L.GPX(url, {
|
||||
async: true,
|
||||
polyline_options: { color: '#1F6B5A', weight: 2, opacity: 0.7 },
|
||||
marker_options: { startIconUrl: null, endIconUrl: null, shadowUrl: null }
|
||||
}).addTo(map);
|
||||
});
|
||||
```
|
||||
|
||||
Disabling start/end markers keeps the map clean — the entry pins already mark key stops.
|
||||
|
||||
- [ ] **Step 4: Test with a sample GPX**
|
||||
|
||||
Create a minimal 3-point GPX file to test without a real Komoot export:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="test">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="35.6762" lon="139.6503"><time>2026-03-25T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="35.0116" lon="135.7681"><time>2026-03-27T10:00:00Z</time></trkpt>
|
||||
<trkpt lat="37.5665" lon="126.9780"><time>2026-04-01T10:00:00Z</time></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
```
|
||||
|
||||
Upload via Grav Admin to the trip page media, then verify the map at `/trips/japan-korea-2026/map` renders the polyline. Remove the test file after verification.
|
||||
|
||||
- [ ] **Step 5: Verify map still works without GPX**
|
||||
|
||||
Confirm map renders normally when no `.gpx` files are present (gpxUrls = []).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/templates/map.html.twig
|
||||
git -C user commit -m "feat: add GPX route rendering to trip map via leaflet-gpx"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update post form, Makefile, demo content, and tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/pages/02.post/post-form.md` — `pageconfig.parent`
|
||||
- Modify: `Makefile` — `demo-load` and `demo-reset` paths
|
||||
- Modify: `scripts/test-post.sh` — `TRACKER` variable
|
||||
- Modify: `scripts/test-form-config.sh` — expected parent value
|
||||
- Modify: `tests/ui/tracker.spec.js` — any hardcoded `/tracker` URL references
|
||||
- Modify: `user/docs/demo/` — move demo entries to new path structure
|
||||
|
||||
- [ ] **Step 1: Update post form parent**
|
||||
|
||||
In `user/pages/02.post/post-form.md`, change:
|
||||
```yaml
|
||||
pageconfig:
|
||||
parent: '/tracker'
|
||||
```
|
||||
To:
|
||||
```yaml
|
||||
pageconfig:
|
||||
parent: '/trips/japan-korea-2026/tracker'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update demo content structure**
|
||||
|
||||
```bash
|
||||
mkdir -p user/docs/demo/trips/japan-korea-2026/tracker
|
||||
mv user/docs/demo/tracker/* user/docs/demo/trips/japan-korea-2026/tracker/
|
||||
rmdir user/docs/demo/tracker
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update Makefile demo targets**
|
||||
|
||||
In `Makefile`, update `demo-load` and `demo-reset`:
|
||||
|
||||
```makefile
|
||||
demo-load:
|
||||
cp -r user/docs/demo/trips/japan-korea-2026/tracker/. user/pages/01.trips/japan-korea-2026/01.tracker/
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
|
||||
demo-reset:
|
||||
@for dir in user/docs/demo/trips/japan-korea-2026/tracker/*/; do \
|
||||
folder=$$(basename "$$dir"); \
|
||||
rm -rf "user/pages/01.trips/japan-korea-2026/01.tracker/$$folder"; \
|
||||
done
|
||||
docker exec intotheeast_grav bash -c "cd /var/www/html && php bin/grav clearcache"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `test-post.sh` TRACKER path**
|
||||
|
||||
Find the line setting `TRACKER=` in `scripts/test-post.sh` and change it to:
|
||||
```bash
|
||||
TRACKER="user/pages/01.trips/japan-korea-2026/01.tracker"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `test-form-config.sh` expected parent**
|
||||
|
||||
Find the assertion that checks `parent: '/tracker'` and update to check for `parent: '/trips/japan-korea-2026/tracker'`.
|
||||
|
||||
- [ ] **Step 6: Check Playwright tests for hardcoded paths**
|
||||
|
||||
Search `tests/ui/` for any hardcoded `/tracker` URL references:
|
||||
```bash
|
||||
grep -rn "tracker\|/map\|/stats" tests/ui/
|
||||
```
|
||||
|
||||
Update any that reference the old paths to use the new trip-scoped paths.
|
||||
|
||||
- [ ] **Step 7: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test-config && make test-post && make test-ui
|
||||
```
|
||||
Expected: all pass (14/14, 6/6, 25/25).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
# Main repo changes (Makefile + test scripts)
|
||||
git add Makefile scripts/test-post.sh scripts/test-form-config.sh tests/
|
||||
git commit -m "fix: update paths for trips/japan-korea-2026 restructure"
|
||||
|
||||
# User repo changes
|
||||
git -C user add pages/02.post/post-form.md docs/demo/
|
||||
git -C user commit -m "fix: update post form parent and demo content paths for trip structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Admin blueprint for trip page type
|
||||
|
||||
**Files:**
|
||||
- Create: `user/themes/intotheeast/blueprints/trip.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: "Trip" tab in Grav Admin when editing the trip page, with date range and cover image fields
|
||||
|
||||
- [ ] **Step 1: Create `trip.yaml` blueprint**
|
||||
|
||||
`user/themes/intotheeast/blueprints/trip.yaml`:
|
||||
```yaml
|
||||
title: 'Trip'
|
||||
'@extends':
|
||||
type: default
|
||||
context: blueprints://pages
|
||||
|
||||
form:
|
||||
fields:
|
||||
tabs:
|
||||
type: tabs
|
||||
active: 1
|
||||
fields:
|
||||
trip:
|
||||
type: tab
|
||||
title: Trip
|
||||
fields:
|
||||
header.date_start:
|
||||
type: date
|
||||
label: 'Start Date'
|
||||
placeholder: '2026-06-17'
|
||||
help: 'First day of the trip'
|
||||
|
||||
header.date_end:
|
||||
type: date
|
||||
label: 'End Date'
|
||||
placeholder: ''
|
||||
help: 'Leave blank if trip is ongoing'
|
||||
|
||||
header.cover_image:
|
||||
type: text
|
||||
label: 'Cover Image Filename'
|
||||
placeholder: 'cover.jpg'
|
||||
help: 'Used in the trips listing page'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify blueprint appears in Admin**
|
||||
|
||||
Open Grav Admin → Pages → Trips → Japan & Korea 2026 → Edit. Confirm the "Trip" tab appears with start date, end date, cover image fields.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C user add themes/intotheeast/blueprints/trip.yaml
|
||||
git -C user commit -m "feat: add Admin blueprint for trip page type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks, run end-to-end check:
|
||||
|
||||
1. `make test-config && make test-post && make test-ui` — all must pass
|
||||
2. Navigate to `http://localhost:8081/trips/japan-korea-2026/tracker` — entries display in date order
|
||||
3. Navigate to `http://localhost:8081/trips/japan-korea-2026/map` — entry pins render, GPX polyline renders if a `.gpx` file is present on the trip page
|
||||
4. Navigate to `http://localhost:8081/trips/japan-korea-2026/stats` — stats compute correctly
|
||||
5. Navigate to `http://localhost:8081/trips` — trip listing shows Japan & Korea 2026
|
||||
6. Submit a post via `/post` — new entry appears under `/trips/japan-korea-2026/tracker`
|
||||
7. Grav Admin: edit the trip page → "Trip" tab visible with date fields
|
||||
@@ -0,0 +1,472 @@
|
||||
# Trip Page Filter Bar — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the three unstyled nav links on the trip page with an in-page filter bar (All content / Journal / Stories) and an inline Stats toggle — no page navigation needed.
|
||||
|
||||
**Architecture:** Pure client-side. `data-type` attributes on article cards let vanilla JS show/hide by content type. Stats computation is inlined into `trip.html.twig` from `stats.html.twig`. No new files, no Grav config changes, no page navigation.
|
||||
|
||||
**Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- CSS variables only — no raw hex values; use tokens from `tokens.css`
|
||||
- ES5 JS — no arrow functions, no `const`/`let`, no template literals (inline script in Twig)
|
||||
- Touch the minimum: only `trip.html.twig` and `style.css`
|
||||
- Do not modify `stats.html.twig`, `dailies.html.twig`, or any sub-page template
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Story card border + data-type attributes
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig:77,119`
|
||||
- Modify: `user/themes/intotheeast/css/style.css:816-819`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `data-type="journal"` and `data-type="story"` attributes on all article cards — consumed by Tasks 3 and 4
|
||||
|
||||
- [ ] **Step 1: Add data-type to journal article (trip.html.twig line 77)**
|
||||
|
||||
Find this line:
|
||||
```twig
|
||||
<article class="entry-card" id="entry-{{ entry.slug }}" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||
```
|
||||
Replace with:
|
||||
```twig
|
||||
<article class="entry-card" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add data-type to story article (trip.html.twig line 119)**
|
||||
|
||||
Find this line:
|
||||
```twig
|
||||
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}">
|
||||
```
|
||||
Replace with:
|
||||
```twig
|
||||
<article class="entry-card entry-card--story" id="entry-{{ entry.slug }}" data-type="story">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace story card border in style.css**
|
||||
|
||||
Find the existing `.entry-card--story` rule (around line 816):
|
||||
```css
|
||||
.entry-card--story {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
```
|
||||
Replace with:
|
||||
```css
|
||||
.entry-card--story {
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify visually**
|
||||
|
||||
Open the trip page in the browser. In DevTools:
|
||||
- Select a journal article → confirm it has `data-type="journal"`
|
||||
- Select a story article → confirm it has `data-type="story"`
|
||||
- Story cards should now appear as a boxed card with a full teal border and rounded corners instead of a left-only bar
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
|
||||
git commit -m "feat: add data-type attributes to feed cards; restyle story card with full border"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Filter bar markup + CSS (static, no JS yet)
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig:58-62`
|
||||
- Modify: `user/themes/intotheeast/css/style.css` (append after `.home-trip-counts` block, around line 694)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from prior tasks (static HTML)
|
||||
- Produces: `.trip-filter-bar`, `.trip-filter-btn`, `.trip-stats-btn` CSS classes consumed by Task 3
|
||||
|
||||
- [ ] **Step 1: Replace nav with filter bar in trip.html.twig**
|
||||
|
||||
Find the existing nav block (lines 58–62):
|
||||
```twig
|
||||
<nav class="trip-nav">
|
||||
<a href="{{ page.route }}/dailies">Journal</a>
|
||||
<a href="{{ page.route }}/stats">Stats</a>
|
||||
<a href="{{ page.route }}/stories">Stories</a>
|
||||
</nav>
|
||||
```
|
||||
Replace with:
|
||||
```twig
|
||||
<div class="trip-filter-bar">
|
||||
<div class="trip-filter-group">
|
||||
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
|
||||
<button class="trip-filter-btn" data-filter="journal">Journal</button>
|
||||
<button class="trip-filter-btn" data-filter="story">Stories</button>
|
||||
</div>
|
||||
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add filter bar CSS to style.css**
|
||||
|
||||
After the `.home-trip-counts` rule (around line 694), append:
|
||||
```css
|
||||
/* ── Trip page filter bar ────────────────────────────────────────────────────── */
|
||||
|
||||
.trip-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trip-filter-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trip-filter-btn,
|
||||
.trip-stats-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-ink-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-1) var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.trip-filter-btn:hover,
|
||||
.trip-stats-btn:hover {
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
.trip-filter-btn.is-active,
|
||||
.trip-stats-btn.is-active {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify visually**
|
||||
|
||||
Open the trip page. Confirm:
|
||||
- Three filter pills (All content / Journal / Stories) and a Stats button appear below the trip title
|
||||
- "All content" pill has teal active styling
|
||||
- Other pills are muted/bordered
|
||||
- Clicking the buttons does nothing yet (no JS)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
|
||||
git commit -m "feat: add filter bar markup and pill button styles to trip page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Filter JS (show/hide cards by type)
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (append to the existing `<script>` block at the bottom, before `</script>`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `data-type` on articles (Task 1); `.trip-filter-btn`, `data-filter` (Task 2)
|
||||
- Produces: working filter interaction
|
||||
|
||||
- [ ] **Step 1: Add an empty-state element to the feed**
|
||||
|
||||
In `trip.html.twig`, find the closing `</div>` of the `.feed` block (after the `{% else %}` empty message). Add a hidden filter-empty message right before `</div>`:
|
||||
```twig
|
||||
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
The full `.feed` block close should look like:
|
||||
```twig
|
||||
{% else %}
|
||||
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
|
||||
{% endif %}
|
||||
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Append filter JS to the existing script block**
|
||||
|
||||
In `trip.html.twig`, find the closing `</script>` tag at the bottom. Insert before it:
|
||||
```javascript
|
||||
(function() {
|
||||
var filterBtns = document.querySelectorAll('.trip-filter-btn');
|
||||
var cards = document.querySelectorAll('[data-type]');
|
||||
var filterEmpty = document.getElementById('feed-filter-empty');
|
||||
|
||||
filterBtns.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
filterBtns.forEach(function(b) { b.classList.remove('is-active'); });
|
||||
btn.classList.add('is-active');
|
||||
|
||||
var filter = btn.getAttribute('data-filter');
|
||||
var visible = 0;
|
||||
|
||||
cards.forEach(function(card) {
|
||||
var show = filter === 'all' || card.getAttribute('data-type') === filter;
|
||||
card.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
if (filterEmpty) {
|
||||
if (visible === 0) {
|
||||
filterEmpty.textContent = filter === 'story'
|
||||
? 'No stories yet for this trip.'
|
||||
: 'No entries yet.';
|
||||
filterEmpty.style.display = '';
|
||||
} else {
|
||||
filterEmpty.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify filter behavior**
|
||||
|
||||
Open the trip page. With demo entries loaded (run `make demo-load` if needed):
|
||||
- Click **Journal** → only journal cards visible, story cards hidden
|
||||
- Click **Stories** → only story cards visible, journal cards hidden
|
||||
- Click **All content** → all cards visible again
|
||||
- If no stories exist, clicking Stories shows "No stories yet for this trip."
|
||||
- "All content" pill always has active state after clicking it
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/trip.html.twig
|
||||
git commit -m "feat: wire up feed filter — All content / Journal / Stories"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Inline stats block (Twig computation + HTML + toggle JS)
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||
- Modify: `user/themes/intotheeast/css/style.css` (append after filter bar CSS from Task 2)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `.trip-stats-btn#trip-stats-toggle` (Task 2); `journal_entries` variable already set at top of template
|
||||
- Produces: expandable stats block; `STATS_GPS` JS variable for haversine distance
|
||||
|
||||
- [ ] **Step 1: Add stats Twig computation at the top of the template**
|
||||
|
||||
In `trip.html.twig`, after line 19 (`{% set story_count = story_entries|length %}`), add:
|
||||
```twig
|
||||
{# Stats computation #}
|
||||
{% set days_on_road = 0 %}
|
||||
{% set first_ts = null %}
|
||||
{% for entry in journal_entries %}
|
||||
{% set ts = entry.date|date('U') %}
|
||||
{% if first_ts is null or ts < first_ts %}
|
||||
{% set first_ts = ts %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if first_ts is not null %}
|
||||
{% set now_ts = "now"|date('U') %}
|
||||
{% set diff_seconds = now_ts - first_ts %}
|
||||
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||
{% endif %}
|
||||
|
||||
{% set seen_lower = [] %}
|
||||
{% set country_display = [] %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.location_country is not empty %}
|
||||
{% set lower = entry.header.location_country|trim|lower %}
|
||||
{% if lower not in seen_lower %}
|
||||
{% set seen_lower = seen_lower|merge([lower]) %}
|
||||
{% set country_display = country_display|merge([entry.header.location_country|trim]) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set gps_points = [] %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add stats block HTML between filter bar and feed**
|
||||
|
||||
In `trip.html.twig`, find the `<div class="feed">` line and insert the stats block immediately before it:
|
||||
```twig
|
||||
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
|
||||
<div class="trip-stats-grid">
|
||||
<div class="stat-block">
|
||||
<span class="stat-value">{{ days_on_road }}</span>
|
||||
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value">{{ journal_count }}</span>
|
||||
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value">{{ country_display|length }}</span>
|
||||
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="stat-distance">—</span>
|
||||
<span class="stat-label">km traveled</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if country_display|length > 0 %}
|
||||
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
|
||||
{% endif %}
|
||||
<p class="trip-stats-note">Distance is approximate — straight lines between entry locations.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add stats block CSS to style.css**
|
||||
|
||||
Append after the filter bar CSS added in Task 2:
|
||||
```css
|
||||
/* ── Trip page inline stats block ───────────────────────────────────────────── */
|
||||
|
||||
.trip-stats-block {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.trip-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.trip-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.trip-stats-countries {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.trip-stats-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
```
|
||||
|
||||
Note: `.stat-block`, `.stat-value`, `.stat-label` are reused from `stats.html.twig` and already have CSS defined. Do not add duplicate rules.
|
||||
|
||||
- [ ] **Step 4: Verify those existing CSS classes exist**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "\.stat-block\|\.stat-value\|\.stat-label" user/themes/intotheeast/css/style.css
|
||||
```
|
||||
Expected: at least 3 matches. If not found, copy from `stats.html.twig`'s inline `<style>` block if one exists.
|
||||
|
||||
- [ ] **Step 5: Add stats toggle JS + haversine distance**
|
||||
|
||||
In `trip.html.twig`, append to the existing `<script>` block (before `</script>`):
|
||||
```javascript
|
||||
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||
|
||||
(function() {
|
||||
function haversine(lat1, lng1, lat2, lng2) {
|
||||
var R = 6371;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return R * 2 * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
var totalKm = 0;
|
||||
for (var i = 1; i < STATS_GPS.length; i++) {
|
||||
totalKm += haversine(
|
||||
parseFloat(STATS_GPS[i - 1][0]), parseFloat(STATS_GPS[i - 1][1]),
|
||||
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||
);
|
||||
}
|
||||
var distEl = document.getElementById('stat-distance');
|
||||
if (distEl) {
|
||||
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(totalKm).toLocaleString();
|
||||
}
|
||||
|
||||
var statsToggle = document.getElementById('trip-stats-toggle');
|
||||
var statsBlock = document.getElementById('trip-stats-block');
|
||||
if (statsToggle && statsBlock) {
|
||||
statsToggle.addEventListener('click', function() {
|
||||
var isOpen = statsBlock.style.display !== 'none';
|
||||
statsBlock.style.display = isOpen ? 'none' : '';
|
||||
statsToggle.classList.toggle('is-active', !isOpen);
|
||||
});
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify stats block**
|
||||
|
||||
Open the trip page with demo entries loaded:
|
||||
- Click **Stats** → inline block expands between filter bar and feed; Stats button turns teal
|
||||
- Block shows: days on road, entries count, countries count, km distance (or `—` if < 2 GPS points)
|
||||
- Countries list shows below the grid if any entries have `location_country`
|
||||
- Click **Stats** again → block collapses, button returns to default style
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/trip.html.twig user/themes/intotheeast/css/style.css
|
||||
git commit -m "feat: add inline stats block with toggle to trip page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ Filter bar with All / Journal / Stories (Task 2, 3)
|
||||
- ✅ Mutually exclusive, one active at a time (Task 3 JS)
|
||||
- ✅ JS show/hide via data-type (Task 1, 3)
|
||||
- ✅ Empty state for Stories filter (Task 3)
|
||||
- ✅ Stats as inline expansion (Task 4)
|
||||
- ✅ Stats toggle with active state (Task 4)
|
||||
- ✅ Story card full border (Task 1)
|
||||
- ✅ Sub-pages untouched — no changes to stats.html.twig or dailies.html.twig
|
||||
|
||||
**Placeholder scan:** None — all steps contain exact code.
|
||||
|
||||
**Type consistency:**
|
||||
- `data-filter="story"` on button matches `data-type="story"` on article — comparison in Task 3 JS: `card.getAttribute('data-type') === filter` ✅
|
||||
- `id="trip-stats-toggle"` set in Task 2 HTML, read in Task 4 JS ✅
|
||||
- `id="trip-stats-block"` set in Task 4 HTML, read in Task 4 JS ✅
|
||||
- `id="feed-filter-empty"` set in Task 3 HTML, read in Task 3 JS ✅
|
||||
- `id="stat-distance"` set in Task 4 HTML, read in Task 4 JS ✅
|
||||
- `STATS_GPS` set in Task 4 JS, consumed in Task 4 haversine loop ✅
|
||||
- `.stat-block` / `.stat-value` / `.stat-label` reused from existing CSS — Task 4 Step 4 verifies they exist ✅
|
||||
@@ -0,0 +1,301 @@
|
||||
# Tuscany Demo Stories Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add three Italy 2025 Tuscany demo stories that showcase distinct story-mode composition patterns.
|
||||
|
||||
**Architecture:** Three `story.md` files in `user/docs/demo/trips/italy-2025/04.stories/`, committed to the user repo. One Makefile line added to `demo-load` to copy the folder into pages, committed to the main repo. No image files — shortcode image params reference filenames that won't resolve without real photos, consistent with the existing Japan demo story.
|
||||
|
||||
**Tech Stack:** Grav CMS page frontmatter (YAML), Markdown prose, story-blocks shortcodes (chapter-break, scrolly-section, pull-quote, snap-gallery), Makefile.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Story pages live at `user/docs/demo/trips/italy-2025/04.stories/<n>.<slug>/story.md` (note: `user/` is a separate git repo — all story commits use `git -C user ...`)
|
||||
- Shortcode syntax: `[chapter-break title="..." number="..." image="..." alt="..." /]`, `[scrolly-section image="..." alt="..." caption="..."]...[/scrolly-section]`, `[pull-quote image="..."]...[/pull-quote]` (image param optional), `[snap-gallery images="a.jpg,b.jpg" captions="Cap 1,Cap 2" alts="Alt 1,Alt 2" /]`
|
||||
- ScrollySection steps separated by `---` on its own line inside the shortcode tags
|
||||
- All frontmatter fields required: `title`, `date`, `location_name`, `location_country`, `lat`, `lng`, `hero_image`, `hero_alt`, `published: true`
|
||||
- `end_date` optional (Story 2 only)
|
||||
- Makefile changes go in the **main repo** (`git add Makefile && git commit ...` — no `-C user`)
|
||||
- Dev server: `http://localhost:8081`, Docker container: `intotheeast_grav`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Three story.md files
|
||||
|
||||
**Files:**
|
||||
- Create: `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
|
||||
- Create: `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
|
||||
- Create: `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: three story pages loadable by `make demo-load` into `user/pages/01.trips/italy-2025/04.stories/`
|
||||
- Consumed by: Task 2 (Makefile verification)
|
||||
|
||||
- [ ] **Create the demo stories directory**
|
||||
|
||||
```bash
|
||||
mkdir -p user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn
|
||||
mkdir -p user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino
|
||||
mkdir -p user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena
|
||||
```
|
||||
|
||||
- [ ] **Create Story 1: `01.val-dorcia-dawn/story.md`**
|
||||
|
||||
Pattern: gallery-led. Demonstrates two snap-galleries, chapter-break as scene divider, text-only pull-quote (no image param).
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: The Val d'Orcia at Dawn
|
||||
date: 2025-09-05
|
||||
location_name: Val d'Orcia
|
||||
location_country: Italy
|
||||
lat: 43.078
|
||||
lng: 11.676
|
||||
hero_image: hero.jpg
|
||||
hero_alt: Cypress-lined dirt road at first light, Tuscany
|
||||
published: true
|
||||
---
|
||||
We left camp before the heat arrived. At six in the morning the Val d'Orcia belongs entirely to the light — long shadows, pale gold, not a car on the white roads. The kind of silence that has texture.
|
||||
|
||||
[snap-gallery images="dawn-wide.jpg,rolling-hills.jpg,cypress-allee.jpg,dirt-road.jpg" captions="First light on the valley floor,The hills fold endlessly east,The cypress road — every photo you have ever seen of Tuscany,Dust rising behind us on the gravel" alts="Wide valley at dawn with golden light,Rolling green hills under morning sky,Long avenue of cypress trees,White gravel road with dust cloud" /]
|
||||
|
||||
We stopped twice before nine. Once for a puncture, once because the view demanded it.
|
||||
|
||||
[chapter-break image="heat-haze.jpg" title="The Hour Before Heat" alt="Hazy hillside shimmering in early morning warmth" /]
|
||||
|
||||
By ten the temperature had shifted. The colours changed too — softer, more diffuse, the sky turning white at the edges. We dropped into the lower valley and the road surface changed from gravel to packed earth, then back again.
|
||||
|
||||
[snap-gallery images="gravel-detail.jpg,wheel-shadow.jpg,water-bottle.jpg,road-shadow.jpg" captions="The texture of Tuscan gravel — coarser than it looks,Long shadow at the day's first steep pitch,Half a litre left and forty kilometres to go,The road ahead disappears into the heat" alts="Close-up of pale gravel surface,Bicycle wheel casting shadow on road,Half-empty water bottle in cage,Road vanishing into bright haze" /]
|
||||
|
||||
[pull-quote]
|
||||
The best hours of a cycling day are the ones nobody sees. Four in the morning to ten. Then it belongs to the sun.
|
||||
[/pull-quote]
|
||||
|
||||
We made Pienza by noon. It was already thirty degrees and the ice cream queue was six deep.
|
||||
```
|
||||
|
||||
- [ ] **Create Story 2: `02.long-climb-montalcino/story.md`**
|
||||
|
||||
Pattern: scrollytelling-led. Demonstrates two scrolly-sections with different step counts (3 vs. 5), pull-quote with image, chapter-break with number.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: The Long Climb to Montalcino
|
||||
date: 2025-09-05
|
||||
end_date: 2025-09-06
|
||||
location_name: Montalcino
|
||||
location_country: Italy
|
||||
lat: 43.058
|
||||
lng: 11.489
|
||||
hero_image: hero.jpg
|
||||
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
|
||||
published: true
|
||||
---
|
||||
The profile showed fourteen kilometres at an average of six percent. In practice it was steeper at the bottom and gentler at the top, which is the worst possible arrangement. We started climbing at two in the afternoon, which was also the worst possible decision.
|
||||
|
||||
[scrolly-section image="climb-road.jpg" alt="Empty road rising steeply through olive groves" caption="SP55 — 14km, 840m elevation gain"]
|
||||
The first kilometre is the most honest. You find out immediately whether your legs have anything to say.
|
||||
|
||||
---
|
||||
|
||||
By the halfway point the olive groves had given way to scrub oak and the road had narrowed to a single lane. No cars had passed in forty minutes. The silence was absolute except for breathing.
|
||||
|
||||
---
|
||||
|
||||
Then, at the last bend before the top, the town appeared. Just the outline of it — a tower, a wall, rooftops. It was enough.
|
||||
[/scrolly-section]
|
||||
|
||||
[chapter-break image="town-gate.jpg" title="Montalcino" number="II" alt="Medieval town gate with stone archway" /]
|
||||
|
||||
[pull-quote image="vineyard.jpg" alt="Rows of Brunello vines descending from hilltop town"]
|
||||
From the top you could see the whole valley we had spent two days riding through. It looked completely flat from up here.
|
||||
[/pull-quote]
|
||||
|
||||
We found a bar in the main piazza. The owner brought two glasses of water without being asked. Then two more. Then a small plate of bread and oil that nobody ordered. We sat there for an hour.
|
||||
|
||||
[scrolly-section image="piazza.jpg" alt="Shaded medieval piazza with stone buildings" caption="Piazza del Popolo, Montalcino"]
|
||||
The piazza at five in the afternoon is a different place from the piazza at noon. People have returned from wherever they go during the heat.
|
||||
|
||||
---
|
||||
|
||||
A wine shop with barrels in the window and a handwritten list on a chalkboard. We looked at it for a long time and bought nothing. The prices were very reasonable and this felt suspicious.
|
||||
|
||||
---
|
||||
|
||||
A cat on a warm stone wall, watching traffic that did not exist. It had clearly been watching this traffic for years.
|
||||
|
||||
---
|
||||
|
||||
The fortress walls turn amber just before sunset. You could photograph this from a hundred different angles and it would look the same in all of them: very good.
|
||||
|
||||
---
|
||||
|
||||
The descent back to the valley takes twenty minutes. The climb took two and a half hours. This ratio never stops feeling wrong.
|
||||
[/scrolly-section]
|
||||
|
||||
We found the agriturismo by following a handwritten sign nailed to a cypress tree. It was exactly what it promised to be.
|
||||
```
|
||||
|
||||
- [ ] **Create Story 3: `03.one-evening-siena/story.md`**
|
||||
|
||||
Pattern: mood/fragment piece. Demonstrates pull-quote as opening element, 2-step scrolly-section (minimum), snap-gallery as mid-story element, pull-quote with image as closing element.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: One Evening in Siena
|
||||
date: 2025-09-05
|
||||
location_name: Siena
|
||||
location_country: Italy
|
||||
lat: 43.318
|
||||
lng: 11.330
|
||||
hero_image: hero.jpg
|
||||
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
|
||||
published: true
|
||||
---
|
||||
[pull-quote]
|
||||
Siena is not a city that tries to impress you. It has been here for a thousand years and intends to be here for a thousand more. You fit around it.
|
||||
[/pull-quote]
|
||||
|
||||
We rolled in at half past six, legs finished, panniers heavier than they started. The Campo appeared without warning at the end of a narrow street and we both stopped pedalling at exactly the same moment.
|
||||
|
||||
[scrolly-section image="campo.jpg" alt="Piazza del Campo seen from the upper rim, sloping shell-shaped square"]
|
||||
The square fills from the edges inward as evening comes. First the locals — people who have been here before and know which bench faces west. Then the tourists, then the pigeons, then the shadows.
|
||||
|
||||
---
|
||||
|
||||
A busker with a cello at the top of the slope. A couple arguing quietly in a language I couldn't place. Three children running in a circle for reasons nobody questioned. The ordinary business of a city at the end of a summer day.
|
||||
[/scrolly-section]
|
||||
|
||||
[snap-gallery images="campo-dusk.jpg,doorway.jpg,gelato.jpg" captions="The Campo at the moment the light goes warm,A doorway on Via di Città — every doorway in Siena looks like this,The mandatory stop: stracciatella, obviously" alts="Wide shot of Campo at golden hour,Ornate stone doorway with iron lantern,Close-up of gelato cone with city behind" /]
|
||||
|
||||
We found a place to eat down three flights of stairs in a basement that appeared to have no ventilation and no menu. It was perfect. The relief of sitting down after eight hours on a bike is a specific physical sensation that is difficult to describe to anyone who has not experienced it.
|
||||
|
||||
[pull-quote image="sunset.jpg" alt="Sunset view over Siena rooftops from high vantage point"]
|
||||
Cycling makes you earn every place you arrive at. Siena earned.
|
||||
[/pull-quote]
|
||||
```
|
||||
|
||||
- [ ] **Verify shortcode syntax in all three files**
|
||||
|
||||
Check that every shortcode tag opens and closes correctly:
|
||||
```bash
|
||||
grep -n "\[chapter-break\|\[scrolly-section\|\[/scrolly-section\]\|\[pull-quote\|\[/pull-quote\]\|\[snap-gallery" \
|
||||
user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md \
|
||||
user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md \
|
||||
user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md
|
||||
```
|
||||
|
||||
Expected: every `[scrolly-section` has a matching `[/scrolly-section]`, every `[pull-quote` has a matching `[/pull-quote]`.
|
||||
|
||||
- [ ] **Commit to user repo**
|
||||
|
||||
```bash
|
||||
git -C user add docs/demo/trips/italy-2025/04.stories/
|
||||
git -C user commit -m "docs: add three Tuscany demo stories (gallery-led, scrollytelling-led, mood-fragment)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Makefile update + end-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `Makefile` (main repo — no `-C user`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `user/docs/demo/trips/italy-2025/04.stories/` from Task 1
|
||||
- Produces: `make demo-load` copies all three stories into pages; stories listing shows 3 cards; each story page renders shortcode HTML
|
||||
|
||||
- [ ] **Add the stories copy line to `demo-load`**
|
||||
|
||||
In `Makefile`, find this line:
|
||||
```makefile
|
||||
cp user/docs/demo/trips/italy-2025/stories.md user/pages/01.trips/italy-2025/04.stories/stories.md 2>/dev/null || true
|
||||
```
|
||||
|
||||
Add the following line immediately after it:
|
||||
```makefile
|
||||
cp -r user/docs/demo/trips/italy-2025/04.stories/. user/pages/01.trips/italy-2025/04.stories/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
Note: use `04.stories/.` (trailing slash-dot) to copy the **contents** of the folder into the existing `04.stories/` directory (which already contains `stories.md`), rather than creating a nested `04.stories/04.stories/` structure.
|
||||
|
||||
- [ ] **Run `make demo-load` and clear cache**
|
||||
|
||||
```bash
|
||||
make demo-load
|
||||
```
|
||||
|
||||
Expected output ends with Grav cache cleared.
|
||||
|
||||
- [ ] **Verify stories listing shows 3 cards**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories | grep -c 'story-card'
|
||||
```
|
||||
|
||||
Expected: `3` (one card per story).
|
||||
|
||||
- [ ] **Verify Story 1 renders both snap-galleries and text-only pull-quote**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep -c 'pgallery\|pull-quote'
|
||||
```
|
||||
|
||||
Expected: at least `3` (2 pgallery divs + 1 pull-quote).
|
||||
|
||||
Also verify the text-only pull-quote (no background image div):
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/val-dorcia-dawn | grep 'pull-quote__inner--no-image'
|
||||
```
|
||||
|
||||
Expected: one match (the text-only pull-quote variant).
|
||||
|
||||
- [ ] **Verify Story 2 renders two scrolly-sections**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep -c 'class="scrolly"'
|
||||
```
|
||||
|
||||
Expected: `2` (two scrolly-section blocks).
|
||||
|
||||
Also verify pull-quote with image (should NOT have `--no-image` class):
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/long-climb-montalcino | grep 'pull-quote__bg'
|
||||
```
|
||||
|
||||
Expected: one match (the background image div for the pull-quote).
|
||||
|
||||
- [ ] **Verify Story 3 renders pull-quote as opener and closer**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pull-quote'
|
||||
```
|
||||
|
||||
Expected: at least `4` (two pull-quote elements × ~2 class references each).
|
||||
|
||||
Verify the mid-story snap-gallery:
|
||||
```bash
|
||||
curl -s http://localhost:8081/trips/italy-2025/stories/one-evening-siena | grep -c 'pgallery'
|
||||
```
|
||||
|
||||
Expected: at least `1`.
|
||||
|
||||
- [ ] **Run `make demo-reset` and verify cleanup**
|
||||
|
||||
```bash
|
||||
make demo-reset
|
||||
```
|
||||
|
||||
Verify Italy stories are gone:
|
||||
```bash
|
||||
ls user/pages/01.trips/italy-2025/ 2>/dev/null || echo "italy-2025 removed"
|
||||
```
|
||||
|
||||
Expected: `italy-2025 removed` (the entire italy-2025 folder is removed by `demo-reset`).
|
||||
|
||||
- [ ] **Commit Makefile to main repo**
|
||||
|
||||
```bash
|
||||
git add Makefile
|
||||
git commit -m "build: add Italy 2025 stories folder to demo-load target"
|
||||
```
|
||||
@@ -0,0 +1,935 @@
|
||||
# GPX Connector Logic Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Suppress the straight-line connector between adjacent map markers when a single GPX file covers both endpoints; keep connectors for uncovered gaps; add `force_connect` and `transport_mode` fields to entry/story blueprints.
|
||||
|
||||
**Architecture:** Pure client-side. GPX files are already fetched to display tracks; their parsed trackpoints are reused to run a same-file proximity check per adjacent marker pair. Journey segments are built after all GPX fetches settle (Promise.all). The algorithm lives in `maplibre-utils.js` as pure functions exposed on `MapUtils`.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES5 IIFE pattern matching existing code), MapLibre GL 4, `@mapbox/togeojson` 0.16.2, Grav 2 blueprint YAML, Playwright for tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- ES5 syntax only in all JS — no arrow functions, const/let, template literals, or modules (matching existing `maplibre-utils.js` style)
|
||||
- All JS functions inside the existing `maplibre-utils.js` IIFE
|
||||
- Grav blueprint fields use `header.<fieldname>` prefix in the `form.fields` tree
|
||||
- Proximity threshold: **10 km** (hardcoded, not configurable)
|
||||
- Trackpoints stored internally as `[lat, lng]` (latitude first); MapLibre coords are `[lng, lat]` (longitude first) — never mix these up
|
||||
- Demo data required for Playwright tests: run `make demo-load` before the test suite
|
||||
- Dev server runs at `http://localhost:8081`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Blueprint — add `force_connect` and `transport_mode` fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/blueprints/entry.yaml`
|
||||
- Create: `user/themes/intotheeast/blueprints/story.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `entry.header.force_connect` (bool, default false), `entry.header.transport_mode` (string, default null) available in Twig templates and Admin2 UI
|
||||
|
||||
- [ ] **Step 1: Add a Journey tab to `entry.yaml`**
|
||||
|
||||
In `user/themes/intotheeast/blueprints/entry.yaml`, append this tab section after the `publishing:` tab block (before the closing of the `tabs.fields` block). The final file should end with:
|
||||
|
||||
```yaml
|
||||
journey:
|
||||
type: tab
|
||||
title: Journey
|
||||
fields:
|
||||
header.transport_mode:
|
||||
type: select
|
||||
label: How I arrived here
|
||||
default: ''
|
||||
options:
|
||||
'': '— not specified —'
|
||||
'walking': 'Walking'
|
||||
'bicycle': 'Bicycle'
|
||||
'bus': 'Bus'
|
||||
'train': 'Train'
|
||||
'car': 'Car'
|
||||
|
||||
header.force_connect:
|
||||
type: toggle
|
||||
label: Force connector line
|
||||
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
|
||||
highlight: 1
|
||||
default: 0
|
||||
options:
|
||||
1: 'Yes'
|
||||
0: 'No'
|
||||
validate:
|
||||
type: bool
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `story.yaml` blueprint**
|
||||
|
||||
Create `user/themes/intotheeast/blueprints/story.yaml` with this full content (covers all existing story frontmatter fields plus the new Journey tab):
|
||||
|
||||
```yaml
|
||||
title: 'Story'
|
||||
|
||||
form:
|
||||
fields:
|
||||
tabs:
|
||||
type: tabs
|
||||
active: 1
|
||||
fields:
|
||||
|
||||
content:
|
||||
type: tab
|
||||
title: Content
|
||||
fields:
|
||||
header.title:
|
||||
type: text
|
||||
label: Title
|
||||
validate:
|
||||
required: true
|
||||
|
||||
header.date:
|
||||
type: datetime
|
||||
label: Date
|
||||
format: 'Y-m-d H:i'
|
||||
validate:
|
||||
required: true
|
||||
|
||||
header.hero_image:
|
||||
type: text
|
||||
label: Hero Image
|
||||
placeholder: 'hero.jpg'
|
||||
help: 'Filename of the hero image (upload via Media tab)'
|
||||
|
||||
header.hero_alt:
|
||||
type: text
|
||||
label: Hero Image Alt Text
|
||||
placeholder: 'Description of the hero image'
|
||||
|
||||
content:
|
||||
type: markdown
|
||||
label: Content
|
||||
validate:
|
||||
required: true
|
||||
|
||||
location:
|
||||
type: tab
|
||||
title: Location
|
||||
fields:
|
||||
header.location_name:
|
||||
type: text
|
||||
label: Location Name
|
||||
placeholder: 'e.g. Val d''Orcia'
|
||||
|
||||
header.location_country:
|
||||
type: text
|
||||
label: Country
|
||||
placeholder: 'e.g. Italy'
|
||||
|
||||
header.lat:
|
||||
type: text
|
||||
label: Latitude
|
||||
placeholder: '43.0780'
|
||||
help: 'GPS latitude (decimal degrees)'
|
||||
|
||||
header.lng:
|
||||
type: text
|
||||
label: Longitude
|
||||
placeholder: '11.6760'
|
||||
help: 'GPS longitude (decimal degrees)'
|
||||
|
||||
publishing:
|
||||
type: tab
|
||||
title: Publishing
|
||||
fields:
|
||||
header.published:
|
||||
type: toggle
|
||||
label: Published
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: 'Yes'
|
||||
0: 'No'
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
journey:
|
||||
type: tab
|
||||
title: Journey
|
||||
fields:
|
||||
header.transport_mode:
|
||||
type: select
|
||||
label: How I arrived here
|
||||
default: ''
|
||||
options:
|
||||
'': '— not specified —'
|
||||
'walking': 'Walking'
|
||||
'bicycle': 'Bicycle'
|
||||
'bus': 'Bus'
|
||||
'train': 'Train'
|
||||
'car': 'Car'
|
||||
|
||||
header.force_connect:
|
||||
type: toggle
|
||||
label: Force connector line
|
||||
help: 'When GPX tracks are present, always draw a connector from the previous marker to this one'
|
||||
highlight: 1
|
||||
default: 0
|
||||
options:
|
||||
1: 'Yes'
|
||||
0: 'No'
|
||||
validate:
|
||||
type: bool
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
Open Admin2 at `http://localhost:8081/admin` → edit any entry under a dailies folder → confirm a "Journey" tab appears with "How I arrived here" select and "Force connector line" toggle. Then open any story page → confirm the same "Journey" tab is present.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/blueprints/entry.yaml user/themes/intotheeast/blueprints/story.yaml
|
||||
git commit -m "feat: add force_connect and transport_mode fields to entry and story blueprints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Algorithm functions in `maplibre-utils.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/js/maplibre-utils.js`
|
||||
- Create: `tests/ui/gpx-journey.spec.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `MapUtils.extractTrackpoints(geojson)` → `[[lat, lng], ...]`
|
||||
- `MapUtils.buildJourneySegments(entries, allTrackpoints, thresholdKm)` → `[[lng, lat], ...][]`
|
||||
- `MapUtils.addJourneySegments(map, segments, baseSourceId)` → void
|
||||
- Consumes: `toGeoJSON.gpx()` output (GeoJSON FeatureCollection)
|
||||
|
||||
- [ ] **Step 1: Write failing Playwright tests**
|
||||
|
||||
Create `tests/ui/gpx-journey.spec.js`:
|
||||
|
||||
```javascript
|
||||
// @ts-check
|
||||
// Tests: G1–G4 — buildJourneySegments algorithm correctness
|
||||
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
|
||||
// then call the functions with synthetic data via page.evaluate.
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function getMapUtils(page) {
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// G1: No GPX → all pairs connected in one segment
|
||||
test('G1: all markers connected when no GPX files present', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var entries = [
|
||||
{ lat: '43.0', lng: '11.0', force_connect: false },
|
||||
{ lat: '44.0', lng: '12.0', force_connect: false },
|
||||
{ lat: '45.0', lng: '13.0', force_connect: false }
|
||||
];
|
||||
return MapUtils.buildJourneySegments(entries, [], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G2: Same GPX file covers both markers → connector suppressed (0 segments)
|
||||
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||
// Trackpoints covering both (stored as [lat, lng])
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
// G3: force_connect overrides GPX suppression
|
||||
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G4: Markers near DIFFERENT GPX files → connector kept
|
||||
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
|
||||
// Two separate files — each only covers one marker
|
||||
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
|
||||
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
|
||||
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G5: First pair suppressed, second pair kept → one segment [e2, e3]
|
||||
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
// e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
|
||||
// e2→e3: not covered → connector kept → segment [e2, e3]
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
|
||||
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
|
||||
return segs.length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1); // one segment: [e2 → e3]
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
npx playwright test tests/ui/gpx-journey.spec.js
|
||||
```
|
||||
|
||||
Expected: All 5 tests fail with `MapUtils.buildJourneySegments is not a function` (or similar).
|
||||
|
||||
- [ ] **Step 3: Add algorithm functions to `maplibre-utils.js`**
|
||||
|
||||
Inside the IIFE in `user/themes/intotheeast/js/maplibre-utils.js`, add the following functions **before** the `global.MapUtils = ...` line at the bottom:
|
||||
|
||||
```javascript
|
||||
/* ── GPX connector algorithm ────────────────────────────────────────── */
|
||||
|
||||
/* Haversine distance in km between two [lat, lng] points */
|
||||
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
var R = 6371;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/*
|
||||
* Extract trackpoints from a toGeoJSON output.
|
||||
* Returns [[lat, lng], ...] — latitude first (internal convention).
|
||||
* GeoJSON coordinates are [lng, lat]; we flip them here.
|
||||
*/
|
||||
function extractTrackpoints(geojson) {
|
||||
var points = [];
|
||||
(geojson.features || []).forEach(function (feat) {
|
||||
var coords = [];
|
||||
if (feat.geometry.type === 'LineString') {
|
||||
coords = feat.geometry.coordinates;
|
||||
} else if (feat.geometry.type === 'MultiLineString') {
|
||||
feat.geometry.coordinates.forEach(function (line) {
|
||||
coords = coords.concat(line);
|
||||
});
|
||||
}
|
||||
coords.forEach(function (c) { points.push([c[1], c[0]]); }); // [lng,lat] → [lat,lng]
|
||||
});
|
||||
return points;
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a marker is within thresholdKm of any trackpoint in the array.
|
||||
* trackpoints: [[lat, lng], ...] (internal convention, latitude first).
|
||||
* Samples every 10th point for performance; always checks the last point.
|
||||
*/
|
||||
function isNearTrack(markerLat, markerLng, trackpoints, thresholdKm) {
|
||||
if (!trackpoints || trackpoints.length === 0) return false;
|
||||
var degLat = thresholdKm / 111;
|
||||
var degLng = thresholdKm / (111 * Math.cos(markerLat * Math.PI / 180));
|
||||
for (var i = 0; i < trackpoints.length; i += 10) {
|
||||
var pt = trackpoints[i];
|
||||
if (Math.abs(pt[0] - markerLat) > degLat || Math.abs(pt[1] - markerLng) > degLng) continue;
|
||||
if (haversineKm(markerLat, markerLng, pt[0], pt[1]) <= thresholdKm) return true;
|
||||
}
|
||||
var last = trackpoints[trackpoints.length - 1];
|
||||
return haversineKm(markerLat, markerLng, last[0], last[1]) <= thresholdKm;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build journey line segments from entries and GPX trackpoints.
|
||||
*
|
||||
* entries: [{lat, lng, force_connect}, ...] in chronological order
|
||||
* allTrackpoints: [ [[lat,lng],...], ... ] — one sub-array per GPX file
|
||||
* thresholdKm: proximity radius (default 10)
|
||||
*
|
||||
* Returns array of segments, each segment being [[lng, lat], ...] in MapLibre
|
||||
* coordinate order. A segment with < 2 points is omitted.
|
||||
*
|
||||
* Rules:
|
||||
* - No GPX files → all adjacent pairs connected (one segment)
|
||||
* - GPX present, pair covered by same file → connector suppressed
|
||||
* - GPX present, pair NOT covered by any single file → connector drawn
|
||||
* - force_connect on arriving entry → always draw connector
|
||||
*/
|
||||
function buildJourneySegments(entries, allTrackpoints, thresholdKm) {
|
||||
thresholdKm = thresholdKm || 10;
|
||||
var hasGpx = allTrackpoints && allTrackpoints.length > 0;
|
||||
var segments = [];
|
||||
var current = [];
|
||||
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i];
|
||||
var lngLat = [parseFloat(e.lng), parseFloat(e.lat)]; // MapLibre: [lng, lat]
|
||||
|
||||
if (i === 0) {
|
||||
current.push(lngLat);
|
||||
continue;
|
||||
}
|
||||
|
||||
var prev = entries[i - 1];
|
||||
var connect;
|
||||
|
||||
if (!hasGpx || e.force_connect) {
|
||||
connect = true;
|
||||
} else {
|
||||
var pLat = parseFloat(prev.lat);
|
||||
var pLng = parseFloat(prev.lng);
|
||||
var cLat = parseFloat(e.lat);
|
||||
var cLng = parseFloat(e.lng);
|
||||
var covered = false;
|
||||
for (var f = 0; f < allTrackpoints.length; f++) {
|
||||
if (isNearTrack(pLat, pLng, allTrackpoints[f], thresholdKm) &&
|
||||
isNearTrack(cLat, cLng, allTrackpoints[f], thresholdKm)) {
|
||||
covered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
connect = !covered;
|
||||
}
|
||||
|
||||
if (connect) {
|
||||
current.push(lngLat);
|
||||
} else {
|
||||
if (current.length >= 2) segments.push(current);
|
||||
current = [lngLat]; // start new segment from this point
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length >= 2) segments.push(current);
|
||||
return segments;
|
||||
}
|
||||
|
||||
/*
|
||||
* Draw journey segments — calls addJourneyLine once per segment.
|
||||
* baseSourceId: e.g. 'journey' → sources become 'journey-0', 'journey-1', ...
|
||||
* (single segment gets plain 'journey' for backwards compatibility).
|
||||
*/
|
||||
function addJourneySegments(map, segments, baseSourceId) {
|
||||
segments.forEach(function (coords, i) {
|
||||
var sid = segments.length === 1 ? baseSourceId : baseSourceId + '-' + i;
|
||||
addJourneyLine(map, coords, sid);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the `MapUtils` export**
|
||||
|
||||
Replace the existing `global.MapUtils = ...` line at the bottom of the IIFE with:
|
||||
|
||||
```javascript
|
||||
global.MapUtils = {
|
||||
MAP_STYLE: MAP_STYLE,
|
||||
ACCENT: ACCENT,
|
||||
addJourneyLine: addJourneyLine,
|
||||
addJourneySegments: addJourneySegments,
|
||||
buildJourneySegments: buildJourneySegments,
|
||||
extractTrackpoints: extractTrackpoints,
|
||||
createDotMarker: createDotMarker
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to confirm G1–G5 pass**
|
||||
|
||||
```bash
|
||||
npx playwright test tests/ui/gpx-journey.spec.js
|
||||
```
|
||||
|
||||
Expected: All 5 tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/js/maplibre-utils.js tests/ui/gpx-journey.spec.js
|
||||
git commit -m "feat: add GPX proximity algorithm to MapUtils (buildJourneySegments, extractTrackpoints)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewire `map.html.twig` to use the algorithm
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/map.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
|
||||
- Consumes: `entry.header.force_connect` from Grav page frontmatter
|
||||
|
||||
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
|
||||
|
||||
In `map.html.twig`, the `map_entries` loop (lines 24–31) builds the entry JSON. Add `force_connect` to the merge array:
|
||||
|
||||
```twig
|
||||
{% set map_entries = map_entries|merge([{
|
||||
'lat': entry.header.lat|number_format(6, '.', ''),
|
||||
'lng': entry.header.lng|number_format(6, '.', ''),
|
||||
'title': entry.title,
|
||||
'date': entry.date|date('d M Y'),
|
||||
'url': entry.url,
|
||||
'hero': hero_url,
|
||||
'force_connect': entry.header.force_connect ? true : false
|
||||
}]) %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restructure the JS section in `map.html.twig`**
|
||||
|
||||
Replace the entire `<script>` block (lines 42–115) with the following. Key changes: GPX loading now returns Promises with extracted trackpoints; markers and bounds are set up before GPX loads; journey segments are drawn only after Promise.all resolves.
|
||||
|
||||
```javascript
|
||||
<script>
|
||||
var ENTRIES = {{ map_entries|json_encode|raw }};
|
||||
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||
|
||||
var map = new maplibregl.Map({
|
||||
container: 'trip-map',
|
||||
style: MapUtils.MAP_STYLE,
|
||||
center: [20, 20],
|
||||
zoom: 2
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
if (ENTRIES.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'map-empty';
|
||||
empty.textContent = 'No locations yet — entries with GPS will appear here.';
|
||||
document.getElementById('trip-map').appendChild(empty);
|
||||
}
|
||||
|
||||
map.on('load', function () {
|
||||
if (ENTRIES.length === 0) return;
|
||||
|
||||
/* ── Markers + bounds ──────────────────────────────────────── */
|
||||
var bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
ENTRIES.forEach(function (entry, i) {
|
||||
var isLatest = (i === ENTRIES.length - 1);
|
||||
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||
bounds.extend(lngLat);
|
||||
|
||||
var el = MapUtils.createDotMarker(isLatest);
|
||||
el.dataset.url = entry.url;
|
||||
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||
.setLngLat(lngLat)
|
||||
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||
el.addEventListener('mouseenter', function () { popup.addTo(map); });
|
||||
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||
el.addEventListener('click', function () { window.location.href = entry.url; });
|
||||
|
||||
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(map);
|
||||
});
|
||||
|
||||
/* ── Fit bounds ─────────────────────────────────────────────── */
|
||||
if (ENTRIES.length === 1) {
|
||||
map.jumpTo({ center: [parseFloat(ENTRIES[0].lng), parseFloat(ENTRIES[0].lat)], zoom: 10 });
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: 100, maxZoom: 11 });
|
||||
}
|
||||
|
||||
/* ── GPX tracks + journey segments ─────────────────────────── */
|
||||
Promise.all(GPX_URLS.map(function (url, idx) {
|
||||
return fetch(url)
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (text) {
|
||||
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||
var geojson = toGeoJSON.gpx(xml);
|
||||
var sid = 'gpx-' + idx;
|
||||
map.addSource(sid, { type: 'geojson', data: geojson });
|
||||
map.addLayer({
|
||||
id: sid + '-line', type: 'line', source: sid,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
|
||||
});
|
||||
return MapUtils.extractTrackpoints(geojson);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('GPX load failed:', url, err);
|
||||
return [];
|
||||
});
|
||||
})).then(function (allTrackpoints) {
|
||||
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
|
||||
var segments = MapUtils.buildJourneySegments(ENTRIES, validTrackpoints, 10);
|
||||
MapUtils.addJourneySegments(map, segments, 'journey');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the page loads without JS errors**
|
||||
|
||||
```bash
|
||||
npx playwright test tests/ui/maps.spec.js --grep "M1|M2"
|
||||
```
|
||||
|
||||
Expected: M1 and M2 pass (canvas renders, markers visible, no JS errors).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/map.html.twig
|
||||
git commit -m "feat: use buildJourneySegments in map.html.twig — suppress connectors covered by GPX"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewire `trip.html.twig` mini-map to use the algorithm
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
|
||||
- Consumes: `item.page.header.force_connect` from Grav page frontmatter
|
||||
|
||||
- [ ] **Step 1: Add `force_connect` to the Twig entry serialisation**
|
||||
|
||||
In `trip.html.twig`, the `map_entries` loop (around line 89–100) currently builds:
|
||||
|
||||
```twig
|
||||
{% set map_entries = map_entries|merge([{
|
||||
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||
'slug': item.page.slug,
|
||||
'title': item.page.title,
|
||||
'url': item.page.url
|
||||
}]) %}
|
||||
```
|
||||
|
||||
Add `force_connect`:
|
||||
|
||||
```twig
|
||||
{% set map_entries = map_entries|merge([{
|
||||
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||
'slug': item.page.slug,
|
||||
'title': item.page.title,
|
||||
'url': item.page.url,
|
||||
'force_connect': item.page.header.force_connect ? true : false
|
||||
}]) %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restructure the tripMap JS section**
|
||||
|
||||
The tripMap JS block starts around line 303 (`tripMap.on('load', function () {`). Replace the entire `tripMap.on('load', ...)` block with the new version below. Everything outside `tripMap.on('load', ...)` (the `var tripMap = ...` declaration, `setTimeout(function() { tripMap.resize(); }, 100);`, and the filter bar JS) stays unchanged.
|
||||
|
||||
Replace from `tripMap.on('load', function () {` through the closing `});` of that callback with:
|
||||
|
||||
```javascript
|
||||
tripMap.on('load', function () {
|
||||
if (TRIP_ENTRIES.length === 0) {
|
||||
tripMap.jumpTo({ center: [0, 20], zoom: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── Markers + bounds ──────────────────────────────────────── */
|
||||
var bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
TRIP_ENTRIES.forEach(function (entry, i) {
|
||||
var isLatest = (i === TRIP_ENTRIES.length - 1);
|
||||
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||
bounds.extend(lngLat);
|
||||
|
||||
var el = MapUtils.createDotMarker(isLatest);
|
||||
el.dataset.url = entry.url;
|
||||
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||
.setLngLat(lngLat)
|
||||
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||
el.addEventListener('mouseenter', function () { popup.addTo(tripMap); });
|
||||
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||
el.addEventListener('click', function () {
|
||||
var card = document.getElementById('entry-' + entry.slug);
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(tripMap);
|
||||
});
|
||||
|
||||
/* ── Fit bounds ─────────────────────────────────────────────── */
|
||||
if (TRIP_ENTRIES.length === 1) {
|
||||
tripMap.jumpTo({ center: [parseFloat(TRIP_ENTRIES[0].lng), parseFloat(TRIP_ENTRIES[0].lat)], zoom: 10 });
|
||||
} else {
|
||||
tripMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||
}
|
||||
|
||||
/* ── GPX tracks + journey segments ─────────────────────────── */
|
||||
Promise.all(GPX_URLS.map(function (url, idx) {
|
||||
return fetch(url)
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (text) {
|
||||
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||
var geojson = toGeoJSON.gpx(xml);
|
||||
var sid = 'gpx-' + idx;
|
||||
tripMap.addSource(sid, { type: 'geojson', data: geojson });
|
||||
tripMap.addLayer({
|
||||
id: sid + '-line', type: 'line', source: sid,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': MapUtils.ACCENT, 'line-width': 2, 'line-opacity': 0.7 }
|
||||
});
|
||||
return MapUtils.extractTrackpoints(geojson);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('GPX load failed:', url, err);
|
||||
return [];
|
||||
});
|
||||
})).then(function (allTrackpoints) {
|
||||
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
|
||||
var segments = MapUtils.buildJourneySegments(TRIP_ENTRIES, validTrackpoints, 10);
|
||||
MapUtils.addJourneySegments(tripMap, segments, 'trip-journey');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check the stats section — preserve any remaining JS below the map block**
|
||||
|
||||
Scan `trip.html.twig` for `parseGpxFiles` (around line 494). This is a separate GPX parsing call for the stats section. **Do not modify it** — it is a different code path and uses its own GPX fetching logic.
|
||||
|
||||
- [ ] **Step 4: Verify the trip page renders without JS errors**
|
||||
|
||||
```bash
|
||||
npx playwright test tests/ui/maps.spec.js --grep "M4"
|
||||
```
|
||||
|
||||
Expected: M4 passes (home map canvas renders, no JS errors).
|
||||
|
||||
Also manually visit `http://localhost:8081/trips/italy-2025` in a browser and confirm the mini-map renders, markers appear, and the browser console shows no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/trip.html.twig
|
||||
git commit -m "feat: use buildJourneySegments in trip.html.twig mini-map"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rewire `dailies.html.twig` mini-map to use the algorithm
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/dailies.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MapUtils.extractTrackpoints`, `MapUtils.buildJourneySegments`, `MapUtils.addJourneySegments`
|
||||
|
||||
- [ ] **Step 1: Add GPX URL collection to the Twig section of `dailies.html.twig`**
|
||||
|
||||
After the existing `{% set map_entries = [] %}` block (around line 18–29), add GPX URL collection from the parent trip page. Insert before the `{% if map_entries|length > 0 %}` line:
|
||||
|
||||
```twig
|
||||
{# Collect GPX URLs from parent trip page for connector algorithm #}
|
||||
{% set trip_page = page.parent() %}
|
||||
{% set gpx_urls = [] %}
|
||||
{% for name, media in trip_page.media.all %}
|
||||
{% if name|split('.')|last == 'gpx' %}
|
||||
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `force_connect` to the Twig entry serialisation**
|
||||
|
||||
In the existing `map_entries` loop (lines 21–28), add `force_connect`:
|
||||
|
||||
```twig
|
||||
{% set map_entries = map_entries|merge([{
|
||||
'lat': item.page.header.lat,
|
||||
'lng': item.page.header.lng,
|
||||
'title': item.page.title,
|
||||
'slug': item.page.slug,
|
||||
'url': item.page.url,
|
||||
'force_connect': item.page.header.force_connect ? true : false
|
||||
}]) %}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `togeojson` script and `GPX_URLS` variable to the JS section**
|
||||
|
||||
Inside the `{% if map_entries|length > 0 %}` block, the existing script tags are (lines 37–39):
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||
```
|
||||
|
||||
Add the toGeoJSON script between maplibre-gl.js and maplibre-utils.js:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
|
||||
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
|
||||
```
|
||||
|
||||
And add the `GPX_URLS` variable immediately after `FEED_ENTRIES`:
|
||||
|
||||
```javascript
|
||||
var FEED_ENTRIES = {{ map_entries|json_encode|raw }};
|
||||
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Restructure `feedMap.on('load', ...)` to use Promise.all**
|
||||
|
||||
Replace the existing `feedMap.on('load', function () { ... });` block with:
|
||||
|
||||
```javascript
|
||||
feedMap.on('load', function () {
|
||||
var bounds = new maplibregl.LngLatBounds();
|
||||
|
||||
FEED_ENTRIES.forEach(function (entry, i) {
|
||||
var isLatest = (i === FEED_ENTRIES.length - 1);
|
||||
var lngLat = [parseFloat(entry.lng), parseFloat(entry.lat)];
|
||||
bounds.extend(lngLat);
|
||||
|
||||
var el = MapUtils.createDotMarker(isLatest);
|
||||
el.dataset.url = entry.url;
|
||||
var popup = new maplibregl.Popup({ offset: 12, closeButton: false, closeOnClick: false, className: 'map-tip-popup' })
|
||||
.setLngLat(lngLat)
|
||||
.setHTML('<span class="map-tip">' + entry.title + '</span>');
|
||||
el.addEventListener('mouseenter', function () { popup.addTo(feedMap); });
|
||||
el.addEventListener('mouseleave', function () { popup.remove(); });
|
||||
el.addEventListener('click', function () { window.location.href = entry.url; });
|
||||
|
||||
new maplibregl.Marker({ element: el }).setLngLat(lngLat).addTo(feedMap);
|
||||
});
|
||||
|
||||
if (FEED_ENTRIES.length === 1) {
|
||||
feedMap.jumpTo({ center: [parseFloat(FEED_ENTRIES[0].lng), parseFloat(FEED_ENTRIES[0].lat)], zoom: 10 });
|
||||
} else {
|
||||
feedMap.fitBounds(bounds, { padding: 60, maxZoom: 11 });
|
||||
}
|
||||
|
||||
Promise.all(GPX_URLS.map(function (url, idx) {
|
||||
return fetch(url)
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (text) {
|
||||
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||
var geojson = toGeoJSON.gpx(xml);
|
||||
return MapUtils.extractTrackpoints(geojson);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('GPX load failed (feed-map):', url, err);
|
||||
return [];
|
||||
});
|
||||
})).then(function (allTrackpoints) {
|
||||
var validTrackpoints = allTrackpoints.filter(function (tp) { return tp.length > 0; });
|
||||
var segments = MapUtils.buildJourneySegments(FEED_ENTRIES, validTrackpoints, 10);
|
||||
MapUtils.addJourneySegments(feedMap, segments, 'feed-journey');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: the feed-map does **not** display GPX tracks as lines (it's a compact mini-map). GPX files are fetched solely for the proximity algorithm. This is intentional.
|
||||
|
||||
- [ ] **Step 5: Verify no JS errors on the dailies page**
|
||||
|
||||
```bash
|
||||
npx playwright test tests/ui/maps.spec.js --grep "M3"
|
||||
```
|
||||
|
||||
Expected: M3 passes (dailies mini-map canvas renders, no JS errors).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add user/themes/intotheeast/templates/dailies.html.twig
|
||||
git commit -m "feat: apply GPX connector algorithm to dailies feed mini-map"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integration tests — verify algorithm is wired end-to-end
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ui/maps.spec.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: italy-2025 demo data (has GPX files); run `make demo-load` first
|
||||
|
||||
- [ ] **Step 1: Add end-to-end tests to `maps.spec.js`**
|
||||
|
||||
Append these tests to `tests/ui/maps.spec.js`:
|
||||
|
||||
```javascript
|
||||
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
|
||||
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for markers to confirm map.on('load') completed
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
// Give Promise.all time to resolve
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
|
||||
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
|
||||
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
|
||||
await page.waitForFunction(function () {
|
||||
return window.map &&
|
||||
(window.map.getSource('journey') !== undefined ||
|
||||
window.map.getSource('journey-0') !== undefined);
|
||||
}, { timeout: 15000 });
|
||||
|
||||
const hasSource = await page.evaluate(function () {
|
||||
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
|
||||
});
|
||||
|
||||
expect(hasSource).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Expected: All existing tests (M1–M4, F1–F7, G1–G5, N-series, etc.) pass plus M5 and M6.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ui/maps.spec.js
|
||||
git commit -m "test: add M5–M6 integration tests for GPX connector logic"
|
||||
```
|
||||
@@ -0,0 +1,168 @@
|
||||
# Grav 2.0 Upgrade — Design Spec
|
||||
|
||||
**Goal:** Upgrade the intotheeast travel blog from Grav 1.7.x to Grav 2.0 RC on a feature branch, validate full Milestone 1 functionality, and prepare a clean production fresh-install path.
|
||||
|
||||
**Context:** Departure date is 2026-07-15. The production server has never been deployed, so production gets a fresh Grav 2.0 install — no in-place migration required. Local dev uses Docker; production uses PHP 8.4 directly.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Two tracks:
|
||||
|
||||
1. **Local dev track** — swap Docker image to Grav 2.0, validate all functionality
|
||||
2. **Production track** — update `server-install.sh` and `.env` so `make remote-install` deploys Grav 2.0 fresh
|
||||
|
||||
All work on branch `update-to-2.0`.
|
||||
|
||||
---
|
||||
|
||||
## Compatibility Assessment
|
||||
|
||||
| Component | Status | Action required |
|
||||
|---|---|---|
|
||||
| `form`, `login`, `email`, `error`, `problems`, `flex-objects` | ✅ First-party | Auto-updated to 2.0 versions via GPM |
|
||||
| `shortcode-core` | ✅ First-party | Same |
|
||||
| `cache-on-save` (custom) | ✅ Should work | Add Grav 2.0 compat flag to `blueprints.yaml`; uses `onFormProcessed` which is unchanged |
|
||||
| `shortcode-gallery-plusplus` | ✅ Likely works | Plugin arch unchanged; test and confirm |
|
||||
| `add-page-by-form` | ⚠️ Archived Aug 2024 | Try as-is (plugin arch unchanged, may work); if broken, write a custom replacement |
|
||||
| Custom `intotheeast` theme | ✅ Should work | Twig 3 compat mode covers existing templates; test rendering |
|
||||
| `linuxserver/grav` Docker image | ❌ Not supported | Replace with `getgrav/grav` + `GRAV_CHANNEL=beta` |
|
||||
|
||||
---
|
||||
|
||||
## Track 1 — Local Dev
|
||||
|
||||
### Changes
|
||||
|
||||
**`docker-compose.yml`**
|
||||
|
||||
Replace:
|
||||
```yaml
|
||||
image: lscr.io/linuxserver/grav:latest
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ./user:/config/www/user
|
||||
```
|
||||
|
||||
With:
|
||||
```yaml
|
||||
image: getgrav/grav
|
||||
environment:
|
||||
- GRAV_CHANNEL=beta
|
||||
volumes:
|
||||
- ./user:/var/www/html/user
|
||||
```
|
||||
|
||||
**`Makefile`** — three targets reference the linuxserver internal path `/app/www/public`; replace with `/var/www/html`:
|
||||
- `install-plugins`: `docker exec -w /app/www/public` → `docker exec -w /var/www/html`
|
||||
- `demo-load` clear cache: `/app/www/public` → `/var/www/html`
|
||||
- `demo-reset` clear cache: same
|
||||
|
||||
**`user/plugins/cache-on-save/blueprints.yaml`** (create — does not exist yet) — minimal blueprint with Grav 2.0 compat flag:
|
||||
```yaml
|
||||
name: Cache On Save
|
||||
version: 1.0.0
|
||||
description: Clears Grav cache on new-entry form submission
|
||||
author:
|
||||
name: Mischa
|
||||
license: MIT
|
||||
|
||||
dependencies:
|
||||
- { name: grav, version: '>=1.6.0' }
|
||||
|
||||
grav:
|
||||
version: ['1.7', '2.0']
|
||||
```
|
||||
|
||||
**`user/config/system.yaml`** — switch GPM to testing channel so `make setup` resolves 2.0-compatible plugin versions:
|
||||
```yaml
|
||||
gpm:
|
||||
releases: testing
|
||||
```
|
||||
|
||||
### Validation Checklist (smoke test after `make setup`)
|
||||
|
||||
Run in order — stop and investigate if any step fails:
|
||||
|
||||
1. **Site loads** — `http://localhost:8081` returns the tracker page (200, no PHP errors)
|
||||
2. **Admin2 loads** — `/admin` renders the new SPA admin (not the old Twig admin)
|
||||
3. **Login works** — log in via Admin2 with existing credentials
|
||||
4. **Posting form** — submit `/post` form with title + text; entry appears immediately in `/tracker`
|
||||
5. **Photo upload** — submit `/post` form with a photo; image renders in the entry
|
||||
6. **Gallery** — visit an entry with multiple photos; `shortcode-gallery-plusplus` renders gallery with lightbox
|
||||
7. **Cache invalidation** — submit a second post; it appears without a manual cache clear (validates `cache-on-save`)
|
||||
8. **Theme rendering** — check tracker, entry, map, post-form, and stats templates for layout/CSS regressions
|
||||
9. **Playwright suite** — `make test-ui` passes all 25 tests. If any tests fail, investigate whether the failure is a genuine regression (blocker) or a test that needs updating for Admin2's new DOM structure (acceptable — update the test)
|
||||
|
||||
### If `add-page-by-form` fails
|
||||
|
||||
If step 4 fails due to `add-page-by-form` incompatibility, the fallback is to write a custom replacement plugin. The existing `cache-on-save` plugin is a good template — it hooks `onFormProcessed` and that API is unchanged. The replacement would use the same hook to:
|
||||
1. Build the page path and slug from form fields
|
||||
2. Create the page file on disk (same logic `add-page-by-form` does in PHP)
|
||||
3. Clear cache (merging `cache-on-save` functionality)
|
||||
|
||||
This is ~1 day of work and should be planned as a follow-up task if needed.
|
||||
|
||||
---
|
||||
|
||||
## Track 2 — Production (Fresh Install)
|
||||
|
||||
Production has PHP 8.4 (compatible with Grav 2.0's PHP 8.3+ requirement) and has never been deployed.
|
||||
|
||||
### Changes
|
||||
|
||||
**`server-install.sh`** — the download URL for Grav 2.0 RC requires a `?testing` query parameter:
|
||||
|
||||
Current:
|
||||
```bash
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
|
||||
```
|
||||
|
||||
Updated (conditionally append `?testing` for pre-release versions, or accept a full URL suffix via env var):
|
||||
```bash
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX}" -O grav-admin.zip
|
||||
```
|
||||
|
||||
Where `GRAV_CHANNEL_SUFFIX` is `?testing` for RC versions and empty for stable.
|
||||
|
||||
**`.env`** (not committed — edit on the server directly or locally before `make remote-install`) — update:
|
||||
```
|
||||
GRAV_VERSION=2.0.0-rc.9
|
||||
GRAV_CHANNEL_SUFFIX=?testing
|
||||
```
|
||||
|
||||
When Grav 2.0 goes stable, remove `GRAV_CHANNEL_SUFFIX` and update `GRAV_VERSION` to the stable version number.
|
||||
|
||||
**`user/config/system.yaml`** — keep `gpm.releases: testing` (already set in Track 1) so production also installs 2.0-compatible plugin versions.
|
||||
|
||||
### Production deploy
|
||||
|
||||
When local validation passes:
|
||||
```bash
|
||||
make remote-install
|
||||
```
|
||||
|
||||
That's it — fresh Grav 2.0 install from scratch with all plugins, content from Gitea, and the existing `user/` config.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- MCP server setup (`grav-mcp` Node.js binary) — a separate task after Grav 2.0 is stable on production
|
||||
- Admin2 theming or customization
|
||||
- Grav 2.0 REST API integration
|
||||
- Switching `add-page-by-form` to the API-based approach (only if the plugin breaks)
|
||||
|
||||
---
|
||||
|
||||
## Go/No-Go Criteria
|
||||
|
||||
Ship to production before departure (2026-07-15) **only if**:
|
||||
- All 9 smoke test steps pass
|
||||
- Playwright suite passes
|
||||
- `add-page-by-form` posting workflow works end-to-end (or a custom replacement is in place and tested)
|
||||
|
||||
If any of these fail and cannot be resolved with time to spare before departure, stay on Grav 1.7 for the trip and revisit post-trip.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Dark Mode & Visual Polish Design Spec
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the existing warm-paper light theme with a warm-dark "notebook/sketchbook at night" aesthetic — dark-only, no toggle, no system preference detection. Add paper grain texture, switch to dark terrain map tiles, and tighten typography.
|
||||
|
||||
**Architecture:** All changes are CSS and one Twig template update. Color tokens live in `tokens.css` (swap values, keep names). Grain texture is a pure-CSS SVG noise layer on `body::after`. Map tiles swap in `map.html.twig`. No new dependencies, no JS changes.
|
||||
|
||||
**Approach chosen:** B — color token swap + paper grain + typography refinements. Card/hero treatment (Approach C) deferred to a future visual polish pass.
|
||||
|
||||
**Tech Stack:** CSS custom properties, inline SVG data URI for grain, Stadia Maps tile CDN for dark terrain.
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Dark-only — no light mode, no `prefers-color-scheme` media query, no toggle
|
||||
- All changes in `user/` — commit with `git -C user`
|
||||
- No new npm/JS dependencies
|
||||
- Existing token names (`--color-paper`, `--color-ink`, etc.) must not change — only values
|
||||
- Teal accent `#1F6B5A` lightens to `#2A8C73` for dark-background contrast
|
||||
- Map tile provider: Stadia Maps Alidade Smooth Dark (free tier; API key needed for production — see Task 2)
|
||||
- `make test-ui` must pass after implementation (25/25 or pre-existing P2 exception)
|
||||
|
||||
---
|
||||
|
||||
## 1. Color System
|
||||
|
||||
Replace all values in `user/themes/intotheeast/css/tokens.css`. Token names are unchanged.
|
||||
|
||||
### Dark palette
|
||||
|
||||
| Token | Old value | New value | Role |
|
||||
|---|---|---|---|
|
||||
| `--color-paper` | `#F7F5F2` | `#1A1814` | Page background — warm near-black |
|
||||
| `--color-canvas` | `#FFFFFF` | `#22201B` | Card surfaces, form backgrounds |
|
||||
| `--color-ink` | `#17171A` | `#EDE8DF` | Primary text — warm cream |
|
||||
| `--color-ink-2` | `#4A4850` | `#B8B0A4` | Body text — muted warm |
|
||||
| `--color-ink-muted` | `#9896A0` | `#7A7268` | Labels, timestamps, captions |
|
||||
| `--color-border` | `#E8E6E3` | `#2E2B25` | Standard dividers |
|
||||
| `--color-border-soft` | `#F0EDEA` | `#252219` | Subtle dividers |
|
||||
| `--color-accent` | `#1F6B5A` | `#2A8C73` | Teal — lightened for dark contrast |
|
||||
| `--color-accent-hover` | `#185647` | `#236655` | Hover/pressed teal |
|
||||
| `--color-accent-light` | `#EBF5F2` | `#1A2E29` | Pale teal tint backgrounds |
|
||||
| `--color-accent-on` | `#FFFFFF` | `#FFFFFF` | Text on accent surfaces (unchanged) |
|
||||
|
||||
### Additional dark-only tokens (add to tokens.css)
|
||||
|
||||
```css
|
||||
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover states */
|
||||
--color-ink-inverse: #17171A; /* text on accent-colored buttons */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Paper Grain Texture
|
||||
|
||||
Add to `style.css`, in the `body` section:
|
||||
|
||||
```css
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
```
|
||||
|
||||
This overlays a fixed noise texture across the entire viewport. `pointer-events: none` ensures it never blocks clicks. `z-index: 9998` keeps it below any modals or dropdowns (which should use z-index 9999+). Opacity 3.5% — subtle enough to feel like paper texture without being distracting on photography.
|
||||
|
||||
---
|
||||
|
||||
## 3. Map Tiles — Stadia Alidade Smooth Dark
|
||||
|
||||
Replace the tile layer in `user/themes/intotheeast/templates/map.html.twig`.
|
||||
|
||||
**Old:**
|
||||
```javascript
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
```
|
||||
|
||||
**New:**
|
||||
```javascript
|
||||
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 20,
|
||||
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
```
|
||||
|
||||
**Production note:** Stadia Maps requires a free API key for production domains. Add the key as a query param when ready: `?api_key=YOUR_KEY`. During development on localhost no key is needed. Add a `<!-- TODO: add Stadia API key before launch -->` comment above the tile layer call so it's not forgotten.
|
||||
|
||||
Also update the mini-map tile layer in `dailies.html.twig` (same swap — same tile URL, same attribution).
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography Refinements
|
||||
|
||||
Targeted improvements to `style.css` — not a full type system rewrite.
|
||||
|
||||
### 4a. Entry body readability
|
||||
The entry body text (`--text-md` / 1.125rem) already uses `--leading-normal` (1.65) which is good. Increase the paragraph bottom margin slightly for breathing room:
|
||||
|
||||
```css
|
||||
/* current */
|
||||
.entry-body p { margin-bottom: 1.1em; ... }
|
||||
|
||||
/* new */
|
||||
.entry-body p { margin-bottom: 1.4em; ... }
|
||||
```
|
||||
|
||||
### 4b. Heading tracking
|
||||
DM Serif Display at large sizes benefits from slightly tighter tracking. Find heading rules that currently have `letter-spacing: -0.01em` and tighten to `-0.02em`. Only apply to `h1` and `h2` — smaller headings keep current tracking.
|
||||
|
||||
### 4c. Login form dark surface
|
||||
The login form currently hardcodes `background: #f0f0f0; color: #333` on the secondary button (line ~497 in style.css). Replace with tokens:
|
||||
|
||||
```css
|
||||
/* current */
|
||||
.login-form .button.secondary { background: #f0f0f0; color: #333; ... }
|
||||
|
||||
/* new */
|
||||
.login-form .button.secondary { background: var(--color-canvas); color: var(--color-ink); ... }
|
||||
```
|
||||
|
||||
### 4d. Stats numbers
|
||||
On the stats page, numeric values should feel deliberate. Add `font-variant-numeric: tabular-nums` to the stat value elements so columns of numbers align cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Incidental dark-mode fixes
|
||||
|
||||
Some existing styles use hardcoded light colors that will look wrong in dark mode. Audit and fix these in `style.css`:
|
||||
|
||||
- Any `background: #fff` or `background: white` → `var(--color-canvas)`
|
||||
- Any `color: #333` or similar hardcoded dark text → `var(--color-ink)` or `var(--color-ink-2)`
|
||||
- Any `border: 1px solid #eee` or similar → `var(--color-border)`
|
||||
- Focus outline: currently likely a light-mode color — ensure `outline-color` uses `var(--color-accent)`
|
||||
|
||||
Run a grep for literal hex values after implementation: `grep -n '#[0-9a-fA-F]\{3,6\}' user/themes/intotheeast/css/style.css` — every hit is a candidate to tokenize.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After implementation:
|
||||
1. `make test-ui` — all tests pass
|
||||
2. Visual check at `http://localhost:8081/trips/japan-korea-2026/dailies` — warm dark background, cream text, teal accents visible, subtle grain
|
||||
3. Visual check at `http://localhost:8081/trips/japan-korea-2026/map` — dark terrain tiles load, GPX polyline visible, entry pins visible
|
||||
4. Check the post form at `/post` — form fields readable on dark canvas, no white-on-white or black-on-black surfaces
|
||||
5. Run the hardcoded-hex grep and confirm any remaining literals are intentional
|
||||
@@ -0,0 +1,226 @@
|
||||
# Home Page & Content Flow Design Spec
|
||||
|
||||
**Goal:** Replace the redirect-based home page with a real home page showing the active trip's feed and map side by side, add a proper past-trips archive, enrich the trip page with a sticky sidebar index, and introduce story cards into all feeds.
|
||||
|
||||
**Architecture:** Pure Twig + CSS changes on top of the existing Grav stack. The home page is a new Grav page (`00.home/home.md`) with a new `home.html.twig` template. Feeds (home + dailies) are extended to merge journal entries and story entries into one chronological collection, with stories rendered as visually distinct cards. No new plugins, no build pipeline.
|
||||
|
||||
**Tech Stack:** Grav CMS (PHP/Twig), Vanilla CSS, Leaflet.js (already loaded in `dailies.html.twig`)
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All changes in `user/` — commit with `git -C user`
|
||||
- No new Grav plugins
|
||||
- No JS framework — all interactivity is vanilla JS
|
||||
- No build pipeline — CSS shipped as plain files
|
||||
- Existing token names in `tokens.css` must not change
|
||||
- Theme directory: `user/themes/intotheeast/`
|
||||
- Active trip slug: `config.site.active_trip` (set in `user/config/site.yaml`)
|
||||
- `system.yaml` `home.alias` redirect must be removed — `/` becomes a real page
|
||||
- `config.site.active_trip` in `site.yaml` must always be set to a trip slug (even between trips, point it at the last trip) — the home page template has no fallback if this value is empty
|
||||
|
||||
---
|
||||
|
||||
## 1. URL Structure
|
||||
|
||||
| URL | Page file | Template |
|
||||
|---|---|---|
|
||||
| `/` | `user/pages/00.home/home.md` | `home.html.twig` (new) |
|
||||
| `/trips` | `user/pages/01.trips/trips.md` | `trips.html.twig` (update existing) |
|
||||
| `/trips/<slug>/` | existing | `trip.html.twig` (update existing) |
|
||||
| `/trips/<slug>/dailies` | existing | `dailies.html.twig` (update existing) |
|
||||
|
||||
**system.yaml change:** Remove `home: alias: /trips/japan-korea-2026/dailies`. Set `home: alias: /` (or remove the alias entirely so Grav serves the `00.home` page at `/`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Home Page (`/`)
|
||||
|
||||
### Layout
|
||||
|
||||
Two-column CSS grid on desktop. Map left (~45%), entry feed right (~55%).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [Trip name] · 31 journal entries · 4 stories │
|
||||
├────────────────────────┬────────────────────────────────┤
|
||||
│ │ [story card] │
|
||||
│ Leaflet map │ [journal card] │
|
||||
│ (sticky, │ [journal card] │
|
||||
│ 45% width) │ [story card] │
|
||||
│ │ ... │
|
||||
└────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Map is `position: sticky; top: 0; height: 100vh`
|
||||
- Entry feed is scrollable, sorted descending by date
|
||||
- Feed contains both journal entries and story entries merged (see §5)
|
||||
|
||||
### Data
|
||||
|
||||
```twig
|
||||
{% set slug = config.site.active_trip %}
|
||||
{% set trip = grav.pages.find('/trips/' ~ slug) %}
|
||||
{% set dailies = grav.pages.find('/trips/' ~ slug ~ '/dailies') %}
|
||||
{% set stories_page = grav.pages.find('/trips/' ~ slug ~ '/stories') %}
|
||||
{% set journal_entries = dailies ? dailies.children.published().order('date', 'desc') : [] %}
|
||||
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
||||
{# merge and sort handled in template — see §5 #}
|
||||
```
|
||||
|
||||
### Map
|
||||
|
||||
Reuse the existing Leaflet setup from `dailies.html.twig` (`feed-map`). Markers come from journal entries with `lat`/`lng` in frontmatter. GPX route line loaded from trip page media if present (same pattern as `map.html.twig`). Clicking a marker scrolls to that entry card in the feed (use `data-entry-id` on cards + `scrollIntoView`).
|
||||
|
||||
### Mobile
|
||||
|
||||
Stack vertically: map on top at `height: 40vh`, feed below. No hamburger needed — simpler than the dedicated map page.
|
||||
|
||||
---
|
||||
|
||||
## 3. Past Trips Archive (`/trips`)
|
||||
|
||||
Update `trips.html.twig`. Show each trip as a card, sorted newest first.
|
||||
|
||||
Each card contains:
|
||||
- Trip title (links to `/trips/<slug>/`)
|
||||
- Date range: `date_start` – `date_end` from trip page frontmatter (show "Ongoing" if no `date_end`)
|
||||
- Entry count: journal entries + story entries counted separately
|
||||
|
||||
```twig
|
||||
{% set journal_count = grav.pages.find(trip.route ~ '/dailies').children.published()|length %}
|
||||
{% set story_count = grav.pages.find(trip.route ~ '/stories').children.published()|length %}
|
||||
```
|
||||
|
||||
Display: **31 journal entries · 4 stories**
|
||||
|
||||
The active trip appears as the first card. No special treatment needed beyond chronological ordering — it naturally sits at the top.
|
||||
|
||||
---
|
||||
|
||||
## 4. Trip Page (`/trips/<slug>/`)
|
||||
|
||||
Update `trip.html.twig`. Current state: shows title, dates, nav links, 3 recent entries. Target state:
|
||||
|
||||
### Header (update existing `.trip-hero`)
|
||||
```
|
||||
Japan & Korea 2026
|
||||
Jun 2026 – Aug 2026 · 31 journal entries · 4 stories
|
||||
```
|
||||
|
||||
Add entry counts below the date line (small, secondary text).
|
||||
|
||||
### Two-column layout
|
||||
|
||||
Add a right sidebar alongside the existing content:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┬──────────────────────┐
|
||||
│ [full chronological feed] │ Journal │
|
||||
│ (centered, existing max-width) │ Jun 19 Kyoto │
|
||||
│ │ Jun 18 Osaka │
|
||||
│ │ ... │
|
||||
│ │ │
|
||||
│ │ Stories │
|
||||
│ │ The night train │
|
||||
│ │ First ramen │
|
||||
└──────────────────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- Right sidebar: `position: sticky; top: 1rem`
|
||||
- Two sections: **Journal** (list of entry titles as jump-links via `#entry-<slug>`) and **Stories** (same)
|
||||
- Each item in the sidebar is a jump-link to `#entry-<slug>` anchor on the feed card
|
||||
- Feed comes from `dailies.children` + `stories.children` merged (see §5)
|
||||
- On mobile: sidebar collapses to hidden (toggle-able or just hidden — defer this decision to implementation)
|
||||
|
||||
### Remove the current "Recent entries" section
|
||||
|
||||
The right-sidebar index replaces it. The full merged feed is the main content.
|
||||
|
||||
---
|
||||
|
||||
## 5. Story Cards in Feeds (home + trip page)
|
||||
|
||||
Feeds in both `home.html.twig` and `trip.html.twig` show a merged chronological list of journal entries and story entries.
|
||||
|
||||
### Merging collections in Twig
|
||||
|
||||
Grav doesn't natively merge two page collections and sort them. Use a Twig loop to build a combined array:
|
||||
|
||||
```twig
|
||||
{% set all_items = [] %}
|
||||
{% for e in journal_entries %}
|
||||
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.date}]) %}
|
||||
{% endfor %}
|
||||
{% for s in story_entries %}
|
||||
{% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.date}]) %}
|
||||
{% endfor %}
|
||||
{# Sort descending by date #}
|
||||
{% set all_items = all_items|sort((a, b) => a.date < b.date ? 1 : -1) %}
|
||||
```
|
||||
|
||||
### Journal card (existing format, unchanged)
|
||||
|
||||
```html
|
||||
<article class="entry-card" id="entry-{{ item.page.slug }}" data-lat="{{ item.page.header.lat }}" data-lng="{{ item.page.header.lng }}">
|
||||
<!-- existing card markup -->
|
||||
</article>
|
||||
```
|
||||
|
||||
Add `id` and `data-lat`/`data-lng` attributes for sidebar jump-links and map sync.
|
||||
|
||||
### Story card (new)
|
||||
|
||||
```html
|
||||
<article class="entry-card entry-card--story" id="entry-{{ item.page.slug }}">
|
||||
<a class="entry-card-inner" href="{{ item.page.url }}">
|
||||
{% if hero %}
|
||||
<div class="entry-card-photo entry-card-photo--story">
|
||||
<img src="{{ hero.cropResize(720, 405).url }}" alt="{{ item.page.title }}" loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="entry-card-body">
|
||||
<span class="story-badge">✦ Story</span>
|
||||
<h2 class="entry-title">{{ item.page.title }}</h2>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
```
|
||||
|
||||
**Visual treatment:** `entry-card--story` gets a teal left border (3px, `var(--color-accent)`) and no excerpt text. The `✦ Story` badge is small-caps, accent color.
|
||||
|
||||
### Story page (full-screen)
|
||||
|
||||
Story pages (`/trips/<slug>/stories/<story-slug>`) use `stories.html.twig` (already exists). That template should:
|
||||
- Override `{% block nav %}` to render **only** a fixed escape link — not an empty block, not the global nav
|
||||
- Escape link: `← Back` fixed top-left, links to `page.parent.parent.url` (the trip page)
|
||||
|
||||
Implementation of the Snowfall-style scroll-snap interior is **deferred to Milestone 3** — this spec only covers the story card in the feed and the escape link on the story page.
|
||||
|
||||
---
|
||||
|
||||
## 6. Navigation
|
||||
|
||||
Update `base.html.twig` nav. Current: single "Journal" link pointing to active trip dailies. New:
|
||||
|
||||
- **Home** → `/`
|
||||
- **Past Trips** → `/trips`
|
||||
|
||||
The per-trip sub-nav (Journal / Map / Stats / Stories) stays on the trip page — it is not in the global nav.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `user/pages/00.home/home.md` | **Create** — new home page, `template: home` |
|
||||
| `user/themes/intotheeast/templates/home.html.twig` | **Create** — side-by-side map + feed |
|
||||
| `user/themes/intotheeast/templates/trips.html.twig` | **Update** — trip cards with counts |
|
||||
| `user/themes/intotheeast/templates/trip.html.twig` | **Update** — counts in header, two-column + sidebar |
|
||||
| `user/themes/intotheeast/templates/dailies.html.twig` | **Update** — merge stories into feed, story cards, add `id`/`data-` attrs |
|
||||
| `user/themes/intotheeast/templates/stories.html.twig` | **Update** — add escape link, remove global nav |
|
||||
| `user/themes/intotheeast/templates/partials/base.html.twig` | **Update** — new nav links |
|
||||
| `user/themes/intotheeast/css/style.css` | **Update** — home layout, story card styles, sidebar styles |
|
||||
| `user/config/system.yaml` | **Update** — remove `home.alias` redirect |
|
||||
@@ -0,0 +1,143 @@
|
||||
# Stats Redesign — Design Spec
|
||||
|
||||
*2026-06-19*
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Expand trip statistics with new data points already available in entry frontmatter, add smart distance labelling based on whether GPX files are present, and add a dedicated cycling stats panel derived from GPX track data.
|
||||
|
||||
---
|
||||
|
||||
## Data sources
|
||||
|
||||
| Source | Fields available |
|
||||
|---|---|
|
||||
| Entry frontmatter | `date`, `lat`, `lng`, `location_city`, `location_country`, `weather_temp_c` |
|
||||
| Trip page media | `.gpx` files (Komoot exports) |
|
||||
| GPX trackpoints | `lat`, `lon`, `<ele>` (meters), `<time>` (ISO 8601, 1s resolution) |
|
||||
| GPX track metadata | `<type>` (e.g. `racebike`, `hiking`) |
|
||||
|
||||
---
|
||||
|
||||
## Main stats block — changes
|
||||
|
||||
The existing 4-stat grid expands to 6 stats. Both `stats.html.twig` and the inline toggle in `trip.html.twig` get the same treatment.
|
||||
|
||||
### Stats
|
||||
|
||||
| Stat | Label | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| Days on the road | `days on the road` | `(now - first entry date) / 86400` | Unchanged |
|
||||
| Entries posted | `entries posted` | `all_entries\|length` | Unchanged |
|
||||
| Countries visited | `countries visited` | Deduplicated `location_country` | Unchanged; country list shown below grid |
|
||||
| **Cities visited** | `cities visited` | Deduplicated `location_city` | New; same dedup logic as countries |
|
||||
| Distance | see below | see below | Label + icon vary by mode |
|
||||
| **Temperature range** | `°C range` | `min(weather_temp_c)` – `max(weather_temp_c)` | New; shown as e.g. `−2 → 28 °C` |
|
||||
|
||||
### Distance stat — two modes
|
||||
|
||||
**Mode A — GPX present** (any `.gpx` files exist on the trip page):
|
||||
- Value: sum of haversine distances between all consecutive trackpoints across all GPX files
|
||||
- Label: `km cycled`
|
||||
- Icon: cycling icon (or activity-specific icon — see Icon system below)
|
||||
|
||||
**Mode B — No GPX files:**
|
||||
- Value: sum of haversine distances between consecutive entry `lat`/`lng` points (current behaviour)
|
||||
- Label: `km roamed`
|
||||
- Icon: generic travel icon (compass / globe)
|
||||
|
||||
Edge case — trip has both GPX files and many geo-spread entries (e.g. a mixed cycling + backpacking trip): use Mode A (GPX total only). This may understate total travel distance. Accepted limitation; revisit when transport mode is implemented.
|
||||
|
||||
---
|
||||
|
||||
## Cycling panel
|
||||
|
||||
A separate expandable panel, independent of the main stats toggle. Only rendered when GPX files are present on the trip page.
|
||||
|
||||
### Button placement
|
||||
|
||||
Sits next to the existing Stats button in the filter bar area:
|
||||
|
||||
```
|
||||
[ All ] [ Journal ] [ Stories ] [ Stats ] [ Cycling ]
|
||||
```
|
||||
|
||||
The Cycling button is hidden entirely when no GPX files exist. Detection: server-side Twig filters `trip_page.media.all` for `.gpx` extension (same mechanism the map template already uses) and sets a boolean passed to the template.
|
||||
|
||||
### Stats shown
|
||||
|
||||
| Stat | Unit | How computed |
|
||||
|---|---|---|
|
||||
| Distance | km | Sum haversine between all trackpoints (same value as main stats Mode A) |
|
||||
| Elevation gain | m ↑ | Sum of positive `<ele>` differences (threshold: > 1 m per step to filter GPS noise) |
|
||||
| Elevation loss | m ↓ | Sum of negative `<ele>` differences (same threshold) |
|
||||
| Highest point | m | `max(<ele>)` across all files |
|
||||
| Lowest point | m | `min(<ele>)` across all files |
|
||||
| Moving time | h:mm | Total time excluding segments where computed speed < 1 km/h |
|
||||
| Average speed | km/h | Distance ÷ moving time |
|
||||
|
||||
Max speed is explicitly excluded — GPS noise at 1-second resolution produces unreliable spikes.
|
||||
|
||||
### Icon system
|
||||
|
||||
The GPX `<type>` tag on the track element drives the icon shown in both the main stats distance block and the cycling panel header:
|
||||
|
||||
| `<type>` value | Icon |
|
||||
|---|---|
|
||||
| `racebike` | Road bike |
|
||||
| `touringbicycle` | Touring bike |
|
||||
| `mtb` | Mountain bike |
|
||||
| `cycling` (generic) | Generic bike |
|
||||
| `hiking` | Hiking boot |
|
||||
| `hike` | Hiking boot |
|
||||
| Any unrecognised value | Generic bike (fallback) |
|
||||
|
||||
When multiple GPX files exist with different types, use the type from the first file. This is an acceptable heuristic for now.
|
||||
|
||||
---
|
||||
|
||||
## GPX parsing — algorithm
|
||||
|
||||
All parsing is client-side JavaScript. The template passes the list of GPX file URLs to a JS variable; JS fetches and processes them sequentially.
|
||||
|
||||
```
|
||||
for each GPX file URL:
|
||||
fetch(url) → text → DOMParser → XML document
|
||||
extract all <trkpt> elements → array of { lat, lon, ele, time }
|
||||
append to master trackpoint array
|
||||
|
||||
compute over master array:
|
||||
distance = sum haversine(p[i-1], p[i]) for i in 1..n
|
||||
ele_gain = sum max(0, ele[i] - ele[i-1] - 1) for i in 1..n (1m threshold)
|
||||
ele_loss = sum max(0, ele[i-1] - ele[i] - 1)
|
||||
highest = max(ele)
|
||||
lowest = min(ele)
|
||||
speed[i] = haversine(p[i-1], p[i]) / (time[i] - time[i-1]) in km/h
|
||||
moving_time = sum (time[i] - time[i-1]) where speed[i] >= 1 km/h
|
||||
avg_speed = distance / moving_time
|
||||
```
|
||||
|
||||
The 1 m elevation threshold filters out the flat-line noise visible in the Komoot files (many consecutive identical `<ele>` values).
|
||||
|
||||
---
|
||||
|
||||
## Template changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `stats.html.twig` | Add cities stat, temp range stat; update distance stat with mode detection + label + icon |
|
||||
| `trip.html.twig` | Same stats changes; add Cycling button (hidden if no GPX); add cycling panel block with JS parsing |
|
||||
|
||||
The cycling panel JS and the distance mode detection JS share the same GPX fetch logic — extract into a single `parseGpxFiles(urls)` function called once, results used by both.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Transport mode per entry (deferred — tracked separately)
|
||||
- Weather breakdown (dropped — depends on free-text consistency)
|
||||
- Max speed stat (dropped — GPS noise)
|
||||
- Lowest point shown in main stats (cycling panel only)
|
||||
- Per-file breakdown (one aggregate across all GPX files)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Trip Page Filter Bar — Design Spec
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The trip page (`trip.html.twig`) shows a map + combined feed, but the three nav links below the title (Journal · Stats · Stories) navigate *away* from the page, losing the map context. The links are also unstyled (`trip-nav` has no CSS). Stories links to a stub. Stats is a separate page. The user can only return via browser back.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the trip page self-contained. Filtering, stats, and content switching all happen in place. Navigation away from the trip page only happens when the user clicks into an individual entry or story.
|
||||
|
||||
## Design
|
||||
|
||||
### Filter bar (replaces `.trip-nav`)
|
||||
|
||||
Three mutually exclusive pill buttons above the feed, plus a Stats toggle to the right:
|
||||
|
||||
```
|
||||
[ All content ] [ Journal ] [ Stories ] [ Stats ↕ ]
|
||||
```
|
||||
|
||||
- **Default state:** "All content" active
|
||||
- **Behavior:** selecting a filter hides non-matching cards via JS (`display: none` toggle); no page navigation
|
||||
- **Stats** sits right-aligned, visually separated from the filter group; it is a toggle, not a filter
|
||||
|
||||
### Content filtering
|
||||
|
||||
Each `<article>` card in `trip.html.twig` gets a `data-type` attribute:
|
||||
|
||||
- Journal entries: `data-type="journal"`
|
||||
- Story entries: `data-type="story"`
|
||||
|
||||
JS selects all `[data-type]` cards and shows/hides based on the active filter button. Three states:
|
||||
|
||||
| Active button | Visible cards |
|
||||
|---|---|
|
||||
| All content | all |
|
||||
| Journal | `data-type="journal"` only |
|
||||
| Stories | `data-type="story"` only |
|
||||
|
||||
Empty-feed edge case: if Stories is selected and no stories exist yet, show a brief inline message ("No stories yet for this trip.").
|
||||
|
||||
### Stats inline expansion
|
||||
|
||||
Clicking Stats expands a compact stats block between the filter bar and the first card. Clicking Stats again collapses it. Stats button gets an active/pressed visual state while expanded.
|
||||
|
||||
Stats block content (same data as the existing `/stats` page):
|
||||
- Days on the road
|
||||
- Entries posted
|
||||
- Countries visited
|
||||
- km traveled (approximate, straight-line haversine between GPS points)
|
||||
- Countries list
|
||||
|
||||
The computation logic is moved inline into `trip.html.twig` (copied from `stats.html.twig`). The separate `/stats` sub-page is left untouched — it still works as a standalone URL.
|
||||
|
||||
### Story card distinction
|
||||
|
||||
`.entry-card--story` gets a visible border:
|
||||
|
||||
```css
|
||||
border: 2px solid var(--color-accent);
|
||||
```
|
||||
|
||||
No other visual changes to story cards in this session. Full story card redesign (hero image treatment, sneak peek, elegant layout) is deferred to a separate session.
|
||||
|
||||
## What changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `user/themes/intotheeast/templates/trip.html.twig` | Add `data-type` attributes; add stats computation + inline stats block HTML; replace nav links with filter bar HTML; add filter + stats JS |
|
||||
| `user/themes/intotheeast/css/style.css` | Add `.trip-nav` pill styles + active state; add `.trip-stats-block` styles; add story card border |
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- `/dailies`, `/stats`, `/stories` sub-pages continue to exist as standalone URLs
|
||||
- `stats.html.twig` is untouched
|
||||
- `dailies.html.twig` is untouched
|
||||
- No blueprint or page content changes
|
||||
- Story detail page design is out of scope
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Photo galleries, lightbox, full in-feed entry expansion
|
||||
- Story detail page
|
||||
- Feed redesign (full pictures, per-entry photo carousel)
|
||||
@@ -0,0 +1,154 @@
|
||||
# Tuscany Demo Stories — Design Spec
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Goal:** Three demo stories for the Italy 2025 Tuscany trip that showcase distinct story-mode composition patterns. Content is illustrative; the purpose is to demonstrate what the format can do.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Demo content lives in `user/docs/demo/trips/italy-2025/`. The `demo-load` Makefile target currently copies the Italy dailies and GPX files but does not copy a `04.stories/` folder. This spec adds three story files and wires them into `demo-load` / `demo-reset`.
|
||||
|
||||
Story pages must live at:
|
||||
```
|
||||
user/docs/demo/trips/italy-2025/04.stories/<n>.<slug>/story.md
|
||||
```
|
||||
|
||||
The `demo-load` target copies the entire `04.stories/` folder into:
|
||||
```
|
||||
user/pages/01.trips/italy-2025/04.stories/
|
||||
```
|
||||
|
||||
A `stories.md` listing page already exists at `user/docs/demo/trips/italy-2025/stories.md` and is already loaded by `demo-load`.
|
||||
|
||||
---
|
||||
|
||||
## Story 1 — "The Val d'Orcia at Dawn"
|
||||
|
||||
**Composition pattern:** Gallery-led. Multiple `[snap-gallery]` blocks; chapter breaks as pure visual section dividers; minimal prose; PullQuote without background image (text-only variant).
|
||||
|
||||
**Demonstrates:**
|
||||
- Two `[snap-gallery]` blocks in one story (landscape set + detail set)
|
||||
- `[chapter-break]` used as a pure scene-change divider (no thematic text, just atmosphere)
|
||||
- `[pull-quote]` without `image=` parameter → text-only frosted style
|
||||
|
||||
**Frontmatter:**
|
||||
```yaml
|
||||
title: The Val d'Orcia at Dawn
|
||||
date: 2025-09-05
|
||||
location_name: Val d'Orcia
|
||||
location_country: Italy
|
||||
lat: 43.078
|
||||
lng: 11.676
|
||||
hero_image: hero.jpg
|
||||
hero_alt: Cypress-lined dirt road at first light, Tuscany
|
||||
published: true
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
1. Short intro prose (2–3 sentences)
|
||||
2. `[snap-gallery]` — 4 images: landscape wide shots (dawn light, rolling hills, cypress allée, dirt road)
|
||||
3. Brief prose bridge (2 sentences)
|
||||
4. `[chapter-break]` — title "The Hour Before Heat", no number
|
||||
5. More prose (2–3 sentences)
|
||||
6. `[snap-gallery]` — 4 images: close-up details (gravel texture, bike wheel, water bottle, shadow on road)
|
||||
7. `[pull-quote]` — **no image param** (text-only variant) — short reflective line
|
||||
8. One closing sentence
|
||||
|
||||
**Path:** `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
|
||||
|
||||
---
|
||||
|
||||
## Story 2 — "The Long Climb to Montalcino"
|
||||
|
||||
**Composition pattern:** Scrollytelling-led. Two `[scrolly-section]` blocks with different step counts, demonstrating the sticky-image format at different rhythms. PullQuote with background image between the two sections.
|
||||
|
||||
**Demonstrates:**
|
||||
- `[scrolly-section]` with 3 steps (tighter rhythm, effort/grind feeling)
|
||||
- `[scrolly-section]` with 5 steps (longer, more expansive — arrival and wandering)
|
||||
- `[pull-quote]` with `image=` parameter (frosted overlay on photo)
|
||||
- `[chapter-break]` with roman numeral, separating climb from descent/arrival
|
||||
|
||||
**Frontmatter:**
|
||||
```yaml
|
||||
title: The Long Climb to Montalcino
|
||||
date: 2025-09-05
|
||||
end_date: 2025-09-06
|
||||
location_name: Montalcino
|
||||
location_country: Italy
|
||||
lat: 43.058
|
||||
lng: 11.489
|
||||
hero_image: hero.jpg
|
||||
hero_alt: Hairpin road climbing through olive groves towards a hilltop town
|
||||
published: true
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
1. Intro prose (3 sentences — sets the scene: hot afternoon, 14km climb)
|
||||
2. `[scrolly-section]` — image: the climb. **3 steps:** (1) first kilometer, legs fresh; (2) halfway, sun overhead, silence; (3) the last 500m, the town appears
|
||||
3. `[chapter-break]` — title "Montalcino", number "II"
|
||||
4. `[pull-quote image="vineyard.jpg"]` — line about the view from the top
|
||||
5. Prose (2–3 sentences — arrival, finding a bar, cold water)
|
||||
6. `[scrolly-section]` — image: the town/streets. **5 steps:** (1) the main piazza; (2) a wine shop; (3) a cat on a wall; (4) evening light on the fortress; (5) the descent begins
|
||||
7. Closing prose (1–2 sentences)
|
||||
|
||||
**Path:** `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
|
||||
|
||||
---
|
||||
|
||||
## Story 3 — "One Evening in Siena"
|
||||
|
||||
**Composition pattern:** Mood/fragment piece. Short and impressionistic. Opens with a PullQuote (no image) — the quote anchors the story before any prose. Closes with a PullQuote with image. Single short ScrollySection. SnapGallery in the middle.
|
||||
|
||||
**Demonstrates:**
|
||||
- PullQuote **as opening element** (before any body prose) — unusual structure
|
||||
- `[scrolly-section]` with just 2 steps (the minimum — shows it works for very short sections)
|
||||
- `[snap-gallery]` as a mid-story element (not a closing flourish)
|
||||
- PullQuote with image **as closing element**
|
||||
- Overall: the format works for short, atmospheric pieces, not just long narratives
|
||||
|
||||
**Frontmatter:**
|
||||
```yaml
|
||||
title: One Evening in Siena
|
||||
date: 2025-09-05
|
||||
location_name: Siena
|
||||
location_country: Italy
|
||||
lat: 43.318
|
||||
lng: 11.330
|
||||
hero_image: hero.jpg
|
||||
hero_alt: The Piazza del Campo at dusk, terracotta rooftops fading to blue
|
||||
published: true
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
1. `[pull-quote]` — **no image, opens the story** — a single observational sentence about Siena at dusk
|
||||
2. Intro prose (2 sentences — arrival on bike, the square)
|
||||
3. `[scrolly-section image="campo.jpg"]` — **2 steps:** (1) the square fills with people as the sun goes; (2) a busker, a couple arguing, pigeons
|
||||
4. `[snap-gallery]` — 3 images: campo at dusk, a doorway, someone eating gelato
|
||||
5. Prose (2 sentences — finding dinner, the relief of sitting down)
|
||||
6. `[pull-quote image="sunset.jpg"]` — **with image, closes the story** — a line about what cycling does to ordinary moments
|
||||
|
||||
**Path:** `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
|
||||
|
||||
---
|
||||
|
||||
## Makefile Changes
|
||||
|
||||
`demo-load` — add after the existing Italy stories.md copy line:
|
||||
```makefile
|
||||
cp -r user/docs/demo/trips/italy-2025/04.stories user/pages/01.trips/italy-2025/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
`demo-reset` — the existing `rm -rf user/pages/01.trips/italy-2025` already removes everything including stories, so no additional line needed.
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `user/docs/demo/trips/italy-2025/04.stories/01.val-dorcia-dawn/story.md`
|
||||
2. `user/docs/demo/trips/italy-2025/04.stories/02.long-climb-montalcino/story.md`
|
||||
3. `user/docs/demo/trips/italy-2025/04.stories/03.one-evening-siena/story.md`
|
||||
4. `Makefile` — one line added to `demo-load`
|
||||
5. Two commits: one for story files (user repo), one for Makefile (main repo)
|
||||
|
||||
No image files are committed. Shortcode image params reference filenames that won't resolve without real photos — this is consistent with the existing Japan demo story.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Design Spec: Smart GPX-Marker Connector Logic
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The map currently draws a straight connector line between every adjacent pair of journal/story entry markers in chronological order. When GPX track files are also present, this creates two overlapping representations of the same movement — the accurate GPX track line and a redundant straight-line connector. For segments with no GPX coverage (e.g. a train journey), the straight connector is useful and should remain.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Suppress connector lines between adjacent markers only when a single GPX file demonstrably covers both endpoints. Keep connectors for gaps that have no GPX coverage. Provide a per-entry override (`force_connect`) for cases where the algorithm suppresses a connector the author wants to show.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour Modes
|
||||
|
||||
### No GPX files present
|
||||
Existing behaviour unchanged. All adjacent markers are connected by a line in chronological order.
|
||||
|
||||
### GPX files present
|
||||
Auto-connectors off by default. For each adjacent pair of markers (M1 → M2):
|
||||
|
||||
1. If `force_connect: true` on M2 → draw connector (override wins)
|
||||
2. Otherwise run the spatial algorithm (see below)
|
||||
3. If the algorithm finds coverage → suppress connector
|
||||
4. If the algorithm finds no coverage → draw connector
|
||||
|
||||
---
|
||||
|
||||
## Spatial Algorithm
|
||||
|
||||
**Proximity threshold:** 10 km
|
||||
|
||||
For each adjacent pair (M1, M2):
|
||||
|
||||
1. For each loaded GPX file F:
|
||||
a. Pre-filter: if M1 or M2 lies outside F's bounding box expanded by 10 km → skip F cheaply
|
||||
b. Sample every 10th trackpoint in F; compute haversine distance to M1 and to M2
|
||||
c. If both M1 and M2 have at least one sampled point within 10 km → suppress connector for this pair; stop checking further files
|
||||
2. If no file F covered both M1 and M2 → draw connector
|
||||
|
||||
**Rationale for 10 km:** entries are often posted from a hotel, village, or café near (but not on) a trail. 10 km accommodates varied terrain — coastal routes, hilly detours — without false-positives across genuinely separate segments.
|
||||
|
||||
**Rationale for same-file requirement:** two markers each near *different* GPX files (e.g. an inland hike and a coastal walk) must not suppress the connector between them — that gap (e.g. a train journey) is exactly what should be shown.
|
||||
|
||||
---
|
||||
|
||||
## Fallback Behaviour
|
||||
|
||||
If any GPX file fails to load, treat it as absent for the algorithm. Connectors default to drawing rather than hiding — missing data never creates invisible gaps on the map.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
Two new fields added to both the **journal entry** and **story entry** Grav blueprints:
|
||||
|
||||
### `force_connect`
|
||||
- Type: boolean
|
||||
- Default: false / null (unset)
|
||||
- Meaning: "always draw a connector from the previous marker to this entry"
|
||||
- Only has visible effect when GPX files are present (when no GPX, auto-connectors are already on)
|
||||
- Editable via Admin2 on any entry
|
||||
|
||||
### `transport_mode`
|
||||
- Type: enum
|
||||
- Values: `walking`, `bicycle`, `bus`, `train`, `car`
|
||||
- Default: null (unset)
|
||||
- Meaning: how the author arrived at this location (attached to the arriving entry)
|
||||
- **Not visualised yet** — data capture only, for future use (distance-by-mode stats, map icons, filter)
|
||||
- Editable via Admin2 on any entry
|
||||
|
||||
Both fields are exposed in frontmatter. Adding them to the mobile post form is deferred (backlog: blueprint-to-form sync pass).
|
||||
|
||||
---
|
||||
|
||||
## Client-Side Implementation
|
||||
|
||||
### Entry JSON
|
||||
The Twig template that serialises entries into a JS variable (`TRACKER_ENTRIES` or equivalent) gains two new fields per entry: `force_connect` (bool) and `transport_mode` (string or null).
|
||||
|
||||
### Timing
|
||||
Connector drawing is deferred until all GPX files have settled (Promise.all on load events). GPX tracks appear immediately as each file loads. Connectors render once all files are resolved or rejected.
|
||||
|
||||
### Performance
|
||||
- Bounding box pre-filter eliminates most files for any given pair without distance math
|
||||
- Sampling every 10th trackpoint keeps the haversine checks cheap even for full-day GPX files (thousands of points → hundreds of checks per file per pair)
|
||||
|
||||
---
|
||||
|
||||
## Deferred / Out of Scope
|
||||
|
||||
- Visualising `transport_mode` on the map (icons, line styles by mode)
|
||||
- Distance-by-mode statistics
|
||||
- Adding `force_connect` / `transport_mode` to the mobile post form
|
||||
- Making the 10 km threshold configurable in `site.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Affected Files (indicative)
|
||||
|
||||
- `user/themes/intotheeast/templates/map.html.twig` — entry JSON serialisation
|
||||
- `user/themes/intotheeast/js/map.js` (or equivalent) — connector drawing logic + algorithm
|
||||
- Blueprint file(s) for journal and story entries — add two new fields
|
||||
@@ -11,6 +11,9 @@ set -e
|
||||
: "${GITEA_USER:?GITEA_USER is not set}"
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is not set}"
|
||||
|
||||
# GRAV_CHANNEL_SUFFIX: optional, set to '?testing' for RC/beta releases (e.g. 2.0.0-rc.9)
|
||||
# Leave unset or empty for stable releases.
|
||||
|
||||
trap 'rm -f ~/.netrc' EXIT
|
||||
|
||||
echo "==> Setting up credentials (temporary)"
|
||||
@@ -19,7 +22,7 @@ chmod 600 ~/.netrc
|
||||
|
||||
echo "==> Downloading Grav $GRAV_VERSION"
|
||||
cd "$WEBROOT"
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/$GRAV_VERSION" -O grav-admin.zip
|
||||
wget --no-verbose "https://getgrav.org/download/core/grav-admin/${GRAV_VERSION}${GRAV_CHANNEL_SUFFIX:-}" -O grav-admin.zip
|
||||
unzip -oq grav-admin.zip
|
||||
cp -rf grav-admin/. .
|
||||
rm -rf grav-admin grav-admin.zip
|
||||
|
||||
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
|
||||
|
||||
# Config must be in frontmatter, not in the process block
|
||||
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
|
||||
check_grep "parent set to /tracker" "parent: '/tracker'"
|
||||
check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/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"
|
||||
|
||||
+31
-28
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
|
||||
USER="${GRAV_TEST_USER:-}"
|
||||
PASS="${GRAV_TEST_PASS:-}"
|
||||
TRACKER="user/pages/01.tracker"
|
||||
TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies"
|
||||
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
@@ -16,8 +16,13 @@ TEST_SLUG=""
|
||||
cleanup() {
|
||||
rm -f "$COOKIE_JAR"
|
||||
if [ -n "$TEST_SLUG" ] && [ -d "$TRACKER/$TEST_SLUG" ]; then
|
||||
rm -rf "$TRACKER/$TEST_SLUG"
|
||||
# Entry files are created by www-data inside Docker; use docker exec to remove
|
||||
if docker exec intotheeast_grav rm -rf "/var/www/html/$TRACKER/$TEST_SLUG" 2>/dev/null; then
|
||||
echo " [cleanup] Removed test entry: $TEST_SLUG"
|
||||
else
|
||||
rm -rf "$TRACKER/$TEST_SLUG" 2>/dev/null || \
|
||||
echo " [cleanup] Warning: could not remove $TEST_SLUG (permission denied — remove manually)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -37,27 +42,27 @@ echo "────────────────────────
|
||||
LOGIN_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/login") \
|
||||
|| die "Could not reach $BASE_URL/login"
|
||||
|
||||
LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
|
||||
LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
|
||||
[ -n "$LOGIN_NONCE" ] || die "Could not extract login form nonce — is the site running?"
|
||||
|
||||
# ── Step 2: log in ───────────────────────────────────────────────────────────
|
||||
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
|
||||
-L \
|
||||
-d "username=${USER}&password=${PASS}&form-nonce=${LOGIN_NONCE}&task=login" \
|
||||
-d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \
|
||||
"$BASE_URL/login")
|
||||
|
||||
# After login, check we can access /post (302 → 200 means logged in)
|
||||
POST_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
|
||||
"$BASE_URL/post")
|
||||
# After login, fetch /post and verify we see the post form (not the login form)
|
||||
# /post returns 200 for both auth and unauth users — check for form-nonce to confirm login
|
||||
POST_CHECK_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \
|
||||
|| die "Could not reach $BASE_URL/post"
|
||||
|
||||
[ "$POST_STATUS" = "200" ] && ok "Login succeeded and /post is accessible" \
|
||||
|| die "Login failed or /post returned $POST_STATUS — check GRAV_TEST_USER / GRAV_TEST_PASS"
|
||||
POST_STATUS=$(echo "$POST_CHECK_HTML" | grep -c 'name="form-nonce"' || true)
|
||||
[ "$POST_STATUS" -gt 0 ] && ok "Login succeeded and /post is accessible" \
|
||||
|| die "Login failed (post form not visible) — check GRAV_TEST_USER / GRAV_TEST_PASS"
|
||||
|
||||
# ── Step 3: get post form + nonce ────────────────────────────────────────────
|
||||
POST_HTML=$(curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE_URL/post") \
|
||||
|| die "Could not fetch post form"
|
||||
# ── Step 3: extract post form nonce from already-fetched HTML ────────────────
|
||||
POST_HTML="$POST_CHECK_HTML"
|
||||
|
||||
POST_NONCE=$(echo "$POST_HTML" | grep -o 'name="form-nonce" value="[^"]*"' | head -1 | sed 's/.*value="\([^"]*\)".*/\1/')
|
||||
[ -n "$POST_NONCE" ] || die "Could not extract post form nonce"
|
||||
@@ -84,33 +89,31 @@ ok "Form submitted"
|
||||
# ── Step 5: verify entry exists on disk ─────────────────────────────────────
|
||||
sleep 1 # give Grav a moment to write the file
|
||||
|
||||
# Look for the entry — slug might have slight timestamp variation
|
||||
FOUND=$(find "$TRACKER" -name "entry.md" -newer "$TRACKER/2026-06-17.entry/entry.md" \
|
||||
-not -path "*/2026-*" 2>/dev/null | head -1)
|
||||
# Find an entry containing the test title — search all .md and .en.md files
|
||||
# add-page-by-form may produce date-based slugs in various formats
|
||||
ENTRY_FILE=$(grep -rl "$TEST_TITLE" "$TRACKER" --include="*.md" 2>/dev/null | head -1)
|
||||
|
||||
# Also look for today's dated entries
|
||||
FOUND_TODAY=$(find "$TRACKER" -maxdepth 1 -type d -name "$(date '+%Y-%m-%d')*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$FOUND_TODAY" ]; then
|
||||
TEST_SLUG=$(basename "$FOUND_TODAY")
|
||||
if [ -n "$ENTRY_FILE" ]; then
|
||||
TEST_SLUG=$(basename "$(dirname "$ENTRY_FILE")")
|
||||
ok "Entry created on disk: $TEST_SLUG"
|
||||
|
||||
# Verify it has an entry.md inside
|
||||
if [ -f "$TRACKER/$TEST_SLUG/entry.md" ]; then
|
||||
ok "entry.md exists inside the entry folder"
|
||||
# Verify file name is entry.md or entry.en.md (template-named file)
|
||||
ENTRY_BASENAME=$(basename "$ENTRY_FILE")
|
||||
if [ "$ENTRY_BASENAME" = "entry.md" ] || [ "$ENTRY_BASENAME" = "entry.en.md" ]; then
|
||||
ok "Entry file exists: $ENTRY_BASENAME"
|
||||
else
|
||||
fail "Entry folder exists but entry.md is missing"
|
||||
fail "Entry file has unexpected name: $ENTRY_BASENAME (expected entry.md or entry.en.md)"
|
||||
fi
|
||||
|
||||
# Verify the title is in the frontmatter
|
||||
if grep -q "$TEST_TITLE" "$TRACKER/$TEST_SLUG/entry.md"; then
|
||||
if grep -q "$TEST_TITLE" "$ENTRY_FILE"; then
|
||||
ok "Title appears in entry frontmatter"
|
||||
else
|
||||
fail "Title not found in entry.md — frontmatter may be malformed"
|
||||
fail "Title not found in $ENTRY_BASENAME — frontmatter may be malformed"
|
||||
fi
|
||||
else
|
||||
fail "No entry created on disk — form processing failed silently"
|
||||
echo " Expected a folder matching: $TRACKER/$(date '+%Y-%m-%d')-*/"
|
||||
echo " Searched $TRACKER for files containing: $TEST_TITLE"
|
||||
fi
|
||||
|
||||
# ── Result ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
// Tests: T1–T5 — tracker feed and individual entry pages
|
||||
// Tests: T1–T5 — dailies feed and individual entry pages
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// Known fixture entries that always exist in the repo
|
||||
@@ -12,18 +12,18 @@ const KNOWN_COUNTRY = 'Japan';
|
||||
const NEWER_SLUG = '2026-06-17.entry'; // most recent fixture (June 17)
|
||||
const OLDER_SLUG = '2026-03-25-1540-wheels-down-narita.entry'; // oldest fixture (March 25)
|
||||
|
||||
// ── T1: Tracker page loads ─────────────────────────────────────────────────────
|
||||
test('T1: /tracker loads and shows at least one entry card', async ({ page }) => {
|
||||
await page.goto('/tracker');
|
||||
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
||||
test('T1: /trips/japan-korea-2026/dailies loads and shows at least one entry card', async ({ page }) => {
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
await expect(page.locator('.entry-card').first()).toBeVisible();
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── T2: Entries are newest-first ──────────────────────────────────────────────
|
||||
// Verify using two known fixture entries rather than all entries
|
||||
// (the tracker may contain noisy test-run debris with inconsistent dates).
|
||||
test('T2: tracker shows newer entries before older entries', async ({ page }) => {
|
||||
await page.goto('/tracker');
|
||||
// (the dailies may contain noisy test-run debris with inconsistent dates).
|
||||
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
|
||||
// Both fixture entries must be visible on the page
|
||||
const newerCard = page.locator(`.entry-card a[href*="${NEWER_SLUG}"]`);
|
||||
@@ -44,15 +44,15 @@ test('T2: tracker shows newer entries before older entries', async ({ page }) =>
|
||||
});
|
||||
|
||||
// ── T3: Individual entry page loads ───────────────────────────────────────────
|
||||
test('T3: individual entry page loads at /tracker/{slug}', async ({ page }) => {
|
||||
await page.goto(`/tracker/${KNOWN_SLUG}`);
|
||||
test('T3: individual entry page loads at /trips/japan-korea-2026/dailies/{slug}', async ({ page }) => {
|
||||
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('article.entry')).toBeVisible();
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── T4: Entry page shows title, date, and content ─────────────────────────────
|
||||
test('T4: entry page shows title and body content', async ({ page }) => {
|
||||
await page.goto(`/tracker/${KNOWN_SLUG}`);
|
||||
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
|
||||
await expect(page.locator('.entry-body')).not.toBeEmpty();
|
||||
await expect(page.locator('time.entry-date')).toBeVisible();
|
||||
@@ -60,7 +60,7 @@ test('T4: entry page shows title and body content', async ({ page }) => {
|
||||
|
||||
// ── T5: Entry page shows location when present ────────────────────────────────
|
||||
test('T5: entry page shows city and country when set', async ({ page }) => {
|
||||
await page.goto(`/tracker/${KNOWN_SLUG}`);
|
||||
await page.goto(`/trips/japan-korea-2026/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
|
||||
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// @ts-check
|
||||
// Tests: G1–G5 — buildJourneySegments algorithm correctness
|
||||
// These tests load the italy-2025 map page (which has GPX) to get MapUtils in scope,
|
||||
// then call the functions with synthetic data via page.evaluate.
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function getMapUtils(page) {
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// G1: No GPX → all pairs connected in one segment
|
||||
test('G1: all markers connected when no GPX files present', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var entries = [
|
||||
{ lat: '43.0', lng: '11.0', force_connect: false },
|
||||
{ lat: '44.0', lng: '12.0', force_connect: false },
|
||||
{ lat: '45.0', lng: '13.0', force_connect: false }
|
||||
];
|
||||
return MapUtils.buildJourneySegments(entries, [], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G2: Same GPX file covers both markers → connector suppressed (0 segments)
|
||||
test('G2: connector suppressed when same GPX file covers both markers', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||
// Trackpoints covering both (stored as [lat, lng])
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
// G3: force_connect overrides GPX suppression
|
||||
test('G3: force_connect keeps connector even when GPX covers both markers', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: true };
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]];
|
||||
return MapUtils.buildJourneySegments([e1, e2], [track], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G4: Markers near DIFFERENT GPX files → connector kept
|
||||
test('G4: connector kept when markers are near different GPX files', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '45.000', lng: '13.000', force_connect: false };
|
||||
// Two separate files — each only covers one marker
|
||||
var trackA = [[43.000, 11.000], [43.005, 11.005]]; // near e1 only
|
||||
var trackB = [[45.000, 13.000], [45.005, 13.005]]; // near e2 only
|
||||
return MapUtils.buildJourneySegments([e1, e2], [trackA, trackB], 10).length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
// G5: First pair suppressed, second pair kept → one segment [e2, e3]
|
||||
test('G5: suppressed first pair leaves one segment from e2 to e3', async ({ page }) => {
|
||||
await getMapUtils(page);
|
||||
|
||||
var count = await page.evaluate(function () {
|
||||
// e1→e2: covered by track → suppressed; e1 is orphaned (< 2 pts, not pushed)
|
||||
// e2→e3: not covered → connector kept → segment [e2, e3]
|
||||
var e1 = { lat: '43.000', lng: '11.000', force_connect: false };
|
||||
var e2 = { lat: '43.010', lng: '11.010', force_connect: false };
|
||||
var e3 = { lat: '45.000', lng: '13.000', force_connect: false };
|
||||
var track = [[43.000, 11.000], [43.005, 11.005], [43.010, 11.010]]; // covers e1 and e2 only
|
||||
var segs = MapUtils.buildJourneySegments([e1, e2, e3], [track], 10);
|
||||
return segs.length;
|
||||
});
|
||||
|
||||
expect(count).toBe(1); // one segment: [e2 → e3]
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.tracker');
|
||||
const TRACKER_DIR = path.join(__dirname, '../../user/pages/01.trips/japan-korea-2026/01.dailies');
|
||||
|
||||
/**
|
||||
* Wait for all filepond items to finish XHR upload.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @ts-check
|
||||
// Tests: M1–M4 — MapLibre GL canvas renders on all three map surfaces
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// ── M1: Full map page renders MapLibre canvas ─────────────────────────────────
|
||||
test('M1: /map page renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/japan-korea-2026/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on map page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M2: Full map page — dot markers are in the DOM ───────────────────────────
|
||||
test('M2: /map page has at least one dot marker', async ({ page }) => {
|
||||
await page.goto('/trips/japan-korea-2026/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Markers are added in map.on('load') — wait for first to appear in the DOM
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
const markerCount = await page.locator('.maplibregl-marker').count();
|
||||
expect(markerCount, 'At least one marker present').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── M3: Dailies mini-map renders MapLibre canvas ─────────────────────────────
|
||||
test('M3: Dailies mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on dailies page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M4: Home map renders MapLibre canvas ─────────────────────────────────────
|
||||
test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#home-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
|
||||
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for markers to confirm map.on('load') completed
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
// Give Promise.all time to resolve
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
|
||||
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2025/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
|
||||
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
|
||||
await page.waitForFunction(function () {
|
||||
return window.map &&
|
||||
(window.map.getSource('journey') !== undefined ||
|
||||
window.map.getSource('journey-0') !== undefined);
|
||||
}, { timeout: 15000 });
|
||||
|
||||
const hasSource = await page.evaluate(function () {
|
||||
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
|
||||
});
|
||||
|
||||
expect(hasSource).toBe(true);
|
||||
});
|
||||
+14
-15
@@ -2,46 +2,45 @@
|
||||
// Tests: N1–N5 — page loads and navigation links
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// ── N1: /tracker renders ──────────────────────────────────────────────────────
|
||||
test('N1: /tracker page loads with site header', async ({ page }) => {
|
||||
// ── N1: /trips/japan-korea-2026/dailies renders ───────────────────────────────
|
||||
test('N1: /trips/japan-korea-2026/dailies page loads with site header', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/tracker');
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
await expect(page).toHaveTitle(/Into the East/i);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N2: /map renders without JS errors ───────────────────────────────────────
|
||||
test('N2: /map page loads without JS errors', async ({ page }) => {
|
||||
// ── N2: /trips/japan-korea-2026/map renders without JS errors ─────────────────
|
||||
test('N2: /trips/japan-korea-2026/map page loads without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/map');
|
||||
await page.goto('/trips/japan-korea-2026/map');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N3: /stats renders ───────────────────────────────────────────────────────
|
||||
test('N3: /stats page loads with site header', async ({ page }) => {
|
||||
// ── N3: /trips/japan-korea-2026/stats renders ─────────────────────────────────
|
||||
test('N3: /trips/japan-korea-2026/stats page loads with site header', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/stats');
|
||||
await page.goto('/trips/japan-korea-2026/stats');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N4: "Journal" nav link goes to /tracker ───────────────────────────────────
|
||||
test('N4: Journal nav link navigates to /tracker', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.click('nav a[href*="tracker"]');
|
||||
await expect(page).toHaveURL(/\/tracker/);
|
||||
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
|
||||
test('N4: trip page filter bar has Journal button', async ({ page }) => {
|
||||
await page.goto('/trips/japan-korea-2026');
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
|
||||
test('N5: Map nav link navigates to /map', async ({ page }) => {
|
||||
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.click('nav a[href*="map"]');
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
|
||||
@@ -16,7 +16,7 @@ test.afterAll(() => {
|
||||
});
|
||||
|
||||
// ── P1: Post without photo ─────────────────────────────────────────────────────
|
||||
test('P1: post text-only entry → created on disk and visible on /tracker', async ({ page }) => {
|
||||
test('P1: post text-only entry → created on disk and visible on /dailies', async ({ page }) => {
|
||||
const tag = `p1-${Date.now()}`;
|
||||
const title = `UI Test ${tag}`;
|
||||
|
||||
@@ -39,12 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /tracker', asy
|
||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
|
||||
|
||||
await page.goto('/tracker');
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
});
|
||||
|
||||
// ── P2: Post with photo ────────────────────────────────────────────────────────
|
||||
test('P2: post entry with photo → photo saved in entry folder and visible on /tracker', async ({ page }) => {
|
||||
test('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
|
||||
const tag = `p2-${Date.now()}`;
|
||||
const title = `UI Test ${tag}`;
|
||||
|
||||
@@ -70,7 +70,7 @@ test('P2: post entry with photo → photo saved in entry folder and visible on /
|
||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
|
||||
|
||||
await page.goto('/tracker');
|
||||
await page.goto('/trips/japan-korea-2026/dailies');
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @ts-check
|
||||
// Tests: S1–S6 — story mode rendering and navigation
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const STORIES_URL = '/trips/italy-2025/stories';
|
||||
const STORY_GALLERY = '/trips/italy-2025/stories/val-dorcia-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
|
||||
const STORY_SCROLLY = '/trips/italy-2025/stories/long-climb-montalcino'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
|
||||
const JAPAN_STORY = '/trips/japan-korea-2026/stories/the-thousand-gates';
|
||||
|
||||
// ── S1: Stories listing shows cards ──────────────────────────────────────────
|
||||
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
|
||||
await page.goto(STORIES_URL);
|
||||
const cards = page.locator('.story-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 5000 });
|
||||
const count = await cards.count();
|
||||
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// ── S2: Gallery-led story — hero image + snap-gallery + chapter-break + text-only pull-quote ──
|
||||
test('S2: gallery-led story renders hero, snap-gallery, chapter-break, text-only pull-quote', async ({ page }) => {
|
||||
await page.goto(STORY_GALLERY);
|
||||
// Hero: real image rendered, no placeholder
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||
// Snap-gallery (there are two in this story)
|
||||
const galleries = page.locator('.pgallery');
|
||||
await expect(galleries.first()).toBeVisible();
|
||||
expect(await galleries.count(), 'Two snap-galleries').toBe(2);
|
||||
// Chapter-break
|
||||
await expect(page.locator('.chapter-break')).toBeVisible();
|
||||
// Text-only pull-quote (no background image variant)
|
||||
await expect(page.locator('.pull-quote__inner--no-image')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── S3: Scrolly-led story — two scrolly-sections + pull-quote with image ─────
|
||||
test('S3: scrolly-led story renders two scrolly-sections and pull-quote with background image', async ({ page }) => {
|
||||
await page.goto(STORY_SCROLLY);
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
// Two scrolly-sections
|
||||
const scrollySections = page.locator('.scrolly');
|
||||
await expect(scrollySections.first()).toBeVisible();
|
||||
expect(await scrollySections.count(), 'Two scrolly-sections').toBe(2);
|
||||
// Pull-quote with background image
|
||||
await expect(page.locator('.pull-quote__bg')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── S4: Scrolly story loads without JS errors (Scrollama CDN) ────────────────
|
||||
test('S4: scrolly story page loads without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(STORY_SCROLLY);
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(errors, 'No JS errors on story page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── S5: Back button returns to stories listing ────────────────────────────────
|
||||
test('S5: back button navigates back to stories listing', async ({ page }) => {
|
||||
// Establish history: listing → story → back
|
||||
await page.goto(STORIES_URL);
|
||||
await page.locator('.story-card').first().click();
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
await page.locator('.story-escape').click();
|
||||
// After history.back(), URL should be the stories listing
|
||||
await expect(page).toHaveURL(/italy-2025\/stories$/);
|
||||
await expect(page.locator('.story-card').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ── S6: Japan story — cross-trip hero image sanity check ─────────────────────
|
||||
test('S6: Japan story renders hero image without placeholder', async ({ page }) => {
|
||||
await page.goto(JAPAN_STORY);
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
await expect(page.locator('.story-hero__img-placeholder')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
// Tests: F1–F7 — trip page filter bar and inline stats toggle
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const TRIP_URL = '/trips/japan-korea-2026';
|
||||
|
||||
// ── F1: filter bar renders with three buttons ─────────────────────────────────
|
||||
test('F1: trip page shows All/Journal/Stories filter buttons', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).toBeVisible();
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── F2: "All content" is active by default ────────────────────────────────────
|
||||
test('F2: "All content" filter button is active on load', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const allBtn = page.locator('.trip-filter-btn[data-filter="all"]');
|
||||
await expect(allBtn).toHaveClass(/is-active/);
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).not.toHaveClass(/is-active/);
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="story"]')).not.toHaveClass(/is-active/);
|
||||
});
|
||||
|
||||
// ── F3: clicking Journal makes it active and deactivates All ─────────────────
|
||||
test('F3: clicking Journal sets it as active filter', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toHaveClass(/is-active/);
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="all"]')).not.toHaveClass(/is-active/);
|
||||
});
|
||||
|
||||
// ── F4: Journal filter hides story cards ─────────────────────────────────────
|
||||
test('F4: Journal filter hides story cards', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||
const storyCards = page.locator('[data-type="story"]');
|
||||
const count = await storyCards.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(storyCards.nth(i)).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
// ── F5: All content filter shows journal cards ────────────────────────────────
|
||||
test('F5: All content filter makes journal cards visible', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
await page.click('.trip-filter-btn[data-filter="journal"]');
|
||||
await page.click('.trip-filter-btn[data-filter="all"]');
|
||||
const journalCards = page.locator('[data-type="journal"]');
|
||||
await expect(journalCards.first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ── F6: Stories filter shows empty state when no stories exist ────────────────
|
||||
test('F6: Stories filter shows empty message when no stories exist', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const storyCount = await page.locator('[data-type="story"]').count();
|
||||
if (storyCount === 0) {
|
||||
await page.click('.trip-filter-btn[data-filter="story"]');
|
||||
await expect(page.locator('#feed-filter-empty')).toBeVisible();
|
||||
await expect(page.locator('#feed-filter-empty')).toContainText('No stories');
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
// ── F7: Stats button toggles inline stats block ───────────────────────────────
|
||||
test('F7: Stats button expands and collapses inline stats block', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const statsBlock = page.locator('#trip-stats-block');
|
||||
const statsBtn = page.locator('#trip-stats-toggle');
|
||||
|
||||
// hidden by default
|
||||
await expect(statsBlock).toBeHidden();
|
||||
|
||||
// click to expand
|
||||
await statsBtn.click();
|
||||
await expect(statsBlock).toBeVisible();
|
||||
await expect(statsBtn).toHaveClass(/is-active/);
|
||||
|
||||
// click to collapse
|
||||
await statsBtn.click();
|
||||
await expect(statsBlock).toBeHidden();
|
||||
await expect(statsBtn).not.toHaveClass(/is-active/);
|
||||
});
|
||||
Reference in New Issue
Block a user