Compare commits
38
Commits
65e18be92a
...
9440bdc29d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9440bdc29d | ||
|
|
fb9a47ea0c | ||
|
|
58d2d70c13 | ||
|
|
725131e128 | ||
|
|
0f9a3b86b8 | ||
|
|
a639dc6e41 | ||
|
|
3fffa02bec | ||
|
|
6ad62360c6 | ||
|
|
a28ef8f8d7 | ||
|
|
1710ad8612 | ||
|
|
52010c9733 | ||
|
|
b47b1e9657 | ||
|
|
3085cede28 | ||
|
|
2f733f668d | ||
|
|
99f290fbca | ||
|
|
b1b7f64996 | ||
|
|
2695bce835 | ||
|
|
7984b3a75e | ||
|
|
4dc5bf6812 | ||
|
|
a1425e851b | ||
|
|
7407129812 | ||
|
|
a2457d6402 | ||
|
|
2729a8c14c | ||
|
|
aeea6744bd | ||
|
|
a9ca68d790 | ||
|
|
9fbc61ee6e | ||
|
|
abab85ca5c | ||
|
|
0427b75b6e | ||
|
|
4665e014be | ||
|
|
567ea7bb89 | ||
|
|
dd3c89e88a | ||
|
|
301de51add | ||
|
|
119e8e5a35 | ||
|
|
532fbda801 | ||
|
|
fcd8ed13ce | ||
|
|
a80b0a90fc | ||
|
|
4df191b9f4 | ||
|
|
0d3a3451f3 |
+57
-12
@@ -1,26 +1,71 @@
|
||||
# SSH connection
|
||||
REMOTE_USER=root
|
||||
# .env.example — template for the project's environment files.
|
||||
#
|
||||
# There are TWO kinds of env file, loaded by the Makefile in this order:
|
||||
#
|
||||
# .env → LOCAL / shared config. ALWAYS loaded. NO remote credentials.
|
||||
# Used by local targets (docker compose ${UID}/${GID} + the
|
||||
# travel-memories env_file, and `make test-post` / `make test`).
|
||||
# Copy the LOCAL section below into it.
|
||||
#
|
||||
# .env.test → REMOTE config for the test environment.
|
||||
# .env.prod → REMOTE config for production.
|
||||
# Loaded only when a remote target sets ENV, e.g.
|
||||
# `make remote-install-prod`. Copy the REMOTE section below into
|
||||
# each, with the values for that environment.
|
||||
#
|
||||
# .env, .env.test and .env.prod are all gitignored — never commit real values.
|
||||
# This .example file is the only one that IS committed; keep its values as
|
||||
# placeholders.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LOCAL → copy into .env
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host user/group id for container file ownership (docker-compose ${UID}:${GID}).
|
||||
# Match your local user: run `id -u` / `id -g` (usually 1000 on a single-user box).
|
||||
UID=1000
|
||||
GID=1000
|
||||
|
||||
# Local Grav dev server. GRAV_BASE_URL is used by the Playwright suite and
|
||||
# scripts/test-post.sh.
|
||||
GRAV_BASE_URL=http://localhost:8081
|
||||
# Test login for `make test` — OPTIONAL. If unset, the suite auto-creates and
|
||||
# uses a dedicated local-only account (testrunner / Testpass1234), gitignored so
|
||||
# it is never pushed to prod (see `make test-account`). Override only to test as
|
||||
# a different account; keep the password free of shell/Make/URL-special chars.
|
||||
# GRAV_TEST_USER=testrunner
|
||||
# GRAV_TEST_PASS=Testpass1234
|
||||
GRAV_USER_DIR=/absolute/path/to/travel-blog-intotheeast/user
|
||||
|
||||
# travel-memories service (docker-compose `env_file: .env`). Fill in whatever
|
||||
# that Flask app needs — e.g. its Immich connection. Leave commented until set.
|
||||
# IMMICH_URL=
|
||||
# IMMICH_API_KEY=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# REMOTE → copy into .env.test AND .env.prod (with per-env values)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# SSH connection to the target server.
|
||||
REMOTE_USER=deploy
|
||||
REMOTE_HOST=example.com
|
||||
REMOTE_PORT=22
|
||||
REMOTE_HOME=/home/example.com
|
||||
|
||||
# Server paths (override here if your setup differs from the Makefile defaults)
|
||||
# Server paths. Optional — default to $(REMOTE_HOME)/public_html and
|
||||
# $(REMOTE_HOME)/site-config. Set explicitly only if the layout differs
|
||||
# (e.g. a per-domain webroot like /home/deploy/domains/test.example.com/public_html).
|
||||
WEBROOT=/home/example.com/public_html
|
||||
SITE_CONFIG_DIR=/home/example.com/site-config
|
||||
|
||||
# Grav
|
||||
GRAV_VERSION=2.0.0-rc.10
|
||||
# Grav version installed by scripts/server-install.sh (remote-install).
|
||||
GRAV_VERSION=2.0.4
|
||||
|
||||
# Repos
|
||||
# Repos cloned/pulled on the server.
|
||||
USER_REPO=https://gitea.example.com/org/intotheeast-user.git
|
||||
MAIN_REPO=https://gitea.example.com/org/travel-blog-intotheeast.git
|
||||
|
||||
# Gitea credentials — never commit these; only ever in .env (local) or ~/.env-project (server, temporary)
|
||||
# Gitea credentials used by remote-install / remote-env-setup.
|
||||
GITEA_HOST=gitea.example.com
|
||||
GITEA_USER=deploy-user
|
||||
GITEA_TOKEN=your-gitea-personal-access-token
|
||||
|
||||
# Test credentials — used by 'make test-post' (must be a valid Grav site login user)
|
||||
GRAV_TEST_USER=mischa
|
||||
GRAV_TEST_PASS=TravelBlog2026!
|
||||
GRAV_BASE_URL=http://localhost:8081
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.prod
|
||||
.env.test
|
||||
|
||||
# Grav CMS
|
||||
/user/
|
||||
@@ -20,6 +22,9 @@ playwright-report/
|
||||
tests/.auth/
|
||||
user/pages/**/ui-test-trip/
|
||||
|
||||
# Services (moved to separate projects)
|
||||
services/
|
||||
|
||||
# travel-memories state
|
||||
docs/immich-workflow/*.json
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "user"]
|
||||
path = user
|
||||
url = ssh://git@m038-nas.tail63ee39.ts.net:222/m038/intotheeast-com-content.git
|
||||
branch = main
|
||||
@@ -8,16 +8,22 @@
|
||||
|
||||
- **./**: Grav CMS dev environment for intotheeast travel blog
|
||||
- **scripts/**: Server install and maintenance scripts
|
||||
- **user/**: Site content, config, pages, and theme (standalone git repo — do not modify from here)
|
||||
- **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
|
||||
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
|
||||
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
|
||||
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
|
||||
|
||||
### Current stack
|
||||
|
||||
- **Grav:** 2.0.0-rc.10 (baked into the custom Docker image via `Dockerfile`)
|
||||
- **Admin:** Admin2 v2.0.0-rc.15 (plugin slug: `admin2`, NOT `admin`)
|
||||
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=beta`
|
||||
- **Grav:** 2.0.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
|
||||
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
|
||||
- **GPM channel:** `stable` — set in `user/config/system.yaml` → `gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
|
||||
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
|
||||
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
|
||||
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
|
||||
|
||||
> Known issue (2026-07-04): Form 9.1.10 regressed the `filepond` upload field — on the post-submit re-render, `filepond.html.twig` runs `merge` on a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6 `post.spec.js` UI specs. Being fixed separately in the form-to-page/image-upload rework — **do not** work around it here.
|
||||
|
||||
### Dev server
|
||||
|
||||
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
|
||||
@@ -27,39 +33,73 @@ The Docker dev server runs at **http://localhost:8081** (mapped from container p
|
||||
The site is structured around Trip entities. Key facts:
|
||||
- Active trip is set in `user/config/site.yaml` → `active_trip: japan-korea-2026`
|
||||
- Trip pages live at `user/pages/01.trips/<slug>/`
|
||||
- Each trip has: `01.dailies/`, `02.map/`, `03.stats/`, `04.stories/`
|
||||
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
|
||||
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
|
||||
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
|
||||
- Post form parent (`post-form.md` → `pageconfig.parent`) **must be kept in sync** with `active_trip`
|
||||
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories) — do NOT add nav links back to `/dailies`, `/stats`, `/stories` on the trip page
|
||||
- Stats are shown inline on the trip page via a toggle; the standalone `/stats` sub-page still exists as a URL but is not linked from the trip page
|
||||
- GPX route files live as media on the trip page itself, served via leaflet-gpx CDN
|
||||
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
|
||||
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
|
||||
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
|
||||
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
|
||||
|
||||
### Shared feed-map partial
|
||||
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial
|
||||
|
||||
The mini-map above the feed is shared across two pages via a Twig partial:
|
||||
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle.
|
||||
|
||||
- **Partial:** `user/themes/intotheeast/templates/partials/feed-map.html.twig`
|
||||
- **Used by:** `dailies.html.twig` and `stories.html.twig`
|
||||
- **NOT used by:** `trip.html.twig` (uses its own `#trip-map` / `.home-map-col` layout)
|
||||
The map **markup + invocation** is shared via one partial:
|
||||
|
||||
**Parameters (passed via `{% include ... with {...} only %}`):**
|
||||
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
|
||||
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `map_entries` | array | `[{lat, lng, title, slug, url, type, force_connect, transport_mode}]` |
|
||||
| `map_id` | string | HTML id for map div: `'feed-map'` or `'stories-map'` |
|
||||
| `map_var` | string | JS global variable: `'feedMap'` or `'storiesMap'` |
|
||||
| `link_href` | string\|null | "View full map" link URL; `null` hides it |
|
||||
| `card_prefix` | string | Scroll-to ID prefix: `'entry-'` (dailies) or `'story-'` (stories) |
|
||||
| `trip_page` | Page | Trip page object for autoconnect setting |
|
||||
| `show_journey` | bool | `true` draws the route connector; `false` skips it |
|
||||
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
|
||||
|
||||
The partial always: starts attribution collapsed, shows the fullscreen button (mobile-only, CSS `display:none` ≥769px), and on marker click scrolls to `#<card_prefix><slug>` + flashes `.is-highlighted`.
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Trip passes | Home passes |
|
||||
|---|---|---|---|
|
||||
| `map_id` | string | `'trip-map'` | `'home-map'` |
|
||||
| `map_global` | string | `'tripMap'` | `'homeMap'` |
|
||||
| `entries` | array | `[{lat, lng, slug, title, url, type?, force_connect, ...}]` | same |
|
||||
| `card_prefix` | string | `'entry-'` | `'entry-'` |
|
||||
| `story_markers` | bool | `true` (diamond markers) | `false` |
|
||||
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||
| `use_gpx` | bool | `page.header.use_gpx ?? true` | derived from `trip.header` |
|
||||
| `autoconnect` | string | `page.header.autoconnect ?? 'on'` | derived from `trip.header` |
|
||||
| `gpx_source_prefix` | string | `'gpx'` | `'home-gpx'` |
|
||||
| `journey_id` | string | `'trip-journey'` | `'home-journey'` |
|
||||
|
||||
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them.
|
||||
|
||||
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`.
|
||||
|
||||
### Shared trip-feed-col partial
|
||||
|
||||
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map.
|
||||
|
||||
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
|
||||
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `home.html.twig` picks it with `{% if all_items|length == 0 %}` → `home-predeparture` `{% else %}` → `trip-feed-col`. Keep `trip-feed-col` single-purpose — do NOT fold the pre-departure branch back into it.
|
||||
|
||||
**Parameters (`trip-feed-col`):**
|
||||
|
||||
| Parameter | Type | Trip passes | Home-active passes |
|
||||
|---|---|---|---|
|
||||
| `trip_page` | Page | `page` | `trip` |
|
||||
| `all_items` | array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
|
||||
| `journal_entries` | array | dailies children | dailies children |
|
||||
| `journal_count` / `story_count` | int | counts | counts |
|
||||
| `has_gpx` | bool | `has_gpx` | `home_gpx_urls\|length > 0` |
|
||||
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||
| `gps_points` | array | `gps_points` | `gps_points` |
|
||||
| `show_sort` | bool | `true` | `false` (home keeps its own feed order, no sort button) |
|
||||
|
||||
`home-predeparture` takes only `trip_page`.
|
||||
|
||||
**Stats/cycling JS glue:** the partial emits an inline `DOMContentLoaded` script calling `window.initTripStats({ gpxUrls, gpsPoints, hasGpx })` — one shared function in `js/src/main.js` (rebuild with `make build-assets`; never hand-edit `js/main.js`). It no-ops when `#stat-distance` is absent, populates exact distance + cycling stats from GPX, and falls back to a `~`-prefixed haversine estimate (or `—` for `<2` points) when there is no GPX. It depends on `window.MapUtils` from `map.js` (loaded in the `bottom` asset group on both pages).
|
||||
|
||||
### GPX file management
|
||||
|
||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `map.html.twig` via `trip_page.media.all`.
|
||||
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `trip.html.twig` (and `home.html.twig`) via `trip_page.media.all`, filtered to `.gpx`, and passed to the shared `entry-map` partial.
|
||||
|
||||
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
|
||||
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
|
||||
@@ -84,11 +124,11 @@ Two places hardcode the active trip slug. Grav's config and page frontmatter are
|
||||
|
||||
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
|
||||
|
||||
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the standard four subfolders.
|
||||
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the two content subfolders `01.dailies/` and `04.stories/` (each with an inert `routable:false` container `.md`), plus the trip's `trip.md`. Do **not** recreate `02.map/` or `03.stats/` — those standalone views were retired.
|
||||
|
||||
### Environment
|
||||
|
||||
**Never read `.env`** — it contains sensitive credentials. You may pass it to commands (e.g. `docker compose`, `make`) but never read its contents directly. Ask the user if you need environment-specific information.
|
||||
**Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information.
|
||||
|
||||
### Remote operations
|
||||
|
||||
@@ -106,6 +146,15 @@ Always use `make` commands for anything on the production server (`make remote-i
|
||||
|
||||
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
|
||||
|
||||
### Dual-repo submodule structure
|
||||
|
||||
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
|
||||
|
||||
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
|
||||
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
|
||||
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
|
||||
- **Worktrees for parallel work.** A worktree off `main` gets its own `user/` (`git submodule update --init user`) and can run its own dev server (`docker compose -p itte-<feature> up` — the `./user` mount is relative, so each worktree serves its own content). Tooling worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
|
||||
|
||||
## 1. Environment modes
|
||||
|
||||
### Rule: do not switch modes during development
|
||||
@@ -130,7 +179,7 @@ Before going live, change in `user/config/system.yaml`:
|
||||
|---|---|---|
|
||||
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
|
||||
|
||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in `/trips/italy-2026-demo/dailies` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
||||
|
||||
### What the cache-on-save plugin handles
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Concepts
|
||||
|
||||
Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.
|
||||
|
||||
## Relationships
|
||||
|
||||
A **Trip** owns its **Entries** and **Stories**. Exactly one Trip is the **Active Trip** at a time; it is the one surfaced on the home page and the target for new posts. Entries and Stories are always scoped to a Trip — they do not exist independently.
|
||||
|
||||
## Trip
|
||||
|
||||
### Trip
|
||||
A single journey the blog is organised around — the top-level content entity. A Trip aggregates its Entries and Stories and carries its own metadata (title, start/end dates, cover image, route GPX files). Each Trip renders as one consolidated **Trip page** showing an inline map, a filtered feed, and inline stats; the journal, map, stats, and story views are not separate pages.
|
||||
|
||||
### Active Trip
|
||||
The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip.
|
||||
|
||||
### Entry
|
||||
A single dated journal post within a Trip — the atomic unit of the day-to-day travel log.
|
||||
*Avoid:* daily, journal post
|
||||
|
||||
The Trip's journal section is labelled "Journal" and lives in the Trip's `dailies` container, so an Entry is colloquially "a daily"; in templates and page metadata the same thing is called an `entry`. Entries carry a date, optional location and coordinates, weather, and photos, and are ordered by date within a Trip.
|
||||
|
||||
### Story
|
||||
A long-form, designed narrative piece within a Trip — hero image plus scrollytelling/gallery sections — distinct from the short, dated Entry. Stories are curated set pieces; Entries are the running log.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "daily" / "entry" / "journal post" all refer to the same concept (a dated journal post). Canonical term: **Entry**. The section/folder is named "dailies" and the nav label is "Journal" — these name the *collection*, not a different entity.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM getgrav/grav
|
||||
|
||||
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip' \
|
||||
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
|
||||
-o /tmp/grav-admin.zip \
|
||||
&& unzip -q /tmp/grav-admin.zip -d /tmp \
|
||||
&& cp -rf /tmp/grav-admin/assets /var/www/html/ \
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# Local/shared config — always loaded. Keep remote credentials OUT of here;
|
||||
# those live in .env.test / .env.prod. (docker compose also reads .env directly
|
||||
# for ${UID}/${GID} substitution and the travel-memories env_file.)
|
||||
-include .env
|
||||
|
||||
# Remote config — loaded only when targeting an environment. ENV is set
|
||||
# automatically by the env-suffixed remote targets (e.g. `make remote-install-prod`);
|
||||
# each .env.<ENV> holds a full, self-contained set of remote vars.
|
||||
ENV ?=
|
||||
-include .env.$(ENV)
|
||||
export
|
||||
|
||||
REMOTE_PORT ?= 22
|
||||
@@ -6,15 +15,44 @@ SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
|
||||
WEBROOT ?= $(REMOTE_HOME)/public_html
|
||||
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
||||
|
||||
# ── Environment guard + generated per-env remote targets ──────────────────────
|
||||
# Every remote-* target below gains `-test` / `-prod` variants, e.g.
|
||||
# make remote-install-prod → runs remote-install with ENV=prod
|
||||
# Calling a bare remote target (no ENV) fails via guard-env.
|
||||
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
||||
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
||||
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
||||
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
|
||||
ENVS := test prod
|
||||
|
||||
guard-env:
|
||||
@test -n "$(ENV)" || { echo "ERROR: no environment. Use an env-suffixed target, e.g. 'make remote-install-prod'."; exit 1; }
|
||||
@test -f ".env.$(ENV)" || { echo "ERROR: missing .env.$(ENV)"; exit 1; }
|
||||
|
||||
define make-env-target
|
||||
$(1)-$(2): ; @$$(MAKE) --no-print-directory $(1) ENV=$(2)
|
||||
endef
|
||||
$(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,$(t),$(e)))))
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Local test account — auto-created, never committed (see user/.gitignore).
|
||||
# Keep the password free of shell/Make/URL-special chars so every consumer agrees.
|
||||
GRAV_TEST_USER ?= testrunner
|
||||
GRAV_TEST_PASS ?= Testpass1234
|
||||
|
||||
test-account:
|
||||
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||
|
||||
test-config:
|
||||
@bash scripts/test-form-config.sh
|
||||
|
||||
test-post:
|
||||
test-post: test-account
|
||||
@bash scripts/test-post.sh
|
||||
|
||||
test-ui:
|
||||
test-ui: test-account
|
||||
@npx playwright test
|
||||
|
||||
test: test-config test-post test-ui
|
||||
@@ -52,10 +90,8 @@ install-plugins:
|
||||
demo-load:
|
||||
# Load italy-2026-demo trip (create pages if absent)
|
||||
docker exec intotheeast_grav bash -c "\
|
||||
mkdir -p /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies /var/www/html/user/pages/01.trips/italy-2026-demo/02.map /var/www/html/user/pages/01.trips/italy-2026-demo/03.stats /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories && \
|
||||
mkdir -p /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories && \
|
||||
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/trip.md /var/www/html/user/pages/01.trips/italy-2026-demo/trip.md 2>/dev/null || true && \
|
||||
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/map.md /var/www/html/user/pages/01.trips/italy-2026-demo/02.map/map.md 2>/dev/null || true && \
|
||||
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/stats.md /var/www/html/user/pages/01.trips/italy-2026-demo/03.stats/stats.md 2>/dev/null || true && \
|
||||
cp /var/www/html/user/docs/demo/trips/italy-2026-demo/stories.md /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/stories.md 2>/dev/null || true && \
|
||||
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/04.stories/. /var/www/html/user/pages/01.trips/italy-2026-demo/04.stories/ 2>/dev/null || true && \
|
||||
cp -r /var/www/html/user/docs/demo/trips/italy-2026-demo/dailies/. /var/www/html/user/pages/01.trips/italy-2026-demo/01.dailies/ && \
|
||||
@@ -82,21 +118,21 @@ content-pull:
|
||||
|
||||
# ── Remote credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
remote-env-setup:
|
||||
remote-env-setup: guard-env
|
||||
@$(SSH) "printf 'GITEA_HOST=%s\nGITEA_USER=%s\nGITEA_TOKEN=%s\n' \
|
||||
'$(GITEA_HOST)' '$(GITEA_USER)' '$(GITEA_TOKEN)' > ~/.env-intotheeast && chmod 600 ~/.env-intotheeast"
|
||||
@echo "Credentials written to server. Run 'make remote-env-remove' when done."
|
||||
|
||||
remote-env-remove:
|
||||
remote-env-remove: guard-env
|
||||
@$(SSH) "rm -f ~/.env-intotheeast"
|
||||
@echo "Credentials removed from server."
|
||||
|
||||
# ── Remote: initial install ────────────────────────────────────────────────────
|
||||
|
||||
remote-wipe:
|
||||
remote-wipe: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && rm -rf assets backup bin cache images logs system tmp vendor webserver-configs index.php .htaccess CHANGELOG.md LICENSE.txt README.md"
|
||||
|
||||
remote-install:
|
||||
remote-install: guard-env
|
||||
$(SSH) "WEBROOT=$(WEBROOT) \
|
||||
SITE_CONFIG_DIR=$(SITE_CONFIG_DIR) \
|
||||
USER_REPO=$(USER_REPO) \
|
||||
@@ -110,24 +146,35 @@ remote-install:
|
||||
|
||||
# ── Remote: ongoing maintenance ────────────────────────────────────────────────
|
||||
|
||||
remote-fetch:
|
||||
remote-fetch: guard-env
|
||||
$(SSH) "git -C $(SITE_CONFIG_DIR) checkout main && git -C $(SITE_CONFIG_DIR) pull"
|
||||
|
||||
remote-fetch-content:
|
||||
$(SSH) "git -C $(WEBROOT)/user checkout main && git -C $(WEBROOT)/user pull"
|
||||
remote-fetch-content: guard-env
|
||||
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
|
||||
|
||||
remote-install-plugins:
|
||||
remote-install-plugins: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
||||
|
||||
remote-update-plugins: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||
|
||||
remote-upgrade-grav:
|
||||
$(SSH) "cd $(WEBROOT) && php bin/grav upgrade"
|
||||
remote-upgrade-grav: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||
|
||||
remote-clean:
|
||||
remote-git-sync-disable: guard-env
|
||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
|
||||
|
||||
remote-git-sync-enable: guard-env
|
||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
|
||||
|
||||
remote-content-status: guard-env
|
||||
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
||||
|
||||
remote-clean: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||
|
||||
remote-maintenance-on:
|
||||
remote-maintenance-on: guard-env
|
||||
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
||||
|
||||
remote-maintenance-off:
|
||||
remote-maintenance-off: guard-env
|
||||
$(SSH) "bash -s off $(WEBROOT)" < scripts/server-maintenance.sh
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ services:
|
||||
build: .
|
||||
container_name: intotheeast_grav
|
||||
environment:
|
||||
- GRAV_CHANNEL=beta
|
||||
- GRAV_CHANNEL=production
|
||||
- APACHE_RUN_USER=#1000
|
||||
- APACHE_RUN_GROUP=#1000
|
||||
ports:
|
||||
|
||||
@@ -42,23 +42,21 @@ pageconfig:
|
||||
|
||||
## Step 3 — Create the new trip page tree
|
||||
|
||||
Create the standard four subfolders under `user/pages/01.trips/<new-slug>/`:
|
||||
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
||||
|
||||
```
|
||||
user/pages/01.trips/japan-korea-2026/
|
||||
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
||||
├─ 01.dailies/
|
||||
│ └─ dailies.md ← template: dailies (list page)
|
||||
├─ 02.map/
|
||||
│ └─ map.md ← template: map
|
||||
├─ 03.stats/
|
||||
│ └─ stats.md ← template: stats
|
||||
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
||||
└─ 04.stories/
|
||||
└─ stories.md ← template: stories
|
||||
└─ stories.md ← inert container: template: default, routable: false, visible: false
|
||||
```
|
||||
|
||||
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
||||
|
||||
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable.
|
||||
|
||||
Fields in `trip.md` to update:
|
||||
|
||||
| Field | Example | Notes |
|
||||
@@ -85,5 +83,5 @@ This commits and pushes the `user/` repo to Gitea. The webhook triggers a produc
|
||||
|
||||
After pushing, check:
|
||||
1. Home page shows the new trip (title and date)
|
||||
2. Submit a test entry via `/post` — verify it lands under `user/pages/01.trips/<new-slug>/01.dailies/`
|
||||
3. Map at `/trips/<new-slug>/map` shows the correct (empty or GPX-only) state
|
||||
2. Submit a test entry via `/post` — verify it lands under `user/pages/01.trips/<new-slug>/01.dailies/` and appears in the feed at `/trips/<new-slug>`
|
||||
3. The inline map on `/trips/<new-slug>` shows the correct (empty or GPX-only) state
|
||||
|
||||
@@ -8,13 +8,14 @@ How the intotheeast site hangs together.
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|---|---|---|
|
||||
| CMS | Grav 2.0.0-rc.10 | Flat-file PHP CMS; no database |
|
||||
| Admin | Admin2 v2.0.0-rc.15 | Plugin slug: `admin2` (not `admin`) |
|
||||
| CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
|
||||
| Admin | Admin2 v2.0.10 | Plugin slug: `admin2` (not `admin`) |
|
||||
| GPM channel | `stable` | Authoritative in `user/config/system.yaml` → `gpm.releases`; `GRAV_CHANNEL=production` in compose is cosmetic |
|
||||
| Container | Docker (`getgrav/grav` base + custom `Dockerfile`) | Grav 2.0 baked in at build time |
|
||||
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
|
||||
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet; all 3 map templates use it |
|
||||
| GPX rendering | maplibre-gl-leaflet-gpx (CDN) | Renders GPX files as route layers |
|
||||
| Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (`MapUtils.initEntryMap`) on trip + home |
|
||||
| GPX rendering | toGeoJSON (bundled in `js/map.js`) | Parses GPX → GeoJSON route layers client-side; no CDN |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +50,14 @@ Other notable plugins:
|
||||
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
||||
| `admin2` | Admin panel at /admin |
|
||||
|
||||
### Plugin management model
|
||||
|
||||
Three categories, by how each plugin is installed and maintained:
|
||||
|
||||
1. **GPM-managed** (`plugins.txt` → `make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
|
||||
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`. Versioned in the user repo.
|
||||
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
|
||||
|
||||
---
|
||||
|
||||
## Template hierarchy
|
||||
@@ -62,15 +71,13 @@ templates/
|
||||
├─ home.html.twig ← extends base; context-aware two-column layout
|
||||
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
|
||||
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
|
||||
├─ dailies.html.twig ← extends base; journal feed list
|
||||
├─ map.html.twig ← extends base; full-height MapLibre trip map
|
||||
├─ stats.html.twig ← extends base; trip stats (days, distance, elevation)
|
||||
├─ stories.html.twig ← extends base; stories grid
|
||||
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
|
||||
└─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
|
||||
```
|
||||
|
||||
Partials live in `templates/partials/`. Currently one partial: `base.html.twig` (the site shell extended by all page templates).
|
||||
The standalone `dailies.html.twig`, `map.html.twig`, `stats.html.twig` and `stories.html.twig` view templates were **removed** in the 2026-07-04 standalone-page cleanup — the trip page (`trip.html.twig`) consolidated the feed, inline map, and inline stats.
|
||||
|
||||
Partials live in `templates/partials/` (plus macros in `templates/macros/`). Key partials: `base.html.twig` (site shell extended by all page templates), `entry-map.html.twig` (shared map column + `initEntryMap` call, used by trip + home), `trip-feed-col.html.twig` (feed column chrome, shared by trip + home), `home-predeparture.html.twig`, `entry-journal.html.twig` / `entry-story.html.twig` (feed cards), and `weather-icons.html.twig`.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,13 +89,13 @@ The site is organized around Trip entities. The active trip is set in `user/conf
|
||||
user/pages/01.trips/
|
||||
└─ japan-korea-2026/
|
||||
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
|
||||
├─ *.gpx ← GPX route files (served as page media; auto-detected by map.html.twig)
|
||||
├─ 01.dailies/ ← journal entries (template: dailies list + entry children)
|
||||
├─ 02.map/map.md ← template: map
|
||||
├─ 03.stats/stats.md ← template: stats
|
||||
└─ 04.stories/ ← story pages (template: stories list + story children)
|
||||
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
|
||||
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
||||
└─ 04.stories/ ← story children (container .md is routable:false)
|
||||
```
|
||||
|
||||
`01.dailies/` and `04.stories/` are inert data containers — the trip page aggregates their children; visiting the container routes directly 404s/redirects. (The former `02.map/` and `03.stats/` folders were removed with their view templates.)
|
||||
|
||||
---
|
||||
|
||||
## GPX data flow
|
||||
@@ -100,10 +107,10 @@ GPX file uploaded to trip page media
|
||||
user/pages/01.trips/<slug>/*.gpx
|
||||
│
|
||||
▼
|
||||
map.html.twig: trip_page.media.all → filter .gpx files → pass as JS array
|
||||
trip.html.twig / home.html.twig: trip_page.media.all → filter .gpx → entry-map partial
|
||||
│
|
||||
▼
|
||||
MapLibre source: each GPX file added as a GeoJSON source via maplibre-gl-leaflet-gpx
|
||||
MapLibre source: each GPX file parsed by toGeoJSON (bundled in js/map.js) → GeoJSON source
|
||||
│
|
||||
▼
|
||||
Connector suppression: same-file 10km proximity check prevents spurious inter-track segments
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# FindPenguins — Feature Research
|
||||
|
||||
*Researched June 2026. Source: findpenguins.com, App Store, support docs, reviews.*
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
FindPenguins is a German travel tracking and community app. Core features are free; premium subscription ($4.99/month or $32.99/year) unlocks more photos per post and ebook exports. Revenue comes from subscriptions and printed photo books ($40–240). It leans more social than Polarsteps — discovery, community, and inspiring other travelers are central to its identity.
|
||||
|
||||
---
|
||||
|
||||
## Core User Flow
|
||||
|
||||
1. User creates a **Trip** (title, dates, cover)
|
||||
2. App runs in background with **automatic GPS + flight detection tracking**
|
||||
3. User creates **Footprints** — individual journal entries tied to a location and time
|
||||
4. Each Footprint can contain: location, title, date, text story, photos, video, weather
|
||||
5. Footprints appear in a **chronological timeline** per trip
|
||||
6. Trip is shareable; social followers can view, comment, react
|
||||
7. At the end, optionally order a printed **photo book**
|
||||
|
||||
---
|
||||
|
||||
## Map Features
|
||||
|
||||
- **Automatic route tracking**: GPS + flight detection, works offline
|
||||
- **Interactive world map**: route lines drawn between footprints
|
||||
- **3D flyover video**: auto-generated cinematic route visualization, free
|
||||
- **Countries/continents highlighted**: on personal map
|
||||
- **Visited places completion**: stats on what % of a country/region visited
|
||||
- Battery usage: ~4% per day (comparable to Polarsteps)
|
||||
- Route visualized as path on map, not just pins
|
||||
|
||||
---
|
||||
|
||||
## Footprints (Journal Entries)
|
||||
|
||||
Each "Footprint" is the core content unit:
|
||||
|
||||
- **Location**: GPS-detected, shown as city/country; uses reverse geocoding (LocationIQ)
|
||||
- **Title**: required, user-set
|
||||
- **Date**: required, defaults to current time
|
||||
- **Text story**: freeform journal text
|
||||
- **Photos**: 6 (free) / 10 (premium) per footprint
|
||||
- **Videos**: 1 (free) / 2 (premium) per footprint
|
||||
- **Weather**: auto-populated at location + time; manually editable
|
||||
- **Place name**: auto-detected city/neighborhood/country, editable
|
||||
- **Selective sharing**: each footprint can be public, friends-only, or private
|
||||
- **Delayed posting**: option to share location with a time delay (privacy feature)
|
||||
|
||||
---
|
||||
|
||||
## Photo Handling
|
||||
|
||||
- Up to 6 photos per footprint (free), 10 (premium)
|
||||
- 1 video per footprint (free), 2 (premium)
|
||||
- Photos displayed in carousel/grid within footprint
|
||||
- High-res stored for photobook printing
|
||||
- Cover photo selectable per trip
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
|
||||
- Countries visited (count + list + % world)
|
||||
- Continents visited
|
||||
- Total distance traveled
|
||||
- Number of footprints / trips
|
||||
- Days on the road
|
||||
- World coverage percentage
|
||||
- Shown on profile and within photo books
|
||||
|
||||
---
|
||||
|
||||
## Social & Discovery Features
|
||||
|
||||
- **Follower system**: follow other travelers, see their public footprints
|
||||
- **Comments**: friends/followers can comment on individual footprints
|
||||
- **Reactions**: like/react to footprints
|
||||
- **Discovery**: browse 10M+ travel experiences from other users by destination
|
||||
- **Group trips**: invite co-travelers to add footprints to a shared trip (with known bug: co-travelers can delete each other's content)
|
||||
- **Travel inspiration**: browse community trips to plan your own
|
||||
- **Explore by destination**: search real traveler experiences for any city/country
|
||||
|
||||
---
|
||||
|
||||
## Privacy Controls
|
||||
|
||||
- Per-footprint visibility: public / friends / private
|
||||
- **Delayed sharing**: share location with a configurable time delay (safety feature for solo travelers)
|
||||
- Trip-level privacy: whole trip can be private or public
|
||||
- Can hide real-time location from followers
|
||||
|
||||
---
|
||||
|
||||
## Photo Book (Premium)
|
||||
|
||||
- Printed book with maps, photos, text, statistics, and friend comments
|
||||
- €40–€240 depending on size/format (hardcover or layflat)
|
||||
- Free ebook version for premium subscribers
|
||||
- 5% discount on books with premium
|
||||
|
||||
---
|
||||
|
||||
## 3D Flyover Video
|
||||
|
||||
- Free feature: auto-generates a cinematic 3D video of your route
|
||||
- Shareable directly from the app
|
||||
- No native app required for viewing (shareable link)
|
||||
|
||||
---
|
||||
|
||||
## Offline Capability
|
||||
|
||||
- Tracker works fully offline (GPS, flight detection)
|
||||
- Footprints can be created and edited offline
|
||||
- Syncs when connected
|
||||
|
||||
---
|
||||
|
||||
## What Makes FindPenguins Distinctive
|
||||
|
||||
1. **Flight detection**: auto-detects flights and logs them on the route
|
||||
2. **3D flyover video**: compelling visual output, free
|
||||
3. **Delayed sharing**: useful for solo travelers worried about broadcasting real-time location
|
||||
4. **Richer social layer**: comments on individual footprints, community discovery
|
||||
5. **Destination exploration**: browse real traveler posts for any place (like a user-generated travel guide)
|
||||
6. **Premium photo books**: more polished physical product with friend comments included
|
||||
|
||||
---
|
||||
|
||||
## Limitations (relevant to our context)
|
||||
|
||||
- Requires native app for GPS/flight tracking — not reproducible in a web CMS
|
||||
- Social discovery features irrelevant for a solo personal blog
|
||||
- Group trip feature has a bug (co-travelers can delete your content)
|
||||
- Premium paywall for basic things like more than 6 photos per post
|
||||
- Community/social focus means the UX is designed around a social graph we don't have
|
||||
- 3D flyover video requires proprietary rendering pipeline
|
||||
- Real-time delayed sharing is a privacy feature for apps broadcasting live location — moot for a blog that posts after the fact
|
||||
@@ -1,137 +0,0 @@
|
||||
# Polarsteps — Feature Research
|
||||
|
||||
*Researched June 2026. Source: polarsteps.com, App Store, support docs, reviews.*
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Polarsteps is a travel tracking and journaling app used by 20M+ travelers. It is ad-free, primarily free to use, with paid travel books as the main revenue stream. It positions itself as "by travelers, for travelers" — clean, minimal, focused on personal memory-keeping and sharing with close friends/family rather than a social discovery platform.
|
||||
|
||||
---
|
||||
|
||||
## Core User Flow
|
||||
|
||||
1. User creates a **Trip** (name, start/end dates, cover photo)
|
||||
2. App runs in background and **auto-tracks GPS route** continuously (dots on map)
|
||||
3. App auto-generates **Step Suggestions** when you stay somewhere — a notification asks "Are you in [City]? Add a step?"
|
||||
4. User accepts or manually creates a **Step**: a journal entry tied to a location
|
||||
5. Each Step gets: title, text, photos/videos, date, and auto-populated metadata
|
||||
6. Steps appear in a **timeline feed** ordered chronologically
|
||||
7. Trip is shareable via link; friends/family can follow in real time
|
||||
|
||||
---
|
||||
|
||||
## Map Features
|
||||
|
||||
- **Route tracking**: GPS + WiFi + cell towers → white dots plotted on world map as you move
|
||||
- **Offline tracking**: stores locally, syncs when connected
|
||||
- **Travel Tracker steps**: actual route taken (not straight lines), with transport mode tagging (car, bus, train, taxi, walk, fly)
|
||||
- **Route visualization**: colored line on map connecting all steps
|
||||
- **Countries/continents visited**: highlighted on world map
|
||||
- **Battery usage**: ~4% per day (very efficient)
|
||||
- **World completion %**: gamified stat showing % of the globe visited
|
||||
- Tracks distance, speed, and estimated travel time between steps
|
||||
|
||||
---
|
||||
|
||||
## Steps (Journal Entries)
|
||||
|
||||
Each "Step" is the core content unit:
|
||||
|
||||
- **Location**: auto-detected city/country, adjustable
|
||||
- **Title**: auto-suggested from location, editable
|
||||
- **Date/time**: auto from GPS
|
||||
- **Text**: rich freeform journal text
|
||||
- **Photos**: unlimited (mobile app), displayed in a grid/carousel
|
||||
- **Videos**: supported on mobile only, excluded from printed books
|
||||
- **Weather**: auto-populated (temperature, conditions) at time of step
|
||||
- **Altitude**: recorded from GPS
|
||||
- **GPS coordinates**: stored and displayed
|
||||
- **Transport**: mode of travel to reach this step (car/train/fly/etc.)
|
||||
|
||||
---
|
||||
|
||||
## Photo Handling
|
||||
|
||||
- Add photos directly from camera roll per step
|
||||
- Choose cover photo for the trip
|
||||
- Photos displayed in gallery within each step
|
||||
- High-resolution stored for travel book printing
|
||||
- No hard per-step photo limit mentioned (effectively unlimited)
|
||||
- Videos supported on mobile, excluded from print
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
|
||||
Displayed on trip and profile level:
|
||||
- Total km/miles traveled
|
||||
- Countries visited (count + list)
|
||||
- Continents visited
|
||||
- Number of steps/entries
|
||||
- Days on the road
|
||||
- World completion percentage
|
||||
- Furthest point from home
|
||||
- Number of followers / following
|
||||
|
||||
---
|
||||
|
||||
## Sharing & Social Features
|
||||
|
||||
- **Privacy**: "Only me", "Followers only", or "Public"
|
||||
- **Shareable link**: send a URL to anyone to follow the trip live
|
||||
- **Followers**: people can follow your profile and see all public trips
|
||||
- **Reactions/comments**: followers can react and comment on steps
|
||||
- **Social media sharing**: export to Facebook, Instagram, etc.
|
||||
- **Travel Buddy**: invite friends to join and co-document a trip together
|
||||
- **Editors' Choice**: curated featured trips for discovery (like a magazine)
|
||||
- **Trip Reels**: auto-generated short video from photos/videos + visited places, shareable
|
||||
|
||||
---
|
||||
|
||||
## Planning Features (2025 addition)
|
||||
|
||||
- **AI Itinerary Builder**: generates multi-stop travel plan on the map, with transport modes
|
||||
- **Accommodation import**: forward booking confirmation emails to plan@polarsteps.app → appears on map
|
||||
- **Activity planning**: add stays, restaurants, activities to itinerary
|
||||
- **Travel DNA**: personality-based personalization for AI suggestions
|
||||
|
||||
---
|
||||
|
||||
## Travel Book
|
||||
|
||||
- Print a hardback book of your trip (€30–80, 24–300 pages)
|
||||
- Each step on its own page: photo, text, map thumbnail, metadata
|
||||
- Statistics page at the end
|
||||
- Designed, high-quality output — main revenue for Polarsteps
|
||||
|
||||
---
|
||||
|
||||
## Offline Capability
|
||||
|
||||
- Full offline posting (text, photos)
|
||||
- GPS route tracking continues offline
|
||||
- All data syncs when back online
|
||||
|
||||
---
|
||||
|
||||
## What Makes Polarsteps Distinctive
|
||||
|
||||
1. **Simplicity** — minimal UI, auto-everything, almost no friction to log a day
|
||||
2. **Route tracking** — actually shows where you walked/drove, not just pins
|
||||
3. **"Step suggestions"** — proactive nudges to journal without opening the app
|
||||
4. **Printed book** — the premium product, excellent quality
|
||||
5. **Ad-free** — rare among free travel apps
|
||||
6. **Battery efficiency** — 4% per day, usable on long trips
|
||||
|
||||
---
|
||||
|
||||
## Limitations (relevant to our context)
|
||||
|
||||
- Requires native mobile app for GPS tracking (cannot do in browser)
|
||||
- Videos excluded from print
|
||||
- Social/discovery features add little value for a solo personal blog
|
||||
- AI itinerary builder overkill for one-person blog
|
||||
- Travel Buddy / follower system assumes a social graph we don't have
|
||||
- Reels require the native app video processing pipeline
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Dual-repo submodule workflow — outer dev-env repo + user/ content submodule
|
||||
date: 2026-07-04
|
||||
category: architecture-patterns
|
||||
module: Repo structure — outer repo + user/ content repo
|
||||
problem_type: architecture_pattern
|
||||
component: git
|
||||
severity: medium
|
||||
applies_when:
|
||||
- Starting a feature that touches both the outer repo and user/ (theme, plugins, pages)
|
||||
- Setting up a git worktree for long-running work while doing other work in parallel
|
||||
- Deciding when to bump the user/ submodule pointer in the outer repo
|
||||
- A git worktree of the outer repo shows an empty or broken user/ directory
|
||||
- Seeing a persistent "M user" / "m user" dirty state in the outer repo
|
||||
tags: [git, submodule, worktree, dual-repo, user-repo, docker, content-sync, pointer-bump]
|
||||
---
|
||||
|
||||
# Dual-repo submodule workflow
|
||||
|
||||
## Context
|
||||
|
||||
This project is **two independent git repositories** that happen to be nested:
|
||||
|
||||
- **Outer repo** (`intotheeast-com.git`) — the Grav dev environment: `tests/`, `scripts/`, `docs/`, `docker-compose.yml`, `Dockerfile`, `Makefile`, `CLAUDE.md`.
|
||||
- **`user/` repo** (`intotheeast-com-content.git`) — all site content and the theme: `pages/`, `config/`, `accounts/`, `themes/`. It has its own remote (the Gitea content mirror) and its own release cadence (`make content-push` → webhook → production pull).
|
||||
|
||||
As of 2026-07-04, the outer repo tracks `user/` as a **proper git submodule** (`.gitmodules` at the outer root, git dir absorbed into `.git/modules/user`). Before that it was an *orphaned gitlink* — a `160000` tree entry with no `.gitmodules`, so git had no URL to populate or update it. That broke worktrees (a fresh outer worktree got an empty `user/`) and offered no supported sync path.
|
||||
|
||||
## Why a submodule (and not untracking)
|
||||
|
||||
Two options were weighed: make it a real submodule, or stop tracking `user/` in the outer repo entirely (gitignore it, symlink the real checkout in).
|
||||
|
||||
The submodule was chosen deliberately, for one reason that outweighs its ceremony:
|
||||
|
||||
- **Routine content churn is benign** — day-to-day entries/stories change `user/` constantly and never break the dev environment. Those changes do **not** need to be reflected in the outer repo.
|
||||
- **Cross-repo *features* must be tracked together.** A feature like the journal post-form touches both repos (a plugin + theme JS/CSS in `user/`, and tests/docs in the outer repo). The outer repo pinning an exact `user/` commit records *"this dev-env state expects this content/theme state"* — so checking out the outer feature also gets the matching `user/` code. That coupling is real and worth having.
|
||||
- It enables **per-worktree `user/` checkouts**, which is what makes true parallel work across both repos possible (see below). This was a hard requirement.
|
||||
|
||||
The cost accepted: a persistent `M user` dirty signal (intrinsic to submodules under active development) and the possibility of gitlink merge conflicts between outer branches. Neither is removed by the submodule; they are the price of version pinning.
|
||||
|
||||
## The pointer-bump convention
|
||||
|
||||
The outer repo's `user` gitlink stores an exact `user/` commit SHA. **When to bump it:**
|
||||
|
||||
- **Routine content changes → do not bump.** Push content with `make content-push` and leave the outer pin where it is. A stale pin during normal content work is expected and harmless.
|
||||
- **At the end of a cross-repo feature → bump once.** When the feature's `user/` work is finalized, update the outer pin to the finished `user/` commit, as the final step of the feature (its own `chore: bump user pointer to <sha>` commit, or folded into the final integration commit).
|
||||
|
||||
Two rules keep the pin from dangling for other machines/clones:
|
||||
|
||||
1. **Pin a commit reachable from `user/`'s published `main`.** Prefer the **merge-to-main commit**. Pinning a feature-branch tip is safe *only* if that exact commit survives onto `main` (fast-forward / no-squash merge); a squashed-away tip becomes an orphaned SHA and `git submodule update` fails elsewhere.
|
||||
2. **Push `user/` before the outer repo.** The submodule golden rule: the superproject references a child SHA, so the child must already be pushed. `make content-push` handles the `user/` push — just do it before pushing the outer branch.
|
||||
|
||||
Production is unaffected either way: prod pulls `user/` directly via the content-remote webhook, independent of the outer repo's pin. The pin is **dev-side coordination only**.
|
||||
|
||||
## Parallel work: worktree + its own dev server
|
||||
|
||||
The payoff. Because `docker-compose.yml` mounts `./user` **relative to the compose file**, and a worktree is a full copy of the outer tree (compose file included), each worktree serves *its own* `user/`. Two worktrees = two independent sites, no gitlink collisions.
|
||||
|
||||
Set up a feature worktree off `main`:
|
||||
|
||||
```bash
|
||||
# outer worktree on a new feature branch
|
||||
git worktree add .worktrees/<feature> -b feat/<feature> main
|
||||
cd .worktrees/<feature>
|
||||
|
||||
# populate user/ at the pinned SHA, then branch it for the cross-repo work
|
||||
git submodule update --init user
|
||||
git -C user checkout -b feat/<feature>
|
||||
|
||||
# its own dev server — separate project name + port from the main checkout's :8081
|
||||
docker compose -p itte-<feature> up -d
|
||||
```
|
||||
|
||||
`.worktrees/` is kept out of git via `.git/info/exclude` (local, shared across worktrees — no committed `.gitignore` change needed).
|
||||
|
||||
### Teardown
|
||||
|
||||
A submodule inside a linked worktree stores its git dir under `.git/modules/user/worktrees/<name>`, so removing the outer worktree needs a second cleanup step:
|
||||
|
||||
```bash
|
||||
docker compose -p itte-<feature> down
|
||||
cd "$(git rev-parse --show-toplevel)" # back to the main checkout
|
||||
git -C .worktrees/<feature> submodule deinit user # detach the submodule worktree
|
||||
git worktree remove .worktrees/<feature> # remove the outer worktree
|
||||
git branch -d feat/<feature> # if merged
|
||||
```
|
||||
|
||||
### Landing a commit on main without disturbing the main checkout
|
||||
|
||||
When the main checkout is mid-work on another branch, add a commit to `main` through a throwaway worktree instead of `git checkout main` (which would yank branches out from under an open IDE):
|
||||
|
||||
```bash
|
||||
git worktree add .worktrees/main-tmp main
|
||||
git -C .worktrees/main-tmp cherry-pick <sha> # or edit + commit
|
||||
git worktree remove .worktrees/main-tmp
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`M user` / `m user` is normal.** Uppercase `M` = the pin differs from `user/` HEAD (bump pending or intentional). Lowercase `m` = the submodule working tree is dirty (e.g. an uncommitted `config/site.yaml` used for local testing). Neither is an error.
|
||||
- **Gitlink merge conflicts still happen.** If two outer branches pin different `user/` SHAs, merging them conflicts on the `user` entry. Resolve by choosing the correct (usually newer, merged) SHA, then `git add user`.
|
||||
- **Worktrees need `submodule update --init`.** A fresh outer worktree has an empty `user/` until you run it — it is not automatic.
|
||||
- **The submodule git dir was absorbed** (`git submodule absorbgitdirs user`) so all worktrees share `.git/modules/user`. `user/.git` is now a gitfile (`gitdir: ../.git/modules/user`), not a directory. `make content-push`/`content-pull` still operate on `user/` normally.
|
||||
- **Access requires the content remote** (SSH over Tailscale). A machine that cannot reach it cannot `submodule update` — but it could never clone `user/` anyway, so this is not a regression.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Retiring a standalone Grav sub-page that was consolidated onto another page
|
||||
date: 2026-07-04
|
||||
category: architecture-patterns
|
||||
module: Grav theme — trip pages / templates
|
||||
problem_type: architecture_pattern
|
||||
component: rails_view
|
||||
severity: medium
|
||||
applies_when:
|
||||
- Deleting a standalone Grav view whose content now renders inside another page
|
||||
- A page folder holds child pages that other templates fetch via grav.pages.find(route).children
|
||||
- Detail pages have a Back link that falls back to the deleted page
|
||||
- The affected trip is demo content regenerated by make demo-load
|
||||
tags: [grav, twig, page-tree, routable, back-link, demo-content, page-retirement]
|
||||
---
|
||||
|
||||
# Retiring a standalone Grav sub-page that was consolidated onto another page
|
||||
|
||||
## Context
|
||||
|
||||
The site consolidated four standalone trip views — `/map`, `/stats`, `/dailies`, `/stories` — onto a single trip page (inline map + filter bar + inline stats). Removing the now-redundant view pages looks like a simple `git rm`, but a Grav "page" is a **folder + a `.md` that names a template**, and several things quietly depend on both halves. Missing any of them ships a broken site or a broken `make demo-load`. This captures the safe procedure and the three traps that are not obvious from the file listing.
|
||||
|
||||
## Guidance
|
||||
|
||||
Treat a page as two separable roles: a **routable view** (the `.md`'s template renders a URL) and a **data container** (the folder holds child pages other code reads). Retiring the view must preserve the container.
|
||||
|
||||
**1. Keep the folder; neutralise the view — do not delete the container.**
|
||||
`01.dailies/` and `04.stories/` hold the journal/story children, and `trip.html.twig` / `home.html.twig` reach them via `grav.pages.find(route ~ '/dailies').children`. Deleting the folder (or its `.md`) breaks that lookup and the entries vanish from the feed. Instead, keep the folder and repoint its `.md` to an inert container:
|
||||
|
||||
```yaml
|
||||
# 01.dailies/dailies.md
|
||||
---
|
||||
title: Journal
|
||||
template: default # was: dailies (dailies.html.twig is deleted)
|
||||
routable: false # the container's own URL 404s
|
||||
visible: false # not enumerated in nav
|
||||
---
|
||||
```
|
||||
|
||||
`routable: false` makes the container URL inert **without** unrouting its children — individual entries stay reachable at `/trips/<slug>/dailies/<entry>`, and `find(...).children` still resolves because the page remains in the tree. (Folders that were pure views with no children — `02.map/`, `03.stats/` — can be deleted outright.)
|
||||
|
||||
**2. Repoint Back-link fallbacks to the surviving surface (grandparent), not the retired parent.**
|
||||
Detail templates used `href="{{ page.parent().url }}"` with an `onclick` that runs `history.back()` when history exists. `history.back()` covers in-app navigation, but the `href` is the fallback for **direct-landing visitors** (shared link, new browser tab, search result) — and it pointed at the now-inert container:
|
||||
|
||||
```twig
|
||||
{# entry.html.twig / story.html.twig — BEFORE (breaks on direct landing) #}
|
||||
<a class="back-pill" href="{{ page.parent().url }}"
|
||||
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||
|
||||
{# AFTER — fall back to the trip page (grandparent), the surface that survived #}
|
||||
<a class="back-pill" href="{{ page.parent().parent().url }}"
|
||||
onclick="if(history.length > 1){ history.back(); return false; }">← Back</a>
|
||||
```
|
||||
|
||||
This is a **silent** regression: the detail page renders fine (200, no Twig error), so curl/CI-of-the-page all pass. It only breaks when a cold visitor clicks Back — the exact path a shared link takes.
|
||||
|
||||
**3. Sync the gitignored demo source AND the Makefile, or the next reload undoes the cleanup.**
|
||||
The `italy-2026-demo` trip pages are **gitignored**; they are regenerated by `make demo-load` from `user/docs/demo/trips/italy-2026-demo/` (which **is** tracked in the user repo). Deleting pages from the live tree is not enough — you must also:
|
||||
- delete/repoint the demo **source** files (`map.md`, `stats.md`, and the `dailies/`/`stories` container `.md`s), and
|
||||
- update the `demo-load` Makefile target so it no longer `mkdir`s `02.map`/`03.stats` or copies the deleted `.md`s.
|
||||
|
||||
Otherwise the next `make demo-load` recreates the deleted pages pointing at deleted templates. This matters doubly because Playwright's `global-setup` runs `make demo-load` before the suite — stale demo source breaks tests, not just a manual reload.
|
||||
|
||||
**4. Sweep the test suite for the deleted routes.** Delete tests that target the gone pages; re-point tests whose behaviour moved to the consolidation surface (e.g. the feed sort toggle is now `#trip-sort-toggle` on the trip page). Note the consolidation surface may sort differently (the trip page is oldest-first; the old `/dailies` view was newest-first) — re-pointed ordering assertions may need to invert.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The two halves of a Grav page (routable view vs. data container) are invisible in a file listing but load-bearing. The failure modes are asymmetric and sneaky: deleting a container **loudly** empties a feed (easy to catch), but the back-link fallback fails **silently** for only a subset of visitors, and the demo-source drift fails **later** — on the next reload or CI run, not during the change. A curl/HTTP smoke test passes all three while two are broken. Getting the procedure right the first time avoids a shipped regression and a red suite that looks unrelated to the change.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Deleting any Grav view page whose feed/map/list now renders inside another page.
|
||||
- Any time a page folder is a parent of child pages that templates fetch via `find(route).children` — keep it as a `routable:false` container.
|
||||
- Whenever a detail page's Back link (or any `page.parent()` reference) could resolve to the page being retired.
|
||||
- Whenever the affected trip is `italy-2026-demo` (or any gitignored, `demo-load`-regenerated content).
|
||||
|
||||
## Examples
|
||||
|
||||
**Verification that proves the container split worked** (children of a `routable:false` parent stay reachable):
|
||||
|
||||
```
|
||||
# keepers
|
||||
/ → 200
|
||||
/trips/italy-2026-demo → 200 (feed still populated)
|
||||
/trips/italy-2026-demo/dailies/<entry> → 200 (child of routable:false container)
|
||||
/trips/italy-2026-demo/stories/<story> → 200
|
||||
# retired views
|
||||
/trips/italy-2026-demo/map → 404
|
||||
/trips/italy-2026-demo/stats → 404
|
||||
/trips/italy-2026-demo/dailies → 404 (container inert; children still route)
|
||||
```
|
||||
|
||||
**Grep gate before declaring done** — no `include`/`import`/link references to the deleted templates remain (a lone `macros/stats.html.twig` hit is the shared macro, a keeper — not the deleted `stats.html.twig` page):
|
||||
|
||||
```
|
||||
grep -rnE "include .*(feed-map|dailies|stories|map|stats)\.html|~ '/map'|~ '/stats'" templates/
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/working/plans/2026-07-04-standalone-page-cleanup.md` — the plan this learning came from
|
||||
- `docs/reference/architecture.md` — page-tree structure and the single `MapUtils.initEntryMap` map path
|
||||
- `docs/guides/trip-switching.md` — new trips now scaffold only `01.dailies/` + `04.stories/` (inert containers)
|
||||
- `CLAUDE.md` → "Trip entity architecture" and "One map path" — the current-state contract
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: "Grav login new-user grants api.* but not admin.* on Admin2-only installs"
|
||||
date: 2026-07-04
|
||||
category: docs/solutions/test-failures
|
||||
module: testing / account provisioning
|
||||
problem_type: test_failure
|
||||
component: authentication
|
||||
symptoms:
|
||||
- "gpx-manager Playwright specs fail (401 / login form shown) after switching the suite onto a dedicated test account"
|
||||
- "an authenticated account still sees the login form at /gpx-manager instead of the manager UI"
|
||||
- "the generated accounts/*.yaml has an access.api block but no access.admin block"
|
||||
root_cause: missing_permission
|
||||
resolution_type: tooling_addition
|
||||
severity: medium
|
||||
related_components:
|
||||
- testing_framework
|
||||
- tooling
|
||||
tags:
|
||||
- grav
|
||||
- login-plugin
|
||||
- admin2
|
||||
- permissions
|
||||
- playwright
|
||||
- test-account
|
||||
- gpx-manager
|
||||
---
|
||||
|
||||
# Grav login new-user grants api.* but not admin.* on Admin2-only installs
|
||||
|
||||
## Problem
|
||||
When the Playwright suite was moved onto a dedicated local `testrunner` account, every `/gpx-manager` spec started failing — the account could authenticate but was treated as unauthorized for the manager page. The account had been created with `bin/plugin login new-user ... -P b` (Admin + Site access) but **without** `--admin-type`, and on this Admin2-only install that grants `api.*` permissions and no `admin.*` permissions.
|
||||
|
||||
## Symptoms
|
||||
- The `/gpx-manager` Playwright specs fail after switching from the real user to the `testrunner` account (they passed as the real user).
|
||||
- An authenticated `testrunner` still gets the Login plugin's login form at `/gpx-manager` instead of the manager UI.
|
||||
- The generated `user/accounts/testrunner.yaml` contains an `access.api` block (`login: true`, `super: true`) but **no** `access.admin` block.
|
||||
|
||||
## What Didn't Work
|
||||
- **Assuming `-P b` was enough.** `-P/--permissions b` selects the *category* of access (Admin + Site), but the *type* of admin permission — classic `admin.*` vs Admin2 `api.*` — is a separate axis controlled by `--admin-type`, which defaults to auto-detect. `-P b` alone does not guarantee `admin.login`.
|
||||
- **Blaming the wrong specs.** In the same push, the home `H1`/map specs were also red, which looked like it might be the same auth problem. It was not — those were gated by `site.yaml` `travelling: false` hiding the active-trip view, a completely separate cause. Conflating the two delayed pinning the permission root cause.
|
||||
|
||||
## Solution
|
||||
Create the account with an explicit `--admin-type both`, and bake it into the idempotent `make test-account` target so every recreation is faithful:
|
||||
|
||||
```make
|
||||
test-account:
|
||||
@docker exec intotheeast_grav sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||
```
|
||||
|
||||
Verify the resulting permissions actually include `admin.login`:
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav rm -f /var/www/html/user/accounts/testrunner.yaml
|
||||
make test-account
|
||||
docker exec intotheeast_grav sh -c 'grep -A6 "^access:" /var/www/html/user/accounts/testrunner.yaml'
|
||||
# access:
|
||||
# admin:
|
||||
# login: true
|
||||
# super: true
|
||||
# api:
|
||||
# login: true
|
||||
# super: true
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
The `login new-user` help text spells out the axis:
|
||||
|
||||
> `--admin-type` — Which admin permission type to grant when permissions include Admin: `admin` (classic Admin plugin, `admin.*`), `api` (Admin2, `api.*`), or `both`. **If omitted, auto-detects from which admin plugin is installed.**
|
||||
|
||||
This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto-detect resolves to `api` and emits `api.*` alone. `/gpx-manager` is gated by `access.admin.login: true` in its page frontmatter (enforced by the Login plugin), and that check looks specifically for the `admin.login` permission — `api.login` does not satisfy it. Passing `--admin-type both` forces both namespaces into the account, so the admin-gated page accepts the session.
|
||||
|
||||
## Prevention
|
||||
- **On Admin2-only Grav installs, always pass `--admin-type both` (or `admin`) to `login new-user`** when the account must reach any page gated by `access.admin.login` (e.g. `/gpx-manager`). Auto-detect will otherwise silently give you api-only.
|
||||
- **Assert the permission, not the exit code.** After provisioning an account for admin-gated pages, check that `access.admin.login` exists in the generated YAML rather than trusting that account creation "succeeded."
|
||||
- **Keep provisioning in one idempotent place.** The `make test-account` target is the single source of truth; `tests/global-setup.js` calls it, so `make test` and a bare `npx playwright test` both get identical permissions. Don't hand-create the account out-of-band with different flags — that reintroduces the drift this fix removed.
|
||||
|
||||
## Related Issues
|
||||
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
|
||||
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
|
||||
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
|
||||
@@ -4,6 +4,16 @@ Ideas and improvements not yet planned or scheduled.
|
||||
|
||||
---
|
||||
|
||||
## Production — remaining items
|
||||
|
||||
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit — breaks local dev)
|
||||
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
|
||||
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
|
||||
- [ ] Register at carto.com and review terms for production traffic
|
||||
- [ ] Japan & Korea 2026 trip page: set `date_start`, add `cover_image`, upload GPX route file(s)
|
||||
|
||||
---
|
||||
|
||||
## GPX Manager (`/gpx-manager`)
|
||||
|
||||
- [ ] **Polish the UI** — the current design is functional but bare; align with the Field Notes aesthetic, add better empty states, drag-and-drop upload area
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Git Sync Plugin — Setup Notes
|
||||
|
||||
## Folders YAML bug
|
||||
|
||||
The plugin UI always saves the folders field as a single comma-string:
|
||||
|
||||
```yaml
|
||||
folders:
|
||||
- 'pages,config,themes'
|
||||
```
|
||||
|
||||
But the plugin code iterates the array expecting separate items. This causes `git status pages,config,themes` to be passed as a single path, so git sees nothing to commit and sync silently does nothing.
|
||||
|
||||
**Fix:** Edit `user/config/plugins/git-sync.yaml` directly:
|
||||
|
||||
```yaml
|
||||
folders:
|
||||
- pages
|
||||
- config
|
||||
- themes
|
||||
```
|
||||
|
||||
Never use the Admin UI to change folders — it will rewrite the broken format.
|
||||
|
||||
## Files to gitignore
|
||||
|
||||
`user/config/plugins/git-sync.yaml` contains an encrypted token and is server-specific. `user/config/security.yaml` contains Grav nonces/salts, also server-specific. Both are in `.gitignore` and must never be committed.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Milestone 2: Template Refactor — Session Brief
|
||||
|
||||
Use this as the starting point for the brainstorm in a new session.
|
||||
Invoke the brainstorming skill (`/brainstorm`) and hand it this file as context.
|
||||
|
||||
---
|
||||
|
||||
## What this milestone is about
|
||||
|
||||
The asset pipeline (Milestone 1) is done — CDN dependencies eliminated, JS deduplicated into shared bundles. The templates themselves still have structural problems that make them hard to maintain and extend.
|
||||
|
||||
## Problems to solve
|
||||
|
||||
### 1. `trip.html.twig` mixes three concerns
|
||||
|
||||
Currently ~384 lines after Milestone 1 cleanup. Still mixes:
|
||||
- Twig data-building loops (collecting `map_entries`, building entry lists, GPX URL arrays)
|
||||
- HTML structure (cards, panels, filter bar)
|
||||
- Inline JS (map init, GPX stats block)
|
||||
|
||||
Goal: split into focused, readable sections or partials.
|
||||
|
||||
### 2. `map_entries` loop is duplicated across 4 templates
|
||||
|
||||
Near-identical Twig loop that builds `[{lat, lng, title, slug, url, type, ...}]` appears in:
|
||||
- `trip.html.twig`
|
||||
- `dailies.html.twig`
|
||||
- `stories.html.twig`
|
||||
- `map.html.twig`
|
||||
|
||||
Candidate for a Twig macro so a change only needs to happen once.
|
||||
|
||||
### 3. Stats computation is slow Twig loops
|
||||
|
||||
Country counting, temperature range, days on road — currently computed in Twig on every uncached page load. At 60–80 entries this is noticeable.
|
||||
|
||||
**Stronger option:** Move to a small PHP Grav plugin that exposes a single `{{ trip_stats }}` Twig variable. PHP loops are significantly faster than Twig loops. This is also the prerequisite for showing stats on other pages (homepage, story pages) in future.
|
||||
|
||||
### 4. Date range formatting duplicated
|
||||
|
||||
Same date formatting logic in both `story.html.twig` and `stories.html.twig`.
|
||||
|
||||
### 5. Latent bugs on inactive pages (fix while touching templates)
|
||||
|
||||
While refactoring, fix these two issues on pages not yet in active use:
|
||||
- `map.html.twig`: inline map init needs `DOMContentLoaded` wrapper; `{% block map_assets %}` nested inside `{% block content %}` (double-registers assets)
|
||||
- `feed-map.html.twig` (partial): `{% do assets.addCss %}` registers after `{{ assets.css()|raw }}` has rendered; inline map init also needs `DOMContentLoaded`
|
||||
|
||||
---
|
||||
|
||||
## Key constraint
|
||||
|
||||
Mischa wants stats and cycling data (distance, elevation gain/loss, moving time) visible on other pages in future (homepage, story pages). Centralising the computation — whether as Twig macros or a PHP plugin — is the prerequisite for that.
|
||||
|
||||
## What NOT to do in this milestone
|
||||
|
||||
- Don't touch JS or asset pipeline (that's Milestone 1, done)
|
||||
- Don't redesign the visual layout
|
||||
- Don't activate `dailies.html.twig`, `stories.html.twig`, or `map.html.twig` as new features — just fix their structural bugs while you're in the templates
|
||||
|
||||
## Relevant files
|
||||
|
||||
- `user/themes/intotheeast/templates/trip.html.twig` — main template (~384 lines)
|
||||
- `user/themes/intotheeast/templates/partials/base.html.twig` — base layout
|
||||
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — mini-map partial
|
||||
- `user/themes/intotheeast/templates/map.html.twig` — full-page map (inactive)
|
||||
- `user/themes/intotheeast/templates/dailies.html.twig` — journal feed (inactive)
|
||||
- `user/themes/intotheeast/templates/stories.html.twig` — stories grid (inactive)
|
||||
- `user/themes/intotheeast/templates/story.html.twig` — single story page
|
||||
- `user/plugins/` — where a new stats plugin would live
|
||||
|
||||
## Open question for the brainstorm
|
||||
|
||||
The biggest design decision: **PHP plugin vs Twig macro for stats computation.**
|
||||
|
||||
- Twig macro: simpler, no new plugin, but still slow Twig loops
|
||||
- PHP plugin: faster, reusable across pages, but adds a plugin to maintain
|
||||
|
||||
Mischa's stated preference leans toward the PHP plugin given the future-reuse goal, but hasn't committed yet.
|
||||
@@ -0,0 +1,440 @@
|
||||
# Home / Trip View Convergence Implementation Plan
|
||||
|
||||
**Status:** ✅ Complete (2026-06-27)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the home page's active-trip view present the same feed-col chrome (date range, filter bar, stats/cycling panels) as the trip page, by extracting the chrome into one shared Twig partial and the stats computation into one shared JS function.
|
||||
|
||||
**Architecture:** A new partial `templates/partials/trip-feed-col.html.twig` holds the entire `.home-feed-col` markup (header, filter bar, panel toggles, stats/cycling macro calls, feed loop) and is included by both `trip.html.twig` and `home.html.twig` (active branch). The inline stats/cycling computation currently in `trip.html.twig` becomes a window-exposed `initTripStats(config)` in `js/src/main.js`; the partial emits a small `DOMContentLoaded` inline script that calls it with page-specific data. The two intended differences (home has no sort button and keeps its own feed order) are driven by partial params, not separate markup.
|
||||
|
||||
**Tech Stack:** Grav 2.0 / Twig templates, esbuild-bundled vanilla JS (`js/src/main.js` → `js/main.js`), MapLibre via `map.js` (`window.MapUtils`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Only ever write changes inside `travel-blog-intotheeast/` or subfolders.** The `user/` tree is a standalone git repo synced via `make content-push`; commit there as instructed by the execution skill.
|
||||
- **Dev mode stays dev** — `twig.cache: false` is already set. Do NOT toggle any dev/prod config flag to work around caching; theme edits take effect on reload.
|
||||
- **No map convergence.** Both inline map `<script>` blocks and both `.home-map-col` markup blocks stay exactly as they are. Do not touch map markers, fullscreen wiring, or map data-build loops.
|
||||
- **No visual restyling.** Home reuses the trip's existing CSS classes unchanged. No new CSS class names except the pre-departure divider (`home-predeparture-divider`) and reuse of existing `home-highlights-cta` / `home-highlights-cta-wrap` for the pre-departure button.
|
||||
- **No new JS for filter/sort/panels** — `initFilterBar()`, `initPanelToggles()`, `initSortButton()` are already global and selector-guarded. Only `initTripStats` is new.
|
||||
- **Trip page rendered output must be visually and functionally identical** to before for the populated and empty cases — exact bytes may differ (the partial re-indents the feed-col markup, and the stats logic moves into a relocated inline `<script>`). Structural refactor only on that side; verify by behavioral smoke test, not a literal diff.
|
||||
- **Built JS is generated** — never hand-edit `js/main.js`; edit `js/src/main.js` and rebuild with `make build-assets`.
|
||||
- Dev server: `http://localhost:8081`. All verification is manual browser smoke testing (no JS test harness exists).
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `user/themes/intotheeast/js/src/main.js` (edit) | Add `initTripStats(config)`; expose on `window`. Rebuild → `js/main.js`. |
|
||||
| `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` (new) | The entire shared `.home-feed-col`: header, filter bar (sort button gated), panel toggles, stats/cycling macro calls, feed loop, pre-departure block, and the inline `initTripStats` call. |
|
||||
| `user/themes/intotheeast/templates/trip.html.twig` (edit) | Replace inline `.home-feed-col` (`:70-120`) with the partial include; remove inline stats script (`:213-249`). Map untouched. |
|
||||
| `user/themes/intotheeast/templates/home.html.twig` (edit) | Active branch: add `gps_points` build; replace bespoke feed-col (`:60-83`) with the partial include (`show_sort: false`, `pre_departure` gated). Map untouched. Between-trips branch untouched. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Shared stats glue `initTripStats(config)` in main.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/js/src/main.js` (add function near the other init functions, ~after `initPanelToggles` at `:239`; expose on `window`)
|
||||
- Rebuild artifact: `user/themes/intotheeast/js/main.js` (via `make build-assets`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `window.MapUtils.parseGpxFiles(urls, cb)`, `window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)` (from `map.js`, loaded in the `bottom` asset group).
|
||||
- Produces: `window.initTripStats(config)` where `config = { gpxUrls: string[], gpsPoints: [number,number][], hasGpx: boolean }`. Selector-guarded: no-op when `#stat-distance` is absent. No-GPX fallback writes `'—'` (not `~0`) and returns when `gpsPoints.length < 2`. This is the exact contract the partial's inline script (Task 2) and both templates (Tasks 3–4) rely on.
|
||||
|
||||
- [ ] **Step 1: Add the `initTripStats` function**
|
||||
|
||||
In `user/themes/intotheeast/js/src/main.js`, immediately after the `initPanelToggles` function (after line 239, before the `/* ── Boot ── */` comment), add:
|
||||
|
||||
```js
|
||||
/* ── Trip stats / cycling computation (trip + home-active) ───
|
||||
config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
|
||||
No-op if #stat-distance is absent (page rendered no stats panel).
|
||||
No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0'). */
|
||||
function initTripStats(config) {
|
||||
var distEl = document.getElementById('stat-distance');
|
||||
if (!distEl) return;
|
||||
|
||||
var gpxUrls = config.gpxUrls || [];
|
||||
var gpsPoints = config.gpsPoints || [];
|
||||
|
||||
if (config.hasGpx) {
|
||||
MapUtils.parseGpxFiles(gpxUrls, function (result) {
|
||||
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||||
function setText(id, val) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||||
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||||
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||||
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||||
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||||
setText('cyc-moving-time', result.movingTime || '—');
|
||||
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||||
});
|
||||
} else {
|
||||
if (gpsPoints.length < 2) {
|
||||
distEl.textContent = '—';
|
||||
return;
|
||||
}
|
||||
var total = 0;
|
||||
for (var i = 1; i < gpsPoints.length; i++) {
|
||||
total += MapUtils.haversineKm(
|
||||
parseFloat(gpsPoints[i-1][0]), parseFloat(gpsPoints[i-1][1]),
|
||||
parseFloat(gpsPoints[i][0]), parseFloat(gpsPoints[i][1])
|
||||
);
|
||||
}
|
||||
distEl.textContent = '~' + Math.round(total).toLocaleString();
|
||||
}
|
||||
}
|
||||
window.initTripStats = initTripStats;
|
||||
```
|
||||
|
||||
Note: the function is **not** added to the `DOMContentLoaded` boot block — it is called per-page from the partial's inline script (Task 2) with page-specific config. `window.initTripStats =` is required because `main.js` is bundled as an IIFE, so the function is otherwise not reachable from inline template scripts.
|
||||
|
||||
- [ ] **Step 2: Rebuild the JS bundle**
|
||||
|
||||
Run: `make build-assets`
|
||||
Expected: completes without esbuild errors; `user/themes/intotheeast/js/main.js` is regenerated.
|
||||
|
||||
- [ ] **Step 3: Verify the function is exposed in the built bundle**
|
||||
|
||||
Run: `grep -c "initTripStats" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/main.js`
|
||||
Expected: a non-zero count (the minified bundle contains the symbol).
|
||||
|
||||
- [ ] **Step 4: Smoke-test that existing pages still work (no regression from the additive change)**
|
||||
|
||||
Load `http://localhost:8081/trips/japan-korea-2026` (or the active trip) in a browser. The trip page still uses its own inline stats script at this point, so stats should populate exactly as before. Open the console and confirm **no errors** and that `typeof window.initTripStats === 'function'`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||
git add js/src/main.js js/main.js
|
||||
git commit -m "feat(theme): add shared initTripStats() for trip+home stats panels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Shared partial `trip-feed-col.html.twig`
|
||||
|
||||
**Files:**
|
||||
- Create: `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (params, passed via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
|
||||
|
||||
| Param | Type | Trip passes | Home-active passes |
|
||||
|---|---|---|---|
|
||||
| `trip_page` | Page | `page` | `trip` |
|
||||
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
|
||||
| `journal_entries` | array | dailies children | dailies children |
|
||||
| `journal_count` | int | count | count |
|
||||
| `story_count` | int | count | count |
|
||||
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
|
||||
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
|
||||
| `gps_points` | array | `gps_points` | `gps_points` (new on home, Task 4) |
|
||||
| `show_sort` | bool | `true` | `false` |
|
||||
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
|
||||
|
||||
`gpx_urls` and `gps_points` are added to the spec's interface table as the agreed implementation choice: the partial emits the `initTripStats` inline call itself (single place), so it needs the page-specific data.
|
||||
- Consumes globally: `window.initTripStats` (Task 1), `window.MapUtils` (map.js), CSS classes from the existing theme.
|
||||
- Produces: the `.home-feed-col` DOM that `initFilterBar` / `initPanelToggles` / `initSortButton('trip-sort-toggle', …)` already key off (`.trip-filter-btn`, `[data-type]`, `.trip-panel-toggle`, `#feed-filter-empty`, `#trip-sort-toggle`).
|
||||
|
||||
- [ ] **Step 1: Create the partial file**
|
||||
|
||||
Create `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` with exactly:
|
||||
|
||||
```twig
|
||||
{% import 'macros/stats.html.twig' as stats_m %}
|
||||
{% import 'macros/cycling.html.twig' as cycling_m %}
|
||||
<div class="home-feed-col">
|
||||
{% if pre_departure %}
|
||||
{# ── Pre-departure landing state (home-active only) ──────────── #}
|
||||
<div class="home-trip-header">
|
||||
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||
{% if trip_page.header.date_start %}
|
||||
<p class="trip-dates">Departing {{ trip_page.header.date_start|date('d M Y') }}</p>
|
||||
{% endif %}
|
||||
<span class="home-trip-counts">Coming soon</span>
|
||||
</div>
|
||||
<div class="feed">
|
||||
<hr class="home-predeparture-divider">
|
||||
<p class="feed-empty">The journey hasn't begun yet — check back once we're on the road.</p>
|
||||
<div class="home-highlights-cta-wrap">
|
||||
<a class="home-highlights-cta" href="/trips">In the meantime, explore my other trips →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="home-trip-header">
|
||||
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||
{% if trip_page.header.date_start %}
|
||||
<p class="trip-dates">
|
||||
{{ trip_page.header.date_start|date('d M Y') }}
|
||||
{% if trip_page.header.date_end %} — {{ trip_page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<span class="home-trip-counts">
|
||||
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
|
||||
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
|
||||
</span>
|
||||
<div class="trip-filter-bar">
|
||||
<div class="trip-filter-group">
|
||||
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
|
||||
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
|
||||
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
|
||||
</div>
|
||||
{% if show_sort %}
|
||||
<button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="trip-panel-toggles">
|
||||
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||
{% if has_gpx %}
|
||||
<button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ stats_m.stats_panel(journal_entries, trip_page, journal_count, has_gpx) }}
|
||||
|
||||
{% if has_gpx %}
|
||||
{{ cycling_m.cycling_panel() }}
|
||||
{% endif %}
|
||||
|
||||
<div class="feed">
|
||||
{% if all_items|length > 0 %}
|
||||
{% for item in all_items %}
|
||||
{% set entry = item.page %}
|
||||
{% if item.type == 'journal' %}
|
||||
{% include 'partials/entry-journal.html.twig' %}
|
||||
{% else %}
|
||||
{% include 'partials/entry-story.html.twig' %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
|
||||
{% endif %}
|
||||
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initTripStats({
|
||||
gpxUrls: {{ gpx_urls|json_encode|raw }},
|
||||
gpsPoints: {{ gps_points|json_encode|raw }},
|
||||
hasGpx: {{ has_gpx ? 'true' : 'false' }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
Notes baked into this markup:
|
||||
- The non-pre-departure feed keeps the `{% else %}` "No entries yet" fallback so the trip page's empty-case output is unchanged (trip always passes `pre_departure: false`). Home never reaches this fallback because home-empty sets `pre_departure: true`.
|
||||
- The `initTripStats` call is wrapped in `DOMContentLoaded` so `window.initTripStats` and `window.MapUtils` (both in the `bottom` asset group rendered at the end of `<body>`) are defined when it runs.
|
||||
- The call is **not** nested inside any map-entries condition, so a trip with GPX but zero geocoded journal entries still populates the panels.
|
||||
- The partial is included with `only`, so it imports the `stats`/`cycling` macros itself.
|
||||
|
||||
- [ ] **Step 2: Verify Twig syntax compiles (no include yet, so render via a temporary check)**
|
||||
|
||||
The partial isn't referenced anywhere yet, so it can't render on its own. Verify there are no obvious Twig errors by confirming the file is well-formed:
|
||||
|
||||
Run: `grep -c "endif\|endfor\|endmacro" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||
Expected: non-zero (sanity check the file saved). Real verification happens in Task 3 when the trip page includes it.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||
git add templates/partials/trip-feed-col.html.twig
|
||||
git commit -m "feat(theme): add shared trip-feed-col partial"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Refactor `trip.html.twig` to use the partial
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (replace `:70-120`; remove `:213-249`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
|
||||
- Produces: visually and functionally identical trip-page output (regression-critical) — exact bytes may differ (re-indented markup, relocated stats `<script>`); confirm via the behavioral checks in Step 3, not a literal diff. The trip page already builds `all_items` (flag 4), `journal_entries`, `journal_count`, `story_count`, `gps_points`, `gpx_urls`, `has_gpx` — all passed straight through.
|
||||
|
||||
- [ ] **Step 1: Replace the inline `.home-feed-col` block with the include**
|
||||
|
||||
In `user/themes/intotheeast/templates/trip.html.twig`, replace the entire block from line 70 (` <div class="home-feed-col">`) through line 120 (` </div>`, the closing of `.home-feed-col`) with:
|
||||
|
||||
```twig
|
||||
{% include 'partials/trip-feed-col.html.twig' with {
|
||||
trip_page: page,
|
||||
all_items: all_items,
|
||||
journal_entries: journal_entries,
|
||||
journal_count: journal_count,
|
||||
story_count: story_count,
|
||||
has_gpx: has_gpx,
|
||||
gpx_urls: gpx_urls,
|
||||
gps_points: gps_points,
|
||||
show_sort: true,
|
||||
pre_departure: false
|
||||
} only %}
|
||||
```
|
||||
|
||||
Leave the surrounding `<div class="home-layout">` and `.home-map-col` block (lines 58–68) and the closing `</div>` of `.home-layout` (line 121) intact.
|
||||
|
||||
- [ ] **Step 2: Remove the inline stats script**
|
||||
|
||||
In the same file, delete the inline stats block — from line 213 (`var STATS_GPS = …`) through line 249 (the closing `})();` of the stats IIFE), inclusive. Specifically remove:
|
||||
|
||||
```twig
|
||||
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
|
||||
|
||||
(function() {
|
||||
var distEl = document.getElementById('stat-distance');
|
||||
|
||||
if (HAS_GPX) {
|
||||
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
|
||||
...
|
||||
});
|
||||
} else {
|
||||
var total = 0;
|
||||
...
|
||||
}
|
||||
|
||||
})();
|
||||
```
|
||||
|
||||
The map `<script>`'s `document.addEventListener('DOMContentLoaded', function() { … });` wrapper and its closing `}); // DOMContentLoaded` (line 251) stay — only the stats portion inside it is removed. The map setup, marker loop, fitBounds, `renderGpxJourney`, and the fullscreen IIFE (`:201-211`) remain untouched.
|
||||
|
||||
- [ ] **Step 3: Reload and regression-test the trip page**
|
||||
|
||||
Load `http://localhost:8081/trips/japan-korea-2026` (active trip with content). Confirm:
|
||||
- Header, date range, counts render as before.
|
||||
- Filter bar **with** the sort button (`↑`) is present.
|
||||
- Stats panel toggles open; distance populates (GPX → exact number; no GPX → `~`-prefixed estimate).
|
||||
- If the trip has GPX: Cycling toggle present and its panel populates.
|
||||
- Feed lists journal + stories, default order oldest→newest (flag 4, unchanged).
|
||||
- Filter All/Journal/Stories works; sort button flips order.
|
||||
- Console shows no errors.
|
||||
|
||||
- [ ] **Step 4: Verify the map is unaffected**
|
||||
|
||||
On the same page, confirm the map renders with markers, fits bounds, draws the GPX/journey route, and the mobile fullscreen button still works (resize on toggle). Marker click still scrolls to and flashes the card.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||
git add templates/trip.html.twig
|
||||
git commit -m "refactor(theme): trip.html.twig uses shared trip-feed-col partial + initTripStats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire `home.html.twig` active branch to the partial
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch: add `gps_points` build at `:30-31`; replace `:60-83`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
|
||||
- Produces: home-active now renders date range, filter bar (no sort button), and stats/cycling panels, plus the pre-departure block when no entries exist. The between-trips branch (`{% else %}`) is untouched and does not use the partial.
|
||||
|
||||
- [ ] **Step 1: Add the `gps_points` build (no-GPX stats fallback)**
|
||||
|
||||
In `user/themes/intotheeast/templates/home.html.twig`, in the active-trip branch, after the counts at line 30 (`{% set story_count = story_entries|length %}`) and before the `map_entries` build (line 32), insert:
|
||||
|
||||
```twig
|
||||
|
||||
{% set gps_points = [] %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
This mirrors `trip.html.twig:27-32`.
|
||||
|
||||
- [ ] **Step 2: Replace the bespoke feed-col with the include**
|
||||
|
||||
In the same file, replace the entire `<div class="home-feed-col">` block from line 60 through its closing `</div>` at line 83 with:
|
||||
|
||||
```twig
|
||||
{% include 'partials/trip-feed-col.html.twig' with {
|
||||
trip_page: trip,
|
||||
all_items: all_items,
|
||||
journal_entries: journal_entries,
|
||||
journal_count: journal_count,
|
||||
story_count: story_count,
|
||||
has_gpx: home_gpx_urls|length > 0,
|
||||
gpx_urls: home_gpx_urls,
|
||||
gps_points: gps_points,
|
||||
show_sort: false,
|
||||
pre_departure: all_items|length == 0
|
||||
} only %}
|
||||
```
|
||||
|
||||
Leave `<div class="home-layout">` and the `.home-map-col` block (lines 55–58) and the closing `</div>` of `.home-layout` (line 84) intact. The map `<script>` block (lines 86–139, gated by `map_entries|length > 0`) stays untouched.
|
||||
|
||||
- [ ] **Step 3: Reload and test home-active (with content)**
|
||||
|
||||
Ensure `config.site.travelling: true` and the active trip has posts. Load `http://localhost:8081/`. Confirm:
|
||||
- Date range, counts, and filter bar appear — **no** sort button.
|
||||
- Stats panel toggles open and distance populates (`~` estimate from `gps_points` when no GPX; exact when GPX present); Cycling panel appears and populates only if the trip has GPX.
|
||||
- Filter All/Journal/Stories works; panel toggles work.
|
||||
- Feed default order is home's own (flag 3, unchanged from today).
|
||||
- Console shows no errors; map still renders.
|
||||
|
||||
- [ ] **Step 4: Test the pre-departure empty state**
|
||||
|
||||
With `travelling: true` and **no posts** in the active trip's `dailies`/`stories` (temporarily, or on a fresh trip), load `/`. Confirm:
|
||||
- The pre-departure block shows the trip title, "Departing <date>", "Coming soon", a divider, and the "In the meantime, explore my other trips →" button linking to `/trips`.
|
||||
- The filter bar, panel toggles, and the generic "No entries yet" fallback do **not** appear.
|
||||
- After posting one entry (or restoring content), the pre-departure block disappears and the normal filter bar + feed render.
|
||||
|
||||
- [ ] **Step 5: Regression-test between-trips mode**
|
||||
|
||||
Set `config.site.travelling: false`, load `/`. Confirm the highlights grid layout is unchanged (this branch does not use the partial). Restore `travelling: true` afterward if that is the intended dev state.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
|
||||
git add templates/home.html.twig
|
||||
git commit -m "feat(theme): home-active reuses trip-feed-col partial with stats + pre-departure state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Date-range header, filter bar, stats/cycling panels on home-active → Tasks 2 + 4. ✅
|
||||
- Chrome in one place (partial) → Task 2; both pages include it → Tasks 3, 4. ✅
|
||||
- Home keeps own order, no sort button → `show_sort: false`, `all_items` flag 3 unchanged (Task 4). ✅
|
||||
- Stats/cycling from single shared JS → Task 1 (`initTripStats`), called via partial. ✅
|
||||
- No visual/functional change to trip output (exact bytes may differ: re-indented markup, relocated stats `<script>`) → Task 3 passes through existing vars; partial preserves the empty-case `{% else %}` fallback. ✅
|
||||
- Stats glue runs in `DOMContentLoaded`, not nested in map block, `<2`-points guard writes `—` → Task 1 + partial script. ✅
|
||||
- Home `gps_points` build added → Task 4 Step 1. ✅
|
||||
- Pre-departure block (title + start date + "Coming soon" + divider + button; suppresses filter bar/fallback; panels hidden) → Task 2 markup + Task 4 gating. ✅
|
||||
- Map convergence out of scope; both map blocks untouched → Tasks 3, 4 leave map markup/scripts intact. ✅
|
||||
- Between-trips branch untouched → Task 4 only edits the active branch. ✅
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every step has concrete code or an exact command. ✅
|
||||
|
||||
**Type consistency:** `initTripStats` config keys (`gpxUrls`, `gpsPoints`, `hasGpx`) match between Task 1 (definition), the partial's inline call (Task 2), and the data both pages pass (Tasks 3, 4). Partial param names match the include calls in both templates. `gpx_urls`/`gps_points`/`has_gpx` consistent throughout. ✅
|
||||
|
||||
---
|
||||
|
||||
**Plan complete and saved to `docs/working/plans/2026-06-27-home-trip-view-convergence.md`. Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
|
||||
|
||||
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,270 @@
|
||||
# Map Init Consolidation — shared `MapUtils.initEntryMap()`
|
||||
|
||||
**Status:** ✅ Complete (2026-06-27)
|
||||
|
||||
> Plan type: `refactor` · Depth: Standard · Origin: deferred memory `project-map-init-refactor` (re-scoped 2026-06-27 after home/trip convergence)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The recent home/trip convergence work multiplied an already-duplicated pattern: there are now **five** near-identical MapLibre init blocks, each repeating ~50–130 lines of map construction, marker/popup loop, bounds-fitting, journey rendering, and fullscreen wiring. The shared `maplibre-utils.js` already centralizes marker *creation* and GPX/journey rendering, but **not the init orchestration** — that is what is copy-pasted and has since drifted into subtly inconsistent behavior.
|
||||
|
||||
This plan extracts the init orchestration into one config-driven function, `MapUtils.initEntryMap(opts)`, in `user/themes/intotheeast/js/maplibre-utils.js` (which esbuild already bundles into `js/map.js`, so every template that loads `map.js` picks it up). The two actively-used surfaces — the **trip page** and the **home active-trip view** — are converted to call it and become behaviorally identical. The **home highlights (between-trips) view** is converted too, with a deliberate small UX change: marker click navigates to the article instead of scrolling to a grid card (hover-title already exists). The dormant `feed-map.html.twig` partial and `map.html.twig` full-page map are **left untouched** this pass.
|
||||
|
||||
This is a refactor with two intentional, scoped behavior changes (both on the home page) — not byte-for-byte preservation.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
`maplibre-utils.js` gives every map the same building blocks (`createDotMarker`, `createStoryMarker`, `renderGpxJourney`, `MAP_STYLE`), but each template still hand-writes the *assembly*: `new maplibregl.Map(...)`, attribution control, the `on('load')` marker loop with hover popup + click handler, `fitBounds`/`jumpTo`, and the fullscreen toggle IIFE. Five copies exist:
|
||||
|
||||
| Surface | Container | Marker click (today) | In active use? |
|
||||
|---|---|---|---|
|
||||
| `trip.html.twig` | `#trip-map` | scroll+flash `entry-` card, fullscreen-aware | ✅ active |
|
||||
| `home.html.twig` active branch | `#home-map` | set hash to `entry-` card, **no flash, not fullscreen-aware** | ✅ active |
|
||||
| `home.html.twig` highlights branch | `#home-map` | `scrollIntoView` to `highlight-` card | ✅ active |
|
||||
| `partials/feed-map.html.twig` | `#feed-map` / `#stories-map` | scroll+flash else navigate, fullscreen-aware | ⚠️ dormant (dailies/stories) |
|
||||
| `map.html.twig` | `#trip-map` (full page) | always navigate to URL | ⚠️ dormant |
|
||||
|
||||
**Consequences of the duplication:**
|
||||
- The trip page and home active view are meant to be the same component but have already drifted (home active lacks the flash highlight and the fullscreen button trip has).
|
||||
- Any future map change must be applied in up to five places, by hand, with no shared test surface.
|
||||
- The click logic carries five subtly different implementations of just **two** real intents: *scroll to the matching card on this page*, or *navigate to the entry's own page*.
|
||||
|
||||
**Why now:** The deferral in `project-map-init-refactor` was justified by "dailies/stories/full-map aren't in active use." That still holds for those three — but trip and home are now both active and nearly identical, so the high-value consolidation is unblocked while the dormant surfaces stay out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- New `MapUtils.initEntryMap(opts)` in `maplibre-utils.js` + esbuild rebuild.
|
||||
- Convert `trip.html.twig` to call it (behavior preserved).
|
||||
- Convert `home.html.twig` active branch to call it + add a fullscreen button so it matches the trip page exactly.
|
||||
- Convert `home.html.twig` highlights branch to call it (click → navigate to article).
|
||||
|
||||
**Intentional behavior changes (both home page only):**
|
||||
- Home active view **gains** the flash-highlight on card scroll and the fullscreen button/awareness it currently lacks → becomes identical to the trip page.
|
||||
- Home highlights view marker click **changes** from `scrollIntoView` to the grid card → navigate to the article URL. Hover-title popup is unchanged (already present).
|
||||
- **Both home maps' attribution restyles.** `initEntryMap` always constructs with `attributionControl: false` + a compact `AttributionControl` bottom-left, collapsed on load (mirroring trip). The home active and home highlights maps currently use MapLibre's default attribution (expanded, bottom-right), so both move to trip's compact collapsed bottom-left. For home active this is part of "identical to trip"; for home highlights — which is otherwise unchanged except for the click behavior — it is an *incidental* restyle. If highlights should keep the default attribution, parameterize attribution in `opts` (e.g. `attribution: { compact, position, collapse }`) rather than baking trip's treatment into every caller.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Converting `partials/feed-map.html.twig` (dailies/stories) onto `initEntryMap`. It is already a shared partial with low duplication cost and the pages are dormant; retrofit it when those feeds return to active use. The unified click rule already matches feed-map's current behavior, so this will be a near-drop-in later.
|
||||
- Converting `map.html.twig` (full-page map) onto `initEntryMap`. Dormant; navigate-only behavior is the `cardPrefix: null` path, so it too will be a clean later conversion.
|
||||
|
||||
**Out of scope:** no CSS changes, no map-style change, no change to the Twig-side `map_entries`/`gpx_urls` computation, no change to `createDotMarker`/`createStoryMarker`/`renderGpxJourney`.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
**KTD1 — Init orchestration lives in `maplibre-utils.js`, not per-template.**
|
||||
`maplibre-utils.js` is imported by `js/src/map.js` and bundled by esbuild into the minified `js/map.js` that every map-bearing template loads via `{% block map_assets %}`. Adding `initEntryMap` there + rebuilding makes it available everywhere with a single source of truth. This is the whole point of the refactor.
|
||||
|
||||
**KTD2 — One unified click rule, no click-mode enum.**
|
||||
The function takes an optional `cardPrefix`. Click behavior is a single rule: *if `cardPrefix` is set and `document.getElementById(cardPrefix + slug)` exists, scroll to it (via `location.hash`) and flash `is-highlighted`, fullscreen-aware when a fullscreen target is configured; otherwise navigate to `entry.url`.* This single rule subsumes every behavior the in-scope surfaces need — trip and home active pass `cardPrefix: 'entry-'`; home highlights passes no prefix and gets navigate-on-click for free. It also happens to match the dormant feed-map/map.html behaviors, easing their later conversion. No `clickMode` parameter is introduced.
|
||||
|
||||
**Card-absent fallback — note the divergence from trip today.** The current trip handler does `if (!card) return;` (a no-op) when no card matches the slug; the unified rule instead **navigates** to `entry.url`. This is behavior-preserving on every in-scope surface **only under the invariant that every map marker has a matching feed card** (`entry-<slug>`, emitted by both the journal and story entry partials). Document that invariant where it is relied on (U2). If a future map entry can ever lack a feed card (a map-only POI, a new pin type), make the fallback per-surface — trip = no-op, highlights = navigate — rather than letting the shared default retroactively change trip's semantics.
|
||||
|
||||
**KTD3 — `map_entries` / `gpx_urls` stay computed in Twig.**
|
||||
Per the Milestone 2 refactor decision, Twig macros cannot return arrays, so each template keeps its existing Twig loop that builds `map_entries` and serializes it to a JS var. The only change is replacing the inline init `<script>` body with a single `MapUtils.initEntryMap({...})` call inside `DOMContentLoaded`. Each template's map `<script>` shrinks from ~50–90 lines to ~10.
|
||||
|
||||
**KTD4 — Dormant surfaces excluded.**
|
||||
`feed-map.html.twig` and `map.html.twig` keep their current inline init this pass (see Deferred). Reduces blast radius to the two active surfaces plus the home highlights view.
|
||||
|
||||
**KTD5 — Home active fullscreen button reuses existing CSS.**
|
||||
The fullscreen button uses the same `.feed-map-fullscreen-btn` markup as the trip page, and the fullscreen target is `.home-map-col`. Both `.feed-map-fullscreen-btn` (`css/style.css:636`) and `.home-map-col.is-fullscreen` (`css/style.css:911`) already exist — no CSS changes required.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
**Who calls the shared function (after this plan):**
|
||||
|
||||
```
|
||||
maplibre-utils.js ── MapUtils.initEntryMap(opts) ──┐
|
||||
(bundled into js/map.js) │
|
||||
├─ trip.html.twig → cardPrefix:'entry-', fullscreen, story markers
|
||||
├─ home.html.twig (active) → cardPrefix:'entry-', fullscreen
|
||||
└─ home.html.twig (highlights) → no cardPrefix (→ navigate)
|
||||
|
||||
feed-map.html.twig / map.html.twig → unchanged (own inline init, deferred)
|
||||
```
|
||||
|
||||
**Unified marker-click rule** (the single behavior the function implements):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Marker clicked] --> B{cardPrefix set AND<br/>card #prefix+slug exists?}
|
||||
B -- no --> C[navigate to entry.url]
|
||||
B -- yes --> D{fullscreen target<br/>configured AND open?}
|
||||
D -- yes --> E[close fullscreen,<br/>then scroll+flash after delay]
|
||||
D -- no --> F[scroll to hash,<br/>flash is-highlighted]
|
||||
```
|
||||
|
||||
**`initEntryMap(opts)` shape** (directional, not a signature spec):
|
||||
|
||||
| Option | Type | Used by | Notes |
|
||||
|---|---|---|---|
|
||||
| `container` | string | all | map div id (`'trip-map'`, `'home-map'`) |
|
||||
| `entries` | array | all | already-parsed `map_entries` from Twig |
|
||||
| `cardPrefix` | string \| null | trip, home active | `'entry-'`; omit/null → markers navigate to `entry.url` |
|
||||
| `storyMarkers` | bool | trip | render `createStoryMarker()` for `type === 'story'`; default dot markers |
|
||||
| `markLatest` | bool | trip, home active | enlarge the final non-story entry's dot (default `true`); home highlights passes `false` so no marker is singled out in the shuffled set (added during execution — preserves highlights' current all-equal dots) |
|
||||
| `fullscreen` | `{ btnId, colSelector }` \| null | trip, home active | wires the fullscreen toggle; null → no fullscreen |
|
||||
| `gpx` | `{ urls, use, autoconnect, sourcePrefix, journeyId }` \| null | trip, home active | forwarded to `renderGpxJourney`; null → skip |
|
||||
| `fit` | `{ padding, maxZoom, singleZoom }` | all | defaults `{60, 11, 10}`; highlights uses `maxZoom: 8`, `singleZoom: 8` |
|
||||
|
||||
The function returns the map instance and internally does: construct map (`attributionControl: false`) + compact `AttributionControl` bottom-left; on `load` build bounds, loop entries → marker + hover popup + unified click handler, `fitBounds`/`jumpTo`, collapse the attribution `<details>`, call `renderGpxJourney` when `gpx` is set; wire the fullscreen IIFE when `fullscreen` is set; `setTimeout(map.resize, 100)`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Add `MapUtils.initEntryMap(opts)` to `maplibre-utils.js`
|
||||
|
||||
**Goal:** Introduce the config-driven init function and rebuild the bundle. No template consumes it yet.
|
||||
|
||||
**Dependencies:** none.
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/js/maplibre-utils.js` (add `initEntryMap`, export it on the `global.MapUtils` object)
|
||||
- Build artifact (regenerated, do not hand-edit): `user/themes/intotheeast/js/map.js`
|
||||
|
||||
**Approach:**
|
||||
- Add `function initEntryMap(opts) { ... }` near the other public helpers; add `initEntryMap: initEntryMap` to the `global.MapUtils = { ... }` export block.
|
||||
- Implement the full orchestration described in HTD: map construction, attribution control + collapse, the `on('load')` marker loop (hover popup identical to current `map-tip` popups; marker element via `createStoryMarker()` when `opts.storyMarkers && entry.type === 'story'`, else `createDotMarker(isLatest)` where `isLatest = (entry.type !== 'story') && (i === entries.length - 1)` — the final-indexed entry, and only when it is not a story, mirroring `trip.html.twig:110` exactly; note this enlarges *nothing* when the last entry is a story, which is the current trip behavior and must be preserved — do **not** reinterpret it as "the last non-story entry"), bounds fit (`fitBounds` with `opts.fit` defaults, `jumpTo` for single entry), `renderGpxJourney` when `opts.gpx`, the fullscreen toggle IIFE when `opts.fullscreen`, and the trailing `resize`.
|
||||
- Implement KTD2's unified click rule exactly: resolve card by `opts.cardPrefix + entry.slug`; if absent → `window.location.href = entry.url`; if present → set `location.hash`, then after 350ms add `is-highlighted` for 700ms; when `opts.fullscreen` is configured and the col is `.is-fullscreen`, click the fullscreen button first and defer the scroll ~450ms (mirror the current trip handler timings).
|
||||
- Keep the empty-entries behavior cheap: if `entries.length === 0`, still construct the map and return (the trip page's existing "no locations yet" copy is page-specific and stays in the template if needed — do not bake page copy into the util).
|
||||
- Rebuild: run `make build-assets` so `js/map.js` regenerates from source. Never hand-edit `js/map.js`.
|
||||
|
||||
**Patterns to follow:** mirror the existing trip page handler (`trip.html.twig:100-171`) as the canonical behavior, since trip is the surface whose behavior is being preserved; reuse the existing module structure and IIFE export pattern already in `maplibre-utils.js`.
|
||||
|
||||
**Execution note:** This is the load-bearing unit. Implement it to faithfully reproduce the trip page's current behavior before any caller is switched, so U2 is behavior-preserving — a no-op under the card-matching invariant noted in KTD2; the card-absent navigate fallback is the one deliberate divergence and is unreachable on trip today.
|
||||
|
||||
**Test scenarios:**
|
||||
- *Happy path (card present):* with a `cardPrefix` and a matching card in the DOM, clicking a marker sets `location.hash` to `prefix+slug` and toggles `is-highlighted` on the card (added after ~350ms, removed ~700ms later).
|
||||
- *Happy path (no prefix):* with `cardPrefix` null/omitted, clicking a marker sets `window.location.href` to `entry.url`.
|
||||
- *Fallback:* with a `cardPrefix` set but no matching card in the DOM, clicking navigates to `entry.url`.
|
||||
- *Fullscreen-aware:* with `fullscreen` configured and the col `.is-fullscreen`, a marker click triggers the fullscreen button click first, then scrolls.
|
||||
- *Bounds:* one entry → `jumpTo` at `fit.singleZoom`; multiple entries → `fitBounds` with `fit.padding`/`fit.maxZoom`.
|
||||
- *Story markers:* with `storyMarkers: true` and an entry `type === 'story'`, a story marker is used and that entry is never treated as `isLatest`.
|
||||
- *GPX:* with `gpx` set, `renderGpxJourney` is called with the forwarded urls/sourcePrefix/journeyId/connectMode; with `gpx` null it is not called.
|
||||
- *Empty:* `entries: []` constructs the map without throwing and renders no markers.
|
||||
- *Build:* after `make build-assets`, `js/map.js` is regenerated and `window.MapUtils.initEntryMap` is defined at runtime.
|
||||
|
||||
**Verification:** `MapUtils.initEntryMap` is exported and callable; `make build-assets` completes and updates `js/map.js`; no console errors when invoked.
|
||||
|
||||
---
|
||||
|
||||
### U2. Convert `trip.html.twig` to `initEntryMap` (behavior preserved)
|
||||
|
||||
**Goal:** Replace the trip page's inline ~85-line map `<script>` body with a single `initEntryMap` call; behavior is preserved — behaviorally equivalent under the card-matching invariant in KTD2 (not literally byte-for-byte, since the `<script>` body is rewritten and the card-absent fallback changes from no-op to navigate, which is unreachable on trip).
|
||||
|
||||
**Dependencies:** U1.
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/trip.html.twig`
|
||||
|
||||
**Approach:** Keep the Twig `map_entries`/`gpx_urls` computation and the `TRIP_ENTRIES`/`GPX_URLS`/`USE_GPX`/`AUTOCONNECT` JS var declarations. Replace everything inside `DOMContentLoaded` (the `new maplibregl.Map`, the `on('load')` loop, fit-bounds, `renderGpxJourney`, attribution collapse, the fullscreen IIFE, the trailing resize) with one call: `MapUtils.initEntryMap({ container: 'trip-map', entries: TRIP_ENTRIES, cardPrefix: 'entry-', storyMarkers: true, fullscreen: { btnId: 'trip-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'gpx', journeyId: 'trip-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`. Leave the fullscreen button markup and the `#trip-totop` button as-is.
|
||||
|
||||
**Patterns to follow:** existing `trip.html.twig` markup and var names; the include-call style already used for partials.
|
||||
|
||||
**Test scenarios:**
|
||||
- *Markers + hover:* trip page renders one dot per entry plus story markers; hovering shows the `map-tip` title popup (unchanged).
|
||||
- *Click → scroll+flash:* clicking a marker scrolls to its `entry-<slug>` feed card and flashes it.
|
||||
- *Fullscreen:* the fullscreen button still expands `.home-map-col` and the marker-click-while-fullscreen path still closes then scrolls.
|
||||
- *GPX:* GPX tracks + journey segments still render when `use_gpx` is on.
|
||||
- *Regression:* visual diff against pre-change trip page shows no behavioral difference.
|
||||
|
||||
**Verification:** trip page at `localhost:8081/trips/<active_trip>` behaves identically to before — markers, popups, click-scroll-flash, fullscreen, GPX all intact; no console errors.
|
||||
|
||||
---
|
||||
|
||||
### U3. Convert `home.html.twig` active-trip branch + add fullscreen button (match trip page)
|
||||
|
||||
**Goal:** The home active-trip map becomes behaviorally identical to the trip page — it gains the flash-highlight and a working fullscreen button it currently lacks.
|
||||
|
||||
**Dependencies:** U1. Independent of U2.
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch markup + script)
|
||||
|
||||
**Approach:**
|
||||
- Markup: add the fullscreen button inside the active branch's `<div class="home-map" id="home-map">` (currently `home.html.twig:64`), reusing the exact `.feed-map-fullscreen-btn` markup from `trip.html.twig:61-67` with `id="home-map-fullscreen"`. No CSS changes (KTD5).
|
||||
- Script: keep the `HOME_ENTRIES`/`HOME_GPX_URLS`/`USE_GPX`/`AUTOCONNECT` var declarations; replace the inline `new maplibregl.Map` + `on('load')` body with `MapUtils.initEntryMap({ container: 'home-map', entries: HOME_ENTRIES, cardPrefix: 'entry-', fullscreen: { btnId: 'home-map-fullscreen', colSelector: '.home-map-col' }, gpx: { urls: HOME_GPX_URLS, use: USE_GPX, autoconnect: AUTOCONNECT, sourcePrefix: 'home-gpx', journeyId: 'home-journey' }, fit: { padding: 60, maxZoom: 11, singleZoom: 10 } })`.
|
||||
- Note: `storyMarkers` is omitted (home active currently uses dot markers only — preserved).
|
||||
|
||||
**Patterns to follow:** the trip page conversion (U2) and the trip fullscreen button markup.
|
||||
|
||||
**Test scenarios:**
|
||||
- *Parity:* home active map renders markers, hover popups, and click-scroll **with flash** (previously no flash) to `entry-<slug>` cards.
|
||||
- *Fullscreen (new):* the new `home-map-fullscreen` button expands `.home-map-col`, and a marker click while fullscreen closes then scrolls — matching trip.
|
||||
- *GPX:* home GPX journey still renders (`home-gpx` / `home-journey` source ids preserved).
|
||||
- *No story markers:* dot markers only, as before.
|
||||
|
||||
**Verification:** home page (travelling/active state) map matches the trip page in every interaction; fullscreen button visible and functional on mobile widths; no console errors.
|
||||
|
||||
---
|
||||
|
||||
### U4. Convert `home.html.twig` highlights branch (click → navigate)
|
||||
|
||||
**Goal:** The between-trips highlights map uses the shared init, and marker click opens the article instead of scrolling to a grid card. Hover-title popup unchanged.
|
||||
|
||||
**Dependencies:** U1. Lands naturally alongside U3 (same file) but is a distinct behavior change.
|
||||
|
||||
**Files:**
|
||||
- Modify: `user/themes/intotheeast/templates/home.html.twig` (highlights branch script, `home.html.twig:245-289`)
|
||||
|
||||
**Approach:** Keep the `HIGHLIGHTS_ENTRIES` var declaration. Replace the inline `new maplibregl.Map` + `on('load')` body (including the current `scrollIntoView` click handler) with `MapUtils.initEntryMap({ container: 'home-map', entries: HIGHLIGHTS_ENTRIES, fit: { padding: 60, maxZoom: 8, singleZoom: 8 } })` — no `cardPrefix`, no `fullscreen`, no `gpx`. The absent `cardPrefix` yields navigate-on-click per KTD2; the `map-tip` hover popup is provided by the shared loop, so hover-title is preserved with no extra code. Note: this also restyles the highlights map's attribution to trip's compact collapsed bottom-left (see Scope Boundaries → "Both home maps' attribution restyles") — an incidental change; pass an attribution `opts` override if the MapLibre default should be retained here.
|
||||
|
||||
**Patterns to follow:** the navigate path of the unified click rule (KTD2).
|
||||
|
||||
**Test scenarios:**
|
||||
- *Hover:* hovering a highlights marker shows the article title popup (preserved).
|
||||
- *Click → navigate:* clicking a highlights marker navigates to `entry.url` (changed from `scrollIntoView`).
|
||||
- *Bounds:* highlights map still fits at the wider zoom (`maxZoom`/`singleZoom` 8).
|
||||
- *No fullscreen / no GPX:* no fullscreen button appears and no GPX journey renders on the highlights map.
|
||||
|
||||
**Verification:** between-trips home state shows the highlights map; hovering a pin shows its title, clicking it opens the article; no console errors.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Regression on the two live surfaces.** Trip and home active are the primary UI. Mitigation: U2 is a strict behavior-preserving change verified by visual diff; U1 is built to reproduce the trip handler exactly before any caller switches. Check existing Playwright map coverage (see `docs/working/plans/2026-06-22-align-maps-tests.md` / `2026-06-21-playwright-tests.md`) and run it after U2–U4.
|
||||
- **Stale bundle.** `js/map.js` is generated; forgetting `make build-assets` ships old behavior. Mitigation: U1 explicitly includes the rebuild and a runtime check that `MapUtils.initEntryMap` is defined.
|
||||
- **Duplicate element id.** The new `home-map-fullscreen` button must exist only in the active branch (highlights branch has no fullscreen). The two `#home-map` containers are already in mutually-exclusive Twig branches, so no real-DOM collision occurs.
|
||||
- **Sequencing:** U2, U3, U4 all depend only on U1. U3 and U4 touch the same file and will typically land in one commit.
|
||||
- **Known limitation — filter-hidden card click (accepted).** The unified rule keys on the card *existing* (`getElementById`), not on it being visible. When the feed filter bar (All/Journal/Stories) has hidden the target card (`display:none`), a marker click sets the hash and flashes an off-screen card, so the map appears unresponsive. This is a pre-existing rough edge being carried forward deliberately (not a regression introduced here) — accepted as-is for this pass rather than adding filter-reset or navigate-fallback handling.
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
1. After U1: `make build-assets` succeeds; `js/map.js` updated; `window.MapUtils.initEntryMap` defined.
|
||||
2. After U2: trip page (`/trips/<active_trip>`) — markers, hover, click-scroll-flash, fullscreen, GPX all unchanged.
|
||||
3. After U3: home active state — identical to trip, including the new fullscreen button and flash.
|
||||
4. After U4: home between-trips state — hover-title + click-to-open; wider zoom; no fullscreen/GPX.
|
||||
5. Run existing Playwright map tests; confirm no new failures.
|
||||
6. Confirm net line reduction across `trip.html.twig` + `home.html.twig` (the duplication is gone) and that `maplibre-utils.js` is the single source of init truth.
|
||||
|
||||
---
|
||||
|
||||
## Execution Outcome (2026-06-27)
|
||||
|
||||
All four units landed. `MapUtils.initEntryMap(opts)` added to `maplibre-utils.js` and bundled via `make build-assets`; `trip.html.twig`, both `home.html.twig` branches converted. Two notes from execution:
|
||||
|
||||
- **`markLatest` opt added** — the highlights branch rendered all dots equal (`createDotMarker(false)`), but the shared `isLatest = (i === length-1)` would have enlarged the last (shuffled) highlight. Added a `markLatest` flag (default `true`; highlights passes `false`) to preserve that.
|
||||
- **Map instance exposed as `window.tripMap` / `window.homeMap`** — `initEntryMap` returns the map, and the templates assign it to these globals. This is the affordance the existing Playwright specs (M7, M8) already assumed; wiring it up turned two perma-failing tests green, giving real regression coverage on the converted surfaces.
|
||||
|
||||
**Test status:** `tests/ui/maps`, `tests/ui/home`, `tests/ui/trip`, `tests/ui/gpx` — 38 passed. Remaining failures are pre-existing and out of scope: **M6** asserts `window.map` on the deferred `map.html.twig` (untouched this pass); **H1** is a parallel-load timing flake (passes 4/4 in isolation). Both fail identically on the pre-refactor baseline.
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Origin: memory `project-map-init-refactor` (deferral), re-scoped after `project-homepage-redesign` / home-trip convergence.
|
||||
- Milestone 2 refactor decision that `map_entries` stays Twig-side: memory `project-template-refactor-milestone2`, plan `docs/working/plans/2026-06-23-template-refactor.md`.
|
||||
- Current behavior read directly from: `trip.html.twig:83-174`, `home.html.twig:87-139` (active) and `:245-289` (highlights), `partials/feed-map.html.twig`, `map.html.twig`, `js/maplibre-utils.js`, build config `package.json` `build` script.
|
||||
- No external research — strong local patterns; behavior is fully specified by the existing code.
|
||||
@@ -0,0 +1,550 @@
|
||||
# Grav 2.0.4 Upgrade + GPM-Manage Plugins — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Status:** ✅ Complete (2026-07-04) — local (Phase 1) validated and shipped; Task 5 (remote test-env, Phase 2) is user-gated and parked; Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond.
|
||||
|
||||
**Goal:** Upgrade Grav core `2.0.0-rc.10` → `2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
|
||||
|
||||
**Architecture:** One dependency-forced atomic upgrade. Local core is baked into the Docker image (rebuild); the server upgrades in place via `bin/gpm self-upgrade` + `bin/gpm update`. The GPM release channel is switched from `testing` to `stable` in `user/config/system.yaml`. `git-sync` is disabled for the duration of the remote upgrade and left off pending user validation.
|
||||
|
||||
**Tech Stack:** Grav 2.0 (PHP 8.3), GPM CLI, Docker Compose, Make (env-suffixed remote targets), Gitea content sync.
|
||||
|
||||
**Spec:** `docs/working/specs/2026-07-04-grav-2.0.4-upgrade-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Version floors (GPM enforces): `grav >= 2.0.4`, `api >= 1.0.6`, `admin2 >= 2.0.9`, `flex-objects >= 1.4.3`, `login >= 3.8.11`, `form >= 6.0.0`. Assert with `>=`, not `==` — GPM may serve a newer stable patch at execution time.
|
||||
- Only write inside `travel-blog-intotheeast/` or its subfolders.
|
||||
- **Dual git repos.** The project root is one repo; `user/` is a *separate* repo where only `pages/ config/ accounts/ themes/` are tracked (`plugins/` is gitignored except `cache-on-save/` and `story-blocks/`). Changes to `user/config/system.yaml` commit to the **user repo** and reach the server via `make content-push` → server pull; everything else commits to the **root repo**.
|
||||
- GPM channel authority is `user/config/system.yaml` → `gpm.releases` (must be `stable` on the server *before* any GPM op). `GRAV_CHANNEL` in docker-compose is cosmetic/consistency only.
|
||||
- Never read `.env*`. Use `make remote-*` targets for all server ops.
|
||||
- Do not touch `twig.cache` (stays `false` in dev per CLAUDE.md).
|
||||
- `git-sync` is remote-only: never add it to `plugins.txt`; disable it during the remote upgrade and leave it disabled until the user re-enables.
|
||||
- Prod is empty → **Phase 3 is documentation only, never executed.**
|
||||
- Verified CLI names (against the rc.10 container): `php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins), `php bin/grav cache` (clear cache). `bin/grav upgrade` does **not** exist.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Repo | Responsibility |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | root | Baked local core version (grav-admin zip URL) |
|
||||
| `plugins.txt` | root | GPM plugin manifest — gains admin2/api/flex-objects |
|
||||
| `docker-compose.yml` | root | `GRAV_CHANNEL` cosmetic bump |
|
||||
| `user/config/system.yaml` | **user** | Authoritative GPM channel (`gpm.releases`) |
|
||||
| `scripts/server-install.sh` | root | Fresh-install script — drop admin2/api special-casing |
|
||||
| `scripts/git-sync-toggle.sh` | root | New: idempotently set git-sync `enabled:` on the server |
|
||||
| `Makefile` | root | Fix `remote-upgrade-grav`; add 3 remote targets |
|
||||
| `docs/working/plans/...` `CLAUDE.md` `docs/reference/architecture.md` | root | Runbook + stack docs |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Phase 0 — core, plugin-list, and channel edits
|
||||
|
||||
**Files:**
|
||||
- Modify: `Dockerfile` (the grav-admin zip URL line)
|
||||
- Modify: `plugins.txt`
|
||||
- Modify: `docker-compose.yml` (`GRAV_CHANNEL`)
|
||||
- Modify: `user/config/system.yaml` (`gpm.releases`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: local image that installs Grav 2.0.4; `plugins.txt` containing `api`, `admin2`, `flex-objects`; `stable` GPM channel consumed by Task 2 (local) and Task 5 (server).
|
||||
|
||||
- [ ] **Step 1: Bump the core version in the Dockerfile**
|
||||
|
||||
In `Dockerfile`, change the download URL:
|
||||
|
||||
```dockerfile
|
||||
RUN curl -sL 'https://github.com/getgrav/grav/releases/download/2.0.4/grav-admin-v2.0.4.zip' \
|
||||
-o /tmp/grav-admin.zip \
|
||||
```
|
||||
|
||||
(Only the URL changes — the zip still extracts to `/tmp/grav-admin/`, so every `cp` line below it is unchanged.)
|
||||
|
||||
- [ ] **Step 2: Add the three plugins to `plugins.txt`**
|
||||
|
||||
Append these lines to `plugins.txt` (order is not significant; GPM resolves deps):
|
||||
|
||||
```
|
||||
api
|
||||
admin2
|
||||
flex-objects
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Switch the GPM channel to stable**
|
||||
|
||||
In `user/config/system.yaml`, under the `gpm:` block (currently line ~212):
|
||||
|
||||
```yaml
|
||||
gpm:
|
||||
releases: stable
|
||||
official_gpm_only: true
|
||||
```
|
||||
|
||||
(Change `testing` → `stable`. Leave `official_gpm_only` as-is.)
|
||||
|
||||
- [ ] **Step 4: Bump the cosmetic channel env**
|
||||
|
||||
In `docker-compose.yml`, under the `grav` service environment:
|
||||
|
||||
```yaml
|
||||
- GRAV_CHANNEL=production
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit the user-repo change**
|
||||
|
||||
```bash
|
||||
cd user
|
||||
git add config/system.yaml
|
||||
git commit -m "config: switch GPM release channel testing -> stable"
|
||||
cd ..
|
||||
```
|
||||
|
||||
Expected: commit succeeds in the `user` repo.
|
||||
|
||||
- [ ] **Step 6: Commit the root-repo changes**
|
||||
|
||||
```bash
|
||||
git add Dockerfile plugins.txt docker-compose.yml
|
||||
git commit -m "build: pin Grav core 2.0.4 and add admin2/api/flex-objects to plugins.txt"
|
||||
```
|
||||
|
||||
Expected: commit succeeds on branch `grav-2.0.4-upgrade`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Phase 1 — local build, clean install, validation
|
||||
|
||||
**Files:**
|
||||
- No file edits. Executes the Task 1 changes locally.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 (Dockerfile 2.0.4, plugins.txt, stable channel).
|
||||
- Produces: a proven-working local 2.0.4 stack — the go/no-go gate for the remote phases.
|
||||
|
||||
- [ ] **Step 1: Remove the stale manually-extracted plugin folders**
|
||||
|
||||
These were hand-extracted from the rc.10 bundle; GPM must install them fresh.
|
||||
|
||||
```bash
|
||||
rm -rf user/plugins/admin2 user/plugins/api user/plugins/flex-objects
|
||||
```
|
||||
|
||||
Expected: the three folders are gone (`ls user/plugins/` no longer lists them). They are gitignored, so `git status` in `user/` is unaffected.
|
||||
|
||||
- [ ] **Step 2: Rebuild the image with core 2.0.4**
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Expected: build completes; the RUN layer downloads `grav-admin-v2.0.4.zip`.
|
||||
|
||||
- [ ] **Step 3: Recreate the container**
|
||||
|
||||
```bash
|
||||
make start
|
||||
```
|
||||
|
||||
Expected: `intotheeast_grav` is up on http://localhost:8081.
|
||||
|
||||
- [ ] **Step 4: Confirm the core version is 2.0.4**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav php bin/grav --version
|
||||
```
|
||||
|
||||
Expected output contains: `Grav CLI Application 2.0.4` (or a newer 2.0.x).
|
||||
|
||||
- [ ] **Step 5: Update already-installed GPM plugins to stable**
|
||||
|
||||
This bumps `login` (3.8.9 → ≥3.8.11, required by `api`) and `form` before the new plugins install. `gpm install` alone would skip them because they already exist.
|
||||
|
||||
```bash
|
||||
docker exec -w /var/www/html intotheeast_grav php bin/gpm update -y
|
||||
```
|
||||
|
||||
Expected: `login`, `form`, `shortcode-core`, etc. report as updated (or already up to date).
|
||||
|
||||
- [ ] **Step 6: Install the newly-listed plugins**
|
||||
|
||||
```bash
|
||||
make install-plugins
|
||||
```
|
||||
|
||||
Expected: `admin2`, `api`, `flex-objects` install; their dependencies resolve against the now-current `login`/`form`; no "requires grav >= 2.0.4" errors.
|
||||
|
||||
- [ ] **Step 7: Clear the cache**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav php bin/grav cache
|
||||
```
|
||||
|
||||
Expected: "Cache cleared" output.
|
||||
|
||||
- [ ] **Step 8: Assert plugin versions meet the floors**
|
||||
|
||||
```bash
|
||||
docker exec intotheeast_grav sh -c 'cd /var/www/html && for p in admin2 api flex-objects login form; do printf "%s: " "$p"; grep -m1 "^version:" user/plugins/$p/blueprints.yaml; done'
|
||||
```
|
||||
|
||||
Expected (at least): `admin2: version: 2.0.9`, `api: version: 1.0.6`, `flex-objects: version: 1.4.3`, `login: version: 3.8.11`, `form: version: 9.1.8` — equal or higher.
|
||||
|
||||
- [ ] **Step 9: Run the automated smoke suite**
|
||||
|
||||
This exercises the posting pipeline (`test-post` submits via the real form → add-page-by-form → cache-on-save) and renders pages via Playwright — the exact admin2/api-critical path.
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: `test-config`, `test-post`, and `test-ui` all pass.
|
||||
|
||||
- [ ] **Step 10: Manual browser spot-check**
|
||||
|
||||
Visit and confirm each renders without error:
|
||||
- http://localhost:8081/ (home)
|
||||
- the active trip page (`/trips/japan-korea-2026`) — filter bar + map load
|
||||
- one story page — hero + shortcodes render
|
||||
- http://localhost:8081/admin2 — login page loads; log in
|
||||
- http://localhost:8081/gpx-manager — list loads; upload a small `.gpx`, then delete it
|
||||
|
||||
Expected: all load; no PHP errors in `docker logs intotheeast_grav`.
|
||||
|
||||
- [ ] **Step 11: Checkpoint (no commit needed)**
|
||||
|
||||
No files changed in this task. If any step failed, stop and diagnose before proceeding — this is the go/no-go gate for remote work.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Remote Makefile targets (fix + additions)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/git-sync-toggle.sh`
|
||||
- Modify: `Makefile` (fix `remote-upgrade-grav`; add `remote-update-plugins`, `remote-git-sync-disable`, `remote-git-sync-enable`; register the new targets in `REMOTE_TARGETS`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `make remote-upgrade-grav-<env>`, `make remote-update-plugins-<env>`, `make remote-git-sync-disable-<env>`, `make remote-git-sync-enable-<env>` — consumed by Task 5.
|
||||
|
||||
- [ ] **Step 1: Create the git-sync toggle script**
|
||||
|
||||
Create `scripts/git-sync-toggle.sh` (piped to the server via `bash -s`, matching the `server-install.sh` pattern). It only ever rewrites the top-level `enabled:` key — never `folders` or the encrypted token.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
FILE="$1"
|
||||
STATE="$2"
|
||||
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -qE '^enabled:' "$FILE"; then
|
||||
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
|
||||
else
|
||||
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
|
||||
fi
|
||||
|
||||
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make it executable**
|
||||
|
||||
```bash
|
||||
chmod +x scripts/git-sync-toggle.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fix the broken `remote-upgrade-grav` target**
|
||||
|
||||
In `Makefile`, replace the body of `remote-upgrade-grav` (currently `php bin/grav upgrade`, which is not a real command):
|
||||
|
||||
```make
|
||||
remote-upgrade-grav: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the plugin-update target**
|
||||
|
||||
Add below `remote-install-plugins`:
|
||||
|
||||
```make
|
||||
remote-update-plugins: guard-env
|
||||
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the git-sync toggle targets**
|
||||
|
||||
```make
|
||||
remote-git-sync-disable: guard-env
|
||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
|
||||
|
||||
remote-git-sync-enable: guard-env
|
||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add a server content-status target**
|
||||
|
||||
For reviewing config drift after the plugin upgrade without raw SSH:
|
||||
|
||||
```make
|
||||
remote-content-status: guard-env
|
||||
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Register the new targets for env-suffix generation**
|
||||
|
||||
In `Makefile`, extend the `REMOTE_TARGETS` list so the `-test`/`-prod` variants get generated:
|
||||
|
||||
```make
|
||||
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
||||
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
||||
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
||||
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify the targets exist and expand correctly (dry run)**
|
||||
|
||||
```bash
|
||||
make -n remote-update-plugins-test
|
||||
make -n remote-git-sync-disable-test
|
||||
make -n remote-upgrade-grav-test
|
||||
make -n remote-content-status-test
|
||||
```
|
||||
|
||||
Expected: each prints the intended `ssh ...` command with `ENV=test` resolved, and no "No rule to make target" error. (No server is contacted by `-n`.)
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/git-sync-toggle.sh Makefile
|
||||
git commit -m "build: fix remote-upgrade-grav; add remote plugin-update, git-sync toggle, content-status targets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fresh-install script cleanup (option B server-side)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/server-install.sh` (remove admin2/api stash+restore)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `plugins.txt` now containing admin2/api/flex-objects (Task 1).
|
||||
- Produces: a fresh-install path where admin2/api/flex install purely via `gpm install $PLUGINS`.
|
||||
|
||||
- [ ] **Step 1: Remove the zip-stash lines**
|
||||
|
||||
In `scripts/server-install.sh`, delete lines 25–26 (the admin2/api stash into `/tmp`):
|
||||
|
||||
```bash
|
||||
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
|
||||
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the restore lines**
|
||||
|
||||
Delete lines 43–45 (the restore after the user re-clone):
|
||||
|
||||
```bash
|
||||
cp -rf /tmp/admin2-plugin user/plugins/admin2
|
||||
cp -rf /tmp/api-plugin user/plugins/api
|
||||
rm -rf /tmp/admin2-plugin /tmp/api-plugin
|
||||
```
|
||||
|
||||
Leave `mkdir -p user/plugins user/accounts user/data` in place. admin2/api/flex now come from `php bin/gpm install $PLUGINS -y` (unchanged line ~48).
|
||||
|
||||
- [ ] **Step 3: Syntax-check the script**
|
||||
|
||||
```bash
|
||||
bash -n scripts/server-install.sh
|
||||
```
|
||||
|
||||
Expected: no output (valid syntax).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/server-install.sh
|
||||
git commit -m "build: drop admin2/api zip-stash from server-install; install via GPM (option B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Phase 2 — test env upgrade
|
||||
|
||||
**Files:**
|
||||
- No file edits. Executes against the test environment using Task 3 targets.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1–4 (pushed to Gitea), the `-test` make targets.
|
||||
- Produces: test env on 2.0.4 with GPM-managed plugins, validated; git-sync left disabled.
|
||||
|
||||
- [ ] **Step 1: Push all changes to Gitea**
|
||||
|
||||
The server pulls `user/` content (incl. the stable-channel `system.yaml`) from Gitea; the root repo pushes normally.
|
||||
|
||||
```bash
|
||||
git push origin grav-2.0.4-upgrade # or merge to the branch the server tracks, per your deploy convention
|
||||
make content-push # pushes the user repo commit (system.yaml) to Gitea
|
||||
```
|
||||
|
||||
Expected: both remotes updated. (Confirm with the user which branch the test server tracks before pushing.)
|
||||
|
||||
- [ ] **Step 2: Disable git-sync on test**
|
||||
|
||||
```bash
|
||||
make remote-git-sync-disable-test
|
||||
```
|
||||
|
||||
Expected: prints `git-sync now: enabled: false`.
|
||||
|
||||
- [ ] **Step 3: Pull latest content to the test server**
|
||||
|
||||
Brings the `gpm.releases: stable` change onto the server *before* any GPM op.
|
||||
|
||||
```bash
|
||||
make remote-fetch-content-test
|
||||
```
|
||||
|
||||
Expected: server `user/` fast-forwards; `user/config/system.yaml` shows `releases: stable`.
|
||||
|
||||
- [ ] **Step 4: Upgrade the core on test**
|
||||
|
||||
```bash
|
||||
make remote-upgrade-grav-test
|
||||
```
|
||||
|
||||
Expected: `bin/gpm self-upgrade` moves core rc.10 → 2.0.4 (stable channel); cache cleared. If it fails on a shared-folder error (see spec Risks), retry is safe — `self-upgrade` supports `-o/--overwrite`; add it to the target temporarily if a retry is needed.
|
||||
|
||||
- [ ] **Step 5: Update all plugins on test**
|
||||
|
||||
```bash
|
||||
make remote-update-plugins-test
|
||||
```
|
||||
|
||||
Expected: admin2 → ≥2.0.9, api → ≥1.0.6, flex-objects → ≥1.4.3, login → ≥3.8.11, form, git-sync all update to their stable versions; cache cleared.
|
||||
|
||||
- [ ] **Step 6: Review server config drift**
|
||||
|
||||
Inspect the server `user/` working tree for unexpected rewrites from the plugin upgrades (do NOT blind-commit):
|
||||
|
||||
```bash
|
||||
make remote-content-status-test
|
||||
```
|
||||
|
||||
Expected: review any `config/` diffs deliberately. Discard server-specific/reformatting churn; keep only intended changes. (git-sync is disabled, so nothing auto-commits while you review.)
|
||||
|
||||
- [ ] **Step 7: Smoke-test the test URL**
|
||||
|
||||
Against the test site (URL per your test env), confirm:
|
||||
- home, a trip page, a story render
|
||||
- admin2 login works
|
||||
- submit one `/post` → the entry appears in the active trip's dailies
|
||||
- `/gpx-manager` lists, uploads, and deletes a file
|
||||
|
||||
Expected: all pass. (git-sync stays disabled, so the new post will not auto-sync yet — that's expected and verified in Step 9.)
|
||||
|
||||
- [ ] **Step 8: Notify the user — validation checkpoint**
|
||||
|
||||
Report results and explicitly state that **git-sync remains disabled** on test pending their validation. Do not re-enable automatically.
|
||||
|
||||
- [ ] **Step 9: (User-gated) Re-enable git-sync and verify sync**
|
||||
|
||||
After the user confirms validation:
|
||||
|
||||
```bash
|
||||
make remote-git-sync-enable-test
|
||||
make content-push # or trigger a content change; confirm it syncs through
|
||||
```
|
||||
|
||||
Expected: `git-sync now: enabled: true`; a content round-trip syncs between the test server and Gitea.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Docs, prod runbook, and memory
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (stack versions + plugin-management model)
|
||||
- Modify: `docs/reference/architecture.md` (versions/channel)
|
||||
- Modify: `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` (this file — Phase 3 runbook + Status)
|
||||
- Modify: memory files under the auto-memory dir (project-grav2-upgrade, project-plugin-architecture)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the completed local + test upgrade.
|
||||
- Produces: current docs; an executable-but-unexecuted prod runbook.
|
||||
|
||||
- [ ] **Step 1: Update the stack facts in `CLAUDE.md`**
|
||||
|
||||
Change the "Current stack" block: Grav `2.0.4` (not rc.10); Admin2 to the installed stable version; note that admin2/api/flex-objects are now **GPM-managed via `plugins.txt`** (no longer hand-extracted); note `gpm.releases: stable`.
|
||||
|
||||
- [ ] **Step 2: Update `docs/reference/architecture.md`**
|
||||
|
||||
Reflect core 2.0.4, stable channel, and the three-category plugin model (GPM-managed / former-manual-now-GPM / remote-only git-sync).
|
||||
|
||||
- [ ] **Step 3: Write the Phase 3 prod runbook**
|
||||
|
||||
Append a "Phase 3 — Production (fresh install, NOT executed)" section to this plan documenting: run `make remote-install-prod` with `GRAV_VERSION=2.0.4`; admin2/api/flex install via GPM from `plugins.txt`; then set up git-sync manually (install, add encrypted token, apply the `folders:` array fix per `docs/working/git-sync-notes.md`), and leave it disabled until first validation.
|
||||
|
||||
- [ ] **Step 4: Update memory**
|
||||
|
||||
Update `project-grav2-upgrade.md` (now on 2.0.4 stable; GPM serves stable so direct-download-only no longer applies) and `project-plugin-architecture.md` (admin2/api/flex now GPM-managed; git-sync remote-only category). Refresh the `MEMORY.md` pointers if the hooks change.
|
||||
|
||||
- [ ] **Step 5: Set the plan Status to complete**
|
||||
|
||||
Change the `**Status:**` line at the top of this file to `✅ Complete (YYYY-MM-DD)` (today's date at execution).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/reference/architecture.md docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md
|
||||
git commit -m "docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook"
|
||||
```
|
||||
|
||||
(Memory files live outside the repo; they are written directly, not committed here.)
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If any phase fails and cannot be fixed forward:
|
||||
1. `git revert` the relevant commits on `grav-2.0.4-upgrade` (root repo) and the `user` repo `system.yaml` commit.
|
||||
2. Local: `make build && make start && make install-plugins`.
|
||||
3. Test server: config, content, and plugin reverts are delivered via `make content-push` + `make remote-fetch-content-test`. **The core is the exception — once `bin/gpm self-upgrade` has completed it cannot downgrade, so treat a completed core upgrade as forward-only and fix forward; there is no revert for it.**
|
||||
|
||||
> ⚠️ Do **not** run the fresh-install path (`scripts/server-install.sh`) against a live server as a rollback. It does `rm -rf user; git clone`, which destroys the server-only, gitignored `user/config/plugins/git-sync.yaml` (the encrypted git-sync token a clone never restores). The fresh-install path is for empty/new servers only.
|
||||
|
||||
Content, config, and accounts are in git, so no data restore is required — but note the core caveat above: "rollback = git" covers config/content/plugins, **not** a completed server core self-upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Execution outcome (2026-07-04)
|
||||
|
||||
**Installed local versions (all at/above floors):** Grav `2.0.4`, admin2 `2.0.10`, api `1.0.7`, flex-objects `1.4.4`, login `3.8.11`, form `9.1.10`, shortcode-core `6.2.2`.
|
||||
|
||||
**Phase 1 (local): validated + shipped.** Image rebuilt on 2.0.4, plugins installed via GPM, cache clears, rendering clean (home, trips, story, `/admin2` login, `/gpx-manager` list/upload/delete all 200). Test suite: **75 passing**. Test-config was re-pointed off the retired `japan-korea-2026` onto the vetted `italy-2026-demo` data. A self-contained, gitignored `testrunner` account (created via `make test-account` with `--admin-type both`) makes `make test` runnable without the real account in `.env`.
|
||||
|
||||
**Known issue — Form 9.1.10 filepond regression (blocked elsewhere, not a go/no-go blocker).** The post form's `filepond` photo field 500s on the post-submit re-render (`filepond.html.twig` runs `merge` on a string). The journal entry still saves correctly (curl/on-disk `test-post.sh` passes); only the browser re-render errors, failing 6 `post.spec.js` UI specs. This is a stock-plugin upgrade regression, being fixed independently in the form-to-page/image-upload rework. **Do not** add a theme-override workaround in this upgrade — let that rework own the fix.
|
||||
|
||||
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
|
||||
|
||||
**Task 5 (Phase 2, remote test-env): parked, user-gated.** Not executed. Requires the user to confirm which branch the test server tracks and that `.env.test` is ready. All `-test` make targets exist and dry-run clean.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Production (fresh install, NOT executed)
|
||||
|
||||
Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan.
|
||||
|
||||
When prod is provisioned:
|
||||
|
||||
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
|
||||
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt` — `admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
|
||||
3. **git-sync (remote-only, manual):** it is deliberately absent from `plugins.txt`. Install it on the server, add the encrypted token to `user/config/plugins/git-sync.yaml` (server-only, gitignored — a fresh clone never restores it), and apply the `folders:` array fix per `docs/working/git-sync-notes.md`. Leave it **disabled** (`make remote-git-sync-disable-prod`) until the first content round-trip is validated, then `make remote-git-sync-enable-prod`.
|
||||
4. **Smoke test** the prod URL as in Task 5 Step 7 (home / trip / story / admin2 login / one `/post` / `/gpx-manager`). Note the Form filepond known-issue above will surface on `/post` until the separate rework lands — the entry still saves.
|
||||
5. **Never** run `scripts/server-install.sh` against a populated server (it `rm -rf user; git clone`, destroying the server-only git-sync token). Fresh/empty servers only.
|
||||
@@ -0,0 +1,344 @@
|
||||
---
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: implementation-ready
|
||||
product_contract_source: ce-brainstorm
|
||||
title: Journal Post Form Improvements - Plan
|
||||
type: feat
|
||||
date: 2026-07-04
|
||||
execution: code
|
||||
---
|
||||
|
||||
# Journal Post Form Improvements — Plan
|
||||
|
||||
**Status:** 📋 Not started
|
||||
|
||||
> Plan type: `feat` · Depth: Deep — feature · Origin: `/ce-brainstorm` "improve the current php plugin that allows me to add a new journal page to the current active trip" (2026-07-04)
|
||||
|
||||
**Product Contract preservation:** Product Contract unchanged. Planning enriches this artifact in place — Requirements R1–R20, Key Flows, and Acceptance Examples are carried verbatim from the brainstorm.
|
||||
|
||||
---
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **Objective:** Redesign the frontend `/post` journal form so a daily entry can be posted end-to-end from an iPhone — auto-targeting the active trip, exposing every entry field, loading a light markdown editor, converting HEIC photos in the browser, and matching the Field Notes design system.
|
||||
- **Authority hierarchy:** This plan's Requirements (R1–R20) and the three resolved Key Technical Decisions govern. Where an implementation detail is unspecified, follow existing repo conventions (esbuild bundle, Playwright suite, plugin structure). `CLAUDE.md` project rules override everything (only write inside `travel-blog-intotheeast/`; `user/` is a standalone repo; never read `.env`; use `make` for remote ops).
|
||||
- **Stop conditions:** Stop and surface if (a) intercepting Grav's managed FilePond instance for HEIC conversion proves infeasible without replacing the field type (U4 is the load-bearing risk), or (b) any change would require a server-side image pipeline or Docker rebuild — that path is explicitly deferred.
|
||||
- **Execution profile:** Frontend-weighted. One PHP handler (U1); the rest is form blueprint, Twig, an esbuild bundle with two new npm deps, and CSS. Dev server at `http://localhost:8081`; rebuild JS with `make build-assets` (never hand-edit `js/main.js`).
|
||||
- **Tail ownership:** Verify with `make test` (config + post + Playwright UI) and manual dev-server walkthrough on a narrow viewport.
|
||||
|
||||
---
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
Redesign the frontend `/post` journal form to auto-target the active trip, expose every entry-blueprint field (core visible, advanced behind "More options"), load a light markdown editor, convert iPhone HEIC photos to JPEG in the browser, and match the site's Field Notes design system — all behind the existing site login, optimised for posting from an iPhone during a trip.
|
||||
|
||||
### Problem Frame
|
||||
|
||||
Posting a daily entry today has five rough edges. The parent trip is hardcoded in `user/pages/02.post/post-form.md` (`pageconfig.parent`) and must be kept in sync by hand with `site.active_trip`; forgetting on a trip switch silently files entries under the wrong trip. The form exposes only a subset of the entry blueprint, so `transport_mode`, `hero_image`, `force_connect`, `featured`, and a proper weather-condition picker are unreachable without opening Admin2. The content field is a bare `<textarea>` — the bundled SimpleMDE never loads because `add-page-by-form` keys its editor on a form named `add_page*`, and this form is `new-entry`. Photos come straight off an iPhone, most often as HEIC, which Grav's GD pipeline cannot read at all, so thumbnails break. The form also uses generic Grav markup rather than the site's visual language, and isn't tuned for one-handed mobile use — which is the only way it will be used during the trip.
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **Active trip is resolved dynamically, not hardcoded.** The parent is derived from `site.active_trip` at submit time instead of from a static `pageconfig.parent`. `add-page-by-form` already honours a submitted `parent` value (`add-page-by-form.php` ~L521), so a small server-side hook injects `<active_trip>/dailies`. This removes the manual two-file sync and its silent-misfile failure mode.
|
||||
- **HEIC is handled client-side only.** Desktop story work sources images from Immich, which already yields JPEG, so HEIC only ever originates from this mobile form — a single path. A browser converter covers it without adding a libheif-enabled ImageMagick to the baked Docker image. Server-side conversion would maintain infrastructure for a case that, by the owner's workflow, never occurs.
|
||||
- **Advanced fields sit behind a "More options" disclosure.** Core fields stay visible for fast phone posting; `hero_image`, `force_connect`, and `featured` are collapsed by default but reachable — nothing is Admin-only anymore.
|
||||
- **Editor is EasyMDE.** The maintained SimpleMDE successor, with a minimal toolbar (bold, italic, list, link) plus a preview toggle — enough affordance without the mobile clutter of a full toolbar.
|
||||
- **The footprint is mostly frontend, not PHP.** Despite the original framing, the only PHP change is the active-trip parent injection. Fields, editor, HEIC conversion, styling, and mobile layout all live in the form blueprint, the Twig template, and its JS/CSS.
|
||||
|
||||
### Requirements
|
||||
|
||||
**Active-trip targeting**
|
||||
|
||||
- R1. Submitting the form stores the new entry under the currently active trip's `dailies` folder, resolved from `site.active_trip` at submit time.
|
||||
- R2. Switching trips (changing `active_trip`) requires no edit to the post form; the hardcoded `pageconfig.parent` coupling is removed.
|
||||
|
||||
**Entry fields**
|
||||
|
||||
- R3. The form can set the following entry fields: title, date, content, photos, location (city, country, lat, lng), weather (condition, temperature), `transport_mode`, `hero_image`, `force_connect`, `featured`. (title/date/content/photos are page-level and media fields supplied by the form; the remaining fields live in `entry.yaml`.)
|
||||
- R4. Core fields are always visible: title, date, content, photos, location, weather condition, weather temperature, transport mode.
|
||||
- R5. `hero_image`, `force_connect`, and `featured` sit behind a "More options" disclosure that is collapsed by default.
|
||||
- R6. Weather condition is a labelled picker matching the blueprint's options; the existing "Get Weather" action pre-fills it, and it stays manually overridable.
|
||||
|
||||
**Editor**
|
||||
|
||||
- R7. The content field uses EasyMDE with a minimal toolbar (bold, italic, list, link) and a preview toggle, bound to the underlying content field so both submission and validation read its value.
|
||||
- R15. EasyMDE syncs its content back to the underlying textarea before the form's custom required-field validation runs (e.g. `editor.codemirror.save()` on submit, or bound on change), so a valid entry is never rejected as empty and an empty one never slips past.
|
||||
|
||||
**Image handling**
|
||||
|
||||
- R8. HEIC/HEIF photos selected on the device are converted to JPEG in the browser before upload, so only web-renderable images reach the server.
|
||||
- R9. Conversion is a no-op for photos already in a web format (JPEG/PNG), including HEIC that iOS Safari has already transcoded on file-pick.
|
||||
- R10. No server-side or Docker image change is required; image handling is entirely client-side. (Client-side conversion is a UX convenience; the trust boundary at the upload endpoint is an accepted, deferred gap — see Open Questions.)
|
||||
- R16. HEIC/HEIF is detected by content sniffing, not filename or MIME alone. If a photo is HEIC/HEIF and conversion fails, times out, or the file is corrupt/ambiguous, that photo is blocked from upload with an inline error while other selected photos and Submit remain usable; the original HEIC is never posted.
|
||||
- R17. Each photo still converting shows a per-thumbnail "converting…" indicator, and Submit is disabled until every selected photo has finished converting.
|
||||
|
||||
**Styling and mobile UX**
|
||||
|
||||
- R11. The form is styled to the Field Notes design system (teal accent, DM Serif Display + DM Sans, warm paper background), consistent with the rest of the site.
|
||||
- R12. The form is single-column and mobile-first: large tap targets, native-keyboard-friendly inputs, a comfortable writing area, and smooth "Get Location" / "Get Weather" / photo-capture actions on iPhone.
|
||||
- R13. Photo input supports selecting or capturing images from an iPhone, up to 4.
|
||||
- R18. "Get Location" and "Get Weather" each expose idle, loading (spinner on the button), success (fields filled), and error/permission-denied states; on failure an inline message appears and the fields stay manually editable.
|
||||
- R19. Submit runs blocking inline validation with per-field messages for missing required fields (at minimum title and content, matching the form's current validation), and on a failed save it preserves all entered input and surfaces a retry.
|
||||
|
||||
**Access**
|
||||
|
||||
- R14. `/post` remains gated by the existing frontend site login; one login persists for the session. No public or unauthenticated posting.
|
||||
- R20. If the site-login session expires while an entry is being composed, submitting does not lose the in-progress **text**: the entered title, content, location, weather, and other field values are preserved so the owner can re-authenticate and resubmit. **Scope limit:** selected/converted photos are *not* preserved across a reload or re-auth — `localStorage` cannot hold `File`/`Blob` objects — so photos must be re-picked after re-authenticating. The form surfaces an inline hint to that effect rather than silently dropping them.
|
||||
|
||||
### Key Flows
|
||||
|
||||
- F1. **Post a daily entry from an iPhone.**
|
||||
- **Trigger:** owner opens `/post` on their phone (already logged into the site).
|
||||
- Fills title, date, content (EasyMDE), taps "Get Location" then "Get Weather" to auto-fill coords + weather, sets transport mode, optionally expands "More options".
|
||||
- Picks up to 4 photos. Any HEIC is converted to JPEG in the browser before upload; already-web-format photos pass through untouched.
|
||||
- On submit, the entry is written to `<site.active_trip>/dailies` (parent injected server-side), media attached, and the page cache cleared so it appears immediately in the feed.
|
||||
|
||||
### Acceptance Examples
|
||||
|
||||
- AE1. **Covers R8, R9.** A HEIC photo is selected → converted to JPEG client-side → the posted entry renders with a working thumbnail and hero. A JPEG photo is selected → uploaded unchanged.
|
||||
- AE2. **Covers R1, R2.** With `active_trip: /trips/japan-korea-2026`, a new post lands in `/trips/japan-korea-2026/dailies` without any edit to the form definition.
|
||||
- AE3. **Covers R4, R5.** On load, title/date/content/photos/location/weather/transport are visible; `hero_image`, `force_connect`, and `featured` are hidden until "More options" is expanded.
|
||||
- AE4. **Covers R16, R17.** While a photo converts it shows a "converting…" indicator and Submit is disabled. A HEIC photo whose conversion fails (or a corrupt/ambiguous file) is blocked with an inline error while other photos and Submit stay usable; the original HEIC is never posted.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
- **Deferred:** server-side HEIC conversion and a custom libheif-enabled ImageMagick Docker image — revisit only if HEIC begins arriving through a non-Immich path.
|
||||
- **Deferred:** server-side upload validation (accept-list + size cap on `/post`). The authenticated SVG/HEIC/oversized-payload gap is real but login-gated and low-risk for a solo owner; left in Open Questions rather than pulled into this plan. Client-side conversion is UX, not the security boundary.
|
||||
- **Separate brainstorm:** moving story authoring into a frontend add-page flow ("capture a story from the road"). Stories remain desktop-authored for now.
|
||||
- **Unchanged:** the auth model (no PIN/magic-link), the travel-memories / Immich pipeline, and Admin2 authoring.
|
||||
|
||||
### Dependencies / Assumptions
|
||||
|
||||
- Desktop story images come from Immich as JPEG — this is what makes client-side-only HEIC handling sufficient.
|
||||
- `add-page-by-form` continues to honour a submitted `parent` value that overrides `pageconfig.parent`.
|
||||
- A browser HEIC→JPEG library ([heic-to](https://github.com/hoppergee/heic-to)) integrates into the filepond upload step.
|
||||
- EasyMDE can be bound to the content field so its value syncs to the submitted form data.
|
||||
- The frontend Login-plugin session persists on iOS for the trip's duration.
|
||||
|
||||
---
|
||||
|
||||
## Planning Contract
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
- KTD1. **Parent injection lives in `cache-on-save`, as a second handler on `onFormValidationProcessed`, and is server-authoritative.** The plugin gains an `onFormValidationProcessed` handler that, for form `new-entry`, reads `site.active_trip` and sets the form's `parent` value (via `$form->value()`) to `<active_trip>/dailies` before `add-page-by-form`'s `add_page` action reads `$form->value()->toArray()['parent']` on its own `onFormProcessed` handler (`add-page-by-form.php:521`). `onFormValidationProcessed` is chosen deliberately over a higher-priority `onFormProcessed`: it is the only pre-write event that can **abort** the submit by failing validation. The existing cache-clear handler is untouched. No `parent` field is added to the form blueprint — injecting server-side (not via a client-submitted hidden field) keeps the write target out of the client's control. `pageconfig.parent` is removed from `post-form.md` so nothing can drift out of sync. **Empty-`active_trip` fail-closed:** if `active_trip` is missing/empty the handler must fail validation (raise an `onFormValidationError` / throw) so `add_page` never runs — merely leaving `parent` unset is not enough, because with `pageconfig.parent` gone `getParentPage('')` resolves to the `/post` page itself and the entry would silently land under `/post` (not the site root). Failing validation is what guarantees no misfile.
|
||||
- KTD2. **EasyMDE + heic-to ship in a `/post`-scoped, code-split bundle, not the global `main.js`.** A new entry `js/src/post-form.js` is bundled to `js/post-form.js` and loaded only by `post-form.html.twig` — keeping ~1.5 MB of converter + editor off every other page. Unlike the site's other bundles (`--format=iife`, no splitting), the `/post` entry is built with `--format=esm --splitting` so the dynamic `import('heic-to')` (KTD4) becomes a **separately-fetched chunk** rather than being inlined — the HEIC converter's weight stays out of the initial `/post` download and is fetched only when a HEIC is actually picked. This matters because `/post` is the cold-load-on-cellular surface. Consequences to carry through: the template must load the entry as `<script type="module" src="js/post-form.js">` (not a classic `<script>`), esbuild emits shared/dynamic chunks alongside the entry (the whole emitted set must ship, so the build's `outdir`/chunk output is committed, not just the single file), and this is the only ESM/split entry in `package.json`'s `build` script — the existing IIFE entries are untouched. CSS is still extracted to `css-compiled/post-form.css`.
|
||||
- KTD3. **EasyMDE flushes to the textarea before validation.** Init EasyMDE on the content `<textarea>`, and call `editor.codemirror.save()` on `change` and at the top of the existing `submit` handler, so the custom `novalidate` validator (which reads `[name="data[content]"]`, `post-form.html.twig:39–45`) sees the live value. This preserves the current validation approach rather than replacing it.
|
||||
- KTD4. **HEIC is detected by magic-byte sniffing and the converter is lazy-loaded.** Sniff the first bytes for the ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands rather than trusting extension or MIME. Only when a HEIC is detected is heic-to dynamically imported (keeps the initial `/post` payload small). On success the file is replaced with a JPEG blob (slugified `.jpg` name); on failure/timeout/corrupt the file is rejected fail-closed with an inline error. Submit is gated on a "conversions in flight" counter.
|
||||
- KTD5. **"More options" is an accessible native `<details>`/disclosure.** Advanced fields (`hero_image`, `force_connect`, `featured`) render inside a `<details>` collapsed by default, auto-expanded if any advanced field is non-empty on load. Native `<details>` gives keyboard/AT support without custom ARIA wiring.
|
||||
- KTD6. **Submit resilience via a `localStorage` draft.** Field values (content especially) are mirrored to `localStorage` on input and restored on load; the draft is cleared on a confirmed successful post. On a failed submit — validation, save error, or a session-expiry response that renders the login form instead of the success message — the draft survives so the owner re-authenticates and resubmits without loss.
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
The submit pipeline spans client (conversion, editor sync, validation) and server (parent injection, page write, cache clear). The load-bearing ordering is that parent injection must run *before* `add-page-by-form`'s `add_page` action.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client
|
||||
A[Pick photos] --> B{HEIC?<br/>magic-byte sniff}
|
||||
B -->|yes| C[Lazy-load heic-to<br/>convert to JPEG]
|
||||
B -->|no| D[Pass through]
|
||||
C -->|fail| E[Block photo,<br/>inline error]
|
||||
C -->|ok| F[Replace with JPEG blob]
|
||||
D --> F
|
||||
G[EasyMDE] -->|codemirror.save| H[textarea value]
|
||||
F --> I{Submit}
|
||||
H --> I
|
||||
I -->|conversions in flight| J[Submit disabled]
|
||||
I -->|required missing| K[Inline validation, preserve draft]
|
||||
I -->|ok| L[POST /post]
|
||||
end
|
||||
subgraph Server
|
||||
L --> M[onFormValidationProcessed<br/>cache-on-save injects parent<br/>= active_trip + /dailies]
|
||||
M --> N[add-page-by-form add_page<br/>reads form_data.parent L521]
|
||||
N --> O[Page written under active trip]
|
||||
O --> P[cache-on-save clears cache]
|
||||
P --> Q[Entry appears in feed]
|
||||
end
|
||||
```
|
||||
|
||||
### Sequencing
|
||||
|
||||
U1 (parent injection) and U2 (fields) are independent and can land first in either order. U3 introduces the `/post` bundle. U4's HEIC logic is independent of U3's editor logic, but U4 depends on that bundle scaffolding — build U3 first so the bundle exists, then U4 adds to it. U5 (styling/disclosure/feedback) depends on U2's field definitions and U3's bundle. U6 (draft resilience) depends on U3 and U5. U7 (tests) comes last and verifies the whole.
|
||||
|
||||
### Assumptions / Execution-time unknowns
|
||||
|
||||
- The exact hook for injecting into Grav's managed FilePond instance (U4) is unresolved and is the plan's chief risk — see Risks. Resolve during implementation by inspecting the rendered filepond field and FilePond's `beforeAddFile` / `server.process` options; a fallback is documented in U4.
|
||||
- Whether `onFormValidationProcessed` exposes a settable `parent` on the form in this Grav/add-page-by-form version, or whether a higher-priority `onFormProcessed` is needed, is confirmed at implementation time against a live submit.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Server-authoritative active-trip parent injection
|
||||
|
||||
- **Goal:** New entries land under `<site.active_trip>/dailies` automatically; the hardcoded parent sync is removed (R1, R2).
|
||||
- **Requirements:** R1, R2. Covers AE2.
|
||||
- **Dependencies:** none.
|
||||
- **Files:**
|
||||
- `user/plugins/cache-on-save/cache-on-save.php` — add a second subscribed event + handler for parent injection.
|
||||
- `user/pages/02.post/post-form.md` — remove `pageconfig.parent`; drop the "keep in sync" comment.
|
||||
- **Approach:** Subscribe to `onFormValidationProcessed` (keep the existing `onFormProcessed` cache-clear). In the new handler, guard on `$form->getName() === 'new-entry'`, read `active_trip` from `$this->grav['config']->get('site.active_trip')`, and set the form's `parent` value to `<active_trip>/dailies` so `add-page-by-form` picks it up at `add-page-by-form.php:521`. Do not add a `parent` form field. If `active_trip` is empty, fail validation (raise an `onFormValidationError` / throw) so the `add_page` action never runs — do not just leave `parent` unset, which would misfile under `/post` (see KTD1). Optionally tighten the existing `deleteAll()` to run once (minor; only if trivially safe).
|
||||
- **Patterns to follow:** existing `cache-on-save.php` handler shape and `getSubscribedEvents()`.
|
||||
- **Test scenarios:**
|
||||
- Covers AE2. With `active_trip: /trips/italy-2026-demo`, a form post creates the page under `/trips/italy-2026-demo/dailies`.
|
||||
- Change `active_trip` to another trip → next post lands there with no edit to `post-form.md`.
|
||||
- `active_trip` empty/unset → validation fails and the `add_page` action never runs; no page is written under `/post` or anywhere.
|
||||
- Existing cache-clear behavior still fires (new entry appears immediately in the feed).
|
||||
- **Verification:** `make test-post` and `make test-config` pass; a manual post at `http://localhost:8081/post` lands in the active trip and appears in its dailies feed immediately.
|
||||
|
||||
### U2. Full entry-field exposure + weather picker
|
||||
|
||||
- **Goal:** The form can set every entry field, with a proper weather-condition picker; core fields visible, advanced fields defined for the U5 disclosure (R3, R4, R6).
|
||||
- **Requirements:** R3, R4, R6. Supports R5 (disclosure UI in U5).
|
||||
- **Dependencies:** none.
|
||||
- **Files:** `user/pages/02.post/post-form.md` — field definitions.
|
||||
- **Approach:** Change `weather_desc` from `hidden` to a `select` mirroring `entry.yaml`'s options (the emoji-labelled conditions). Add `transport_mode` (select, options from `entry.yaml`), `hero_image` (text), `force_connect` (toggle), `featured` (toggle). Keep `weather_temp_c` (populated by Get Weather; a `number` input so it stays user-editable). Order fields so core (title, date, content, photos, location, weather condition, weather temp, transport) precede the advanced trio; the visual grouping/disclosure is U5. Field names must match the `entry.yaml` header keys so `pagefrontmatter` serialization lands them correctly. **Caution:** turning `weather_desc` into a `<select>` breaks the existing Get Weather handler's `getField('weather_desc')` lookup, which queries `input[name="data[weather_desc]"]` (`post-form.html.twig:65-67`) and will return `null` for a select — U5 must generalize that selector when it migrates the handler, or Get Weather's condition pre-fill silently no-ops.
|
||||
- **Patterns to follow:** `entry.yaml` field types and option lists; existing field blocks in `post-form.md`.
|
||||
- **Test scenarios:**
|
||||
- Covers AE3 (field presence half). A logged-in `/post` render shows title, date, content, photos, location, weather condition (as a select with emoji options), weather temp, and transport mode.
|
||||
- Posting with `transport_mode`, `hero_image`, `force_connect`, `featured` set writes those keys into the entry frontmatter.
|
||||
- Weather condition select round-trips a manually chosen value (not overwritten unless Get Weather runs).
|
||||
- **Verification:** `make test-config` passes; posted entry frontmatter contains the new fields; entry renders with transport/weather on the trip feed.
|
||||
|
||||
### U3. EasyMDE editor + validation sync + `/post` bundle scaffolding
|
||||
|
||||
- **Goal:** Content uses EasyMDE with a minimal toolbar + preview, synced to the textarea before validation; establish the `/post`-scoped bundle (R7, R15).
|
||||
- **Requirements:** R7, R15.
|
||||
- **Dependencies:** none (introduces the bundle U4/U5/U6 extend).
|
||||
- **Files:**
|
||||
- `user/themes/intotheeast/package.json` — add `easymde` dep; add a `js/src/post-form.js` esbuild entry to the `build` script built with `--format=esm --splitting` (per KTD2, so KTD4's `import('heic-to')` is a real deferred chunk), CSS extracted to `css-compiled/post-form.css`. The existing IIFE entries stay as-is.
|
||||
- `user/themes/intotheeast/js/src/post-form.js` — new bundle entry: init EasyMDE, wire sync.
|
||||
- `user/themes/intotheeast/templates/post-form.html.twig` — load the bundle as `<script type="module" src="js/post-form.js">` + `css-compiled/post-form.css` (page-scoped); migrate the inline validation script's content read to use the synced textarea. (Module scripts defer by default — ensure any inline init that depends on globals accounts for that.)
|
||||
- **Approach:** In `post-form.js`, guard on the presence of the content textarea (no-op otherwise, mirroring `initTripStats`). Init EasyMDE with `toolbar: ['bold','italic','unordered-list','link','preview']`. On `editor.codemirror` `change` and at the start of the existing submit handler, call `editor.codemirror.save()` so `[name="data[content]"]` holds the live value for validation and submission. Rebuild with `make build-assets` (never hand-edit `js/post-form.js`).
|
||||
- **Patterns to follow:** `js/src/main.js` `initTripStats` presence-guard pattern; `package.json` `build` script esbuild invocation; `base.html.twig` `assets.addJs(..., {group:'bottom'})` for the page-scoped adds in the template.
|
||||
- **Test scenarios:**
|
||||
- Typing content in EasyMDE, then submitting, posts the entered markdown (content persists).
|
||||
- Submitting with an empty editor triggers the required-field error (sync makes the empty value visible to the validator).
|
||||
- Content with markdown (bold, list, link) round-trips into the entry body.
|
||||
- Preview toggle renders markdown without breaking submit.
|
||||
- **Verification:** `make build-assets` completes clean; `make test-ui` post spec (U7) passes; manual check that a valid entry is never wrongly rejected as empty.
|
||||
|
||||
### U4. Client-side HEIC→JPEG conversion with progress + failure states
|
||||
|
||||
- **Goal:** HEIC photos are detected and converted before upload with a converting indicator and fail-closed handling; web-format photos pass through (R8, R9, R16, R17).
|
||||
- **Requirements:** R8, R9, R16, R17. Covers AE1, AE4.
|
||||
- **Dependencies:** U3 (the `/post` bundle).
|
||||
- **Files:**
|
||||
- `user/themes/intotheeast/package.json` — add `heic-to` dep (dynamically imported).
|
||||
- `user/themes/intotheeast/js/src/post-form.js` — HEIC detection, conversion, progress/failure UI, Submit gating.
|
||||
- `user/themes/intotheeast/css/style.css` (or `post-form.css` bundle) — converting indicator + inline photo error styles.
|
||||
- **Approach:** Hook the filepond field's file intake. Sniff the first bytes for an ISO-BMFF `ftyp` box with `heic`/`heif`/`mif1` brands. On a HEIC, dynamically `import('heic-to')`, convert to a JPEG blob, and substitute it (slugified `.jpg` name) before it uploads; show a per-thumbnail "converting…" state and increment an in-flight counter that disables Submit. On success decrement; on failure/timeout/corrupt, reject that file with an inline error, leave other files + Submit usable, and never upload the original. Non-HEIC files pass through untouched (R9), including HEIC already transcoded to JPEG by iOS on pick.
|
||||
- **Execution note:** This is the plan's highest-risk unit — Grav's `filepond` field manages its own FilePond instance. Resolve the exact interception point at implementation time (FilePond `beforeAddFile` / `server.process`, or converting the `File` before it enters filepond). **Fallback if the managed instance can't be hooked cleanly:** replace the `filepond` field with a plain multiple `file` input for `/post` and drive conversion + preview directly. Surface this as a blocker (per Goal Capsule stop condition) before adopting the fallback.
|
||||
- **Patterns to follow:** none local for filepond interception — see Sources; follow `initTripStats` presence-guard for the init.
|
||||
- **Test scenarios:**
|
||||
- Covers AE1. A JPEG uploads unchanged; the posted entry renders a working thumbnail + hero.
|
||||
- Covers AE4. A HEIC file shows a "converting…" indicator, converts, and posts as JPEG; Submit is disabled until conversion completes.
|
||||
- A corrupt/ambiguous HEIC (or a conversion that throws) is blocked with an inline error; other selected photos and Submit remain usable; the original HEIC is not posted.
|
||||
- A HEIC renamed to `.jpg` (misleading extension) is still detected by sniffing and converted, not passed through.
|
||||
- Selecting a 5th photo respects the `limit: 4` cap.
|
||||
- **Verification:** `make build-assets` clean; `make test-ui` HEIC spec (U7) passes using a real `.heic` fixture; manual iPhone-Safari check that a camera HEIC posts with a working thumbnail.
|
||||
|
||||
### U5. Field Notes styling, mobile layout, "More options" disclosure, async feedback
|
||||
|
||||
- **Goal:** The form matches the design system and is mobile-first; advanced fields sit behind an accessible disclosure; Get Location / Get Weather / submit validation expose full feedback states (R5, R11, R12, R13, R18, R19).
|
||||
- **Requirements:** R5, R11, R12, R13, R18, R19. Covers AE3 (disclosure half).
|
||||
- **Dependencies:** U2 (field definitions), U3 (the `/post` bundle + EasyMDE-synced content value).
|
||||
- **Files:**
|
||||
- `user/themes/intotheeast/templates/post-form.html.twig` — wrap advanced fields in a `<details>` "More options"; restructure for single-column mobile; migrate inline scripts into the bundle where practical.
|
||||
- `user/themes/intotheeast/js/src/post-form.js` — Get Location / Get Weather state machine (idle/loading/success/error), Get Weather disabled until coords present, blocking submit validation with per-field messages.
|
||||
- `user/themes/intotheeast/css/style.css` and/or `post-form.css` — Field Notes tokens (`tokens.css`), large tap targets, disclosure styling, `.form-status` states, `.field-error`.
|
||||
- **Approach:** Use `tokens.css` variables (teal accent, DM Serif Display + DM Sans, paper background) for a single-column layout with ≥44px tap targets and native-friendly inputs. Advanced fields render inside `<details>` collapsed by default, auto-`open` when any advanced field is non-empty. Extend the existing Get Location / Get Weather handlers (`post-form.html.twig:69–124`) with explicit loading (button spinner), success, and error/permission-denied states; disable Get Weather with a hint until lat/lng exist. Keep the fields manually editable on failure. When migrating the Get Weather handler, generalize the `weather_desc` lookup so it matches the U2 `<select>` (not `input[...]`). Submit validation stays the custom `novalidate` approach (title + content required), now reading the EasyMDE-synced value. Add a **save-failure feedback state** distinct from field validation: on a failed `add_page`/`upload` — including KTD1's empty-`active_trip` validation error — show an inline error with an explicit retry affordance while the draft (U6) is preserved; specify what the empty-`active_trip` case tells the user ("no active trip is set").
|
||||
- **Patterns to follow:** `css/tokens.css` variables; existing `.form-status--ok` / `.form-status--err` classes; `.journal-post` / site card styling for visual consistency.
|
||||
- **Test scenarios:**
|
||||
- Covers AE3 (disclosure). Advanced trio is hidden until "More options" is expanded; expands automatically when an advanced field has a value.
|
||||
- Get Location denied → inline error, lat/lng stay manually editable.
|
||||
- Get Weather tapped before coords exist → disabled/hint, no dead tap.
|
||||
- Get Weather success fills the weather condition select + temp; failure shows an inline message.
|
||||
- Submit with empty title → per-field inline error, focus moves to the field, no navigation.
|
||||
- Save failure (e.g. empty `active_trip`) → inline save-error message with a retry affordance; entered content preserved (not reset).
|
||||
- Narrow viewport (~375px) renders single-column with no horizontal scroll.
|
||||
- **Verification:** `make test-ui` (incl. `tests/ui/a11y/accessibility.spec.js`) passes; manual dev-server walkthrough at 375px width.
|
||||
|
||||
### U6. Submit resilience — draft persistence
|
||||
|
||||
- **Goal:** A failed submit or an expired session mid-compose never loses the entry's **text** (R19 preservation, R20); photos are out of scope for persistence and the form says so.
|
||||
- **Requirements:** R19 (preserve-on-failure), R20.
|
||||
- **Dependencies:** U3, U5 (bundle + submit handling).
|
||||
- **Files:** `user/themes/intotheeast/js/src/post-form.js` — draft mirror/restore; `user/themes/intotheeast/templates/post-form.html.twig` — re-auth hint markup if needed.
|
||||
- **Approach:** Mirror **text** field values (content especially — title, date, content, location, weather, transport, advanced fields) to `localStorage` on input under a `new-entry` key. Photos are explicitly out of scope: `File`/`Blob` objects can't be serialized to `localStorage`, so picked/converted photos are not persisted and must be re-selected after a reload or re-auth — render an inline hint near the photo field on restore ("photos need re-selecting"). On load, restore any text draft into the fields + editor. Clear the draft only after a confirmed successful post (success message present) — and ensure this clear runs before/independently of the form's `process.reset: true`, so the reset doesn't repopulate blank fields back into `localStorage`. **Text-draft survival is guaranteed by this clear-only-on-success invariant**, independent of any failure-type detection. The tailored "session expired — log in and resubmit" hint is best-effort on top: verify at implementation time what a multipart POST under an expired session/nonce actually returns (an inline `#grav-login`, a Grav nonce/validation error, or a 302 redirect) before keying the hint on it — the auth spec's `#grav-login` assumption is GET-scoped and may not hold for the POST.
|
||||
- **Patterns to follow:** the auth spec's assumption that `/post` renders `#grav-login` inline when unauthenticated (`tests/ui/auth/auth.spec.js` A4) — detect that to distinguish session-expiry from other failures.
|
||||
- **Test scenarios:**
|
||||
- Type content, reload the page → content is restored from the draft.
|
||||
- Successful post → draft is cleared (a fresh `/post` load is empty).
|
||||
- Failed validation submit → entered values persist (not wiped by reset).
|
||||
- Simulated session-expiry response (login form) → text draft survives; re-auth + resubmit posts the text without loss.
|
||||
- After a reload with a photo previously picked → the photo is gone (expected) and the inline "photos need re-selecting" hint is shown; text fields are still restored.
|
||||
- **Verification:** `make test-ui` draft spec (U7) passes; manual check that a reload mid-compose restores content.
|
||||
|
||||
### U7. Post-form test coverage
|
||||
|
||||
- **Goal:** Lock the behavior with a Playwright spec and fixtures (verifies AE1–AE4 and the new UX).
|
||||
- **Requirements:** verification for R1–R20. Two are preserve/constraint requirements with no new-behavior scenario: R10 (no server-side/Docker change) is enforced by the "Scope discipline" Definition-of-Done line; R14 (login gating, no public posting) is covered by the existing `tests/ui/auth/auth.spec.js` (A4).
|
||||
- **Dependencies:** U1–U6.
|
||||
- **Files:**
|
||||
- `tests/ui/post/post.spec.js` and `tests/ui/post/validation.spec.js` — **update existing specs**: they (and `tests/ui/helpers.js`) currently fill `textarea[name="data[content]"]`, which EasyMDE hides once U3 lands. Retarget content entry to the CodeMirror instance (type into `.CodeMirror textarea` or call the EasyMDE API) or the Playwright suite goes red.
|
||||
- `tests/ui/helpers.js` — update the shared content-fill helper for the same reason.
|
||||
- `tests/ui/post/post.spec.js` — extend with the new coverage (or add a focused sibling spec) for disclosure, HEIC conversion + failure, active-trip landing, feedback states, draft restore.
|
||||
- `tests/fixtures/test-photo.heic` — real HEIC fixture for the conversion path.
|
||||
- `scripts/test-post.sh` — extend if the active-trip landing assertion belongs there rather than in Playwright.
|
||||
- **Approach:** Follow the existing spec style (`tests/ui/post/post.spec.js`, `auth.spec.js`): use the logged-in storage state, drive `/post`, and assert field presence (AE3), disclosure behavior, HEIC conversion + failure (AE1/AE4), active-trip landing (AE2), and draft restore. Add the `.heic` fixture alongside `test-photo.jpg` / `test-nonimage.txt`. Note the filepond-targeting specs (and helpers) also need updating if U4's plain-input fallback is adopted.
|
||||
- **Patterns to follow:** existing `tests/ui/**` specs; `.env.test` provides `GRAV_TEST_USER` / `GRAV_TEST_PASS` / `GRAV_BASE_URL`.
|
||||
- **Test scenarios:** the spec *is* the scenarios — AE1, AE2, AE3, AE4, plus disclosure, feedback states, and draft restore.
|
||||
- **Verification:** `make test` (config + post + UI) is green.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
| Gate | Command | Proves |
|
||||
|---|---|---|
|
||||
| Asset build | `make build-assets` | `/post` bundle compiles as ESM with splitting; `js/post-form.js` entry + the `heic-to` dynamic chunk + `css-compiled/post-form.css` all emitted and committed |
|
||||
| Form config | `make test-config` (`scripts/test-form-config.sh`) | `post-form.md` blueprint is valid; new fields parse |
|
||||
| Post pipeline | `make test-post` (`scripts/test-post.sh`) | A post lands under the active trip and appears in the feed |
|
||||
| UI suite | `make test-ui` (`npx playwright test`) | AE1–AE4, disclosure, feedback states, draft restore, accessibility |
|
||||
| Full gate | `make test` | All of the above in sequence |
|
||||
|
||||
Manual: on `http://localhost:8081/post` at ~375px width, post a real iPhone HEIC and confirm a working thumbnail; verify the entry lands in the active trip's dailies immediately.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- **Global:** All of R1–R20 satisfied; `make test` green; `make build-assets` clean with no hand-edits to generated `js/*.js`; the form is posted successfully end-to-end from a narrow (mobile) viewport including one real HEIC photo.
|
||||
- **Per unit:** each unit's Test scenarios pass and its Verification holds.
|
||||
- **Scope discipline:** no server-side image pipeline, no Docker change, no server-side upload validation added (deferred per decision); `pageconfig.parent` removed and no new client-submittable `parent` field introduced.
|
||||
- **Cleanup:** any exploratory filepond-interception dead-ends removed; if the U4 fallback (plain file input) was adopted, the managed-filepond attempt is not left commented in the bundle.
|
||||
- **Docs:** if `active_trip`/post-form coupling notes in `CLAUDE.md` are now stale (the two-file sync is gone), update them.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
Both are deferred (security posture), not launch-blocking. They stay in Open Questions by owner decision:
|
||||
|
||||
- **HEIC single-path durability.** The client-side-only decision assumes HEIC only ever enters via this mobile form, but Admin2 media edits, Immich-served originals, and the same `/post` form opened in a desktop browser can each introduce an unconverted HEIC that bypasses the converter. Decide whether to add a cheap server-side HEIC rejection backstop or to explicitly accept (and document) that non-`/post` HEIC uploads render broken. Reversal cost of the deferred server-side path is a Docker image rebuild.
|
||||
- **Server-side upload validation vs. client-only posture.** A direct authenticated POST can bypass the browser conversion and the `accept: image/*` filter — sending still-HEIC, oversized, non-image, or SVG payloads (`media.yaml` serves `svg`, making an uploaded SVG stored XSS). Deferred: login-gated and low-risk for a solo owner. If pulled in later, enforce a server-side accept-list (jpeg/png/webp; reject SVG + HEIC) and per-file size cap in the same `cache-on-save` handler added in U1, treating client-side conversion as UX rather than a security control.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Filepond interception (U4) is the load-bearing risk.** Grav's managed FilePond instance may not expose a clean hook for pre-upload conversion. Mitigation: documented fallback to a plain file input scoped to `/post`; surface as a blocker before adopting it.
|
||||
- **heic-to browser support.** Relies on WASM/libheif in-browser; verify it works in iOS Safari (the only target). Mitigation: the U7 HEIC fixture test plus a manual real-device check.
|
||||
- **EasyMDE ↔ custom validation ordering.** If `codemirror.save()` doesn't fire before the validator reads the textarea, valid entries get rejected. Mitigated by KTD3 (save on change *and* at submit-handler top) and a U3 test.
|
||||
- **`onFormValidationProcessed` parent settability.** The exact event/priority at which `parent` is settable before `add-page-by-form` reads it is confirmed against a live submit in U1; a higher-priority `onFormProcessed` is the fallback.
|
||||
|
||||
---
|
||||
|
||||
## Sources / Research
|
||||
|
||||
- **Code:** `user/pages/02.post/post-form.md`, `user/plugins/add-page-by-form/add-page-by-form.php` (`parent` override L521–523), `user/plugins/cache-on-save/cache-on-save.php` (`onFormProcessed` handler), `user/themes/intotheeast/blueprints/entry.yaml` (field types/options), `user/themes/intotheeast/templates/post-form.html.twig` (inline validation + Get Location/Weather), `user/themes/intotheeast/package.json` (esbuild `build` script), `user/themes/intotheeast/templates/partials/base.html.twig` (asset loading), `user/config/media.yaml` (no `heic`; serves `svg`), `user/config/site.yaml` (`active_trip`), `tests/ui/**` (Playwright suite), `tests/fixtures/` (`test-photo.jpg`).
|
||||
- **External:** [Grav Media docs](https://learn.getgrav.org/17/content/media) (HEIC unsupported; jpg/png/gif/svg) · [Grav forum — image upload preprocessing](https://getgrav.org/forum/forms-blueprints/image-upload-with-preprocessing-t610) · [heic-to](https://github.com/hoppergee/heic-to) · [EasyMDE](https://github.com/Ionaru/easy-markdown-editor).
|
||||
- **Design:** `docs/reference/design-system.md`, `user/themes/intotheeast/css/tokens.css`.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: requirements-only
|
||||
product_contract_source: ce-brainstorm
|
||||
---
|
||||
|
||||
# Standalone Sub-Page Cleanup — retire redundant map/stats/dailies/stories pages
|
||||
|
||||
**Status:** ✅ Complete (2026-07-04). Phase 1 (retire map/stats/dailies/stories views), Phase 1.5 (align dailies title to "Journal"), Phase 2 (shared entry-map partial), plus follow-ups: fixed a live back-button fallback regression (entry/story pills pointed at retired containers → now the trip page) and re-pointed/cleaned the Playwright suite (9 spec files) off the deleted views. All pushed to production. Auth-gated gpx-manager/post specs not run here (no test creds); everything else green.
|
||||
|
||||
> Plan type: `refactor` · Depth: Standard · Origin: sequel to `2026-06-27-map-init-consolidation.md` — that plan unified the map *engine* (`MapUtils.initEntryMap()`) onto trip + home but deferred `feed-map`/`map.html`; this plan removes those deferred surfaces entirely.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Every trip carries four standalone sub-pages — `02.map`, `03.stats`, plus the standalone `dailies` and `stories` list views — that are **fully consolidated onto the trip page** (inline map + filter bar + inline stats via `trip-feed-col`) and are **no longer reachable from navigation**. They are also the last consumers of the *old* map code path: `feed-map.html.twig` hand-rolls an inline MapLibre init that duplicates `MapUtils.initEntryMap()`, and `map.html.twig` is a third variant built on `renderGpxJourney` directly.
|
||||
|
||||
This plan removes the dead pages/logic (**Phase 1**) and then finishes the shared-logic arc by de-duplicating the map markup that trip and home still copy-paste (**Phase 2**).
|
||||
|
||||
**Net effect after both phases:** the entire site renders maps through exactly one code path (`initEntryMap()`), invoked from exactly one shared partial.
|
||||
|
||||
## Goal
|
||||
|
||||
- Remove unused pages and view logic so the codebase has no orphaned templates or dead map variants.
|
||||
- Preserve all content and all currently-linked behavior — this is a pure internal cleanup, no user-visible feature change on the pages that remain.
|
||||
- Converge on a single map code path.
|
||||
|
||||
## Product authority / decisions locked
|
||||
|
||||
- **Scope = Option 2** (all four standalone views retired), confirmed by owner.
|
||||
- **Keepers — must not break:** `home.html.twig`, `trips.html.twig` (trip overview), `trip.html.twig`, `story.html.twig`, and every shared element they use (`trip-feed-col`, `home-predeparture`, `macros/stats`, `macros/cycling`, `macros/date-range`, `map.css`, `map.js`).
|
||||
- **Containers stay:** `01.dailies/` and `04.stories/` folders remain as data containers (they physically hold entries/stories; trip + home fetch children via `grav.pages.find(route ~ '/dailies').children`).
|
||||
- **Old URLs are don't-care:** `/map`, `/stats`, `/dailies`, `/stories` direct hits may 404. No redirects required (owner decision). Individual entry/story detail pages underneath remain reachable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Cleanup (content + logic + old templates)
|
||||
|
||||
**Delete these page templates (old / unreachable views):**
|
||||
|
||||
- `user/themes/intotheeast/templates/map.html.twig` — old map variant (`renderGpxJourney` inline)
|
||||
- `user/themes/intotheeast/templates/stats.html.twig` — orphaned (nothing links to it)
|
||||
- `user/themes/intotheeast/templates/dailies.html.twig` — standalone list view, consolidated onto trip page
|
||||
- `user/themes/intotheeast/templates/stories.html.twig` — standalone list view, consolidated onto trip page
|
||||
|
||||
**Delete this partial (dies with its only two consumers):**
|
||||
|
||||
- `user/themes/intotheeast/templates/partials/feed-map.html.twig` — old inline-script map duplicate; included **only** by the two deleted list views.
|
||||
|
||||
**Delete these page folders (empty pure-view shells) across every trip:**
|
||||
|
||||
- `user/pages/01.trips/<slug>/02.map/`
|
||||
- `user/pages/01.trips/<slug>/03.stats/`
|
||||
- (applies to all trips: `central-asia-2023`, `italy-2025`, `italy-2026-demo`, `slovenia-2024`, `us-canada-mex-2024`)
|
||||
|
||||
**Repoint the two container pages so nothing errors** (keep the folders, retire the view):
|
||||
|
||||
- `01.dailies/dailies.md` and `04.stories/stories.md`: change `template:` off the deleted templates (e.g. to `default`), and mark the container non-routable/non-visible so its own URL is inert while children stay reachable.
|
||||
|
||||
**Also update / remove any dangling reference to the deleted pages found during work** (e.g. the `link_href: … ~ '/map'` line lived inside `dailies.html.twig`, which is being deleted — confirm no *other* template links to `/map`, `/stats`, `/dailies`, `/stories` as a destination).
|
||||
|
||||
### Phase 1 acceptance criteria
|
||||
|
||||
- Site renders with no Twig errors on: home (active-trip + between-trips), `/trips`, every trip page, and an individual entry and story detail page.
|
||||
- Trip page + home still show the inline map, filter bar, and stats correctly (child-fetch via `find(...).children` still resolves).
|
||||
- `grep` for `feed-map.html.twig`, `map.html.twig`, `stats.html.twig`, `dailies.html.twig`, `stories.html.twig` returns **no remaining `include`/`import`/link references**.
|
||||
- No content lost: journal entries and stories still present and reachable at their detail URLs.
|
||||
- Only one non-`initEntryMap` map path removed — confirm `map.css`/`map.js` and `macros/stats` are untouched.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Optimization (finish the shared-logic arc)
|
||||
|
||||
`trip.html.twig` and `home.html.twig` still hand-write near-identical map markup (`home-map-col` → `home-map`/`trip-map` div → fullscreen button) plus a thin inline `<script>` calling `MapUtils.initEntryMap(...)`. Extract the shared shape.
|
||||
|
||||
- Create `user/themes/intotheeast/templates/partials/entry-map.html.twig` taking parameters for: container id, fullscreen button id, entries array, and gpx config (urls / use / autoconnect / sourcePrefix / journeyId), plus the fit config.
|
||||
- `trip.html.twig` and `home.html.twig` (active branch) both `{% include … with {…} only %}` the new partial instead of their inline markup + script.
|
||||
- Keep the JS engine (`initEntryMap`) as the single source of truth — the partial only supplies markup + the thin invocation.
|
||||
|
||||
### Phase 2 acceptance criteria
|
||||
|
||||
- Trip and home maps render and behave identically to pre-Phase-2 (markers, popups, click-to-scroll-and-highlight, GPX journey, fullscreen toggle).
|
||||
- The map-div markup + invocation exist in exactly one place (`entry-map.html.twig`); no copy-paste twin remains in trip/home.
|
||||
- Existing map-alignment tests (that assert `window.tripMap` / `window.homeMap` globals) still pass.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals / scope boundaries
|
||||
|
||||
- **Not** deleting the `01.dailies/` or `04.stories/` container folders or any content inside them.
|
||||
- **Not** adding redirects for old URLs (owner deferred; 404 is acceptable).
|
||||
- **Not** touching the keeper pages' behavior or the `trip-feed-col` / `home-predeparture` partials, the stats/cycling macros, or `map.css`/`map.js`.
|
||||
- **Not** changing the GPX-manager, post form, or trip-switching config.
|
||||
|
||||
## Risks & verification
|
||||
|
||||
- **Risk:** container repoint leaves child entries unreachable. **Mitigation:** verify `routable: false` on a parent does not unroute children in this Grav version — load an entry and a story detail URL after the change.
|
||||
- **Risk:** a stray reference to a deleted template elsewhere (e.g. `trips.html.twig` counts, sitemap, feed). **Mitigation:** repo-wide grep before declaring Phase 1 done (acceptance criterion above).
|
||||
- **Verification path:** dev server at `http://localhost:8081`; walk home (both modes), `/trips`, each trip, one entry, one story; then run the map-alignment test suite for Phase 2.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None blocking. (Container repoint mechanism — `routable:false` vs a minimal redirect — is an implementation detail for planning; owner has already ruled old-URL behavior don't-care.)
|
||||
@@ -1,74 +0,0 @@
|
||||
# Production Todo
|
||||
|
||||
Work through Phase 1 first (local fixes and config), then Phase 2 (server deployment and go-live).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Local fixes before deploy
|
||||
|
||||
These are changes made in the local dev environment and committed before anything touches the server.
|
||||
|
||||
### 1.1 Fix server-install.sh for Grav 2.0
|
||||
|
||||
`server-install.sh` had a gap: it copied the `grav-admin` bundle (which includes `user/plugins/admin2/`) but then immediately did `rm -rf user && git clone ...`, wiping admin2. It never got reinstalled because GPM doesn't carry Admin2.
|
||||
|
||||
- [x] Updated `server-install.sh` to stash admin2 before wiping user/, then restore it after
|
||||
- [x] Removed `admin` from `plugins.txt` — Admin2 replaces it and both conflict on `/admin`
|
||||
|
||||
### 1.2 Update config for production
|
||||
|
||||
- [x] Cleared `custom_base_url` in `user/config/system.yaml` (was pointing to local dev IP; empty means Grav auto-detects from the request, which works both locally and in production)
|
||||
|
||||
### 1.3 Content and metadata
|
||||
|
||||
- [ ] Set `date_start` on the Japan & Korea 2026 trip page (`user/pages/01.trips/japan-korea-2026/trip.md`)
|
||||
- [ ] Add `cover_image` to the trip page (used on the trips listing)
|
||||
- [ ] Upload actual GPX route file(s) to `/gpx-manager` or drop directly into `user/pages/01.trips/japan-korea-2026/`
|
||||
- [ ] Run `make content-push` to push all local changes to Gitea
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Server deployment and go-live
|
||||
|
||||
### 2.1 Configure .env
|
||||
|
||||
- [x] Set `GRAV_VERSION=2.0.0-rc.10` in `.env` (GitHub releases URL, no channel suffix needed)
|
||||
- [x] Set `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PORT`, `REMOTE_HOME` for the production server
|
||||
- [ ] Set `USER_REPO` and `MAIN_REPO` (Gitea URLs)
|
||||
- [ ] Set `GITEA_HOST`, `GITEA_USER`, `GITEA_TOKEN` for the install-time clone
|
||||
|
||||
### 2.2 Run the install
|
||||
|
||||
```bash
|
||||
make remote-env-setup # writes Gitea token to server temporarily
|
||||
make remote-install # downloads Grav, clones repos, installs plugins
|
||||
make remote-env-remove # removes token from server
|
||||
```
|
||||
|
||||
After install, the script prints the server's SSH public key. Add it as a deploy key to both Gitea repos so `make remote-fetch` works going forward.
|
||||
|
||||
### 2.3 Verify post-install config
|
||||
|
||||
These are committed to the `user/` repo and should be present after the clone — just confirm:
|
||||
|
||||
- [ ] `user/config/system.yaml` has `accounts.type: flex` and `pages.type: flex`
|
||||
- [ ] `user/accounts/mischa.yaml` has `api.super: true` and `api.access: true`
|
||||
- [ ] Old admin plugin is absent from `plugins.txt` (not installed)
|
||||
|
||||
### 2.4 Switch to production mode
|
||||
|
||||
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit this to the repo — it would break local dev)
|
||||
- [ ] If Grav can't auto-detect the base URL (e.g. behind a reverse proxy), set `custom_base_url` in `user/config/system.yaml` on the server
|
||||
|
||||
### 2.5 Smoke test
|
||||
|
||||
- [ ] Submit one post via `/post`, confirm entry appears in `/trips/japan-korea-2026/dailies` immediately (verifies cache-on-save plugin works with `twig.cache: true`)
|
||||
|
||||
### 2.6 Security
|
||||
|
||||
- [ ] Change admin password to a strong production password
|
||||
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
|
||||
|
||||
### 2.7 Map tiles
|
||||
|
||||
- [ ] Register at [carto.com](https://carto.com) and review terms for production traffic (CartoDB dark tiles are free but registration is expected for production use)
|
||||
@@ -0,0 +1,313 @@
|
||||
# Frontend Polish Design Spec
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Elevate the visual identity and consistency of the five primary page templates — home, trip, trips listing, individual entry, and story — without touching the map page or dailies index. Improvements fall into three categories: visual identity (header, stats, pills), typographic consistency (emoji removal), and content pages (trip cards, story transitions).
|
||||
|
||||
**Architecture:** Mostly CSS changes in `style.css`. Two Twig templates need small additions (`trips.html.twig`, `story.html.twig`). One blueprint gets a new field (`blueprints/trip.yaml`). One partial gets emoji removed (`partials/entry-journal.html.twig`). No new JS libraries.
|
||||
|
||||
**Already completed as part of this session:**
|
||||
- `entry.html.twig` unified with `partials/entry-journal.html.twig` — hero image removed, custom lightbox replaced with PhotoSwipe, dead CSS stripped
|
||||
- See git log for the entry template rewrite commit
|
||||
|
||||
---
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All changes in `user/` — commit with `git -C user`
|
||||
- All new CSS must use token variables — never hardcode hex values
|
||||
- No new JS libraries or CDN dependencies
|
||||
- Changes must degrade gracefully if optional data (cover image, location) is absent
|
||||
- `prefers-reduced-motion` must be respected for any new animations
|
||||
|
||||
---
|
||||
|
||||
## A — Trip Cards: Cover Image
|
||||
|
||||
### Problem
|
||||
The trips listing (`/trips`) renders a vertical stack of text-only cards: title, date range, entry count. For a travel blog, the archive is the viewer's first encounter with trips they haven't visited — showing no visual context is a significant missed opportunity.
|
||||
|
||||
### Design decision
|
||||
Each `.trip-card` gets a full-width banner image above the existing text. Aspect ratio 3:1 — wide enough to suggest landscape/geography without dominating a card in a list.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [cover image — 3:1 aspect ratio] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Japan & South Korea │
|
||||
│ Apr 2026 — Jun 2026 · 24 entries │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Image resolution
|
||||
The card is constrained to `--content-width` (720px). Use `cropResize(720, 240)` for the 3:1 crop.
|
||||
|
||||
### Image source priority
|
||||
1. `trip.header.cover_image` — a filename from the trip page's own media (explicit, curated)
|
||||
2. First image from the first published journal entry in the trip (automatic fallback)
|
||||
3. No image — card degrades to text-only (existing layout, unchanged)
|
||||
|
||||
### Blueprint change
|
||||
Add a `cover_image` field to `user/themes/intotheeast/blueprints/trip.yaml`:
|
||||
|
||||
```yaml
|
||||
cover_image:
|
||||
type: filepicker
|
||||
label: Cover Image
|
||||
preview_images: true
|
||||
folder: '@self'
|
||||
accept:
|
||||
- image/*
|
||||
```
|
||||
|
||||
### New CSS
|
||||
|
||||
```css
|
||||
.trip-card-cover {
|
||||
aspect-ratio: 3 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
background: var(--color-border);
|
||||
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-5);
|
||||
}
|
||||
|
||||
.trip-card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.45s ease;
|
||||
}
|
||||
|
||||
.trip-card:hover .trip-card-cover img { transform: scale(1.04); }
|
||||
```
|
||||
|
||||
The negative margin pulls the image flush to the card edges while the card keeps its existing padding for the text below.
|
||||
|
||||
---
|
||||
|
||||
## B — Replace Emoji Icons
|
||||
|
||||
### Problem
|
||||
`partials/entry-journal.html.twig` (which now also powers `entry.html.twig`) uses `📍` for location and emoji for weather conditions (☀️, 🌧️, etc.). These are OS-rendered, variable in size, and break the typographic consistency of the warm-dark palette.
|
||||
|
||||
### Design decision
|
||||
- **Location:** Replace `📍` with a minimal inline SVG mappin. 16×16, `currentColor`, single path.
|
||||
- **Weather:** Drop the emoji prefix entirely. The text description ("Sunny", "Rain", "Partly cloudy") is the information — the emoji is decoration. Text-only is cleaner and the muted color already signals it as secondary metadata.
|
||||
|
||||
### SVG mappin (inline, replaces `📍`)
|
||||
|
||||
```html
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true" style="flex-shrink:0;margin-top:1px">
|
||||
<path d="M6 0C3.24 0 1 2.24 1 5c0 3.75 5 9 5 9s5-5.25 5-9c0-2.76-2.24-5-5-5zm0 6.75A1.75 1.75 0 1 1 6 3.25a1.75 1.75 0 0 1 0 3.5z"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C — Header Identity
|
||||
|
||||
### Problem
|
||||
The site header reads like a product app: text logo left, two nav links right, 60px tall, 3px teal stripe on top. The brand "into the east" at `--text-lg` with `-0.01em` tracking is timid. The content pages are atmospheric and cinematic; the header is functional and forgettable.
|
||||
|
||||
### Design decision
|
||||
Two targeted CSS-only changes:
|
||||
|
||||
1. **Site title tracking:** Increase from `--text-lg` to `--text-xl`, set `letter-spacing: 0.06em`. Wider tracking on a dark background is a deliberate typographic mark — it reads as a designed wordmark rather than placeholder text.
|
||||
|
||||
2. **Accent stripe:** Increase from `3px` to `4px`. Apply a two-stop gradient along the 90deg axis: `linear-gradient(90deg, var(--color-accent), var(--color-accent-hover))`. This gives the stripe direction (reads left-to-right like a journey) and signals it was chosen, not defaulted.
|
||||
|
||||
No layout change, no template change, no height change.
|
||||
|
||||
---
|
||||
|
||||
## D — Story Opening Transition
|
||||
|
||||
### Problem
|
||||
After the Ken Burns hero and the 40vh spacer, `story.html.twig` begins the body content immediately with prose. There is no visual breath between the cinematic full-screen image and the reading experience. The reader has no bearing — no confirmation of where they are or when.
|
||||
|
||||
### Design decision
|
||||
Add a `.story-opener` block at the top of `.story-body`, before `{{ page.content|raw }}`. It displays the location and formatted date string centered, separated from the prose below by a thin ruled line.
|
||||
|
||||
```
|
||||
Sorano, Italy · 14–16 June 2026
|
||||
────────────────────────────────
|
||||
[prose begins here]
|
||||
```
|
||||
|
||||
Data comes from `location` and `date_str`, already computed at the top of `story.html.twig`. If both are empty the opener renders nothing (zero markup visible).
|
||||
|
||||
The opener fades in using the existing `storyReveal` keyframe (`filter: blur → 0`, `opacity: 0 → 1`, `translateY(22px → 0)`) with a 0.8s delay so it appears after the hero title animation completes.
|
||||
|
||||
### New CSS
|
||||
|
||||
```css
|
||||
.story-opener {
|
||||
text-align: center;
|
||||
padding-bottom: var(--space-12);
|
||||
margin-bottom: var(--space-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
opacity: 0;
|
||||
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.8s both;
|
||||
}
|
||||
|
||||
.story-opener__text {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.story-opener { opacity: 1; animation: none; }
|
||||
}
|
||||
```
|
||||
|
||||
### Template addition (in `story.html.twig`, inside `.story-body`, before `page.content`)
|
||||
|
||||
```twig
|
||||
{% if location or date_str %}
|
||||
<div class="story-opener">
|
||||
<span class="story-opener__text">
|
||||
{{- date_str -}}
|
||||
{%- if location and date_str %} · {% endif -%}
|
||||
{{- location -}}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E — Reading Progress Bar
|
||||
|
||||
### Problem
|
||||
Story pages are long-form — the longest may scroll for several minutes of reading. There is no visual feedback about progress through the piece. This is a small but meaningful quality signal on an immersive reading experience.
|
||||
|
||||
### Design decision
|
||||
A 2px teal bar fixed to the bottom edge of the site header (`top: var(--site-header-height)`), filling left-to-right as the reader scrolls through `.story-body`. Progress is calculated relative to the story body element (not the full page including the hero), so the bar reads 0% when the hero exits and 100% when the last line of content reaches the viewport bottom.
|
||||
|
||||
The bar is invisible before the story body enters view. It does not render at all if `prefers-reduced-motion` is set — there should be no static `width: 0` bar for reduced-motion users.
|
||||
|
||||
### New CSS
|
||||
|
||||
```css
|
||||
.story-progress {
|
||||
position: fixed;
|
||||
top: var(--site-header-height);
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
background: var(--color-accent);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
will-change: width;
|
||||
}
|
||||
```
|
||||
|
||||
### JS logic (no transition — rAF-driven for smoothness)
|
||||
|
||||
```javascript
|
||||
(function () {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
var bar = document.getElementById('story-progress');
|
||||
var body = document.querySelector('.story-body');
|
||||
if (!bar || !body) return;
|
||||
|
||||
function update() {
|
||||
var rect = body.getBoundingClientRect();
|
||||
var total = body.offsetHeight - window.innerHeight;
|
||||
var scrolled = -rect.top;
|
||||
var pct = total > 0 ? Math.min(100, Math.max(0, (scrolled / total) * 100)) : 0;
|
||||
bar.style.width = pct.toFixed(1) + '%';
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
update();
|
||||
})();
|
||||
```
|
||||
|
||||
The element `<div class="story-progress" id="story-progress"></div>` is added to `story.html.twig` immediately after the opening `{% block content %}`.
|
||||
|
||||
---
|
||||
|
||||
## F — Pill Shape Differentiation
|
||||
|
||||
### Problem
|
||||
All interactive pills use `border-radius: 9999px` regardless of their role. Back-navigation pills, filter buttons, panel toggles, sort toggles — they all look identical, which collapses the visual grammar. A reader cannot tell at a glance whether tapping a pill will navigate them away or toggle a filter.
|
||||
|
||||
### Design decision
|
||||
Establish a two-shape grammar:
|
||||
|
||||
| Role | Shape | Classes |
|
||||
|---|---|---|
|
||||
| Navigation (go somewhere, leave the page) | Full pill `9999px` | `.back-pill`, `.story-escape`, `.story-totop` |
|
||||
| Controls (toggle, filter, sort in place) | Rounded rect `var(--radius-sm)` = 4px | `.trip-filter-btn`, `.trip-stats-btn` |
|
||||
| Panel toggles (secondary, in-place) | Full pill (keep — less prominent than controls) | `.trip-panel-toggle` |
|
||||
|
||||
CSS-only change. `.back-pill`, `.story-escape`, `.story-totop` are unchanged. Only `.trip-filter-btn` and `.trip-stats-btn` change from `border-radius: var(--radius-full)` to `border-radius: var(--radius-sm)`.
|
||||
|
||||
---
|
||||
|
||||
## G — Stats: Field Notes Treatment
|
||||
|
||||
### Problem
|
||||
`.stat-block` renders as a bordered card with a `background: var(--color-canvas)` surface, box shadow, and teal accent numbers. This reads as a metrics dashboard — every SaaS product uses this pattern. For a travel journal, numbers like "1,847 km" and "3 countries" should feel earned and written, not computed and charted.
|
||||
|
||||
### Design decision
|
||||
Two changes:
|
||||
|
||||
1. **Remove the box.** Drop `background`, `border`, and `box-shadow` from `.stat-block`. Replace with a `border-left: 2px solid var(--color-accent)` and `padding-left: var(--space-4)`. Text left-aligns. Numbers feel like notes in a margin, not cells in a table.
|
||||
|
||||
2. **Change number color.** `stat-value` moves from `var(--color-accent)` to `var(--color-ink)`. Teal numbers on dark are a SaaS color decision. Cream numbers on dark with a teal accent stripe are a traveler's notation.
|
||||
|
||||
The teal accent is now only the left rule — restrained, singular.
|
||||
|
||||
### CSS change
|
||||
|
||||
```css
|
||||
/* Before */
|
||||
.stat-block {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--color-accent);
|
||||
...
|
||||
}
|
||||
|
||||
/* After */
|
||||
.stat-block {
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding: var(--space-2) 0 var(--space-2) var(--space-4);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--color-ink);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The `.trip-stats-grid` and `.stats-grid` gap/column settings are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After full implementation, check each page:
|
||||
|
||||
| Page | Check |
|
||||
|---|---|
|
||||
| `/trips` | Trip cards show cover image (or degrade to text-only gracefully); hover scales image |
|
||||
| `/trips/<any-trip>` | Stats panel shows left-rule style, cream numbers; filter buttons are rounded-rect |
|
||||
| `/trips/<any-trip>/dailies/<any-entry>` (standalone) | Photo strip renders via PhotoSwipe, no broken lightbox; no hero image at top |
|
||||
| `/trips/<any-trip>/<any-story>` | Opener block shows location + date; progress bar fills while scrolling; no bar if reduced-motion |
|
||||
| Any page | Header title has wider tracking; accent stripe is slightly thicker with gradient |
|
||||
| Any page with journal entries | Location shows SVG pin; weather shows text only, no emoji |
|
||||
@@ -0,0 +1,146 @@
|
||||
# Home / Trip View Convergence Design
|
||||
|
||||
**Date:** 2026-06-27
|
||||
**Status:** Approved for implementation
|
||||
|
||||
## Problem
|
||||
|
||||
The home page in active-trip mode (`home.html.twig`, `config.site.travelling` branch) and the trip page (`trip.html.twig`) are meant to present the same experience — the same content, behaving near-identically. Today their feeds already match (both render journal + story entries via the shared `entry-journal`/`entry-story` partials), but the **feed-col chrome diverges**:
|
||||
|
||||
| Feature | Trip page | Home-active | Converge? |
|
||||
|---|---|---|---|
|
||||
| Feed lists journal + stories | ✅ | ✅ | already matches |
|
||||
| Date-range header | ✅ | ❌ | **yes** |
|
||||
| Filter bar (All / Journal / Stories) | ✅ | ❌ | **yes** |
|
||||
| Stats panel | ✅ | ❌ | **yes** |
|
||||
| Cycling panel | ✅ (if GPX) | ❌ | **yes** |
|
||||
| Sort toggle button | ✅ | ❌ | **no — intended difference** |
|
||||
| Default feed order | oldest→newest (sort flag 4) | its own (sort flag 3) | **no — intended difference** |
|
||||
|
||||
The chrome markup is the divergence. The supporting **behavior is already global**: `js/main.js` (loaded for every page via `base.html.twig:10`) runs `initFilterBar()`, `initPanelToggles()`, and `initSortButton('trip-sort-toggle', …)`, each a silent no-op when its markup is absent. The entry partials already emit `data-type`, which the filter relies on. So rendering the same markup on home is enough for the filter bar, panel toggles, and (where present) the sort button to work with **zero new JS**.
|
||||
|
||||
The single exception is the **stats/cycling computation glue** (writing distance/elevation values into `#stat-distance`, `#cyc-*`). That code is currently *inline* in `trip.html.twig` and not global, so the stats/cycling panels cannot function on home until it is shared.
|
||||
|
||||
## Goals
|
||||
|
||||
- Home-active gains the date-range header, filter bar, and stats/cycling panels — matching the trip page.
|
||||
- The shared feed-col chrome lives in **one** place (a partial), so future header/chrome changes apply to both pages.
|
||||
- Home-active keeps its own default feed order and has **no** sort button (the two intended differences).
|
||||
- Stats/cycling computation works on both pages from a single shared JS function.
|
||||
- No change to the trip page's rendered output (structural refactor only on that side).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Map convergence is out of scope.** The home-active map omitting story markers, lacking a fullscreen button, and its hash-only click behavior are all deferred to a later map-init spec (see `project-map-init-refactor` memory). Both inline map scripts and both map-col markup blocks stay exactly as they are.
|
||||
- Extracting the `all_items` / `map_entries` build loops to a macro — Twig macros output HTML, not arrays (established constraint; see `2026-06-23-template-refactor-design.md`). Each page keeps its own data-build loops.
|
||||
- Any visual restyling of the chrome — home reuses the trip's existing CSS classes unchanged.
|
||||
- Adding a sort button to home, or changing home's default order.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. New shared partial: `templates/partials/trip-feed-col.html.twig`
|
||||
|
||||
Holds the entire `.home-feed-col` content currently inline in `trip.html.twig:70-120`:
|
||||
|
||||
- header (`.home-trip-header`): title, date range (when `trip_page.header.date_start` set), counts
|
||||
- filter bar (`.trip-filter-bar`): All / Journal / Stories buttons
|
||||
- the sort button (`#trip-sort-toggle`) — **rendered only when `show_sort` is true**
|
||||
- panel toggles (`.trip-panel-toggles`): Stats, and Cycling (when `has_gpx`)
|
||||
- `stats_panel(...)` and (when `has_gpx`) `cycling_panel(...)` macro calls
|
||||
- the feed loop over `all_items` with the `#feed-filter-empty` sentinel
|
||||
|
||||
**Interface** (called via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
|
||||
|
||||
| Param | Type | Trip passes | Home-active passes |
|
||||
|---|---|---|---|
|
||||
| `trip_page` | Page | `page` | `trip` |
|
||||
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
|
||||
| `journal_entries` | array | dailies children | dailies children |
|
||||
| `journal_count` | int | count | count |
|
||||
| `story_count` | int | count | count |
|
||||
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
|
||||
| `show_sort` | bool | `true` | `false` |
|
||||
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
|
||||
|
||||
Because the partial is called with `only`, it must `{% import 'macros/stats.html.twig' %}` and `{% import 'macros/cycling.html.twig' %}` itself.
|
||||
|
||||
Both pages already build `all_items`, the counts, and `has_gpx` for their existing map data, so these are passed in rather than rebuilt — no new duplication is introduced.
|
||||
|
||||
### 2. Shared stats glue: `initTripStats(config)` in `js/src/main.js`
|
||||
|
||||
Extract the inline stats/cycling computation from `trip.html.twig:213-249` into a config-driven function. Current inline logic: if GPX present, `MapUtils.parseGpxFiles(urls, …)` fills `#stat-distance` and all `#cyc-*` fields; otherwise sum `haversineKm` over `gps_points` and write the `~`-prefixed estimate to `#stat-distance` — but when `gps_points` has fewer than 2 points, write `—` (not `~0`) and return, preserving the existing guard at `trip.html.twig:245`. This matters on home-active in the pre-departure / zero-entry state, where dropping the guard would render "~0 km roamed" instead of the macro's `—` placeholder.
|
||||
|
||||
```js
|
||||
function initTripStats(config) {
|
||||
// config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
|
||||
// No-op if #stat-distance is absent (page has no stats panel).
|
||||
// No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0').
|
||||
}
|
||||
```
|
||||
|
||||
Called from the boot block alongside the other inits. Each page provides the config via a small inline `<script>` that defines the data (the `*_GPX_URLS` / `gps_points` arrays are page-specific Twig output), then calls `initTripStats(...)` — or the boot reads globals the page sets. Implementation detail for the plan; the contract is: the function is selector-guarded and runs on any page that rendered a stats panel. Two placement invariants the current working code relies on must carry forward: the inline call **must run inside a `DOMContentLoaded` handler** (mirroring the current `trip.html.twig` stats IIFE) so that `initTripStats` and `MapUtils` — loaded via the `bottom` asset group rendered at the end of `<body>`, after content-block inline scripts — are defined when it executes; and it **must not be nested inside the `{% if map_entries|length > 0 %}` map block**, or a trip with GPX but zero geocoded journal entries would render the panels yet never populate them.
|
||||
|
||||
`js/main.js` is the built artifact; the asset pipeline rebuilds it from `js/src/main.js` (see `2026-06-22-asset-pipeline-design.md`).
|
||||
|
||||
### 3. Home-active data additions
|
||||
|
||||
Home-active currently builds `map_entries` (journal-only) and `home_gpx_urls`. For the stats panel's no-GPX fallback it must also build `gps_points` (journal entries with lat/lng), mirroring `trip.html.twig:27-32`.
|
||||
|
||||
### 4. Template wiring
|
||||
|
||||
**`trip.html.twig`**: replace the inline `.home-feed-col` block (`:70-120`) with the partial include; remove the inline stats script (`:213-249`) in favor of `initTripStats(...)`. Map script, fullscreen wiring, and map data-build stay untouched.
|
||||
|
||||
**`home.html.twig`** (active branch): replace the bespoke feed-col (`:60-83`) with the same partial include (`show_sort: false`); add `gps_points` build; wire `initTripStats(...)`. Map script and map-col stay untouched.
|
||||
|
||||
### 5. Home-active pre-departure empty state
|
||||
|
||||
Resolves the review finding that home-active (the landing page) would otherwise show two competing empty states before the first post — the static `{% else %}` "No entries yet" feed fallback *and* the JS `#feed-filter-empty` sentinel — once a filter tab is clicked.
|
||||
|
||||
When `all_items` is empty (gated by the new `pre_departure` param), the partial renders a single **pre-departure block** instead of the filter bar, panel toggles, and the generic feed fallback:
|
||||
|
||||
- the active trip's title and `trip_page.header.date_start` (e.g. "Departing 17 Jun 2026"), with a "Coming soon" note
|
||||
- a clear divider
|
||||
- a short line + button — "In the meantime, explore my other trips →" — linking to the Past Trips page
|
||||
|
||||
This block renders **only while `all_items|length == 0`** and disappears entirely once the first entry is posted, at which point the normal filter bar + feed render. It is home-active-only: the trip page passes `pre_departure: false` and is unaffected (it is not reachable before content exists). The block is new home-only markup but reuses existing typography/button classes — no new visual language.
|
||||
|
||||
Open sub-decision for implementation: whether the Stats/Cycling panels are also hidden in this state (they would otherwise show "0 days / 0 entries"). Defaulting to hidden, for consistency with the suppressed filter bar.
|
||||
|
||||
## Data / behavior flow after change
|
||||
|
||||
```
|
||||
base.html.twig ──loads──> js/main.js (global)
|
||||
├─ initFilterBar() ← works on both via .trip-filter-btn + [data-type]
|
||||
├─ initPanelToggles() ← works on both via .trip-panel-toggle
|
||||
├─ initSortButton('trip-sort-toggle', …) ← trip only (home omits button → no-op)
|
||||
└─ initTripStats(cfg) ← works on both via #stat-distance guard
|
||||
|
||||
trip.html.twig ─include─> partials/trip-feed-col.html.twig (show_sort: true)
|
||||
home.html.twig ─include─> partials/trip-feed-col.html.twig (show_sort: false)
|
||||
└─ stats_panel(), cycling_panel(), feed loop
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
No JS test harness exists in this project; verification is manual browser smoke testing at `http://localhost:8081`, consistent with prior template work.
|
||||
|
||||
**Trip page (regression — must be unchanged):**
|
||||
1. Load the trip page. Confirm header, date range, filter bar, sort button, stats/cycling panels, and feed render identically to before.
|
||||
2. Filter bar All/Journal/Stories filters the feed; sort button flips order; Stats/Cycling panels toggle open/closed.
|
||||
3. Stats panel distance and cycling figures populate (GPX present) or show the `~` estimate (no GPX).
|
||||
|
||||
**Home page, active-trip mode (new behavior):**
|
||||
4. With `config.site.travelling: true`, load `/`. Confirm date range, counts, filter bar (no sort button), and Stats panel (+ Cycling if the trip has GPX) now appear.
|
||||
5. Filter bar filters the feed; panel toggles work; stats figures populate.
|
||||
6. Confirm the feed default order is home's own order (unchanged from today) and that no sort button is present.
|
||||
6b. **Pre-departure state:** with `travelling: true` and no posts yet, confirm home shows the trip title + start date + "Coming soon" and the "explore my other trips" divider/button — and that the filter bar and the "No entries yet" fallback do *not* both appear. Post one entry and confirm the pre-departure block disappears and the normal filter bar + feed render.
|
||||
|
||||
**Home page, between-trips mode (regression):**
|
||||
7. With `config.site.travelling: false`, load `/`. Confirm the highlights layout is unaffected (this branch does not use the partial).
|
||||
|
||||
## Files touched
|
||||
|
||||
- **New:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
|
||||
- **Edit:** `user/themes/intotheeast/templates/trip.html.twig` (feed-col → include; remove inline stats script)
|
||||
- **Edit:** `user/themes/intotheeast/templates/home.html.twig` (active branch feed-col → include; add `gps_points`; wire stats)
|
||||
- **Edit:** `user/themes/intotheeast/js/src/main.js` (+`initTripStats`); rebuild `js/main.js`
|
||||
@@ -0,0 +1,209 @@
|
||||
# Grav 2.0.4 Upgrade + GPM-Manage admin2/api/flex-objects — Design
|
||||
|
||||
**Date:** 2026-07-04
|
||||
**Status:** Design approved — pending implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Perform one coordinated upgrade of the intotheeast stack:
|
||||
|
||||
1. Bump Grav core from `2.0.0-rc.10` → `2.0.4` (stable).
|
||||
2. Promote `admin2`, `api`, and `flex-objects` from bundle-extracted plugins to
|
||||
**GPM-managed** plugins (this is "option B").
|
||||
|
||||
These two changes are **not separable**. The stable plugins hard-require the
|
||||
stable core, so GPM enforces an atomic upgrade of the whole chain.
|
||||
|
||||
## Background / findings
|
||||
|
||||
### Version gaps
|
||||
|
||||
The project has been frozen on the final release candidate since the original
|
||||
Grav 2.0 upgrade. Grav went stable on 2026-06-21; latest patch is `2.0.4`
|
||||
(2026-06-29). No breaking changes exist within the 2.0.x line — the
|
||||
2.0.1–2.0.4 releases are security hardening (XSS re-checks on editor Twig,
|
||||
ZIP-bomb limits, `.htaccess` case-insensitive bypass fix) plus bugfixes.
|
||||
|
||||
Installed vs. bundled-in-2.0.4 versions:
|
||||
|
||||
| Plugin | Installed | 2.0.4 stable |
|
||||
|---|---|---|
|
||||
| core (grav) | 2.0.0-rc.10 | 2.0.4 |
|
||||
| admin2 | 2.0.0-rc.15 | 2.0.9 |
|
||||
| api | 1.0.0-rc.15 | 1.0.6 |
|
||||
| flex-objects | 1.4.0-rc.7 | 1.4.3 |
|
||||
| login | 3.8.9 | 3.8.11 |
|
||||
| form | 9.1.6 | 9.1.8 |
|
||||
| shortcode-core | 6.0.0 | 6.2.1 |
|
||||
|
||||
### Dependency chain (why it's atomic)
|
||||
|
||||
From the stable plugin blueprints:
|
||||
|
||||
- `api` 1.0.6 requires `grav >= 2.0.4` **and** `login >= 3.8.11`
|
||||
- `admin2` 2.0.9 requires `api >= 1.0.6`
|
||||
- `flex-objects` 1.4.3 requires `form >= 6.0.0`, `api >= 1.0.0`
|
||||
|
||||
So stable admin2/api cannot run on the rc.10 core — GPM would refuse. This is
|
||||
the core reason option B is the right approach: `gpm` resolves and enforces the
|
||||
entire chain automatically, which the previous manual-extract approach never
|
||||
did.
|
||||
|
||||
### Three plugin management categories
|
||||
|
||||
The upgrade must account for the fact that plugins reached the servers three
|
||||
different ways:
|
||||
|
||||
| Category | Plugins | In `plugins.txt`? | How installed | Upgrade mechanism |
|
||||
|---|---|---|---|---|
|
||||
| GPM-managed | email, error, form, login, problems, add-page-by-form, shortcode-gallery-plusplus | yes | `gpm install` | `gpm update` |
|
||||
| Manually-placed → GPM (option B) | admin2, api, flex-objects | **will add** | hand-extracted from grav-admin zip | `plugins.txt` for fresh installs; `gpm update` on existing test env |
|
||||
| Remote-only | git-sync | **no** (config gitignored, holds encrypted token) | installed directly on the server | documented separately; carried by `gpm update`; **disabled during upgrade** |
|
||||
|
||||
### git-sync compatibility
|
||||
|
||||
git-sync is version 3.4.4 with an explicit `compatibility: 2.0` flag and is one
|
||||
of Grav's own reference plugins for the Admin Next / API. It is safe to carry
|
||||
through the upgrade. Note the documented folders-YAML quirk
|
||||
(`docs/working/git-sync-notes.md`): its config must list `folders` as an array,
|
||||
never the UI-written comma-string.
|
||||
|
||||
### Local vs. server upgrade mechanisms differ
|
||||
|
||||
- **Local** bakes the core into the Docker image (`Dockerfile`) → upgrade by
|
||||
rebuilding the image.
|
||||
- **Server** is a native webroot install → core upgrades via
|
||||
`bin/grav upgrade`, plugins via `bin/gpm update`.
|
||||
|
||||
The plan therefore has distinct local and remote steps.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Option B (GPM management)** for admin2/api/flex-objects. Add them to
|
||||
`plugins.txt` so future fresh installs pull them via GPM.
|
||||
- **Rollout order:** local → test → prod.
|
||||
- **Prod is currently empty** → the prod phase is written as a *documented
|
||||
fresh-install runbook only* and is **not executed** in this effort. Fresh prod
|
||||
install uses the option-B-modified `server-install.sh` with
|
||||
`GRAV_VERSION=2.0.4`.
|
||||
- **Rollback = git.** All relevant data (`pages/`, `config/`, `accounts/`,
|
||||
`themes/`) is committed. No separate backup step. Rollback is `git revert` of
|
||||
this branch plus a rebuild/redeploy.
|
||||
- **Upgrade verb on existing installs is `gpm update` (update-all)**, not
|
||||
`gpm install <plugins.txt>`. `install` skips already-installed plugins and
|
||||
never touches git-sync (which is not in the list); `update` upgrades every
|
||||
installed plugin regardless of how it was placed, catching the manual and
|
||||
remote-only categories in one shot.
|
||||
- **git-sync stays out of `plugins.txt`** (that list is shared with local; git-sync
|
||||
is remote-only with a manual encrypted token). Documented as separately
|
||||
managed.
|
||||
- **git-sync is disabled before the test upgrade and left disabled**, so the
|
||||
upgrade cannot auto-commit reformatted/server-specific config back into the
|
||||
shared Gitea `user` repo. The user validates first, then re-enables it as a
|
||||
separate deliberate step.
|
||||
|
||||
## File changes (Phase 0)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `Dockerfile` | grav-admin zip URL `2.0.0-rc.10/grav-admin-v2.0.0-rc.10.zip` → `2.0.4/grav-admin-v2.0.4.zip`. Verified: the 2.0.4 zip still extracts to a `grav-admin/` folder, so the existing `cp` block is unchanged. |
|
||||
| `plugins.txt` | add `api`, `admin2`, `flex-objects` (`form`, `login` already present as their deps) |
|
||||
| `user/config/system.yaml` | **`gpm.releases: testing → stable`** — this is the authoritative GPM channel. `testing` is what has been serving RC/pre-release versions. Tracked in the `user` repo, so it applies to both local and server once pushed. |
|
||||
| `docker-compose.yml` | `GRAV_CHANNEL=beta` → `production` for consistency only. This env drives the base image's `docker-entrypoint.sh`, **not** `bin/gpm`'s channel — `gpm.releases` above is what governs updates. |
|
||||
| `scripts/server-install.sh` | remove the admin2/api stash+restore special-casing (lines 25–26 and 43–45); they now install via `gpm install` from `PLUGINS` |
|
||||
| `Makefile` | **fix broken `remote-upgrade-grav`:** `php bin/grav upgrade` is not a real command — change to `php bin/gpm self-upgrade -y`. Add the new remote targets (below) to `REMOTE_TARGETS` so each gets `-test`/`-prod` variants. |
|
||||
| `CLAUDE.md`, `docs/reference/architecture.md`, memory | update stack versions; document the three-category plugin model and the channel change |
|
||||
|
||||
### New Makefile targets
|
||||
|
||||
Added to the `REMOTE_TARGETS` list (Makefile:22–24) so the env-suffix macro
|
||||
(Makefile:31–34) auto-generates `-test` / `-prod` variants:
|
||||
|
||||
- `remote-update-plugins` → `cd $(WEBROOT) && php bin/gpm update -y`
|
||||
- `remote-git-sync-disable` → set `enabled: false` in
|
||||
`$(WEBROOT)/user/config/plugins/git-sync.yaml` (touch only the `enabled` key;
|
||||
never rewrite `folders`)
|
||||
- `remote-git-sync-enable` → set `enabled: true` in the same file
|
||||
|
||||
`remote-upgrade-grav` exists but its command is **broken** (`php bin/grav
|
||||
upgrade` is not a Grav CLI command) — it is fixed to `php bin/gpm self-upgrade
|
||||
-y` as part of Phase 0. Core self-upgrade respects the `gpm.releases` channel.
|
||||
|
||||
**Verified CLI command names** (against the running rc.10 container):
|
||||
`php bin/gpm self-upgrade -y` (core), `php bin/gpm update -y` (all plugins),
|
||||
`php bin/grav cache` (clear cache; aliases `clearcache`/`cache-clear`).
|
||||
|
||||
> The exact idempotent shell used to toggle the `enabled` key is finalized in the
|
||||
> implementation plan; it must not disturb the `folders` array or the encrypted
|
||||
> token in `git-sync.yaml`.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — branch + edits
|
||||
New branch off `main`. Apply all file changes above.
|
||||
|
||||
### Phase 1 — local
|
||||
1. Remove the stale manually-extracted `admin2`, `api`, `flex-objects` folders
|
||||
from `user/plugins/` so GPM does a clean install.
|
||||
2. `make build` (core → 2.0.4)
|
||||
3. `make start`
|
||||
4. Install the newly-listed plugins **and** update the already-installed ones
|
||||
to their 2.0.4-compatible versions via GPM. Note: `gpm install` skips plugins
|
||||
that are already present, so `login` (3.8.9 → ≥3.8.11, required by `api`) and
|
||||
`form` need `gpm update`, not `install`. The exact `gpm update` + `gpm install`
|
||||
sequencing (run inside the container via `docker exec`) is pinned in the plan.
|
||||
5. Assert versions: admin2 2.0.9, api 1.0.6, flex-objects 1.4.3, login ≥ 3.8.11.
|
||||
6. **Smoke test:** admin2 login; submit `/post` → entry appears in the active
|
||||
trip's dailies; `/gpx-manager` list + upload + delete; a trip page and a
|
||||
story render; maps load.
|
||||
|
||||
Prerequisite: the Phase 0 config changes (esp. `system.yaml`
|
||||
`gpm.releases: stable`) are committed and pushed to Gitea, or GPM on the server
|
||||
will still resolve the `testing` channel and pull RCs.
|
||||
|
||||
1. `make remote-git-sync-disable-test`
|
||||
2. `make remote-fetch-content-test` — pull latest `user/` content to the test
|
||||
server so `system.yaml` `gpm.releases: stable` is in place before any GPM
|
||||
operation.
|
||||
3. `make remote-upgrade-grav-test` (core self-upgrade → 2.0.4)
|
||||
4. `make remote-update-plugins-test` (`gpm update -y` — all plugins incl.
|
||||
admin2/api/flex/git-sync/login/form)
|
||||
5. Clear cache on the server (`php bin/grav cache`).
|
||||
6. Review `git status` in the server's `user/` for unexpected config diffs;
|
||||
handle any deliberately (do not blind-commit).
|
||||
7. Smoke test on the test URL (same checklist as Phase 1).
|
||||
8. **Leave git-sync disabled and notify the user.** After the user validates,
|
||||
re-enable as a separate deliberate step: `make remote-git-sync-enable-test`,
|
||||
then a `content-push` round-trip to confirm sync still works.
|
||||
|
||||
### Phase 3 — prod (DOCUMENTED, NOT EXECUTED)
|
||||
Prod is empty, so this is a fresh install, not an upgrade. Documented as a
|
||||
runbook:
|
||||
|
||||
- Run the option-B-modified `server-install.sh` with `GRAV_VERSION=2.0.4`
|
||||
(`make remote-install-prod`).
|
||||
- admin2/api/flex-objects now install via GPM from `plugins.txt` — no manual
|
||||
extraction.
|
||||
- Set up git-sync manually afterward: install, add the encrypted token, apply
|
||||
the folders-YAML array fix (`docs/working/git-sync-notes.md`).
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert` the branch (Dockerfile + plugins.txt + docker-compose +
|
||||
server-install.sh + Makefile) and rebuild/redeploy. Content, config, and
|
||||
accounts are already in git, so no data restore is needed.
|
||||
|
||||
## Risks
|
||||
|
||||
- **git-sync auto-commit during upgrade** — mitigated by disabling git-sync
|
||||
before the test upgrade and reviewing `git status` before re-enabling.
|
||||
- **admin2/api behavioral changes across RC→stable** — these back the `/post`
|
||||
form and `/gpx-manager`; covered by the smoke tests, which are the
|
||||
highest-weight validation in this effort.
|
||||
- **GPM channel** — `gpm.releases` must be `stable` on the server *before* any
|
||||
`gpm update`/`self-upgrade`, or GPM pulls RCs. Enforced by pushing the
|
||||
`system.yaml` change and running `remote-fetch-content` first (Phase 2 step 2).
|
||||
- **`bin/gpm self-upgrade` on shared hosting** — Grav 2.0.3 fixed self-upgrade
|
||||
failures on shared-folder setups. On the native server this can still be
|
||||
fragile; run `php bin/gpm preflight` first and use `-o/--overwrite` if a retry
|
||||
is needed.
|
||||
@@ -5,3 +5,6 @@ login
|
||||
problems
|
||||
add-page-by-form
|
||||
shortcode-gallery-plusplus
|
||||
api
|
||||
admin2
|
||||
flex-objects
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
FILE="$1"
|
||||
STATE="$2"
|
||||
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -qE '^enabled:' "$FILE"; then
|
||||
sed -i -E "s/^enabled:.*/enabled: ${STATE}/" "$FILE"
|
||||
else
|
||||
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
|
||||
fi
|
||||
|
||||
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
|
||||
@@ -22,8 +22,6 @@ cd "$WEBROOT"
|
||||
wget --no-verbose "https://github.com/getgrav/grav/releases/download/${GRAV_VERSION}/grav-admin-v${GRAV_VERSION}.zip" -O grav-admin.zip
|
||||
unzip -oq grav-admin.zip
|
||||
cp -rf grav-admin/. .
|
||||
cp -rf grav-admin/user/plugins/admin2 /tmp/admin2-plugin
|
||||
cp -rf grav-admin/user/plugins/api /tmp/api-plugin
|
||||
rm -rf grav-admin grav-admin.zip
|
||||
|
||||
echo "==> Cloning user repo"
|
||||
@@ -40,9 +38,6 @@ fi
|
||||
|
||||
echo "==> Creating required directories"
|
||||
mkdir -p user/plugins user/accounts user/data
|
||||
cp -rf /tmp/admin2-plugin user/plugins/admin2
|
||||
cp -rf /tmp/api-plugin user/plugins/api
|
||||
rm -rf /tmp/admin2-plugin /tmp/api-plugin
|
||||
|
||||
echo "==> Installing plugins"
|
||||
php bin/gpm install $PLUGINS -y
|
||||
|
||||
@@ -26,7 +26,7 @@ grep -q "add_page:\|addpage:" "$FORM" && ok "Process action is 'add_page' (plugi
|
||||
|
||||
# Config must be in frontmatter, not in the process block
|
||||
check_grep "pageconfig block exists in frontmatter" "^pageconfig:"
|
||||
check_grep "parent set to /trips/japan-korea-2026/dailies" "parent: '/trips/japan-korea-2026/dailies'"
|
||||
check_grep "parent set to /trips/italy-2026-demo/dailies" "parent: '/trips/italy-2026-demo/dailies'"
|
||||
check_grep "slug_field set (determines entry folder name)" "slug_field:"
|
||||
check_grep "pagefrontmatter block exists in frontmatter" "^pagefrontmatter:"
|
||||
check_grep "template: entry (creates entry.md filename)" "template: entry"
|
||||
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
BASE_URL="${GRAV_BASE_URL:-http://localhost:8081}"
|
||||
USER="${GRAV_TEST_USER:-}"
|
||||
PASS="${GRAV_TEST_PASS:-}"
|
||||
TRACKER="user/pages/01.trips/japan-korea-2026/01.dailies"
|
||||
TRACKER="user/pages/01.trips/italy-2026-demo/01.dailies"
|
||||
COOKIE_JAR="$(mktemp /tmp/grav-test-cookies.XXXXXX)"
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
@@ -49,7 +49,10 @@ LOGIN_NONCE=$(echo "$LOGIN_HTML" | grep -o 'name="login-form-nonce" value="[^"]*
|
||||
LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
|
||||
-L \
|
||||
-d "username=${USER}&password=${PASS}&login-form-nonce=${LOGIN_NONCE}&task=login.login" \
|
||||
--data-urlencode "username=${USER}" \
|
||||
--data-urlencode "password=${PASS}" \
|
||||
--data-urlencode "login-form-nonce=${LOGIN_NONCE}" \
|
||||
--data-urlencode "task=login.login" \
|
||||
"$BASE_URL/login")
|
||||
|
||||
# After login, fetch /post and verify we see the post form (not the login form)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
playwright install chromium --with-deps
|
||||
COPY app/ ./app/
|
||||
ENV FLASK_APP=app
|
||||
ENV FLASK_RUN_HOST=0.0.0.0
|
||||
ENV FLASK_RUN_PORT=8082
|
||||
CMD ["flask", "run"]
|
||||
@@ -1,26 +0,0 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
|
||||
def create_app(state_dir=None, pages_dir=None):
|
||||
app = Flask(__name__)
|
||||
app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state")
|
||||
app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages")
|
||||
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
|
||||
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
|
||||
|
||||
from .routes import albums, triage, proxy, notes, nav, curate, group, write, export
|
||||
app.register_blueprint(albums.bp)
|
||||
app.register_blueprint(triage.bp)
|
||||
app.register_blueprint(proxy.bp)
|
||||
app.register_blueprint(notes.bp)
|
||||
app.register_blueprint(nav.bp)
|
||||
app.register_blueprint(curate.bp)
|
||||
app.register_blueprint(group.bp)
|
||||
app.register_blueprint(write.bp)
|
||||
app.register_blueprint(export.bp)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
@@ -1,30 +0,0 @@
|
||||
import requests
|
||||
|
||||
|
||||
class ImmichClient:
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
def _get(self, path: str, **kwargs):
|
||||
try:
|
||||
r = requests.get(f"{self.base_url}{path}",
|
||||
headers=self.headers, timeout=10, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ConnectionError(f"Cannot reach Immich: {e}") from e
|
||||
|
||||
def list_albums(self) -> list:
|
||||
return self._get("/api/albums").json()
|
||||
|
||||
def get_album(self, album_id: str) -> dict:
|
||||
return self._get(f"/api/albums/{album_id}",
|
||||
params={"withoutAssets": "false"}).json()
|
||||
|
||||
def get_thumbnail(self, asset_id: str) -> bytes:
|
||||
return self._get(f"/api/assets/{asset_id}/thumbnail",
|
||||
params={"size": "preview"}).content
|
||||
|
||||
def get_original(self, asset_id: str) -> bytes:
|
||||
return self._get(f"/api/assets/{asset_id}/original").content
|
||||
@@ -1,79 +0,0 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, redirect, render_template, request
|
||||
from app.immich import ImmichClient
|
||||
from app.state import TripState, Photo, load_state, save_state
|
||||
|
||||
bp = Blueprint("albums", __name__)
|
||||
|
||||
|
||||
def _sanitise_slug(s: str) -> str:
|
||||
s = s.strip().lower()
|
||||
s = re.sub(r'[^a-z0-9-]+', '-', s)
|
||||
return s.strip('-')
|
||||
|
||||
|
||||
def _client():
|
||||
return ImmichClient(current_app.config["IMMICH_URL"],
|
||||
current_app.config["IMMICH_API_KEY"])
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
def index():
|
||||
try:
|
||||
albums = _client().list_albums()
|
||||
error = None
|
||||
except ConnectionError as e:
|
||||
albums = []
|
||||
error = str(e)
|
||||
state_dir = Path(current_app.config["STATE_DIR"])
|
||||
for album in albums:
|
||||
album["has_state"] = (state_dir / f"{album['id']}.json").exists()
|
||||
return render_template("phase1.html", albums=albums, error=error,
|
||||
current_phase="", album_id=None,
|
||||
phase_stale=[], notes_content="")
|
||||
|
||||
|
||||
@bp.post("/select")
|
||||
def select():
|
||||
album_ids = request.form.getlist("album_ids[]")
|
||||
grav_trip_slug = _sanitise_slug(request.form["grav_trip_slug"])
|
||||
start_over = request.form.get("start_over") == "1"
|
||||
|
||||
if len(album_ids) == 1:
|
||||
primary_id = album_ids[0]
|
||||
else:
|
||||
primary_id = "__merged__" + "_".join(sorted(album_ids))
|
||||
|
||||
existing = load_state(primary_id, current_app)
|
||||
if existing and not start_over:
|
||||
return redirect(f"/{existing.phase}?album_id={primary_id}")
|
||||
|
||||
# Fetch and merge assets, deduplicating by asset ID
|
||||
all_assets = {}
|
||||
album_name_parts = []
|
||||
for aid in album_ids:
|
||||
album = _client().get_album(aid)
|
||||
album_name_parts.append(album["albumName"])
|
||||
for asset in album["assets"]:
|
||||
if asset["id"] not in all_assets:
|
||||
all_assets[asset["id"]] = asset
|
||||
|
||||
photos = [
|
||||
Photo(id=a["id"], original_filename=a["originalFileName"],
|
||||
local_datetime=a["localDateTime"])
|
||||
for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"])
|
||||
]
|
||||
for i, p in enumerate(photos):
|
||||
p.order = i
|
||||
|
||||
state = TripState(
|
||||
album_id=primary_id,
|
||||
album_name=", ".join(album_name_parts),
|
||||
grav_trip_slug=grav_trip_slug,
|
||||
photos=photos,
|
||||
)
|
||||
save_state(state, current_app)
|
||||
return redirect(f"/triage?album_id={primary_id}")
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("curate", __name__)
|
||||
|
||||
|
||||
@bp.get("/curate")
|
||||
def curate():
|
||||
album_id = request.args["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
kept = [p for p in state.photos if p.tag in ("journal", "story")]
|
||||
photos_by_day = {}
|
||||
for p in kept:
|
||||
day = p.local_datetime[:10]
|
||||
photos_by_day.setdefault(day, []).append(p)
|
||||
return render_template(
|
||||
"phase3.html",
|
||||
state=state,
|
||||
photos_by_day=photos_by_day,
|
||||
current_phase="curate",
|
||||
album_id=album_id,
|
||||
phase_stale=state.phase_stale,
|
||||
notes_content=state.notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/curate/remove")
|
||||
def remove():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
|
||||
if photo is None:
|
||||
return jsonify({"ok": False, "error": "photo not found"}), 404
|
||||
photo.tag = "skip"
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/curate/swap")
|
||||
def swap():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
|
||||
if photo is None:
|
||||
return jsonify({"ok": False, "error": "photo not found"}), 404
|
||||
photo.tag = "story" if photo.tag == "journal" else "journal"
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "new_tag": photo.tag})
|
||||
|
||||
|
||||
@bp.post("/curate/reorder")
|
||||
def reorder():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
order_map = {aid: i for i, aid in enumerate(body["order"])}
|
||||
for p in state.photos:
|
||||
if p.id in order_map:
|
||||
p.order = order_map[p.id]
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/curate/done")
|
||||
def done():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
if "curate" not in state.phases_completed:
|
||||
state.phases_completed.append("curate")
|
||||
state.phase = "group"
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"})
|
||||
@@ -1,229 +0,0 @@
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from app.immich import ImmichClient
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("export", __name__)
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
return re.sub(r"[\s_-]+", "-", text).strip("-")
|
||||
|
||||
|
||||
def _yaml_str(s: str) -> str:
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def _client():
|
||||
return ImmichClient(
|
||||
current_app.config["IMMICH_URL"],
|
||||
current_app.config["IMMICH_API_KEY"],
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/export")
|
||||
def export_view():
|
||||
album_id = request.args["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
to_export = [g for g in state.groups if g.status == "written"]
|
||||
skipped = [g for g in state.groups if g.status == "skipped"]
|
||||
return render_template(
|
||||
"phase6.html",
|
||||
state=state,
|
||||
to_export=to_export,
|
||||
skipped=skipped,
|
||||
current_phase="export",
|
||||
album_id=album_id,
|
||||
phase_stale=state.phase_stale,
|
||||
notes_content=state.notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export/run")
|
||||
def run_export():
|
||||
body = request.get_json()
|
||||
album_id = body["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
pages_dir = Path(current_app.config["PAGES_DIR"])
|
||||
client = _client()
|
||||
photo_map = {p.id: p for p in state.photos}
|
||||
exported = 0
|
||||
all_failed = []
|
||||
|
||||
for group in state.groups:
|
||||
if group.status != "written":
|
||||
continue
|
||||
|
||||
title_slug = slugify(group.title or group.date or "entry")
|
||||
if group.entry_type == "journal":
|
||||
folder_name = f"{group.date}-{title_slug}.entry"
|
||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
|
||||
md_file = "entry.md"
|
||||
template = "entry"
|
||||
else:
|
||||
folder_name = f"{title_slug}.story"
|
||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
||||
md_file = "story.md"
|
||||
template = "story"
|
||||
|
||||
if dest.exists():
|
||||
save_state(state, current_app)
|
||||
return jsonify({"conflict": True, "path": str(dest)})
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download photos
|
||||
failed = []
|
||||
hero_filename = None
|
||||
photo_num = 1
|
||||
for pid in group.photo_ids:
|
||||
photo = photo_map.get(pid)
|
||||
if not photo:
|
||||
continue
|
||||
filename = f"photo-{photo_num}.jpg"
|
||||
try:
|
||||
data = client.get_original(pid)
|
||||
(dest / filename).write_bytes(data)
|
||||
if pid == group.hero_photo_id or photo_num == 1:
|
||||
hero_filename = filename
|
||||
photo_num += 1
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
||||
failed.append(pid)
|
||||
|
||||
# Build frontmatter
|
||||
date_str = (group.date + " 12:00") if group.date else ""
|
||||
if group.entry_type == "journal":
|
||||
frontmatter = (
|
||||
f"---\n"
|
||||
f"title: '{_yaml_str(group.title)}'\n"
|
||||
f"date: '{date_str}'\n"
|
||||
f"template: {template}\n"
|
||||
f"published: true\n"
|
||||
f"location_city: '{_yaml_str(group.location_city)}'\n"
|
||||
f"location_country: '{_yaml_str(group.location_country)}'\n"
|
||||
f"hero_image: {hero_filename or ''}\n"
|
||||
f"---\n"
|
||||
)
|
||||
else:
|
||||
frontmatter = (
|
||||
f"---\n"
|
||||
f"title: '{_yaml_str(group.title)}'\n"
|
||||
f"date: '{date_str}'\n"
|
||||
f"template: {template}\n"
|
||||
f"published: true\n"
|
||||
f"hero_image: {hero_filename or ''}\n"
|
||||
f"---\n"
|
||||
)
|
||||
|
||||
body_text = group.body or ""
|
||||
if group.shortcode_hints:
|
||||
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
|
||||
|
||||
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
||||
group.status = "exported"
|
||||
exported += 1
|
||||
all_failed.extend(failed)
|
||||
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
|
||||
|
||||
|
||||
@bp.post("/export/overwrite")
|
||||
def overwrite_export():
|
||||
body = request.get_json()
|
||||
album_id = body["album_id"]
|
||||
conflict_path = Path(body["path"])
|
||||
state = load_state(album_id, current_app)
|
||||
pages_dir = Path(current_app.config["PAGES_DIR"])
|
||||
client = _client()
|
||||
photo_map = {p.id: p for p in state.photos}
|
||||
|
||||
# Remove the conflicting folder so the run loop can proceed past it
|
||||
if conflict_path.exists():
|
||||
shutil.rmtree(conflict_path)
|
||||
|
||||
exported = 0
|
||||
all_failed = []
|
||||
|
||||
for group in state.groups:
|
||||
if group.status != "written":
|
||||
continue
|
||||
|
||||
title_slug = slugify(group.title or group.date or "entry")
|
||||
if group.entry_type == "journal":
|
||||
folder_name = f"{group.date}-{title_slug}.entry"
|
||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
|
||||
md_file = "entry.md"
|
||||
template = "entry"
|
||||
else:
|
||||
folder_name = f"{title_slug}.story"
|
||||
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
|
||||
md_file = "story.md"
|
||||
template = "story"
|
||||
|
||||
if dest.exists():
|
||||
save_state(state, current_app)
|
||||
return jsonify({"conflict": True, "path": str(dest)})
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed = []
|
||||
hero_filename = None
|
||||
photo_num = 1
|
||||
for pid in group.photo_ids:
|
||||
photo = photo_map.get(pid)
|
||||
if not photo:
|
||||
continue
|
||||
filename = f"photo-{photo_num}.jpg"
|
||||
try:
|
||||
data = client.get_original(pid)
|
||||
(dest / filename).write_bytes(data)
|
||||
if pid == group.hero_photo_id or photo_num == 1:
|
||||
hero_filename = filename
|
||||
photo_num += 1
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
|
||||
failed.append(pid)
|
||||
|
||||
date_str = (group.date + " 12:00") if group.date else ""
|
||||
if group.entry_type == "journal":
|
||||
frontmatter = (
|
||||
f"---\n"
|
||||
f"title: '{_yaml_str(group.title)}'\n"
|
||||
f"date: '{date_str}'\n"
|
||||
f"template: {template}\n"
|
||||
f"published: true\n"
|
||||
f"location_city: '{_yaml_str(group.location_city)}'\n"
|
||||
f"location_country: '{_yaml_str(group.location_country)}'\n"
|
||||
f"hero_image: {hero_filename or ''}\n"
|
||||
f"---\n"
|
||||
)
|
||||
else:
|
||||
frontmatter = (
|
||||
f"---\n"
|
||||
f"title: '{_yaml_str(group.title)}'\n"
|
||||
f"date: '{date_str}'\n"
|
||||
f"template: {template}\n"
|
||||
f"published: true\n"
|
||||
f"hero_image: {hero_filename or ''}\n"
|
||||
f"---\n"
|
||||
)
|
||||
|
||||
body_text = group.body or ""
|
||||
if group.shortcode_hints:
|
||||
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
|
||||
|
||||
(dest / md_file).write_text(frontmatter + "\n" + body_text)
|
||||
group.status = "exported"
|
||||
exported += 1
|
||||
all_failed.extend(failed)
|
||||
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
|
||||
@@ -1,116 +0,0 @@
|
||||
import uuid
|
||||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||||
from app.state import Group, load_state, save_state
|
||||
|
||||
bp = Blueprint("group", __name__)
|
||||
|
||||
|
||||
def _build_groups(state):
|
||||
"""Compute display groups from kept photos + dividers."""
|
||||
kept = sorted(
|
||||
[p for p in state.photos if p.tag in ("journal", "story")],
|
||||
key=lambda p: p.order,
|
||||
)
|
||||
divider_orders = sorted(d["after_order"] for d in state.dividers)
|
||||
divider_ids = {d["after_order"]: d["id"] for d in state.dividers}
|
||||
|
||||
groups = []
|
||||
current_group = []
|
||||
for photo in kept:
|
||||
current_group.append(photo)
|
||||
if photo.order in divider_orders:
|
||||
div_id = divider_ids[photo.order]
|
||||
groups.append({
|
||||
"photos": current_group,
|
||||
"divider_id": div_id,
|
||||
"label": state.group_labels.get(div_id, ""),
|
||||
})
|
||||
current_group = []
|
||||
if current_group:
|
||||
groups.append({"photos": current_group, "divider_id": None, "label": ""})
|
||||
return groups, kept
|
||||
|
||||
|
||||
@bp.get("/group")
|
||||
def group():
|
||||
album_id = request.args["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
groups, kept = _build_groups(state)
|
||||
return render_template(
|
||||
"phase4.html",
|
||||
state=state,
|
||||
groups=groups,
|
||||
kept=kept,
|
||||
current_phase="group",
|
||||
album_id=album_id,
|
||||
phase_stale=state.phase_stale,
|
||||
notes_content=state.notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/group/divider")
|
||||
def add_divider():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
after_order = int(body["after_order"])
|
||||
if not any(d["after_order"] == after_order for d in state.dividers):
|
||||
state.dividers.append({"id": str(uuid.uuid4()), "after_order": after_order})
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/group/remove-divider")
|
||||
def remove_divider():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
state.dividers = [d for d in state.dividers if d["id"] != body["divider_id"]]
|
||||
state.group_labels.pop(body["divider_id"], None)
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/group/label")
|
||||
def set_label():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
state.group_labels[body["divider_id"]] = body["label"]
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/group/done")
|
||||
def done():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
groups, _ = _build_groups(state)
|
||||
state.groups = []
|
||||
for g in groups:
|
||||
first_photo = g["photos"][0]
|
||||
state.groups.append(Group(
|
||||
id=str(uuid.uuid4()),
|
||||
photo_ids=[p.id for p in g["photos"]],
|
||||
entry_type=first_photo.tag,
|
||||
date=first_photo.local_datetime[:10],
|
||||
label=g["label"],
|
||||
))
|
||||
if "group" not in state.phases_completed:
|
||||
state.phases_completed.append("group")
|
||||
state.phase = "write"
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "redirect": f"/write?album_id={body['album_id']}"})
|
||||
|
||||
|
||||
@bp.post("/group/from-note")
|
||||
def from_note():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
state.groups.append(Group(
|
||||
id=str(uuid.uuid4()),
|
||||
photo_ids=[],
|
||||
entry_type="journal",
|
||||
body=body.get("text", ""),
|
||||
))
|
||||
if "write" in state.phases_completed and "write" not in state.phase_stale:
|
||||
state.phase_stale.append("write")
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
@@ -1,51 +0,0 @@
|
||||
from flask import Blueprint, current_app, jsonify, redirect, request
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("nav", __name__)
|
||||
|
||||
STALE_DOWNSTREAM = {
|
||||
"triage": ["curate", "group", "write"],
|
||||
"curate": ["group", "write"],
|
||||
"group": ["write"],
|
||||
"write": [],
|
||||
"export": [],
|
||||
}
|
||||
|
||||
|
||||
@bp.post("/nav/phase")
|
||||
def goto_phase():
|
||||
body = request.get_json()
|
||||
target = body["target_phase"]
|
||||
state = load_state(body["album_id"], current_app)
|
||||
if state is None:
|
||||
return jsonify({"error": "no state"}), 404
|
||||
|
||||
# Mark downstream completed phases and the current phase as stale
|
||||
downstream = STALE_DOWNSTREAM.get(target, [])
|
||||
candidates = set(downstream) & (set(state.phases_completed) | {state.phase})
|
||||
newly_stale = [p for p in candidates if p not in state.phase_stale]
|
||||
state.phase_stale = list(set(state.phase_stale + newly_stale))
|
||||
state.phase = target
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "phase": target})
|
||||
|
||||
|
||||
@bp.post("/nav/dismiss-stale")
|
||||
def dismiss_stale():
|
||||
album_id = request.form["album_id"]
|
||||
phase = request.form["phase"]
|
||||
state = load_state(album_id, current_app)
|
||||
if state:
|
||||
state.phase_stale = [p for p in state.phase_stale if p != phase]
|
||||
save_state(state, current_app)
|
||||
return redirect(f"/{phase}?album_id={album_id}")
|
||||
|
||||
|
||||
@bp.get("/state/<album_id>")
|
||||
def get_state(album_id):
|
||||
"""Debug/test endpoint — returns full state JSON."""
|
||||
state = load_state(album_id, current_app)
|
||||
if state is None:
|
||||
return jsonify({"error": "no state"}), 404
|
||||
from dataclasses import asdict
|
||||
return jsonify(asdict(state))
|
||||
@@ -1,23 +0,0 @@
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("notes", __name__)
|
||||
|
||||
|
||||
@bp.post("/notes/save")
|
||||
def save_notes():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
if state is None:
|
||||
return jsonify({"error": "no state"}), 404
|
||||
state.notes = body["notes"]
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.get("/notes/<album_id>")
|
||||
def get_notes(album_id):
|
||||
state = load_state(album_id, current_app)
|
||||
if state is None:
|
||||
return jsonify({"error": "no state"}), 404
|
||||
return jsonify({"notes": state.notes})
|
||||
@@ -1,29 +0,0 @@
|
||||
from flask import Blueprint, current_app, Response, abort
|
||||
from app.immich import ImmichClient
|
||||
|
||||
bp = Blueprint("proxy", __name__)
|
||||
|
||||
|
||||
def _client() -> ImmichClient:
|
||||
return ImmichClient(
|
||||
base_url=current_app.config["IMMICH_URL"],
|
||||
api_key=current_app.config["IMMICH_API_KEY"],
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/proxy/thumb/<asset_id>")
|
||||
def thumb(asset_id):
|
||||
try:
|
||||
data = _client().get_thumbnail(asset_id)
|
||||
except ConnectionError:
|
||||
abort(502)
|
||||
return Response(data, content_type="image/jpeg")
|
||||
|
||||
|
||||
@bp.get("/proxy/original/<asset_id>")
|
||||
def original(asset_id):
|
||||
try:
|
||||
data = _client().get_original(asset_id)
|
||||
except ConnectionError:
|
||||
abort(502)
|
||||
return Response(data, content_type="image/jpeg")
|
||||
@@ -1,51 +0,0 @@
|
||||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("triage", __name__)
|
||||
|
||||
|
||||
@bp.get("/triage")
|
||||
def triage():
|
||||
album_id = request.args["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
photos_by_day = {}
|
||||
for p in state.photos:
|
||||
day = p.local_datetime[:10]
|
||||
photos_by_day.setdefault(day, []).append(p)
|
||||
all_tagged = all(p.tag != "untagged" for p in state.photos)
|
||||
return render_template(
|
||||
"phase2.html",
|
||||
state=state,
|
||||
photos_by_day=photos_by_day,
|
||||
all_tagged=all_tagged,
|
||||
current_phase="triage",
|
||||
album_id=album_id,
|
||||
phase_stale=state.phase_stale,
|
||||
notes_content=state.notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/triage/tag")
|
||||
def tag():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
for p in state.photos:
|
||||
if p.id == body["asset_id"]:
|
||||
p.tag = body["tag"]
|
||||
break
|
||||
save_state(state, current_app)
|
||||
tagged_count = sum(1 for p in state.photos if p.tag != "untagged")
|
||||
return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)})
|
||||
|
||||
|
||||
@bp.post("/triage/done")
|
||||
def done():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
if not all(p.tag != "untagged" for p in state.photos):
|
||||
return jsonify({"error": "not all tagged"}), 400
|
||||
if "triage" not in state.phases_completed:
|
||||
state.phases_completed.append("triage")
|
||||
state.phase = "curate"
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"})
|
||||
@@ -1,103 +0,0 @@
|
||||
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
|
||||
from app.state import load_state, save_state
|
||||
|
||||
bp = Blueprint("write", __name__)
|
||||
|
||||
|
||||
@bp.get("/write")
|
||||
def write():
|
||||
album_id = request.args["album_id"]
|
||||
group_idx = int(request.args.get("group_idx", 0))
|
||||
state = load_state(album_id, current_app)
|
||||
active_groups = [g for g in state.groups if g.status != "exported"]
|
||||
total = len(active_groups)
|
||||
group = active_groups[group_idx] if group_idx < total else None
|
||||
done_count = sum(1 for g in active_groups if g.status in ("written", "skipped"))
|
||||
if group is None:
|
||||
all_done = all(g.status in ("written", "skipped", "exported") for g in active_groups)
|
||||
if not all_done:
|
||||
first_incomplete = next(i for i, g in enumerate(active_groups) if g.status == "draft")
|
||||
return redirect(url_for("write.write", album_id=album_id, group_idx=first_incomplete))
|
||||
photos = []
|
||||
if group:
|
||||
by_id = {p.id: p for p in state.photos}
|
||||
photos = [by_id[pid] for pid in group.photo_ids if pid in by_id]
|
||||
return render_template(
|
||||
"phase5.html",
|
||||
state=state,
|
||||
group=group,
|
||||
photos=photos,
|
||||
group_idx=group_idx,
|
||||
total=total,
|
||||
done_count=done_count,
|
||||
current_phase="write",
|
||||
album_id=album_id,
|
||||
phase_stale=state.phase_stale,
|
||||
notes_content=state.notes,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/write/autosave")
|
||||
def autosave():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
for g in state.groups:
|
||||
if g.id == body["group_id"] and g.status != "exported":
|
||||
g.title = body.get("title", g.title)
|
||||
g.body = body.get("body", g.body)
|
||||
g.location_city = body.get("location_city", g.location_city)
|
||||
g.location_country = body.get("location_country", g.location_country)
|
||||
g.date = body.get("date", g.date)
|
||||
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
|
||||
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
|
||||
if body.get("entry_type"):
|
||||
g.entry_type = body["entry_type"]
|
||||
break
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/write/save")
|
||||
def save():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
for g in state.groups:
|
||||
if g.id == body["group_id"] and g.status != "exported":
|
||||
g.title = body.get("title", g.title)
|
||||
g.body = body.get("body", g.body)
|
||||
g.location_city = body.get("location_city", g.location_city)
|
||||
g.location_country = body.get("location_country", g.location_country)
|
||||
g.date = body.get("date", g.date)
|
||||
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
|
||||
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
|
||||
if body.get("entry_type"):
|
||||
g.entry_type = body["entry_type"]
|
||||
g.status = "written"
|
||||
break
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/write/skip")
|
||||
def skip():
|
||||
body = request.get_json()
|
||||
state = load_state(body["album_id"], current_app)
|
||||
for g in state.groups:
|
||||
if g.id == body["group_id"] and g.status != "exported":
|
||||
g.status = "skipped"
|
||||
break
|
||||
save_state(state, current_app)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.post("/write/done")
|
||||
def write_done():
|
||||
album_id = request.form["album_id"]
|
||||
state = load_state(album_id, current_app)
|
||||
if state is None:
|
||||
return jsonify({"ok": False, "error": "not found"}), 404
|
||||
if "write" not in state.phases_completed:
|
||||
state.phases_completed.append("write")
|
||||
state.phase = "export"
|
||||
save_state(state, current_app)
|
||||
return redirect(f"/export?album_id={album_id}")
|
||||
@@ -1,71 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
@dataclass
|
||||
class Photo:
|
||||
id: str
|
||||
original_filename: str
|
||||
local_datetime: str
|
||||
tag: str = "untagged" # untagged | journal | story | skip
|
||||
order: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
id: str
|
||||
photo_ids: list = field(default_factory=list)
|
||||
entry_type: str = "journal" # journal | story
|
||||
label: str = ""
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
location_city: str = ""
|
||||
location_country: str = ""
|
||||
date: str = ""
|
||||
hero_photo_id: Optional[str] = None
|
||||
shortcode_hints: str = ""
|
||||
status: str = "draft" # draft | written | skipped | exported
|
||||
|
||||
|
||||
@dataclass
|
||||
class TripState:
|
||||
album_id: str
|
||||
album_name: str
|
||||
grav_trip_slug: str
|
||||
phase: str = "triage"
|
||||
phases_completed: list = field(default_factory=list)
|
||||
phase_stale: list = field(default_factory=list)
|
||||
photos: list = field(default_factory=list)
|
||||
groups: list = field(default_factory=list)
|
||||
notes: str = ""
|
||||
dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}]
|
||||
group_labels: dict = field(default_factory=dict) # {divider_id: label}
|
||||
|
||||
|
||||
def _state_path(album_id: str, app) -> Path:
|
||||
return Path(app.config["STATE_DIR"]) / f"{album_id}.json"
|
||||
|
||||
|
||||
def load_state(album_id: str, app) -> Optional[TripState]:
|
||||
path = _state_path(album_id, app)
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
photos = [Photo(**p) for p in data.pop("photos", [])]
|
||||
groups = [Group(**g) for g in data.pop("groups", [])]
|
||||
return TripState(photos=photos, groups=groups, **data)
|
||||
|
||||
|
||||
def save_state(state: TripState, app) -> None:
|
||||
path = _state_path(state.album_id, app)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(asdict(state), f, indent=2)
|
||||
os.rename(tmp, path)
|
||||
@@ -1,34 +0,0 @@
|
||||
function notesApp(initialNotes, albumId) {
|
||||
return {
|
||||
open: false,
|
||||
notes: initialNotes,
|
||||
status: '',
|
||||
saveTimer: null,
|
||||
|
||||
scheduleAutosave() {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.status = 'Saving…';
|
||||
this.saveTimer = setTimeout(() => this.doSave(), 500);
|
||||
},
|
||||
|
||||
async doSave() {
|
||||
const res = await fetch('/notes/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, notes: this.notes }),
|
||||
});
|
||||
this.status = res.ok ? 'Saved ✓' : 'Error';
|
||||
},
|
||||
|
||||
async convertToEntry(text) {
|
||||
const res = await fetch('/group/from-note', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, text }),
|
||||
});
|
||||
if (res.ok) {
|
||||
this.status = 'Added as entry ✓';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="forest" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>travel-memories</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200" x-data="notesApp({{ notes_content | tojson }}, '{{ album_id }}')">
|
||||
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
|
||||
<div class="navbar-start px-4 font-bold text-lg">travel-memories</div>
|
||||
<div class="navbar-center">
|
||||
<ul class="steps">
|
||||
{% set phases = [('','Album'),('triage','Triage'),('curate','Curate'),('group','Group'),('write','Write'),('export','Export')] %}
|
||||
{% for key, label in phases %}
|
||||
<li class="step {% if current_phase == key %}step-primary{% endif %}
|
||||
{% if key in phase_stale %}step-warning{% endif %}">
|
||||
{% if album_id %}
|
||||
<a hx-post="/nav/phase" hx-vals='{"album_id":"{{ album_id }}","target_phase":"{{ key }}"}' href="/{{ key }}{% if album_id %}?album_id={{ album_id }}{% endif %}">{{ label }}</a>
|
||||
{% else %}{{ label }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end px-4">
|
||||
{% if album_id %}
|
||||
<button class="btn btn-ghost btn-sm" @click="open = !open">📝 Notes</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stale warning -->
|
||||
{% if current_phase in phase_stale %}
|
||||
<div class="alert alert-warning rounded-none" id="stale-banner">
|
||||
<span>You changed earlier decisions — review this phase before exporting.</span>
|
||||
<form method="post" action="/nav/dismiss-stale">
|
||||
<input type="hidden" name="album_id" value="{{ album_id }}">
|
||||
<input type="hidden" name="phase" value="{{ current_phase }}">
|
||||
<button class="btn btn-xs">Dismiss</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Body with notes drawer -->
|
||||
<div class="flex relative">
|
||||
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Notes panel -->
|
||||
<div class="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-base-100 shadow-2xl p-4 flex flex-col transition-transform z-30"
|
||||
:class="open ? 'translate-x-0' : 'translate-x-full'" id="notes-panel">
|
||||
<h3 class="font-bold text-base mb-2">Notes</h3>
|
||||
<textarea class="textarea textarea-bordered flex-1 resize-none text-sm"
|
||||
x-model="notes"
|
||||
@input="scheduleAutosave()"
|
||||
placeholder="Jot down memories at any time…"></textarea>
|
||||
<div class="text-xs text-right mt-1 opacity-60" x-text="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-4">Select Album</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>Cannot reach Immich: {{ error }}</span>
|
||||
<a href="/" class="btn btn-sm">Retry</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/select">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{% for album in albums %}
|
||||
<label class="album-card card bg-base-100 shadow cursor-pointer hover:shadow-lg transition"
|
||||
data-album-id="{{ album.id }}">
|
||||
<figure class="h-40 overflow-hidden">
|
||||
<img src="/proxy/thumb/{{ album.albumThumbnailAssetId }}"
|
||||
class="w-full h-full object-cover" alt="">
|
||||
</figure>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="checkbox" name="album_ids[]" value="{{ album.id }}"
|
||||
class="checkbox checkbox-primary mt-1">
|
||||
<div>
|
||||
<p class="font-semibold">{{ album.albumName }}</p>
|
||||
<p class="text-sm opacity-60">{{ album.assetCount }} photos</p>
|
||||
{% if album.has_state %}
|
||||
<span class="resume-badge badge badge-warning badge-sm mt-1">In progress</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4 max-w-xs">
|
||||
<label class="label"><span class="label-text">Grav trip slug</span></label>
|
||||
<input id="grav-slug" type="text" name="grav_trip_slug" required
|
||||
placeholder="central-asia-2023" class="input input-bordered">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="start_over" id="start-over-flag" value="0">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Start →</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
onclick="document.getElementById('start-over-flag').value='1'; this.closest('form').submit()">
|
||||
Start over (discard progress)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,628 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
|
||||
@keydown.j.window="tagFocused('journal')"
|
||||
@keydown.s.window="tagFocused('story')"
|
||||
@keydown.x.window="tagFocused('skip')"
|
||||
@keydown.space.prevent.window="tagFocused('skip')"
|
||||
@keydown.left.prevent.window="navigate(-1)"
|
||||
@keydown.right.prevent.window="navigate(1)"
|
||||
@keydown.escape.window="closeLightbox()"
|
||||
@keydown.enter.window="focused && openLightbox(focused)">
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold">Triage</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm opacity-60" id="tagged-count">
|
||||
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
|
||||
/ {{ state.photos | length }} tagged
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="skipUntagged()">
|
||||
Skip untagged
|
||||
</button>
|
||||
<button id="done-btn"
|
||||
class="btn btn-primary btn-sm"
|
||||
{% if not all_tagged %}disabled{% endif %}
|
||||
@click="done()">
|
||||
Done triaging →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Desktop grid (hidden on mobile) ── #}
|
||||
<div id="desktop-view">
|
||||
{% for day, photos in photos_by_day.items() %}
|
||||
<div class="day-group mb-6">
|
||||
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
|
||||
{% for photo in photos %}
|
||||
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
|
||||
{% if photo.tag == 'journal' %}border-amber-500
|
||||
{% elif photo.tag == 'story' %}border-sky-400
|
||||
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
|
||||
{% else %}border-transparent{% endif %}"
|
||||
data-asset-id="{{ photo.id }}"
|
||||
data-tag="{{ photo.tag }}"
|
||||
tabindex="0"
|
||||
@click="openLightbox($el)"
|
||||
@focus="select($el)">
|
||||
<img src="/proxy/thumb/{{ photo.id }}"
|
||||
class="w-full aspect-square object-cover" loading="lazy" alt="">
|
||||
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
|
||||
{{ photo.local_datetime[11:16] }}
|
||||
</div>
|
||||
{% if photo.tag == 'journal' %}
|
||||
<div class="absolute top-1 right-1 badge badge-xs bg-amber-500 text-black border-0 font-bold">J</div>
|
||||
{% elif photo.tag == 'story' %}
|
||||
<div class="absolute top-1 right-1 badge badge-xs bg-sky-400 text-black border-0 font-bold">S</div>
|
||||
{% elif photo.tag == 'skip' %}
|
||||
<div class="absolute top-1 right-1 badge badge-xs badge-ghost opacity-60">X</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ── Lightbox overlay (desktop) ── #}
|
||||
<div id="lb" class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" style="display:none">
|
||||
<button class="absolute top-4 right-4 btn btn-circle btn-sm btn-ghost text-white opacity-60 hover:opacity-100 text-lg"
|
||||
@click="closeLightbox()">✕</button>
|
||||
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
|
||||
@click="navigate(-1)">‹</button>
|
||||
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
|
||||
@click="navigate(1)">›</button>
|
||||
<div class="flex flex-col items-center gap-3 px-16 max-w-full">
|
||||
<img id="lb-img" src="" class="max-h-[82vh] max-w-[88vw] object-contain rounded-lg shadow-2xl" alt="">
|
||||
<div class="flex items-center gap-4 text-white/60 text-sm">
|
||||
<span id="lb-date"></span>
|
||||
<span id="lb-filename" class="opacity-40"></span>
|
||||
<span id="lb-tag-badge" class="badge badge-sm"></span>
|
||||
<span class="opacity-30 text-xs">J journal · S story · X skip · ← → navigate · Esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Mobile card UI (hidden on desktop) ── #}
|
||||
<div id="mobile-view" style="display:none">
|
||||
{# Progress bar #}
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-xs opacity-60 mb-1">
|
||||
<span id="m-progress-text">0 / {{ state.photos | length }} tagged</span>
|
||||
<span id="m-undo-btn-wrap" style="display:none">
|
||||
<button id="m-undo-btn" class="btn btn-ghost btn-xs">← Back</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-base-300 rounded-full h-1.5">
|
||||
<div id="m-progress-bar" class="bg-primary h-1.5 rounded-full transition-all" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Card stack #}
|
||||
<div id="m-card-area" class="relative w-full" style="height:70vh">
|
||||
{# Card is injected by JS #}
|
||||
<div id="m-completion" style="display:none"
|
||||
class="flex flex-col items-center justify-center h-full gap-4 text-center">
|
||||
<div class="text-4xl">✓</div>
|
||||
<p class="text-lg font-semibold">All tagged!</p>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('done-btn').click()">
|
||||
Done triaging →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Action buttons #}
|
||||
<div id="m-buttons" class="flex justify-center gap-6 mt-4">
|
||||
<button id="m-btn-skip"
|
||||
class="btn btn-circle btn-lg btn-ghost border-2 border-base-300 text-2xl"
|
||||
onclick="mobileApp && mobileApp.doTag('skip')">✕</button>
|
||||
<button id="m-btn-journal"
|
||||
class="btn btn-circle btn-lg btn-ghost border-2 border-success text-2xl"
|
||||
onclick="mobileApp && mobileApp.doTag('journal')">J</button>
|
||||
<button id="m-btn-story"
|
||||
class="btn btn-circle btn-lg btn-ghost border-2 border-info text-2xl"
|
||||
onclick="mobileApp && mobileApp.doTag('story')">S</button>
|
||||
</div>
|
||||
|
||||
{# Thumbnail strip — all photos, colored dot per tag, tap to jump #}
|
||||
<div id="m-thumb-strip"
|
||||
class="mt-3 flex gap-1.5 overflow-x-auto pb-2"
|
||||
style="scrollbar-width:thin;-webkit-overflow-scrolling:touch"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
||||
<script>
|
||||
// ── Shared badge helper ──────────────────────────────────────────────────────
|
||||
function updateBadge(cardEl, tag) {
|
||||
let badge = cardEl.querySelector('.badge');
|
||||
if (!badge) {
|
||||
badge = document.createElement('div');
|
||||
cardEl.appendChild(badge);
|
||||
}
|
||||
const MAP = {
|
||||
journal: ['badge-xs bg-amber-500 text-black border-0 font-bold', 'J'],
|
||||
story: ['badge-xs bg-sky-400 text-black border-0 font-bold', 'S'],
|
||||
skip: ['badge-xs badge-ghost opacity-60', 'X'],
|
||||
};
|
||||
if (MAP[tag]) {
|
||||
badge.className = `absolute top-1 right-1 badge ${MAP[tag][0]}`;
|
||||
badge.textContent = MAP[tag][1];
|
||||
} else {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop Alpine app ───────────────────────────────────────────────────────
|
||||
function triageApp(albumId) {
|
||||
return {
|
||||
focused: null,
|
||||
|
||||
lightboxOpen: false,
|
||||
|
||||
init() {
|
||||
const first = document.querySelector('.photo-card');
|
||||
if (first) this.select(first);
|
||||
},
|
||||
|
||||
select(el) {
|
||||
if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
|
||||
this.focused = el;
|
||||
if (el) {
|
||||
el.classList.add('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (this.lightboxOpen) this.updateLightbox();
|
||||
},
|
||||
|
||||
openLightbox(el) {
|
||||
this.select(el);
|
||||
this.lightboxOpen = true;
|
||||
document.getElementById('lb').style.display = '';
|
||||
this.updateLightbox();
|
||||
},
|
||||
|
||||
closeLightbox() {
|
||||
if (!this.lightboxOpen) return;
|
||||
this.lightboxOpen = false;
|
||||
document.getElementById('lb').style.display = 'none';
|
||||
},
|
||||
|
||||
updateLightbox() {
|
||||
const el = this.focused;
|
||||
if (!el) return;
|
||||
const assetId = el.dataset.assetId;
|
||||
const tag = el.dataset.tag;
|
||||
document.getElementById('lb-img').src = `/proxy/thumb/${assetId}`;
|
||||
const timeEl = el.querySelector('div');
|
||||
document.getElementById('lb-date').textContent = timeEl ? timeEl.textContent.trim() : '';
|
||||
document.getElementById('lb-filename').textContent = el.dataset.filename || '';
|
||||
const badgeEl = document.getElementById('lb-tag-badge');
|
||||
const MAP = {
|
||||
journal: ['bg-amber-500 text-black border-0 font-bold', 'Journal'],
|
||||
story: ['bg-sky-400 text-black border-0 font-bold', 'Story'],
|
||||
skip: ['badge-ghost opacity-60', 'Skip'],
|
||||
};
|
||||
if (MAP[tag]) {
|
||||
badgeEl.className = `badge badge-sm ${MAP[tag][0]}`;
|
||||
badgeEl.textContent = MAP[tag][1];
|
||||
} else {
|
||||
badgeEl.className = 'badge badge-sm badge-outline opacity-30';
|
||||
badgeEl.textContent = 'Untagged';
|
||||
}
|
||||
},
|
||||
|
||||
navigate(dir) {
|
||||
const cards = [...document.querySelectorAll('.photo-card')];
|
||||
if (!cards.length) return;
|
||||
const idx = this.focused ? cards.indexOf(this.focused) : -1;
|
||||
const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))];
|
||||
if (next) this.select(next);
|
||||
},
|
||||
|
||||
async tagFocused(tag) {
|
||||
const el = this.focused || document.querySelector('.photo-card');
|
||||
if (!el) return;
|
||||
const assetId = el.dataset.assetId;
|
||||
await fetch('/triage/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
|
||||
});
|
||||
el.dataset.tag = tag;
|
||||
// Remove any existing border/opacity classes before adding new ones
|
||||
el.className = el.className
|
||||
.split(/\s+/)
|
||||
.filter(c => c && !c.startsWith('border-') && c !== 'opacity-40')
|
||||
.join(' ');
|
||||
if (tag === 'journal') {
|
||||
el.classList.add('border-4', 'border-amber-500');
|
||||
} else if (tag === 'story') {
|
||||
el.classList.add('border-4', 'border-sky-400');
|
||||
} else {
|
||||
el.classList.add('border-4', 'border-base-300', 'opacity-40');
|
||||
}
|
||||
updateBadge(el, tag);
|
||||
this.updateCount();
|
||||
if (this.lightboxOpen) this.updateLightbox();
|
||||
},
|
||||
|
||||
updateCount() {
|
||||
const total = document.querySelectorAll('.photo-card').length;
|
||||
const tagged = document.querySelectorAll('.photo-card:not([data-tag="untagged"])').length;
|
||||
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
|
||||
document.getElementById('done-btn').disabled = tagged < total;
|
||||
},
|
||||
|
||||
async skipUntagged() {
|
||||
await fetch('/triage/skip-untagged', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId }),
|
||||
});
|
||||
document.querySelectorAll('.photo-card[data-tag="untagged"]').forEach(el => {
|
||||
el.dataset.tag = 'skip';
|
||||
el.className = el.className
|
||||
.split(' ')
|
||||
.filter(c => !c.startsWith('border-') && c !== 'opacity-40')
|
||||
.join(' ');
|
||||
el.classList.add('border-4', 'border-base-300', 'opacity-40');
|
||||
updateBadge(el, 'skip');
|
||||
});
|
||||
this.updateCount();
|
||||
},
|
||||
|
||||
async done() {
|
||||
const res = await fetch('/triage/done', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.redirect) window.location = data.redirect;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mobile swipe triage app ──────────────────────────────────────────────────
|
||||
let mobileApp = null;
|
||||
|
||||
function mobileTriageApp(albumId, photos) {
|
||||
// Build queue: only untagged photos, in their original order
|
||||
let queue = photos
|
||||
.filter(p => p.tag === 'untagged')
|
||||
.slice(); // shallow copy
|
||||
|
||||
const total = photos.length;
|
||||
let taggedCount = photos.filter(p => p.tag !== 'untagged').length;
|
||||
|
||||
// Undo stack: [{asset_id, previous_tag}, ...] (max 10)
|
||||
const undoStack = [];
|
||||
|
||||
// DOM refs
|
||||
const cardArea = document.getElementById('m-card-area');
|
||||
const completion = document.getElementById('m-completion');
|
||||
const progressBar = document.getElementById('m-progress-bar');
|
||||
const progressText = document.getElementById('m-progress-text');
|
||||
const undoBtnWrap = document.getElementById('m-undo-btn-wrap');
|
||||
const undoBtn = document.getElementById('m-undo-btn');
|
||||
|
||||
undoBtn.addEventListener('click', undo);
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function updateProgress() {
|
||||
progressText.textContent = `${taggedCount} / ${total} tagged`;
|
||||
progressBar.style.width = total > 0 ? `${(taggedCount / total) * 100}%` : '0%';
|
||||
undoBtnWrap.style.display = undoStack.length > 0 ? '' : 'none';
|
||||
// Sync the shared header counter / done button
|
||||
document.getElementById('tagged-count').textContent = `${taggedCount} / ${total} tagged`;
|
||||
document.getElementById('done-btn').disabled = taggedCount < total;
|
||||
}
|
||||
|
||||
function showCompletion() {
|
||||
completion.style.display = '';
|
||||
document.getElementById('m-buttons').style.display = 'none';
|
||||
}
|
||||
|
||||
function makeCard(photo) {
|
||||
const card = document.createElement('div');
|
||||
card.id = 'm-card';
|
||||
card.style.cssText = `
|
||||
position: absolute; inset: 0;
|
||||
border-radius: 16px; overflow: hidden;
|
||||
background: #000;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
will-change: transform;
|
||||
cursor: grab;
|
||||
`;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/proxy/thumb/${photo.id}`;
|
||||
img.style.cssText = 'width:100%; height:100%; object-fit:cover; display:block;';
|
||||
img.draggable = false;
|
||||
card.appendChild(img);
|
||||
|
||||
// Date overlay
|
||||
const dateOverlay = document.createElement('div');
|
||||
dateOverlay.style.cssText = `
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.6));
|
||||
color: #fff; font-size: 14px;
|
||||
`;
|
||||
dateOverlay.textContent = photo.local_datetime
|
||||
? photo.local_datetime.slice(0, 16).replace('T', ' ')
|
||||
: '';
|
||||
card.appendChild(dateOverlay);
|
||||
|
||||
// Colour overlay (shown during drag)
|
||||
const colorOverlay = document.createElement('div');
|
||||
colorOverlay.id = 'm-color-overlay';
|
||||
colorOverlay.style.cssText = `
|
||||
position: absolute; inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
pointer-events: none;
|
||||
border-radius: 16px;
|
||||
`;
|
||||
card.appendChild(colorOverlay);
|
||||
|
||||
return { card, colorOverlay };
|
||||
}
|
||||
|
||||
function showCard() {
|
||||
// Remove existing card if any
|
||||
const old = document.getElementById('m-card');
|
||||
if (old) old.remove();
|
||||
|
||||
if (queue.length === 0) {
|
||||
showCompletion();
|
||||
updateProgress();
|
||||
updateThumbStrip();
|
||||
return;
|
||||
}
|
||||
|
||||
completion.style.display = 'none';
|
||||
document.getElementById('m-buttons').style.display = '';
|
||||
|
||||
const photo = queue[0];
|
||||
const { card, colorOverlay } = makeCard(photo);
|
||||
cardArea.appendChild(card);
|
||||
|
||||
// ── HammerJS gestures ─────────────────────────────────────────────
|
||||
const hammer = new Hammer(card, { recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_ALL, threshold: 5 }]] });
|
||||
// Also enable swipe (velocity-based)
|
||||
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
|
||||
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
hammer.on('panstart', () => {
|
||||
card.style.transition = 'none';
|
||||
});
|
||||
|
||||
hammer.on('panmove', (ev) => {
|
||||
const dx = ev.deltaX;
|
||||
const dy = ev.deltaY;
|
||||
const tilt = dx * 0.08; // degrees of rotation
|
||||
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${tilt}deg)`;
|
||||
|
||||
// Determine dominant direction for colour overlay
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
|
||||
if (absDy > absDx && dy < -30) {
|
||||
// swipe up → story (blue)
|
||||
colorOverlay.style.background = 'rgba(56,189,248,0.35)';
|
||||
colorOverlay.style.opacity = Math.min(absDy / 150, 0.8);
|
||||
} else if (dx > 30) {
|
||||
// swipe right → journal (green)
|
||||
colorOverlay.style.background = 'rgba(74,222,128,0.35)';
|
||||
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
|
||||
} else if (dx < -30) {
|
||||
// swipe left → skip (grey)
|
||||
colorOverlay.style.background = 'rgba(100,116,139,0.35)';
|
||||
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
|
||||
} else {
|
||||
colorOverlay.style.opacity = 0;
|
||||
}
|
||||
});
|
||||
|
||||
hammer.on('panend', (ev) => {
|
||||
const dx = ev.deltaX;
|
||||
const dy = ev.deltaY;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
const THRESHOLD = 50;
|
||||
|
||||
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
|
||||
if (absDy > absDx && dy < -THRESHOLD) {
|
||||
// Swipe up → story
|
||||
flyOut(card, 0, -window.innerHeight, () => doTag('story'));
|
||||
} else if (dx > THRESHOLD) {
|
||||
// Swipe right → journal
|
||||
flyOut(card, window.innerWidth, 0, () => doTag('journal'));
|
||||
} else if (dx < -THRESHOLD) {
|
||||
// Swipe left → skip
|
||||
flyOut(card, -window.innerWidth, 0, () => doTag('skip'));
|
||||
} else {
|
||||
// Snap back
|
||||
card.style.transform = 'translate(0,0) rotate(0deg)';
|
||||
colorOverlay.style.opacity = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flyOut(card, toX, toY, callback) {
|
||||
card.style.transform = `translate(${toX}px, ${toY}px) rotate(${toX * 0.1}deg)`;
|
||||
card.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── Tag action ───────────────────────────────────────────────────────
|
||||
|
||||
async function doTag(tag) {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const photo = queue.shift();
|
||||
const previousTag = photo.tag;
|
||||
|
||||
// Push to undo stack (max 10)
|
||||
undoStack.push({ photo, previousTag });
|
||||
if (undoStack.length > 10) undoStack.shift();
|
||||
|
||||
// Update local photo tag
|
||||
photo.tag = tag;
|
||||
|
||||
// Increment tagged count only if previously untagged
|
||||
if (previousTag === 'untagged') taggedCount++;
|
||||
|
||||
await fetch('/triage/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag }),
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
showCard();
|
||||
updateThumbStrip();
|
||||
}
|
||||
|
||||
// ── Undo ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function undo() {
|
||||
if (undoStack.length === 0) return;
|
||||
|
||||
const { photo, previousTag } = undoStack.pop();
|
||||
|
||||
// Re-insert at front of queue
|
||||
queue.unshift(photo);
|
||||
|
||||
// Revert tagged count
|
||||
if (previousTag === 'untagged' && photo.tag !== 'untagged') taggedCount--;
|
||||
photo.tag = previousTag;
|
||||
|
||||
await fetch('/triage/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: previousTag }),
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
showCard();
|
||||
updateThumbStrip();
|
||||
}
|
||||
|
||||
// ── Thumbnail strip ──────────────────────────────────────────────────
|
||||
|
||||
const thumbStrip = document.getElementById('m-thumb-strip');
|
||||
|
||||
function buildThumbStrip() {
|
||||
thumbStrip.innerHTML = '';
|
||||
photos.forEach(photo => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'relative flex-none cursor-pointer';
|
||||
wrap.style.cssText = 'width:44px;height:44px;';
|
||||
wrap.dataset.thumbId = photo.id;
|
||||
wrap.addEventListener('click', () => jumpToPhoto(photo));
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/proxy/thumb/${photo.id}`;
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:4px;border:2px solid transparent;transition:border-color 0.15s;display:block;';
|
||||
img.draggable = false;
|
||||
wrap.appendChild(img);
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = 'position:absolute;bottom:2px;left:2px;width:7px;height:7px;border-radius:50%;display:none;border:1px solid rgba(0,0,0,0.3);';
|
||||
wrap.appendChild(dot);
|
||||
|
||||
thumbStrip.appendChild(wrap);
|
||||
});
|
||||
updateThumbStrip();
|
||||
}
|
||||
|
||||
function updateThumbStrip() {
|
||||
const currentId = queue.length > 0 ? queue[0].id : null;
|
||||
photos.forEach(photo => {
|
||||
const wrap = thumbStrip.querySelector(`[data-thumb-id="${photo.id}"]`);
|
||||
if (!wrap) return;
|
||||
const img = wrap.querySelector('img');
|
||||
const dot = wrap.querySelector('div');
|
||||
|
||||
img.style.borderColor = photo.id === currentId ? '#fff' : 'transparent';
|
||||
img.style.boxShadow = photo.id === currentId ? '0 0 0 1px rgba(0,0,0,0.4)' : 'none';
|
||||
|
||||
if (photo.tag === 'journal') {
|
||||
dot.style.display = '';
|
||||
dot.style.background = '#f59e0b';
|
||||
} else if (photo.tag === 'story') {
|
||||
dot.style.display = '';
|
||||
dot.style.background = '#38bdf8';
|
||||
} else if (photo.tag === 'skip') {
|
||||
dot.style.display = '';
|
||||
dot.style.background = '#64748b';
|
||||
} else {
|
||||
dot.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
if (currentId) {
|
||||
const currentWrap = thumbStrip.querySelector(`[data-thumb-id="${currentId}"]`);
|
||||
if (currentWrap) currentWrap.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
async function jumpToPhoto(photo) {
|
||||
const queueIdx = queue.findIndex(p => p.id === photo.id);
|
||||
if (queueIdx !== -1) queue.splice(queueIdx, 1);
|
||||
|
||||
if (photo.tag !== 'untagged') taggedCount--;
|
||||
const prevTag = photo.tag;
|
||||
photo.tag = 'untagged';
|
||||
queue.unshift(photo);
|
||||
|
||||
await fetch('/triage/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: 'untagged' }),
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
updateThumbStrip();
|
||||
showCard();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
return { doTag, undo, showCard, buildThumbStrip, updateThumbStrip };
|
||||
}
|
||||
|
||||
// ── View switching on load ───────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.innerWidth < 768) {
|
||||
document.getElementById('desktop-view').style.display = 'none';
|
||||
document.getElementById('mobile-view').style.display = '';
|
||||
|
||||
const albumId = '{{ album_id }}';
|
||||
const photos = {{ state.photos | tojson }};
|
||||
|
||||
mobileApp = mobileTriageApp(albumId, photos);
|
||||
// Seed initial progress
|
||||
const taggedCount = photos.filter(p => p.tag !== 'untagged').length;
|
||||
document.getElementById('m-progress-text').textContent = `${taggedCount} / ${photos.length} tagged`;
|
||||
document.getElementById('m-progress-bar').style.width =
|
||||
photos.length > 0 ? `${(taggedCount / photos.length) * 100}%` : '0%';
|
||||
mobileApp.buildThumbStrip();
|
||||
mobileApp.showCard();
|
||||
}
|
||||
// Desktop: nothing extra needed — Alpine handles it
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,91 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold">Curate</h1>
|
||||
<button id="done-btn" class="btn btn-primary btn-sm" onclick="done()">
|
||||
Curate done →
|
||||
</button>
|
||||
</div>
|
||||
{% for day, photos in photos_by_day.items() %}
|
||||
<div class="day-group mb-6">
|
||||
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
|
||||
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}">
|
||||
{% for photo in photos %}
|
||||
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
|
||||
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
|
||||
data-asset-id="{{ photo.id }}">
|
||||
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
|
||||
<div class="absolute top-1 left-1 flex gap-1">
|
||||
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||||
onclick="retag('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
|
||||
{% if photo.tag == 'journal' %}→S{% else %}→J{% endif %}
|
||||
</button>
|
||||
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
|
||||
onclick="removeFn('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
|
||||
<script>
|
||||
document.querySelectorAll('[id^="day-"]').forEach(function(el) {
|
||||
var albumId = new URLSearchParams(location.search).get('album_id');
|
||||
Sortable.create(el, {
|
||||
onEnd: function(e) {
|
||||
reorder(albumId, e.to);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function removeFn(albumId, assetId, el) {
|
||||
await fetch('/curate/remove', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||||
});
|
||||
el.remove();
|
||||
}
|
||||
|
||||
async function retag(albumId, assetId, el) {
|
||||
await fetch('/curate/swap', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, asset_id: assetId})
|
||||
});
|
||||
el.classList.toggle('border-info');
|
||||
el.classList.toggle('border-success');
|
||||
}
|
||||
|
||||
async function reorder(albumId, container) {
|
||||
var ids = Array.from(container.querySelectorAll('.photo-card')).map(function(e) {
|
||||
return e.dataset.assetId;
|
||||
});
|
||||
var day = container.id.replace('day-', '');
|
||||
await fetch('/curate/reorder', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, date: day, order: ids})
|
||||
});
|
||||
}
|
||||
|
||||
async function done() {
|
||||
var albumId = new URLSearchParams(location.search).get('album_id');
|
||||
var res = await fetch('/curate/done', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.redirect) window.location = data.redirect;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,99 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold">Group</h1>
|
||||
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done →</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
{% for grp in groups %}
|
||||
<div class="group-block border border-base-300 rounded-lg p-2 space-y-1">
|
||||
{% if grp.label %}
|
||||
<div class="text-xs font-semibold opacity-70 px-1">{{ grp.label }}</div>
|
||||
{% endif %}
|
||||
{% for photo in grp.photos %}
|
||||
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
|
||||
data-order="{{ photo.order }}">
|
||||
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
|
||||
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
|
||||
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
|
||||
{{ photo.tag }}
|
||||
</span>
|
||||
</div>
|
||||
{% if not loop.last %}
|
||||
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
|
||||
data-after-order="{{ photo.order }}">
|
||||
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
|
||||
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
|
||||
@click="addDivider({{ photo.order }})">✂ cut here</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if grp.divider_id %}
|
||||
<div class="flex items-center gap-2 my-1 px-1">
|
||||
<input class="group-label input input-sm input-bordered flex-1"
|
||||
value="{{ grp.label }}"
|
||||
placeholder="Label this entry…"
|
||||
@change="setLabel('{{ grp.divider_id }}', $el.value)"
|
||||
@keydown.enter="$el.blur()">
|
||||
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
|
||||
@click="removeDivider('{{ grp.divider_id }}')">✕</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not loop.last and not grp.divider_id %}
|
||||
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
|
||||
data-after-order="{{ grp.photos[-1].order }}">
|
||||
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
|
||||
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
|
||||
@click="addDivider({{ grp.photos[-1].order }})">✂ cut here</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function groupApp(albumId) {
|
||||
return {
|
||||
async addDivider(afterOrder) {
|
||||
await fetch('/group/divider', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, after_order: afterOrder})
|
||||
});
|
||||
window.location.reload();
|
||||
},
|
||||
async removeDivider(dividerId) {
|
||||
await fetch('/group/remove-divider', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, divider_id: dividerId})
|
||||
});
|
||||
window.location.reload();
|
||||
},
|
||||
async setLabel(dividerId, label) {
|
||||
await fetch('/group/label', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label: label})
|
||||
});
|
||||
},
|
||||
async done() {
|
||||
var res = await fetch('/group/done', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.redirect) window.location = data.redirect;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,202 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold">Write</h1>
|
||||
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
|
||||
</div>
|
||||
|
||||
{% if not group %}
|
||||
<div class="alert alert-success mb-4">All groups written or skipped.</div>
|
||||
<form method="post" action="/write/done">
|
||||
<input type="hidden" name="album_id" value="{{ album_id }}">
|
||||
<button type="submit" class="btn btn-primary">Export →</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="flex gap-4">
|
||||
|
||||
<!-- Photos panel -->
|
||||
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
|
||||
{% for photo in photos %}
|
||||
<img src="/proxy/thumb/{{ photo.id }}"
|
||||
id="photo-{{ photo.id }}"
|
||||
class="w-full rounded cursor-pointer border-4 border-transparent transition"
|
||||
onclick="setHero('{{ photo.id }}')"
|
||||
alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<!-- Mode switch -->
|
||||
<div class="tabs">
|
||||
<button id="mode-journal" class="tab tab-bordered tab-active"
|
||||
onclick="setMode('journal')">Journal</button>
|
||||
<button id="mode-story" class="tab tab-bordered"
|
||||
onclick="setMode('story')">Story</button>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Title</label>
|
||||
<input id="title-field" type="text" class="input input-bordered"
|
||||
oninput="scheduleAutosave()"
|
||||
value="{{ group.title | e }}">
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Date</label>
|
||||
<input id="date-field" type="text" class="input input-bordered input-sm"
|
||||
oninput="scheduleAutosave()"
|
||||
value="{{ group.date | e }}">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">City</label>
|
||||
<input id="city-field" type="text" class="input input-bordered input-sm"
|
||||
oninput="scheduleAutosave()"
|
||||
value="{{ group.location_city | e }}">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label text-sm">Country</label>
|
||||
<input id="country-field" type="text" class="input input-bordered input-sm"
|
||||
oninput="scheduleAutosave()"
|
||||
value="{{ group.location_country | e }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control" id="mode-journal-fields">
|
||||
<label class="label text-sm">Body</label>
|
||||
<textarea id="body-field" class="textarea textarea-bordered h-40"
|
||||
oninput="scheduleAutosave()">{{ group.body | e }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Story-only fields (hidden by default if mode is journal) -->
|
||||
<div id="hero-picker" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
|
||||
<label class="label text-sm">Hero photo: <span id="hero-label">{{ group.hero_photo_id or 'none' }}</span></label>
|
||||
<p class="text-xs opacity-60">Click a photo on the left to set it as the hero.</p>
|
||||
</div>
|
||||
|
||||
<div id="shortcode-field-wrap" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
|
||||
<label class="label text-sm">Shortcode hints</label>
|
||||
<input id="shortcode-field" type="text" class="input input-bordered input-sm"
|
||||
oninput="scheduleAutosave()"
|
||||
placeholder="e.g. gallery block, pull quote"
|
||||
value="{{ group.shortcode_hints | e }}">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
{% if group_idx > 0 %}
|
||||
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
|
||||
{% endif %}
|
||||
<button id="skip-btn" class="btn btn-ghost btn-sm" onclick="skipGroup()">Skip for now</button>
|
||||
<button class="btn btn-primary btn-sm ml-auto" onclick="saveAndNext()">Save & next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline notes -->
|
||||
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
|
||||
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
|
||||
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
{% if group %}
|
||||
<script>
|
||||
(function() {
|
||||
var albumId = {{ album_id | tojson }};
|
||||
var groupId = {{ group.id | tojson }};
|
||||
var mode = {{ group.entry_type | tojson }};
|
||||
var heroId = {{ group.hero_photo_id | tojson }};
|
||||
var autosaveTimer = null;
|
||||
|
||||
window.setMode = function(m) {
|
||||
mode = m;
|
||||
var storyFields = ['hero-picker', 'shortcode-field-wrap'];
|
||||
storyFields.forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.style.display = (m === 'story') ? 'block' : 'none';
|
||||
});
|
||||
document.getElementById('mode-journal').classList.toggle('tab-active', m === 'journal');
|
||||
document.getElementById('mode-story').classList.toggle('tab-active', m === 'story');
|
||||
scheduleAutosave();
|
||||
};
|
||||
|
||||
window.setHero = function(id) {
|
||||
heroId = id;
|
||||
// Update border highlight
|
||||
document.querySelectorAll('.group-photos img').forEach(function(img) {
|
||||
img.classList.remove('border-primary');
|
||||
img.classList.add('border-transparent');
|
||||
});
|
||||
var el = document.getElementById('photo-' + id);
|
||||
if (el) { el.classList.remove('border-transparent'); el.classList.add('border-primary'); }
|
||||
var label = document.getElementById('hero-label');
|
||||
if (label) label.textContent = id;
|
||||
scheduleAutosave();
|
||||
};
|
||||
|
||||
window.scheduleAutosave = function() {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(doAutosave, 500);
|
||||
};
|
||||
|
||||
function getFormData() {
|
||||
return {
|
||||
album_id: albumId,
|
||||
group_id: groupId,
|
||||
entry_type: mode,
|
||||
hero_photo_id: heroId,
|
||||
title: document.getElementById('title-field') ? document.getElementById('title-field').value : '',
|
||||
body: document.getElementById('body-field') ? document.getElementById('body-field').value : '',
|
||||
location_city: document.getElementById('city-field') ? document.getElementById('city-field').value : '',
|
||||
location_country: document.getElementById('country-field') ? document.getElementById('country-field').value : '',
|
||||
date: document.getElementById('date-field') ? document.getElementById('date-field').value : '',
|
||||
shortcode_hints: document.getElementById('shortcode-field') ? document.getElementById('shortcode-field').value : '',
|
||||
};
|
||||
}
|
||||
|
||||
function doAutosave() {
|
||||
fetch('/write/autosave', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(getFormData()),
|
||||
});
|
||||
}
|
||||
|
||||
window.skipGroup = function() {
|
||||
fetch('/write/skip', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, group_id: groupId}),
|
||||
}).then(function() {
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
window.saveAndNext = function() {
|
||||
fetch('/write/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(getFormData()),
|
||||
}).then(function() {
|
||||
var url = new URL(window.location.href);
|
||||
var idx = parseInt(url.searchParams.get('group_idx') || '0');
|
||||
url.searchParams.set('group_idx', idx + 1);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize mode display
|
||||
if (mode === 'story') {
|
||||
document.getElementById('mode-story') && document.getElementById('mode-story').classList.add('tab-active');
|
||||
document.getElementById('mode-journal') && document.getElementById('mode-journal').classList.remove('tab-active');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,104 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="p-6 max-w-3xl mx-auto" x-data="exportApp('{{ album_id }}')">
|
||||
<h1 class="text-2xl font-bold mb-4">Export</h1>
|
||||
<div class="stats shadow mb-6">
|
||||
<div class="stat"><div class="stat-title">Ready to export</div>
|
||||
<div class="stat-value text-primary">{{ to_export | length }}</div></div>
|
||||
<div class="stat"><div class="stat-title">Skipped</div>
|
||||
<div class="stat-value opacity-40">{{ skipped | length }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
{% for group in to_export %}
|
||||
<div class="export-item card card-compact bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<p class="font-semibold">{{ group.title }}</p>
|
||||
<p class="text-xs opacity-60">{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button id="export-btn" class="btn btn-primary" @click="runExport()">
|
||||
Export {{ to_export | length }} entries
|
||||
</button>
|
||||
|
||||
<!-- Overwrite confirmation modal -->
|
||||
<dialog id="overwrite-modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold">Destination exists</h3>
|
||||
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="cancelExport()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Results -->
|
||||
<div x-show="successMsg !== ''" class="mt-6 alert alert-success text-sm" x-text="successMsg"></div>
|
||||
<div x-show="failedCount > 0" class="mt-2 alert alert-warning text-sm"
|
||||
x-text="`${failedCount} photo(s) failed to download`"></div>
|
||||
|
||||
<details class="mt-6">
|
||||
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
|
||||
Skipped ({{ skipped | length }}) — not exported
|
||||
</summary>
|
||||
<ul class="mt-2 space-y-1 text-sm opacity-60">
|
||||
{% for g in skipped %}<li>{{ g.title or g.date }}</li>{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function exportApp(albumId) {
|
||||
return {
|
||||
successMsg: '',
|
||||
failedCount: 0,
|
||||
conflictPath: null,
|
||||
overwriteMsg: '',
|
||||
|
||||
async runExport() {
|
||||
const res = await fetch('/export/run', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({album_id: albumId}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.conflict) {
|
||||
this.conflictPath = data.path;
|
||||
this.overwriteMsg = `Destination already exists: ${data.path}`;
|
||||
document.getElementById('overwrite-modal').showModal();
|
||||
} else if (data.ok) {
|
||||
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
|
||||
this.failedCount = (data.failed || []).length;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmOverwrite() {
|
||||
document.getElementById('overwrite-modal').close();
|
||||
const res = await fetch('/export/overwrite', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({album_id: albumId, path: this.conflictPath}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.conflict) {
|
||||
this.conflictPath = data.path;
|
||||
this.overwriteMsg = `Destination already exists: ${data.path}`;
|
||||
document.getElementById('overwrite-modal').showModal();
|
||||
} else if (data.ok) {
|
||||
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
|
||||
this.failedCount = (data.failed || []).length;
|
||||
}
|
||||
},
|
||||
|
||||
cancelExport() {
|
||||
document.getElementById('overwrite-modal').close();
|
||||
this.conflictPath = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
@@ -1,5 +0,0 @@
|
||||
flask==3.1.0
|
||||
requests==2.32.3
|
||||
pytest==8.3.4
|
||||
pytest-playwright==0.6.2
|
||||
pytest-httpserver==1.1.0
|
||||
@@ -1,101 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
TINY_PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d4948445200000001000000010806"
|
||||
"0000001f15c4890000000a4944415478016360000000020001e2"
|
||||
"21bc330000000049454e44ae426082"
|
||||
)
|
||||
|
||||
MOCK_ALBUMS = [
|
||||
{
|
||||
"id": "album-1",
|
||||
"albumName": "Central Asia 2023",
|
||||
"assetCount": 3,
|
||||
"albumThumbnailAssetId": "asset-1",
|
||||
}
|
||||
]
|
||||
|
||||
MOCK_ALBUM_DETAIL = {
|
||||
"id": "album-1",
|
||||
"albumName": "Central Asia 2023",
|
||||
"assets": [
|
||||
{"id": "asset-1", "originalFileName": "IMG_001.jpg",
|
||||
"localDateTime": "2023-09-05T09:03:00"},
|
||||
{"id": "asset-2", "originalFileName": "IMG_002.jpg",
|
||||
"localDateTime": "2023-09-05T14:30:00"},
|
||||
{"id": "asset-3", "originalFileName": "IMG_003.jpg",
|
||||
"localDateTime": "2023-09-06T10:00:00"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", 8099)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_immich(make_httpserver):
|
||||
server = make_httpserver
|
||||
server.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS)
|
||||
server.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL)
|
||||
for asset_id in ["asset-1", "asset-2", "asset-3"]:
|
||||
server.expect_request(
|
||||
f"/api/assets/{asset_id}/thumbnail"
|
||||
).respond_with_data(TINY_PNG, content_type="image/png")
|
||||
server.expect_request(
|
||||
f"/api/assets/{asset_id}/original"
|
||||
).respond_with_data(TINY_PNG, content_type="image/jpeg")
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def state_dir(tmp_path_factory):
|
||||
return tmp_path_factory.mktemp("state")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pages_dir(tmp_path_factory):
|
||||
return tmp_path_factory.mktemp("pages")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def flask_app(state_dir, pages_dir, mock_immich):
|
||||
os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099"
|
||||
os.environ["IMMICH_API_KEY"] = "test-key"
|
||||
from app import create_app
|
||||
return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url(flask_app):
|
||||
server = make_server("127.0.0.1", 8083, flask_app)
|
||||
t = threading.Thread(target=server.serve_forever)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
yield "http://127.0.0.1:8083"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seed_state(state_dir):
|
||||
"""Copy a fixture JSON into the state dir; return the album_id."""
|
||||
def _seed(fixture_name: str) -> str:
|
||||
src = FIXTURES_DIR / f"{fixture_name}.json"
|
||||
with open(src) as f:
|
||||
data = json.load(f)
|
||||
dst = Path(state_dir) / f"{data['album_id']}.json"
|
||||
shutil.copy(src, dst)
|
||||
return data["album_id"]
|
||||
return _seed
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"album_id": "album-1",
|
||||
"album_name": "Central Asia 2023",
|
||||
"grav_trip_slug": "central-asia-2023",
|
||||
"phase": "triage",
|
||||
"phases_completed": [],
|
||||
"phase_stale": [],
|
||||
"photos": [
|
||||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||||
"local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0},
|
||||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1},
|
||||
{"id": "asset-3", "original_filename": "IMG_003.jpg",
|
||||
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": "",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"album_id": "album-1",
|
||||
"album_name": "Central Asia 2023",
|
||||
"grav_trip_slug": "central-asia-2023",
|
||||
"phase": "curate",
|
||||
"phases_completed": ["triage"],
|
||||
"phase_stale": [],
|
||||
"photos": [
|
||||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1},
|
||||
{"id": "asset-3", "original_filename": "IMG_003.jpg",
|
||||
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": "",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"album_id": "album-1",
|
||||
"album_name": "Central Asia 2023",
|
||||
"grav_trip_slug": "central-asia-2023",
|
||||
"phase": "group",
|
||||
"phases_completed": ["triage", "curate"],
|
||||
"phase_stale": [],
|
||||
"photos": [
|
||||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": "I remember the airport was chaos.",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"album_id": "album-1",
|
||||
"album_name": "Central Asia 2023",
|
||||
"grav_trip_slug": "central-asia-2023",
|
||||
"phase": "write",
|
||||
"phases_completed": ["triage", "curate", "group"],
|
||||
"phase_stale": [],
|
||||
"photos": [
|
||||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
|
||||
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
|
||||
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
|
||||
"status": "draft"
|
||||
},
|
||||
{
|
||||
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
|
||||
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
|
||||
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
|
||||
"status": "draft"
|
||||
}
|
||||
],
|
||||
"notes": "I remember the airport was chaos.",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"album_id": "album-1",
|
||||
"album_name": "Central Asia 2023",
|
||||
"grav_trip_slug": "central-asia-2023",
|
||||
"phase": "export",
|
||||
"phases_completed": ["triage", "curate", "group", "write"],
|
||||
"phase_stale": [],
|
||||
"photos": [
|
||||
{"id": "asset-1", "original_filename": "IMG_001.jpg",
|
||||
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
|
||||
{"id": "asset-2", "original_filename": "IMG_002.jpg",
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
|
||||
"label": "", "title": "Arrival in Almaty", "body": "Chaos at the airport.",
|
||||
"location_city": "Almaty", "location_country": "Kazakhstan",
|
||||
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
|
||||
"status": "written"
|
||||
},
|
||||
{
|
||||
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
|
||||
"label": "", "title": "The Market", "body": "Colours everywhere.",
|
||||
"location_city": "Almaty", "location_country": "Kazakhstan",
|
||||
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
|
||||
"status": "skipped"
|
||||
}
|
||||
],
|
||||
"notes": "",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_hard_refresh_preserves_triage_state(base_url, page, seed_state, flask_app):
|
||||
"""State is server-side — hard refresh must not reset it."""
|
||||
album_id = seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
page.locator(".photo-card").first.click()
|
||||
page.keyboard.press("j")
|
||||
page.wait_for_timeout(400)
|
||||
page.reload()
|
||||
first_card = page.locator(".photo-card").first
|
||||
assert "border-success" in first_card.get_attribute("class")
|
||||
|
||||
|
||||
def test_back_nav_from_group_to_triage_marks_curate_group_stale(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state") # completed=[triage, curate], phase=group
|
||||
page.request.post(
|
||||
f"{base_url}/nav/phase",
|
||||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp = page.request.get(f"{base_url}/state/{album_id}")
|
||||
state = resp.json()
|
||||
assert "curate" in state["phase_stale"]
|
||||
assert "group" in state["phase_stale"]
|
||||
|
||||
|
||||
def test_stale_banner_visible_on_stale_phase(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.request.post(
|
||||
f"{base_url}/nav/phase",
|
||||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# Now visit curate (which is stale)
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
assert page.locator("#stale-banner").is_visible()
|
||||
|
||||
|
||||
def test_dismiss_stale_clears_flag(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.request.post(
|
||||
f"{base_url}/nav/phase",
|
||||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
page.locator("#stale-banner button").click()
|
||||
page.wait_for_url("**/curate**")
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
assert "curate" not in state.phase_stale
|
||||
|
||||
|
||||
def test_exported_group_not_affected_by_back_nav(base_url, page, seed_state, flask_app):
|
||||
"""Exporting then going back to triage must not touch the exported group."""
|
||||
album_id = seed_state("phase6_state")
|
||||
# Manually set one group to exported
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state, save_state
|
||||
state = load_state(album_id, flask_app)
|
||||
state.groups[0].status = "exported"
|
||||
save_state(state, flask_app)
|
||||
# Navigate back
|
||||
page.request.post(
|
||||
f"{base_url}/nav/phase",
|
||||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
assert state.groups[0].status == "exported"
|
||||
|
||||
|
||||
def test_notes_autosave_survives_phase_navigation(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.request.post(
|
||||
f"{base_url}/notes/save",
|
||||
data=json.dumps({"album_id": album_id, "notes": "survives navigation"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
resp = page.request.get(f"{base_url}/notes/{album_id}")
|
||||
assert resp.json()["notes"] == "survives navigation"
|
||||
@@ -1,50 +0,0 @@
|
||||
import pytest
|
||||
from app.immich import ImmichClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_immich):
|
||||
return ImmichClient(
|
||||
base_url=f"http://127.0.0.1:8099",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
|
||||
def test_list_albums(client):
|
||||
albums = client.list_albums()
|
||||
assert len(albums) == 1
|
||||
assert albums[0]["albumName"] == "Central Asia 2023"
|
||||
|
||||
|
||||
def test_get_album(client):
|
||||
album = client.get_album("album-1")
|
||||
assert len(album["assets"]) == 3
|
||||
|
||||
|
||||
def test_get_thumbnail_returns_bytes(client):
|
||||
data = client.get_thumbnail("asset-1")
|
||||
assert isinstance(data, bytes)
|
||||
assert len(data) > 0
|
||||
|
||||
|
||||
def test_get_original_returns_bytes(client):
|
||||
data = client.get_original("asset-1")
|
||||
assert isinstance(data, bytes)
|
||||
|
||||
|
||||
def test_list_albums_connection_error_raises(monkeypatch):
|
||||
client = ImmichClient(base_url="http://127.0.0.1:1", api_key="x")
|
||||
with pytest.raises(ConnectionError):
|
||||
client.list_albums()
|
||||
|
||||
|
||||
def test_proxy_thumb_route(base_url, page, seed_state):
|
||||
seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/proxy/thumb/asset-1")
|
||||
assert page.evaluate("document.contentType").startswith("image/")
|
||||
|
||||
|
||||
def test_proxy_original_route(base_url, page, seed_state):
|
||||
seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/proxy/original/asset-1")
|
||||
assert page.evaluate("document.contentType").startswith("image/")
|
||||
@@ -1,40 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def test_notes_save(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
resp = page.request.post(
|
||||
f"{base_url}/notes/save",
|
||||
data=json.dumps({"album_id": album_id, "notes": "hello memory"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.ok
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
|
||||
def test_notes_persist_after_reload(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.request.post(
|
||||
f"{base_url}/notes/save",
|
||||
data=json.dumps({"album_id": album_id, "notes": "persisted note"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
assert page.locator("#notes-panel").inner_text().__contains__("persisted note") or True
|
||||
# Notes content is loaded from server state — verify via API response
|
||||
resp = page.request.get(f"{base_url}/notes/{album_id}")
|
||||
assert resp.json()["notes"] == "persisted note"
|
||||
|
||||
|
||||
def test_nav_back_marks_stale(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state") # phase=group, completed=[triage,curate]
|
||||
page.request.post(
|
||||
f"{base_url}/nav/phase",
|
||||
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp = page.request.get(f"{base_url}/state/{album_id}")
|
||||
data = resp.json()
|
||||
assert "curate" in data["phase_stale"]
|
||||
assert "group" in data["phase_stale"]
|
||||
@@ -1,30 +0,0 @@
|
||||
def test_album_list_renders(base_url, page):
|
||||
page.goto(base_url)
|
||||
assert page.locator(".album-card").count() == 1
|
||||
assert "Central Asia 2023" in page.inner_text(".album-card")
|
||||
|
||||
|
||||
def test_select_single_album_creates_state(base_url, page):
|
||||
page.goto(base_url)
|
||||
page.locator(".album-card input[type=checkbox]").first.check()
|
||||
page.fill("#grav-slug", "central-asia-2023")
|
||||
page.locator("button[type=submit]").click()
|
||||
page.wait_for_url("**/triage**")
|
||||
assert "album_id=album-1" in page.url
|
||||
|
||||
|
||||
def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state):
|
||||
seed_state("phase2_state")
|
||||
page.goto(base_url)
|
||||
assert page.locator("[data-album-id=album-1] .resume-badge").is_visible()
|
||||
|
||||
|
||||
def test_immich_unreachable_shows_error(base_url, page, monkeypatch):
|
||||
import app.routes.albums as a
|
||||
orig = a.ImmichClient
|
||||
class BrokenClient:
|
||||
def __init__(self, *a, **k): pass
|
||||
def list_albums(self): raise ConnectionError("down")
|
||||
monkeypatch.setattr(a, "ImmichClient", BrokenClient)
|
||||
page.goto(base_url)
|
||||
assert page.locator(".alert-error").is_visible()
|
||||
@@ -1,40 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_photos_render_in_day_groups(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
assert page.locator(".day-group").count() >= 1
|
||||
assert page.locator(".photo-card").count() == 3
|
||||
|
||||
|
||||
def test_keyboard_j_tags_journal(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
page.locator(".photo-card").first.click()
|
||||
page.keyboard.press("j")
|
||||
page.wait_for_timeout(300)
|
||||
card = page.locator(".photo-card").first
|
||||
assert "border-success" in card.get_attribute("class")
|
||||
|
||||
|
||||
def test_keyboard_s_tags_story(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
page.locator(".photo-card").first.click()
|
||||
page.keyboard.press("s")
|
||||
page.wait_for_timeout(300)
|
||||
assert "border-info" in page.locator(".photo-card").first.get_attribute("class")
|
||||
|
||||
|
||||
def test_done_button_disabled_until_all_tagged(base_url, page, seed_state):
|
||||
album_id = seed_state("phase2_state")
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
assert page.locator("#done-btn").is_disabled()
|
||||
|
||||
|
||||
def test_done_advances_to_curate(base_url, page, seed_state):
|
||||
album_id = seed_state("phase3_state") # all tagged
|
||||
page.goto(f"{base_url}/triage?album_id={album_id}")
|
||||
page.locator("#done-btn").click()
|
||||
page.wait_for_url("**/curate**")
|
||||
@@ -1,40 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_only_kept_photos_shown(base_url, page, seed_state):
|
||||
album_id = seed_state("phase3_state")
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
# phase3_state has 2 kept (journal+story) and 1 skipped
|
||||
assert page.locator(".photo-card").count() == 2
|
||||
|
||||
|
||||
def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase3_state")
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
page.locator(".remove-btn").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
assert page.locator(".photo-card").count() == 1
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
removed = next(p for p in state.photos if p.id == "asset-1")
|
||||
assert removed.tag == "skip"
|
||||
|
||||
|
||||
def test_retag_journal_to_story(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase3_state")
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
page.locator(".retag-btn").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
p = next(p for p in state.photos if p.id == "asset-1")
|
||||
assert p.tag == "story"
|
||||
|
||||
|
||||
def test_done_advances_to_group(base_url, page, seed_state):
|
||||
album_id = seed_state("phase3_state")
|
||||
page.goto(f"{base_url}/curate?album_id={album_id}")
|
||||
page.locator("#done-btn").click()
|
||||
page.wait_for_url("**/group**")
|
||||
@@ -1,47 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_photos_shown_as_stream(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||||
assert page.locator(".stream-photo").count() == 2
|
||||
|
||||
|
||||
def test_insert_divider_creates_group_boundary(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||||
page.locator(".divider-zone").first.hover()
|
||||
page.locator(".insert-divider-btn").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
assert page.locator(".group-block").count() == 2
|
||||
|
||||
|
||||
def test_remove_divider_merges_groups(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||||
page.locator(".divider-zone").first.hover()
|
||||
page.locator(".insert-divider-btn").first.click()
|
||||
page.wait_for_timeout(200)
|
||||
page.locator(".remove-divider-btn").first.click()
|
||||
page.wait_for_timeout(200)
|
||||
assert page.locator(".group-block").count() == 1
|
||||
|
||||
|
||||
def test_label_edit_persists(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||||
page.locator(".divider-zone").first.hover()
|
||||
page.locator(".insert-divider-btn").first.click()
|
||||
page.wait_for_timeout(200)
|
||||
page.locator(".group-label").first.fill("Morning walk")
|
||||
page.locator(".group-label").first.press("Enter")
|
||||
page.wait_for_timeout(300)
|
||||
page.reload()
|
||||
assert "Morning walk" in page.locator(".group-label").first.input_value()
|
||||
|
||||
|
||||
def test_done_advances_to_write(base_url, page, seed_state):
|
||||
album_id = seed_state("phase4_state")
|
||||
page.goto(f"{base_url}/group?album_id={album_id}")
|
||||
page.locator("#done-btn").click()
|
||||
page.wait_for_url("**/write**")
|
||||
@@ -1,44 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_first_group_shown(base_url, page, seed_state):
|
||||
album_id = seed_state("phase5_state")
|
||||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||||
assert page.locator(".group-photos img").count() >= 1
|
||||
assert page.locator("#title-field").is_visible()
|
||||
|
||||
|
||||
def test_form_autosave_on_input(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase5_state")
|
||||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||||
page.fill("#title-field", "Arrival in Almaty")
|
||||
page.wait_for_timeout(700)
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
assert state.groups[0].title == "Arrival in Almaty"
|
||||
|
||||
|
||||
def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state):
|
||||
album_id = seed_state("phase5_state")
|
||||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||||
page.locator("#mode-story").click()
|
||||
assert page.locator("#hero-picker").is_visible()
|
||||
assert not page.locator("#mode-journal-fields").is_visible() or True
|
||||
|
||||
|
||||
def test_skip_defers_group(base_url, page, seed_state, flask_app):
|
||||
album_id = seed_state("phase5_state")
|
||||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||||
page.locator("#skip-btn").click()
|
||||
page.wait_for_timeout(400)
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
assert state.groups[0].status == "skipped"
|
||||
|
||||
|
||||
def test_notes_shown_inline_in_write_phase(base_url, page, seed_state):
|
||||
album_id = seed_state("phase5_state")
|
||||
page.goto(f"{base_url}/write?album_id={album_id}")
|
||||
assert page.locator("#inline-notes").is_visible()
|
||||
@@ -1,74 +0,0 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_summary_shows_written_and_skipped(base_url, page, seed_state):
|
||||
album_id = seed_state("phase6_state")
|
||||
page.goto(f"{base_url}/export?album_id={album_id}")
|
||||
assert "1 journal" in page.inner_text("body").lower() or page.locator(".export-item").count() >= 1
|
||||
assert page.locator(".skipped-list").is_visible()
|
||||
|
||||
|
||||
def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
|
||||
album_id = seed_state("phase6_state")
|
||||
page.goto(f"{base_url}/export?album_id={album_id}")
|
||||
page.locator("#export-btn").click()
|
||||
page.wait_for_timeout(2000)
|
||||
dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||||
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
|
||||
|
||||
|
||||
def test_export_sets_status_exported(base_url, page, seed_state, flask_app, pages_dir):
|
||||
album_id = seed_state("phase6_state")
|
||||
|
||||
# Ensure dest folder does not exist so export proceeds without conflict
|
||||
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||||
if daily_dest.exists():
|
||||
shutil.rmtree(daily_dest)
|
||||
|
||||
res = page.request.post(
|
||||
f"{base_url}/export/run",
|
||||
data=json.dumps({"album_id": album_id}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
data = res.json()
|
||||
|
||||
# Must not be a conflict — export should succeed
|
||||
assert data.get("ok") is True, f"Expected ok response, got: {data}"
|
||||
|
||||
# The journal entry.md file must exist on disk
|
||||
entry_files = list(daily_dest.glob("**/entry.md")) if daily_dest.exists() else []
|
||||
assert len(entry_files) >= 1, "entry.md not written to disk"
|
||||
|
||||
# Status must be exported in state
|
||||
with flask_app.app_context():
|
||||
from app.state import load_state
|
||||
state = load_state(album_id, flask_app)
|
||||
written = [g for g in state.groups if g.status not in ("skipped", "exported")]
|
||||
assert len(written) == 0
|
||||
|
||||
|
||||
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
|
||||
album_id = seed_state("phase6_state")
|
||||
|
||||
# Clean dest so there's no conflict
|
||||
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
|
||||
if daily_dest.exists():
|
||||
shutil.rmtree(daily_dest)
|
||||
|
||||
res = page.request.post(
|
||||
f"{base_url}/export/run",
|
||||
data=json.dumps({"album_id": album_id}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
data = res.json()
|
||||
|
||||
# Response shape: {"ok": true, "exported": N, "failed": [...]}
|
||||
# g2 "The Market" is skipped — it must not appear as an exported folder
|
||||
stories_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "04.stories"
|
||||
market_dirs = list(stories_dest.glob("*the-market*")) if stories_dest.exists() else []
|
||||
assert len(market_dirs) == 0, "Skipped group 'The Market' must not be exported"
|
||||
|
||||
# And the response must not include a conflict (only written groups are exported)
|
||||
assert data.get("ok") is True, f"Expected ok response, got: {data}"
|
||||
@@ -1,3 +0,0 @@
|
||||
def test_health(base_url, page):
|
||||
page.goto(f"{base_url}/health")
|
||||
assert "ok" in page.content()
|
||||
@@ -1,52 +0,0 @@
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from app.state import TripState, Photo, Group, load_state, save_state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_ctx(flask_app):
|
||||
with flask_app.app_context():
|
||||
yield flask_app
|
||||
|
||||
|
||||
def test_save_and_load_roundtrip(app_ctx, state_dir):
|
||||
state = TripState(
|
||||
album_id="test-album",
|
||||
album_name="Test",
|
||||
grav_trip_slug="test-trip",
|
||||
photos=[Photo(id="p1", original_filename="a.jpg",
|
||||
local_datetime="2023-01-01T10:00:00")],
|
||||
groups=[],
|
||||
)
|
||||
save_state(state, app_ctx)
|
||||
loaded = load_state("test-album", app_ctx)
|
||||
assert loaded.album_id == "test-album"
|
||||
assert loaded.photos[0].id == "p1"
|
||||
|
||||
|
||||
def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch):
|
||||
written_paths = []
|
||||
real_rename = __import__("os").rename
|
||||
def fake_rename(src, dst):
|
||||
written_paths.append(src)
|
||||
real_rename(src, dst)
|
||||
monkeypatch.setattr("app.state.os.rename", fake_rename)
|
||||
state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x")
|
||||
save_state(state, app_ctx)
|
||||
assert any(str(p).endswith(".tmp") for p in written_paths)
|
||||
|
||||
|
||||
def test_load_nonexistent_returns_none(app_ctx):
|
||||
assert load_state("no-such-album", app_ctx) is None
|
||||
|
||||
|
||||
def test_exported_status_field_preserved(app_ctx):
|
||||
state = TripState(
|
||||
album_id="export-test", album_name="E", grav_trip_slug="e",
|
||||
groups=[Group(id="g1", photo_ids=[], entry_type="journal",
|
||||
status="exported")]
|
||||
)
|
||||
save_state(state, app_ctx)
|
||||
loaded = load_state("export-test", app_ctx)
|
||||
assert loaded.groups[0].status == "exported"
|
||||
@@ -13,6 +13,14 @@ module.exports = async function globalSetup() {
|
||||
});
|
||||
}
|
||||
|
||||
// Local test-account defaults (mirror the Makefile) so direct `npx playwright
|
||||
// test` runs are self-contained without needing GRAV_TEST_* in .env.
|
||||
if (!process.env.GRAV_TEST_USER) process.env.GRAV_TEST_USER = 'testrunner';
|
||||
if (!process.env.GRAV_TEST_PASS) process.env.GRAV_TEST_PASS = 'Testpass1234';
|
||||
|
||||
// Ensure the local test account exists (idempotent; never committed).
|
||||
execSync('make test-account', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||
|
||||
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
||||
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ test('A3f: clicking Cycling toggle sets aria-expanded="true" then back to false'
|
||||
|
||||
// ── A4: Photo strip keyboard navigation ───────────────────────────────────────
|
||||
test('A4a: all photo strips have role=region and aria-label', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
const strips = page.locator('.journal-photo-strip');
|
||||
const count = await strips.count();
|
||||
if (count === 0) return;
|
||||
@@ -80,7 +80,7 @@ test('A4a: all photo strips have role=region and aria-label', async ({ page }) =
|
||||
});
|
||||
|
||||
test('A4b: multi-slide photo strips have accessible prev/next controls', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
const multiCount = await page.locator('.journal-photo-strip').evaluateAll(
|
||||
els => els.filter(el => parseInt(el.dataset.slides, 10) >= 2).length
|
||||
);
|
||||
@@ -133,7 +133,6 @@ function axeScan(id, url) {
|
||||
|
||||
axeScan('AX1', '/');
|
||||
axeScan('AX2', '/trips/italy-2026-demo');
|
||||
axeScan('AX3', '/trips/italy-2026-demo/dailies');
|
||||
axeScan('AX4', '/trips/italy-2026-demo/dailies/2026-09-01-0700-setting-off-from-campiglia.entry');
|
||||
axeScan('AX5', '/trips');
|
||||
|
||||
|
||||
@@ -12,17 +12,19 @@ const KNOWN_COUNTRY = 'Italy';
|
||||
const NEWER_SLUG = '2023-10-18-hunting-the-mother-of-georgia-from-above.entry'; // newest date in that trip
|
||||
const OLDER_SLUG = '2023-08-28-welcome-to-my-central-asian-picture-diary.entry'; // oldest date in that trip
|
||||
|
||||
// ── T1: Dailies page loads ─────────────────────────────────────────────────────
|
||||
test('T1: /trips/italy-2026-demo/dailies loads and shows at least one entry card', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
// ── T1: Trip page journal feed loads ──────────────────────────────────────────
|
||||
// The journal feed moved onto the trip page when the standalone /dailies view was retired.
|
||||
test('T1: trip page loads and shows at least one journal entry card', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── T2: Entries are newest-first ──────────────────────────────────────────────
|
||||
// ── T2: Trip feed default order is oldest-first ───────────────────────────────
|
||||
// The trip page sorts the feed oldest→newest by default (home uses newest-first).
|
||||
// Verify using two known real entries from central-asia-2023 (22 entries, stable order).
|
||||
test('T2: dailies shows newer entries before older entries', async ({ page }) => {
|
||||
await page.goto('/trips/central-asia-2023/dailies');
|
||||
test('T2: trip feed shows older entries before newer entries (oldest-first default)', async ({ page }) => {
|
||||
await page.goto('/trips/central-asia-2023');
|
||||
|
||||
// Use attribute selector to handle dots in slug names (CSS dots are class selectors)
|
||||
const newerCard = page.locator(`.journal-post[id="entry-${NEWER_SLUG}"]`);
|
||||
@@ -31,7 +33,7 @@ test('T2: dailies shows newer entries before older entries', async ({ page }) =>
|
||||
await expect(newerCard).toBeVisible();
|
||||
await expect(olderCard).toBeVisible();
|
||||
|
||||
// The newer entry should appear higher in the DOM (lower index)
|
||||
// The older entry should appear higher in the DOM (lower index)
|
||||
const newerIdx = await newerCard.evaluate(el => {
|
||||
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||
});
|
||||
@@ -39,36 +41,36 @@ test('T2: dailies shows newer entries before older entries', async ({ page }) =>
|
||||
return [...document.querySelectorAll('.journal-post')].findIndex(c => c.id === el.id);
|
||||
});
|
||||
|
||||
expect(newerIdx).toBeLessThan(olderIdx);
|
||||
expect(olderIdx).toBeLessThan(newerIdx);
|
||||
});
|
||||
|
||||
// ── T3: Individual entry page loads ───────────────────────────────────────────
|
||||
test('T3: individual entry page loads at /trips/italy-2026-demo/dailies/{slug}', async ({ page }) => {
|
||||
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('article.entry')).toBeVisible();
|
||||
await expect(page.locator('article.journal-post')).toBeVisible();
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── T4: Entry page shows title, date, and content ─────────────────────────────
|
||||
test('T4: entry page shows title and body content', async ({ page }) => {
|
||||
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('.entry-title')).toContainText(KNOWN_TITLE);
|
||||
await expect(page.locator('.entry-body')).not.toBeEmpty();
|
||||
await expect(page.locator('time.entry-date')).toBeVisible();
|
||||
await expect(page.locator('.journal-post-title')).toContainText(KNOWN_TITLE);
|
||||
await expect(page.locator('.journal-post-body')).not.toBeEmpty();
|
||||
await expect(page.locator('.journal-post-meta time')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── T5: Entry page shows location when present ────────────────────────────────
|
||||
test('T5: entry page shows city and country when set', async ({ page }) => {
|
||||
await page.goto(`/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`);
|
||||
await expect(page.locator('.entry-location')).toContainText(KNOWN_CITY);
|
||||
await expect(page.locator('.entry-location')).toContainText(KNOWN_COUNTRY);
|
||||
await expect(page.locator('.journal-post-location')).toContainText(KNOWN_CITY);
|
||||
await expect(page.locator('.journal-post-location')).toContainText(KNOWN_COUNTRY);
|
||||
});
|
||||
|
||||
// ── T6: Entry page has a fixed top back pill and a footer back pill ───────────────
|
||||
test('T6: entry page has fixed back pill at top and back pill in footer', async ({ page }) => {
|
||||
const KNOWN_ENTRY = `/trips/italy-2026-demo/dailies/${KNOWN_SLUG}`;
|
||||
await page.goto(KNOWN_ENTRY);
|
||||
await expect(page.locator('article.entry')).toBeVisible();
|
||||
await expect(page.locator('article.journal-post')).toBeVisible();
|
||||
const topPill = page.locator('.entry-back-fixed');
|
||||
await expect(topPill).toBeVisible();
|
||||
await expect(topPill).toHaveText(/← Back/);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @ts-check
|
||||
// Tests: G1–G5 — buildJourneySegments algorithm correctness
|
||||
// These tests load the italy-2026-demo map page (which has GPX) to get MapUtils in scope,
|
||||
// then call the functions with synthetic data via page.evaluate.
|
||||
// These tests load the italy-2026-demo trip page (which has GPX + the map bundle) to get
|
||||
// MapUtils in scope, then call the functions with synthetic data via page.evaluate.
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function getMapUtils(page) {
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
await expect(page.locator('#trip-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// G1: No GPX → all pairs connected in one segment
|
||||
|
||||
+10
-9
@@ -62,19 +62,20 @@ const USER_DIR = resolveUserDir();
|
||||
const TRACKER_DIR = resolveDailiesDir(USER_DIR) || path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||
|
||||
/**
|
||||
* The Grav route to the active dailies listing page,
|
||||
* read from the post-form.md pageconfig.parent value.
|
||||
* Falls back to '/trips/italy-2026-demo/dailies'.
|
||||
* The Grav route to the active trip page, derived from the post-form.md
|
||||
* pageconfig.parent value (the dailies container route, minus the trailing
|
||||
* `/dailies`). Posted entries surface in this page's journal feed.
|
||||
* Falls back to '/trips/italy-2026-demo'.
|
||||
*/
|
||||
function resolveActiveDailiesUrl() {
|
||||
function resolveActiveTripUrl() {
|
||||
const postFormPath = path.join(USER_DIR, 'pages/02.post/post-form.md');
|
||||
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo/dailies';
|
||||
if (!fs.existsSync(postFormPath)) return '/trips/italy-2026-demo';
|
||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
||||
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+\/dailies)['"]?/);
|
||||
return m ? m[1] : '/trips/italy-2026-demo/dailies';
|
||||
const m = content.match(/parent:\s*['"]?(\/trips\/[^'"]+)\/dailies['"]?/);
|
||||
return m ? m[1] : '/trips/italy-2026-demo';
|
||||
}
|
||||
|
||||
const DAILIES_URL = resolveActiveDailiesUrl();
|
||||
const ACTIVE_TRIP_URL = resolveActiveTripUrl();
|
||||
|
||||
/**
|
||||
* Wait for all filepond items to finish XHR upload.
|
||||
@@ -136,4 +137,4 @@ function readEntryMd(entryDir) {
|
||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||
}
|
||||
|
||||
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL };
|
||||
module.exports = { waitForFilePondUpload, postEntry, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||
|
||||
@@ -48,11 +48,13 @@ test('MUX3: trip page map has a fullscreen toggle button', async ({ page }) => {
|
||||
await expect(fsBtn).toHaveAttribute('aria-label', 'Expand map');
|
||||
});
|
||||
|
||||
// ── MUX4: Dailies sort toggle reverses entry order ───────────────────────────
|
||||
test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
// ── MUX4: Trip feed sort toggle reverses entry order ─────────────────────────
|
||||
// The sort toggle moved onto the trip page (#trip-sort-toggle) when the standalone
|
||||
// /dailies feed view was retired.
|
||||
test('MUX4: trip feed sort toggle reverses the feed entry order', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
|
||||
const sortBtn = page.locator('#feed-sort-toggle');
|
||||
const sortBtn = page.locator('#trip-sort-toggle');
|
||||
await expect(sortBtn).toBeVisible();
|
||||
|
||||
const firstBefore = await page.locator('[data-type]').first().getAttribute('id');
|
||||
@@ -67,21 +69,22 @@ test('MUX4: dailies sort toggle reverses the feed entry order', async ({ page })
|
||||
expect(firstRestored, 'Entry order restored after second toggle').toBe(firstBefore);
|
||||
});
|
||||
|
||||
// ── MUX5: Stories sort toggle reverses story card order ─────────────────────
|
||||
test('MUX5: stories sort toggle reverses the story card order', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/stories');
|
||||
// ── MUX5: Trip feed sort toggle reverses story card order ────────────────────
|
||||
// Story cards live in the trip feed as [data-type="story"] (the /stories grid was retired).
|
||||
test('MUX5: trip feed sort toggle reverses the story card order', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
|
||||
const sortBtn = page.locator('#feed-sort-toggle');
|
||||
const sortBtn = page.locator('#trip-sort-toggle');
|
||||
await expect(sortBtn).toBeVisible();
|
||||
|
||||
const firstBefore = await page.locator('.story-card').first().getAttribute('id');
|
||||
const firstBefore = await page.locator('[data-type="story"]').first().getAttribute('id');
|
||||
|
||||
await sortBtn.click();
|
||||
|
||||
const firstAfter = await page.locator('.story-card').first().getAttribute('id');
|
||||
const firstAfter = await page.locator('[data-type="story"]').first().getAttribute('id');
|
||||
expect(firstAfter, 'Story order reversed after sort').not.toBe(firstBefore);
|
||||
|
||||
await sortBtn.click();
|
||||
const firstRestored = await page.locator('.story-card').first().getAttribute('id');
|
||||
const firstRestored = await page.locator('[data-type="story"]').first().getAttribute('id');
|
||||
expect(firstRestored, 'Story order restored after second toggle').toBe(firstBefore);
|
||||
});
|
||||
|
||||
+5
-104
@@ -1,38 +1,11 @@
|
||||
// @ts-check
|
||||
// Tests: M1–M4 — MapLibre GL canvas renders on all three map surfaces
|
||||
// Tests: M4, M7, M8 — MapLibre GL renders on the two live map surfaces (home + trip page).
|
||||
// The standalone /map, /dailies mini-map and /stories mini-map surfaces were retired
|
||||
// (see docs/working/plans/2026-07-04-standalone-page-cleanup.md); their coverage now
|
||||
// lives on the trip and home maps, both driven by MapUtils.initEntryMap().
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// ── M1: Full map page renders MapLibre canvas ─────────────────────────────────
|
||||
test('M1: /map page renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on map page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M2: Full map page — dot markers are in the DOM ───────────────────────────
|
||||
test('M2: /map page has at least one dot marker', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Markers are added in map.on('load') — wait for first to appear in the DOM
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
const markerCount = await page.locator('.maplibregl-marker').count();
|
||||
expect(markerCount, 'At least one marker present').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── M3: Dailies mini-map renders MapLibre canvas ─────────────────────────────
|
||||
test('M3: Dailies mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on dailies page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M4: Home map renders MapLibre canvas ─────────────────────────────────────
|
||||
test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
@@ -43,42 +16,6 @@ test('M4: Home page map renders MapLibre GL canvas without JS errors', async ({
|
||||
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M5: Italy map — no JS errors with GPX present ────────────────────────────
|
||||
test('M5: Italy map page renders without JS errors (GPX present)', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for markers to confirm map.on('load') completed
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
// Give Promise.all time to resolve
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
expect(errors, 'No JS errors on Italy map page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M6: Italy map — journey source exists after GPX loads ────────────────────
|
||||
test('M6: Italy map has a journey MapLibre source after GPX settles', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await expect(page.locator('canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait until the journey source appears — addJourneySegments runs inside Promise.all.then()
|
||||
// `var map = ...` in map.html.twig is a plain <script> var → available as window.map.
|
||||
await page.waitForFunction(function () {
|
||||
return window.map &&
|
||||
(window.map.getSource('journey') !== undefined ||
|
||||
window.map.getSource('journey-0') !== undefined);
|
||||
}, { timeout: 15000 });
|
||||
|
||||
const hasSource = await page.evaluate(function () {
|
||||
return !!(window.map.getSource('journey') || window.map.getSource('journey-0'));
|
||||
});
|
||||
|
||||
expect(hasSource).toBe(true);
|
||||
});
|
||||
|
||||
// ── M7: Clicking a trip-page map marker adds is-highlighted to the entry card ──
|
||||
test('M7: clicking map marker briefly highlights the corresponding entry card', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
@@ -103,7 +40,7 @@ test('M7: clicking map marker briefly highlights the corresponding entry card',
|
||||
|
||||
// ── M8: Home map has GPX journey source on active trip ────────────────────────
|
||||
test('M8: home map has a journey source after GPX settles (active trip)', async ({ page }) => {
|
||||
// Requires travelling: true in user/config/site.yaml (set in Task 1).
|
||||
// Requires travelling: true in user/config/site.yaml.
|
||||
// Requires GPX files attached to the active trip (italy-2026-demo has 7).
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
@@ -125,39 +62,3 @@ test('M8: home map has a journey source after GPX settles (active trip)', async
|
||||
expect(hasSource, 'Home map has a journey or GPX source').toBe(true);
|
||||
expect(errors, 'No JS errors on home page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M9: Stories mini-map renders MapLibre canvas ──────────────────────────────
|
||||
test('M9: Stories mini-map renders MapLibre GL canvas without JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/stories');
|
||||
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
expect(errors, 'No JS errors on stories page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── M10: Stories mini-map has at least one story marker ──────────────────────
|
||||
test('M10: Stories mini-map has at least one story marker', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/stories');
|
||||
await expect(page.locator('#stories-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('#stories-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const markerCount = await page.locator('#stories-map .maplibregl-marker').count();
|
||||
expect(markerCount, 'At least one story marker').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── M11: Dailies attribution control starts collapsed ─────────────────────────
|
||||
test('M11: Dailies mini-map attribution starts collapsed (no open attribute)', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
await expect(page.locator('#feed-map canvas.maplibregl-canvas')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for markers (added in map.on('load')) to ensure the load callback has run,
|
||||
// which is also where removeAttribute('open') executes.
|
||||
await expect(page.locator('#feed-map .maplibregl-marker').first()).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('#feed-map .maplibregl-ctrl-attrib')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const hasOpen = await page.evaluate(function () {
|
||||
var attrib = document.querySelector('#feed-map .maplibregl-ctrl-attrib');
|
||||
return attrib ? attrib.hasAttribute('open') : null;
|
||||
});
|
||||
expect(hasOpen, 'Attribution is collapsed (no open attribute)').toBe(false);
|
||||
});
|
||||
|
||||
+10
-25
@@ -1,47 +1,32 @@
|
||||
// @ts-check
|
||||
// Tests: N1–N5 — page loads and navigation links
|
||||
// Tests: N1–N4 — page loads and navigation links.
|
||||
// The standalone /map, /stats and /dailies views were retired; their content now
|
||||
// lives on the trip page. N1/N2 are smoke tests for the two live landing surfaces.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
// ── N1: /trips/italy-2026-demo/dailies renders ───────────────────────────────
|
||||
test('N1: /trips/italy-2026-demo/dailies page loads with site header', async ({ page }) => {
|
||||
// ── N1: Trip page renders with site header ───────────────────────────────────
|
||||
test('N1: trip page loads with site header and title', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/dailies');
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
await expect(page).toHaveTitle(/Into the East/i);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N2: /trips/italy-2026-demo/map renders without JS errors ─────────────────
|
||||
test('N2: /trips/italy-2026-demo/map page loads without JS errors', async ({ page }) => {
|
||||
// ── N2: Home page renders without JS errors ──────────────────────────────────
|
||||
test('N2: home page loads with site header and no JS errors', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/map');
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N3: /trips/italy-2026-demo/stats renders ─────────────────────────────────
|
||||
test('N3: /trips/italy-2026-demo/stats page loads with site header', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.goto('/trips/italy-2026-demo/stats');
|
||||
await expect(page.locator('.site-header')).toBeVisible();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── N4: trip page has Journal filter button (replaced nav link) ───────────────
|
||||
// ── N4: trip page has Journal filter button (replaced the old sub-page nav link) ─
|
||||
test('N4: trip page filter bar has Journal button', async ({ page }) => {
|
||||
await page.goto('/trips/italy-2026-demo');
|
||||
await expect(page.locator('.trip-filter-btn[data-filter="journal"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// ── N5: "Map" nav link goes to /map ──────────────────────────────────────────
|
||||
test.skip('N5: Map nav link navigates to /map', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.click('nav a[href*="map"]');
|
||||
await expect(page).toHaveURL(/\/map/);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, DAILIES_URL } = require('../helpers');
|
||||
const { waitForFilePondUpload, cleanupEntry, findEntry, readEntryMd, TRACKER_DIR, ACTIVE_TRIP_URL } = require('../helpers');
|
||||
|
||||
const TEST_PHOTO = path.join(__dirname, '../../fixtures/test-photo.jpg');
|
||||
|
||||
@@ -16,7 +16,7 @@ test.afterAll(() => {
|
||||
});
|
||||
|
||||
// ── P1: Post without photo ─────────────────────────────────────────────────────
|
||||
test('P1: post text-only entry → created on disk and visible on /dailies', async ({ page }) => {
|
||||
test('P1: post text-only entry → created on disk and visible in trip feed', async ({ page }) => {
|
||||
const tag = `p1-${Date.now()}`;
|
||||
const title = `UI Test ${tag}`;
|
||||
|
||||
@@ -39,12 +39,12 @@ test('P1: post text-only entry → created on disk and visible on /dailies', asy
|
||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||
expect(photos.length, 'Text-only entry should have no photos').toBe(0);
|
||||
|
||||
await page.goto(DAILIES_URL);
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
});
|
||||
|
||||
// ── P2: Post with photo ────────────────────────────────────────────────────────
|
||||
test.skip('P2: post entry with photo → photo saved in entry folder and visible on /dailies', async ({ page }) => {
|
||||
test.skip('P2: post entry with photo → photo saved in entry folder and visible in trip feed', async ({ page }) => {
|
||||
const tag = `p2-${Date.now()}`;
|
||||
const title = `UI Test ${tag}`;
|
||||
|
||||
@@ -70,7 +70,7 @@ test.skip('P2: post entry with photo → photo saved in entry folder and visible
|
||||
const photos = fs.readdirSync(entryDir).filter(f => /\.(jpg|jpeg|png|webp|heic)$/i.test(f));
|
||||
expect(photos.length, 'At least one photo should be saved').toBeGreaterThan(0);
|
||||
|
||||
await page.goto(DAILIES_URL);
|
||||
await page.goto(ACTIVE_TRIP_URL);
|
||||
await expect(page.locator('body')).toContainText(tag);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
// Requires demo data: run `make demo-load` before this suite.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const STORIES_URL = '/trips/italy-2026-demo/stories';
|
||||
const TRIP_URL = '/trips/italy-2026-demo'; // stories now surface in the trip feed
|
||||
const STORY_GALLERY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // gallery-led: snap-gallery × 2, chapter-break, text-only pull-quote
|
||||
const STORY_SCROLLY = '/trips/italy-2026-demo/stories/sorano-rock-and-time'; // scrolly-led: scrolly-section × 2, chapter-break, pull-quote with image
|
||||
const DEMO_STORY = '/trips/italy-2026-demo/stories/val-dorcia-at-dawn'; // used for cross-trip hero sanity check
|
||||
|
||||
// ── S1: Stories listing shows cards ──────────────────────────────────────────
|
||||
test('S1: stories listing renders at least 3 story cards', async ({ page }) => {
|
||||
await page.goto(STORIES_URL);
|
||||
const cards = page.locator('.story-card');
|
||||
// ── S1: Trip feed shows story cards ──────────────────────────────────────────
|
||||
test('S1: trip feed renders at least 3 story cards', async ({ page }) => {
|
||||
await page.goto(TRIP_URL);
|
||||
const cards = page.locator('[data-type="story"]');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 5000 });
|
||||
const count = await cards.count();
|
||||
expect(count, 'At least 3 story cards').toBeGreaterThanOrEqual(3);
|
||||
@@ -55,16 +55,16 @@ test('S4: scrolly story page loads without JS errors', async ({ page }) => {
|
||||
expect(errors, 'No JS errors on story page').toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── S5: Back button returns to stories listing ────────────────────────────────
|
||||
test('S5: back button navigates back to stories listing', async ({ page }) => {
|
||||
// Establish history: listing → story → back
|
||||
await page.goto(STORIES_URL);
|
||||
await page.locator('.story-card').first().click();
|
||||
// ── S5: Back button returns to the originating trip page ─────────────────────
|
||||
test('S5: story back button navigates back to the trip page', async ({ page }) => {
|
||||
// Establish history: trip page → story → back
|
||||
await page.goto(TRIP_URL);
|
||||
await page.locator('[data-type="story"]').first().click();
|
||||
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||
await page.locator('.story-escape').click();
|
||||
// After history.back(), URL should be the stories listing
|
||||
await expect(page).toHaveURL(/italy-2026-demo\/stories$/);
|
||||
await expect(page.locator('.story-card').first()).toBeVisible();
|
||||
// story-escape runs history.back() when history exists → back to the trip page
|
||||
await expect(page).toHaveURL(/\/trips\/italy-2026-demo$/);
|
||||
await expect(page.locator('.journal-post').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// ── S6: Demo story — hero image sanity check ─────────────────────────────────
|
||||
|
||||
+1
-1
Submodule user updated: 31f3c6fb2f...924cfc18e2
Reference in New Issue
Block a user