Compare commits

...
6 Commits
Author SHA1 Message Date
m038andClaude Opus 4.8 f4ab73070b content: feature 3 entries as homepage highlights; publish Denmark 2026
- featured: true on Central Asia 2023, Italy 2025, and US/Canada/Mexico 2024 entries
  (surface in the between-trips homepage highlights grid)
- Denmark 2026 trip: published: true + Vestkystruten GPX track

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 22:28:18 +02:00
m038 543e8e3dce Merge feat/journal-post-form: trip publish/unpublish toggle + review fixes
Owner publish/unpublish toggle on the /trips listing (owner-gated API endpoint
mutating trip.md published state with APCu-aware cache invalidation, owner-
visible drafts, home fallback), plus the code-review follow-ups: shared scope-
guard helper, guarded cache-flush, shared api-utils (apiSend/apiErrorMsg with
opt-in timeout), template-asserted publish target, active-trip one-liner, and
the --color-draft-accent token.

Merges cleanly with the denmark-2026 cover content on main (disjoint files).
2026-07-08 17:47:27 +02:00
m038andClaude Opus 4.8 37b669424b fix(trips): remaining publish-toggle review findings
- resolveTripChild now asserts the resolved page uses the trip template, so a
  non-trip direct child of /trips could never be toggled through this endpoint
  (P3 adversarial).
- apiSend gains an optional timeoutMs (AbortController); trip-publish passes 10s
  so a hung toggle can't leave the switch stuck aria-busy. post-form omits it,
  keeping media uploads unbounded (P2 reliability).
- trips.html.twig reuses trip.html.twig's one-line active-trip slug match
  instead of a bespoke 3-branch OR (P2 maintainability).
- Draft-badge amber is now a --color-draft-accent token shared by the trip and
  journal badges instead of a twice-hardcoded #E0A458 literal (P3).

Rebuilt js/trip-publish.js and js/post/post-form.js (shared api-utils change).
PHP lint clean; trip-publish suite 10/10; post suite unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:31:40 +02:00
m038andClaude Opus 4.8 25cee53718 refactor(theme): share apiSend/apiErrorMsg between post-form and trip-publish
Address the P1 maintainability finding that trip-publish.js reimplemented
post-form.js's apiSend + login-expired error copy verbatim. Extract both into
js/src/api-utils.js and import from both entry points; esbuild inlines the
module into each bundle so there is no runtime coupling. Also drops the stale
data-trip-route reference from trip-publish.js's markup-contract comment.

Rebuilt js/trip-publish.js and js/post/post-form.js.

Verified: trip-publish suite 8/8; post suite unchanged (34 pass, same 6
owner-gate environmental fails as baseline — photo-editor specs that exercise
post-form's apiSend/apiErrorMsg all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:14:00 +02:00
m038andClaude Opus 4.8 5b4e31678e refactor(entry-actions): dedup scope guard + guard publish cache-flush
Address code-review findings on the trip publish/unpublish toggle:

- Extract EntryScopeGuard::resolveChildOf() so resolveActiveDailyChild and
  resolveTripChild share one find() + parent-route-assert body instead of two
  copies that could drift (P1 maintainability).
- Wrap setTripPublished's post-save cache invalidation in try/catch. save() has
  already persisted the published flag to disk, so a flush failure now logs a
  loud reconciliation warning (and still returns success + the audit line)
  rather than bubbling to a bare 500 that reads as "nothing happened"
  (P2 reliability / adversarial).

Behavior-preserving; PHP lint clean; trip-publish Playwright suite 8/8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:06:17 +02:00
m038andClaude Opus 4.8 064f0f0c52 feat(trips): owner publish/unpublish toggle on /trips listing
Add an owner-only publish switch to each /trips card. It POSTs to a new
entry-actions route that mutates trip.md `published` and invalidates the
page-tree cache so the listing, nav and home reflect the change on the
next load. Owner sees drafts (Draft badge); anon/non-owner unchanged.

- U1 EntryScopeGuard::resolveTripChild — resolve a slug to a direct child
  of /trips (drafts included, for republish)
- U2 POST /api/v1/trip/{slug}/publish (setTripPublished) — owner-gated
  write, strict is_bool body, header-mutation save, audit log
- U3 trip-publish-toggle partial + CSS (role=switch, Draft badge, visible
  failure toast, ≥44px target)
- U4 owner-aware /trips listing + card restructure (toggle overlays cover
  as a non-anchor sibling; works for coverless drafts)
- U5 home active-trip branch falls back when the active trip is unpublished
- U6 trip-publish.js (confirm/pending/optimistic/revert) + esbuild wiring

Cache note: an in-place frontmatter edit keeps the folder-check cache id,
and driver:auto uses APCu in web memory, so deleteAll()+invalidateCache()
is insufficient — the endpoint also calls apcu_clear_cache().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 14:50:05 +02:00
19 changed files with 42410 additions and 113 deletions
@@ -4,13 +4,14 @@ date: '2023-09-19 06:04'
template: entry template: entry
transport_mode: car transport_mode: car
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '37.755579' lat: '37.755579'
lng: '73.271513' lng: '73.271513'
location_city: 'Alichur' location_city: Alichur
location_country: 'Tajikistan' location_country: Tajikistan
weather_temp_c: '15' weather_temp_c: '15'
weather_desc: 'windy' weather_desc: windy
featured: true
--- ---
11-09 - In this village live just a few hundred people and there is a small military base. There is a school and a mosque, a very hard to find shop (we did not succeed) and some water wells. People drive old, rugged, Soviet UAZs, its the only affordable machine that can survive the rocky roads. 11-09 - In this village live just a few hundred people and there is a small military base. There is a school and a mosque, a very hard to find shop (we did not succeed) and some water wells. People drive old, rugged, Soviet UAZs, its the only affordable machine that can survive the rocky roads.
+2 -2
View File
@@ -4,9 +4,9 @@ template: trip
date: '2026-07-10' date: '2026-07-10'
date_start: '2026-07-10' date_start: '2026-07-10'
date_end: '2026-07-20' date_end: '2026-07-20'
cover_image: 'bikepacking_denmark.jpeg' cover_image: bikepacking_denmark.jpeg
tagline: '10 days along the West Coast to the top of Denmark' tagline: '10 days along the West Coast to the top of Denmark'
published: false published: true
autoconnect: intelligent_gpx autoconnect: intelligent_gpx
--- ---
@@ -3,13 +3,14 @@ title: '600km of Tuscany Begins with an Aperitif'
date: '2025-10-11 17:41' date: '2025-10-11 17:41'
template: entry template: entry
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '43.0183' lat: '43.0183'
lng: '10.6059' lng: '10.6059'
location_city: 'Venturina Terme' location_city: 'Venturina Terme'
location_country: 'Italy' location_country: Italy
weather_temp_c: '18' weather_temp_c: '18'
weather_desc: 'partly cloudy' weather_desc: 'partly cloudy'
featured: true
--- ---
A new adventure started today, The Grand Escape Tuscany. A ~600km no-race cycling tour through the beautiful lands of Tuscany, passing by Orbetello, Sorano, Pienza, Siena, Firenze and Volterra. A new adventure started today, The Grand Escape Tuscany. A ~600km no-race cycling tour through the beautiful lands of Tuscany, passing by Orbetello, Sorano, Pienza, Siena, Firenze and Volterra.
@@ -3,13 +3,14 @@ title: 'Windmills, Craft Beer and Twin Peaks at Dusk'
date: '2024-07-21 19:07' date: '2024-07-21 19:07'
template: entry template: entry
published: true published: true
hero_image: 'photo-1.jpg' hero_image: photo-1.jpg
lat: '37.765049' lat: '37.765049'
lng: '-122.508320' lng: '-122.508320'
location_city: 'San Francisco' location_city: 'San Francisco'
location_country: 'USA' location_country: USA
weather_temp_c: '18' weather_temp_c: '18'
weather_desc: 'partly cloudy' weather_desc: 'partly cloudy'
featured: true
--- ---
19.7 - Exploring the SF Sunset district... Started the day with a Large Mocha and Hazulnut Chocolate Croissant, because sugar free is not the theme for the upcoming days. Afterwards I visited the beautiful windmills in the Golden Gate park and had a nice afternoon drink at the Sunset Reservoir Brewing Company, great beers and a great playlist 🕺 19.7 - Exploring the SF Sunset district... Started the day with a Large Mocha and Hazulnut Chocolate Croissant, because sugar free is not the theme for the upcoming days. Afterwards I visited the beautiful windmills in the Golden Gate park and had a nice afternoon drink at the Sunset Reservoir Brewing Company, great beers and a great playlist 🕺
@@ -97,19 +97,17 @@ class EntryScopeGuard
} }
/** /**
* Resolve a folder segment to the page that is a DIRECT child of the active * Resolve a safe segment to the page that is a DIRECT child of $parentRoute,
* trip's dailies container, or null when the segment is unsafe, no active trip * or null when the segment is unsafe, the page does not exist, or its parent
* is set, the page does not exist, or its parent is not the active dailies. * is not exactly $parentRoute. Resolving via $pages->find() + a parent-route
* assertion (never raw path concatenation) is what closes the traversal hole;
* both public resolvers below share this one body so they cannot drift.
*/ */
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface private static function resolveChildOf(Grav $grav, string $parentRoute, string $segment): ?PageInterface
{ {
if (!self::isSafeSegment($segment)) { if (!self::isSafeSegment($segment)) {
return null; return null;
} }
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
$pages = $grav['pages']; $pages = $grav['pages'];
// In the API request context the page tree is lazily disabled; enable it // In the API request context the page tree is lazily disabled; enable it
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute). // so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
@@ -117,12 +115,47 @@ class EntryScopeGuard
if (method_exists($pages, 'enablePages')) { if (method_exists($pages, 'enablePages')) {
$pages->enablePages(); $pages->enablePages();
} }
$page = $pages->find($dailies . '/' . $segment); $page = $pages->find($parentRoute . '/' . $segment);
if ($page === null) { if ($page === null) {
return null; return null;
} }
$parent = $page->parent(); $parent = $page->parent();
if ($parent === null || $parent->route() !== $dailies) { if ($parent === null || $parent->route() !== $parentRoute) {
return null;
}
return $page;
}
/**
* Resolve a folder segment to the page that is a DIRECT child of the active
* trip's dailies container, or null when the segment is unsafe, no active trip
* is set, the page does not exist, or its parent is not the active dailies.
*/
public static function resolveActiveDailyChild(Grav $grav, string $segment): ?PageInterface
{
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
return self::resolveChildOf($grav, $dailies, $segment);
}
/**
* Resolve a slug to the trip page that is a DIRECT child of /trips, or null
* when the segment is unsafe, the page does not exist, or its parent is not
* /trips. The trip-scoped analogue of resolveActiveDailyChild, used by the
* publish/unpublish route (KTD4).
*
* Unlike the front-end listing collections, this does NOT filter on published
* state: find() must return drafts so the owner can republish an unpublished
* trip from the listing (R7).
*/
public static function resolveTripChild(Grav $grav, string $slug): ?PageInterface
{
$page = self::resolveChildOf($grav, '/trips', $slug);
// Only actual trip pages are publishable — a non-trip page ever added as a
// direct child of /trips must not be toggled through this endpoint.
if ($page === null || $page->template() !== 'trip') {
return null; return null;
} }
return $page; return $page;
@@ -141,4 +141,98 @@ class EntryActionsApiController extends AbstractApiController
return ApiResponse::noContent(); return ApiResponse::noContent();
} }
/**
* POST /api/v1/trip/{slug}/publish
*
* Body: { "published": true|false } — sets the trip's published state and
* persists it to trip.md frontmatter, then invalidates the page-tree cache so
* the /trips listing, nav and home render reflect the change on the next load.
* Owner-only, but (unlike deleteEntry) NOT active-trip scoped: the owner
* publishes/unpublishes ANY trip from the listing. 401 (anon), 403 (non-owner),
* 400 (bad slug / non-boolean body), 404 (slug is not a direct child of /trips).
*
* CSRF boundary: this is a session-cookie write with credentials. Its cross-
* origin protection is the required `Content-Type: application/json`, which
* (with the api plugin's CORS `origins: []`, i.e. same-origin only) forces a
* CORS preflight that a cross-site page cannot satisfy — so a forged request
* from another origin is rejected before it reaches this handler. The strict
* is_bool guard below backs that up (a form-encoded forgery decodes to no key).
*/
public function setTripPublished(ServerRequestInterface $request): ResponseInterface
{
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
$user = $this->getUser($request);
// Enforce the API-key scope cap (GHSA-x7hm) — see deleteEntry above.
$this->requirePermission($request, 'api.pages.write');
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
throw new ForbiddenException('Only the site owner can publish trips.');
}
$slug = $this->getRouteParam($request, 'slug');
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
throw new ApiException(400, 'Bad Request', 'Invalid trip slug.');
}
// Resolve via find() + parent-route assertion; drafts resolve too so the
// owner can republish an unpublished trip (R7, KTD4).
$page = EntryScopeGuard::resolveTripChild($this->grav, $slug);
if ($page === null) {
throw new NotFoundException('Trip not found.');
}
// Strict boolean only — never (bool)-cast (KTD2). A cast would coerce
// "false"/0/""/a missing key into a valid boolean and silently mis-set
// the flag, contradicting R5.
$body = $this->getRequestBody($request);
if (!is_array($body) || !array_key_exists('published', $body) || !is_bool($body['published'])) {
throw new ApiException(400, 'Bad Request', 'Body must include a boolean "published".');
}
$published = $body['published'];
// Persist by mutating the page HEADER before save() (KTD1): in Grav 2.0
// $page->published($v) sets only the in-memory property, while save()
// serializes from the header object and the flag is read one-way from the
// header at init. Mirror cache-on-save's header-mutation pattern.
$header = $page->header();
$header->published = $published;
$page->save();
// A published-flag change rewrites trip.md IN PLACE — the trip folder's
// structure is unchanged, so the pages-index cache id (md5 of the folder
// checksum under cache.check.method: folder) does NOT change (KTD3). This
// differs from deleteEntry, where the removed folder IS a structure change
// that bumps the id, so a fresh id misses cache and rebuilds. With the id
// unchanged, the stale index survives — and because the cache driver is
// APCu (driver: auto), it lives in the web server's shared memory, which a
// CLI `bin/grav clearcache` cannot reach at all. So: flush the runtime
// store (deleteAll → APCu flushAll) AND apcu_clear_cache() directly to be
// certain, clear the compiled files, and reset the in-memory tree so the
// next request rebuilds from disk and re-reads the published flag.
// save() above is already persisted to disk. If any invalidation call
// throws, do NOT let it bubble to a plain 500 (which reads to the owner as
// "nothing happened") and skip the audit line: the on-disk flag DID change.
// Log a loud reconciliation warning instead so an operator knows to clear
// cache manually, then still report success.
try {
$this->grav['cache']->deleteAll();
if (function_exists('apcu_clear_cache')) {
apcu_clear_cache();
}
$this->grav['pages']->reset();
$this->grav['cache']->clearCache('standard');
} catch (\Throwable $e) {
$this->grav['log']->error(sprintf(
'entry-actions: trip "%s" published=%s SAVED to disk but cache invalidation failed (%s) — clear cache manually',
$slug,
$published ? 'true' : 'false',
$e->getMessage()
));
}
// Audit trail: publish state is owner-only and changes site-wide
// visibility — record who flipped which trip to what.
$this->grav['log']->info(sprintf('entry-actions: owner "%s" set trip "%s" published=%s', $user->username, $slug, $published ? 'true' : 'false'));
return ApiResponse::noContent();
}
} }
+6
View File
@@ -11,6 +11,7 @@ use RocketTheme\Toolbox\Event\Event;
* Routes: * Routes:
* - DELETE /api/v1/entry/{slug} — delete a journal entry folder * - DELETE /api/v1/entry/{slug} — delete a journal entry folder
* - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos * - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
* - POST /api/v1/trip/{slug}/publish — publish/unpublish a trip
* *
* The stock DELETE /api/v1/pages<route> only checks write-permission (no trip * The stock DELETE /api/v1/pages<route> only checks write-permission (no trip
* scope, and any admin passes), which violates R6; and no stock endpoint can * scope, and any admin passes), which violates R6; and no stock endpoint can
@@ -65,5 +66,10 @@ class EntryActionsPlugin extends Plugin
// Nested-static-after-param, same shape as the DELETE above — it only // Nested-static-after-param, same shape as the DELETE above — it only
// registers once the API route-map cache is rebuilt (deploy must clear cache). // registers once the API route-map cache is rebuilt (deploy must clear cache).
$routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']); $routes->post('/entry/{slug}/photos/order', [EntryActions\EntryActionsApiController::class, 'reorderPhotos']);
// Publish/unpublish a trip from the /trips listing → mutate trip.md
// `published` and invalidate the page-tree index. Owner-only, any trip
// (not active-scoped). Same registration caveat as above: it only takes
// effect once the API route-map cache is rebuilt (deploy must clear cache).
$routes->post('/trip/{slug}/publish', [EntryActions\EntryActionsApiController::class, 'setTripPublished']);
} }
} }
+139 -2
View File
@@ -289,8 +289,8 @@ body::after {
font-weight: 700; font-weight: 700;
letter-spacing: 0.09em; letter-spacing: 0.09em;
text-transform: uppercase; text-transform: uppercase;
color: #E0A458; /* warm amber — draft/unpublished */ color: var(--color-draft-accent); /* warm amber — draft/unpublished */
border: 1px solid #E0A458; border: 1px solid var(--color-draft-accent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0.1em 0.5em; padding: 0.1em 0.5em;
line-height: 1.5; line-height: 1.5;
@@ -1289,6 +1289,143 @@ body::after {
.trip-card-dates { font-size: var(--text-sm); color: var(--color-ink-2); } .trip-card-dates { font-size: var(--text-sm); color: var(--color-ink-2); }
.trip-card-counts { font-size: var(--text-sm); color: var(--color-ink-muted); } .trip-card-counts { font-size: var(--text-sm); color: var(--color-ink-muted); }
/* ── Owner publish/unpublish toggle (U3) ─────────────────────────────────────── */
/* The card is wrapped in a position:relative container so this overlay can sit
top-right over the cover as a sibling of the navigating <a> (KTD6). The wrapper
also guarantees an anchor even for a coverless draft (a min-height header strip
on the card itself), so the toggle never collapses to nothing. */
.trip-card-wrap {
position: relative;
}
.trip-publish-overlay {
position: absolute;
top: var(--space-3);
right: var(--space-3);
z-index: 2; /* above the card <a> */
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Solid pill so both indicators stay legible over an arbitrary cover photo. */
.trip-draft-badge {
font-family: var(--font-ui);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--color-draft-accent); /* warm amber — matches .journal-draft-badge */
background: var(--color-canvas);
border: 1px solid var(--color-draft-accent);
border-radius: var(--radius-sm);
padding: 0.15em 0.5em;
line-height: 1.5;
white-space: nowrap;
box-shadow: var(--shadow-sm);
}
.trip-draft-badge[hidden] { display: none; }
/* The switch: a solid chip backing keeps the track/knob readable on any cover.
≥44px touch target via padding; the visible track is smaller and centred. */
.trip-publish-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 var(--space-2);
margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-canvas);
box-shadow: var(--shadow-sm);
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.trip-publish-track {
position: relative;
display: block;
width: 40px;
height: 22px;
border-radius: var(--radius-full);
background: var(--color-ink-muted); /* off = muted */
transition: background 0.15s ease;
}
.trip-publish-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-ink);
transition: transform 0.15s ease;
}
/* on = teal track, knob slid right */
.trip-publish-toggle[aria-checked="true"] .trip-publish-track {
background: var(--color-accent);
}
.trip-publish-toggle[aria-checked="true"] .trip-publish-knob {
transform: translateX(18px);
background: var(--color-accent-on);
}
/* Pending (R13): dimmed + wait cursor while a toggle is in flight. */
.trip-publish-toggle[aria-busy="true"] {
opacity: 0.55;
cursor: wait;
}
/* Keyboard focus ring that reads over a busy cover photo (white ring + dark halo). */
.trip-publish-toggle:focus-visible {
outline: 2px solid var(--color-accent-on);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.45);
}
/* Visible page-level failure toast (R15). Distinct from feed-actions.js's
sr-only #feed-actions-live region: the trip card has no inline message slot,
so a sighted owner needs a real, visible notice. trip-publish.js creates and
populates the element; this only styles it. */
.trip-publish-toast {
position: fixed;
top: var(--space-4);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
align-items: center;
gap: var(--space-3);
max-width: calc(100vw - var(--space-8));
padding: var(--space-3) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-ink);
background: var(--color-canvas);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.trip-publish-toast[hidden] { display: none; }
.trip-publish-toast__close {
flex-shrink: 0;
min-width: 32px;
min-height: 32px;
padding: 0;
font-size: var(--text-md);
line-height: 1;
color: var(--color-ink-muted);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.trip-publish-toast__close:hover { color: var(--color-ink); }
/* ── Trip page sidebar ───────────────────────────────────────────────────────── */ /* ── Trip page sidebar ───────────────────────────────────────────────────────── */
.trip-counts { .trip-counts {
+1
View File
@@ -14,6 +14,7 @@
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */ --color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */ --color-ink-inverse: #17171A; /* text on accent-coloured buttons */
--color-error: #c0392b; /* validation errors, form error status */ --color-error: #c0392b; /* validation errors, form error status */
--color-draft-accent: #E0A458; /* warm amber — draft/unpublished badges */
/* ── Glass overlays (paper colour at opacity, for story components) ── */ /* ── Glass overlays (paper colour at opacity, for story components) ── */
--color-paper-glass-low: color-mix(in srgb, var(--color-paper) 8%, transparent); --color-paper-glass-low: color-mix(in srgb, var(--color-paper) 8%, transparent);
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
/*
* api-utils.js — shared owner-facing API helpers.
*
* Imported by post-form.js (edit/photo mutations) and trip-publish.js (the
* publish toggle) so the "login expired" copy and the ok-status handling live
* in ONE place; esbuild inlines this module into each bundle at build time, so
* there is no runtime coupling between the two entry points.
*/
// Mutation fetch that RESOLVES on success and REJECTS with a status-bearing
// Error otherwise, so callers can tell an expired login (401/403) apart from a
// generic failure. `okStatuses` lists extra codes to accept as success (e.g.
// 204 no-content, or 404 already-gone for an idempotent DELETE). Cookies
// auto-included.
//
// `timeoutMs` is OPTIONAL: when set, a hung request is aborted after that many
// ms so the caller's pending state can't stick forever (the abort rejects like
// any network error → generic error copy). Omit it (post-form's media uploads)
// to keep the old unbounded behaviour — a large upload must not be timed out.
export function apiSend(url, opts, okStatuses, timeoutMs) {
var controller = timeoutMs ? new AbortController() : null;
var timer = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null;
var fetchOpts = Object.assign({ credentials: 'include' }, opts);
if (controller) fetchOpts.signal = controller.signal;
return fetch(url, fetchOpts).then(function (r) {
if (timer) clearTimeout(timer);
if (r.ok || (okStatuses && okStatuses.indexOf(r.status) !== -1)) return r;
var e = new Error('HTTP ' + r.status);
e.status = r.status;
throw e;
}, function (err) {
if (timer) clearTimeout(timer);
throw err; // abort (no .status) or network error → generic fallback copy
});
}
// Turn a failed apiSend/fetch into owner-facing copy. A 401/403 almost always
// means the login session lapsed — say so, because a plain "try again" wouldn't
// help until they sign back in; otherwise return the caller's fallback.
export function apiErrorMsg(err, fallback) {
var s = err && err.status;
return (s === 401 || s === 403)
? 'Your login session expired — sign in again, then retry.'
: fallback;
}
+4 -25
View File
@@ -11,6 +11,7 @@ import EasyMDE from 'easymde';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css'; import 'easymde/dist/easymde.min.css';
import './post-form.css'; import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */ /* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() { function initEditor() {
@@ -882,29 +883,7 @@ function initPhotoEditor(route) {
if (sortable) sortable.option('disabled', b); if (sortable) sortable.option('disabled', b);
} }
// Mutation fetch that RESOLVES on success and REJECTS with a status-bearing // apiSend / apiErrorMsg now live in ./api-utils.js (shared with trip-publish.js).
// Error otherwise, so callers can tell an expired login (401/403) apart from a
// generic failure. `okStatuses` lists extra codes to accept as success (e.g.
// 204 no-content, or 404 already-gone for an idempotent DELETE). Cookies
// auto-included. (Superseded the old boolean apiOk, which swallowed the code.)
function apiSend(url, opts, okStatuses) {
return fetch(url, Object.assign({ credentials: 'include' }, opts)).then(function (r) {
if (r.ok || (okStatuses && okStatuses.indexOf(r.status) !== -1)) return r;
var e = new Error('HTTP ' + r.status);
e.status = r.status;
throw e;
});
}
// Turn a failed apiSend/fetch into owner-facing copy. A 401/403 almost always
// means the login session lapsed mid-edit — say so, because a plain "try
// again" wouldn't help until they sign back in.
function editErrorMsg(err, fallback) {
var s = err && err.status;
return (s === 401 || s === 403)
? 'Your login session expired — sign in again, then retry.'
: fallback;
}
function mediaList() { function mediaList() {
return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } }) return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
@@ -990,7 +969,7 @@ function initPhotoEditor(route) {
function () { setStatus(''); render(next); } // saved; DOM already shows it function () { setStatus(''); render(next); } // saved; DOM already shows it
); );
}, function (err) { }, function (err) {
setStatus(editErrorMsg(err, 'Couldnt save the new order — reverted. Try again.'), true); setStatus(apiErrorMsg(err, 'Couldnt save the new order — reverted. Try again.'), true);
render(lastGood); // revert the SortableJS move to last-known-good render(lastGood); // revert the SortableJS move to last-known-good
}).then(function () { setBusy(false); }); }).then(function () { setBusy(false); });
} }
@@ -1038,7 +1017,7 @@ function initPhotoEditor(route) {
); );
}, function (err) { }, function (err) {
// The DELETE request itself failed — nothing changed on disk. // The DELETE request itself failed — nothing changed on disk.
setStatus(editErrorMsg(err, 'Couldnt delete that photo. Try again.'), true); setStatus(apiErrorMsg(err, 'Couldnt delete that photo. Try again.'), true);
render(lastGood); render(lastGood);
}) })
.then(function () { setBusy(false); }); .then(function () { setBusy(false); });
+131
View File
@@ -0,0 +1,131 @@
/*
* trip-publish.js (U6) — owner publish/unpublish toggle for the /trips listing.
*
* Loaded only for the owner (trips.html.twig gates the asset). Each listing card
* carries a switch (partials/trip-publish-toggle.html.twig) overlaid on the cover
* as a sibling of the navigating <a>, so toggling never follows the card link.
*
* Flow: click/Enter/Space on the switch → (if unpublishing the ACTIVE trip)
* window.confirm → lock the switch (aria-busy) → POST /api/v1/trip/<slug>/publish
* {published} (session cookie) → on success flip the switch + Draft badge in place
* (no reload); on failure re-enable and surface a VISIBLE page-level toast.
*
* Markup contract (data-* on button.trip-publish-toggle):
* data-trip-slug, data-published ("true"|"false"),
* data-active ("true" when this is site.active_trip); aria-checked mirrors
* data-published. The sibling .trip-draft-badge is shown iff not published.
*/
import { apiSend, apiErrorMsg } from './api-utils.js';
var TOAST_TIMEOUT_MS = 5000;
var PUBLISH_TIMEOUT_MS = 10000; // abort a hung toggle so the switch never sticks (R13)
var toastTimer = null;
// One visible, page-level polite toast (R15). Modelled on the feed-actions.js
// live region but intentionally NOT sr-only: the trip card has no inline message
// slot, so a sighted owner must actually see the failure. Replaces (never queues)
// the message, auto-dismisses, and carries a manual close control.
function toastEl() {
var el = document.getElementById('trip-publish-live');
if (!el) {
el = document.createElement('div');
el.id = 'trip-publish-live';
el.className = 'trip-publish-toast';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.hidden = true;
var msg = document.createElement('span');
msg.className = 'trip-publish-toast__msg';
var close = document.createElement('button');
close.type = 'button';
close.className = 'trip-publish-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×'; // ×
close.addEventListener('click', hideToast);
el.appendChild(msg);
el.appendChild(close);
document.body.appendChild(el);
}
return el;
}
function showToast(message) {
var el = toastEl();
el.querySelector('.trip-publish-toast__msg').textContent = message;
el.hidden = false;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(hideToast, TOAST_TIMEOUT_MS);
}
function hideToast() {
var el = document.getElementById('trip-publish-live');
if (el) el.hidden = true;
if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
}
function setPublishedUI(btn, published) {
btn.setAttribute('aria-checked', published ? 'true' : 'false');
btn.setAttribute('data-published', published ? 'true' : 'false');
var overlay = btn.closest('.trip-publish-overlay');
var badge = overlay ? overlay.querySelector('.trip-draft-badge') : null;
if (badge) badge.hidden = published;
}
function setPending(btn, pending) {
if (pending) {
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
} else {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
function onToggle(btn) {
if (btn.getAttribute('aria-busy') === 'true') return; // already in flight (R13)
var current = btn.getAttribute('data-published') === 'true';
var next = !current;
// R12: unpublishing the ACTIVE trip removes it from the home page — confirm
// first; cancelling leaves it published (no request, no UI change).
if (!next && btn.getAttribute('data-active') === 'true') {
if (!window.confirm('This is your active trip — unpublishing it also removes it from the home page. Unpublish anyway?')) {
return;
}
}
var slug = btn.getAttribute('data-trip-slug');
setPending(btn, true);
apiSend('/api/v1/trip/' + encodeURIComponent(slug) + '/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ published: next })
}, null, PUBLISH_TIMEOUT_MS).then(function () {
// Success: flip the switch + Draft badge in place, no reload (R14).
setPublishedUI(btn, next);
setPending(btn, false);
}).catch(function (err) {
// Failure: the UI never flipped, so revert is just re-enable (R15).
setPending(btn, false);
showToast(apiErrorMsg(err, "Couldn't update — try again."));
});
}
function initTripPublish() {
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.trip-publish-toggle') : null;
if (!btn) return;
e.preventDefault();
onToggle(btn);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTripPublish);
} else {
initTripPublish();
}
+1
View File
@@ -0,0 +1 @@
(()=>{function c(t,e,i,r){var n=r?new AbortController:null,s=n?setTimeout(function(){n.abort()},r):null,l=Object.assign({credentials:"include"},e);return n&&(l.signal=n.signal),fetch(t,l).then(function(a){if(s&&clearTimeout(s),a.ok||i&&i.indexOf(a.status)!==-1)return a;var d=new Error("HTTP "+a.status);throw d.status=a.status,d},function(a){throw s&&clearTimeout(s),a})}function p(t,e){var i=t&&t.status;return i===401||i===403?"Your login session expired \u2014 sign in again, then retry.":e}var m=5e3,v=1e4,u=null;function g(){var t=document.getElementById("trip-publish-live");if(!t){t=document.createElement("div"),t.id="trip-publish-live",t.className="trip-publish-toast",t.setAttribute("role","status"),t.setAttribute("aria-live","polite"),t.hidden=!0;var e=document.createElement("span");e.className="trip-publish-toast__msg";var i=document.createElement("button");i.type="button",i.className="trip-publish-toast__close",i.setAttribute("aria-label","Dismiss"),i.textContent="\xD7",i.addEventListener("click",h),t.appendChild(e),t.appendChild(i),document.body.appendChild(t)}return t}function b(t){var e=g();e.querySelector(".trip-publish-toast__msg").textContent=t,e.hidden=!1,u&&clearTimeout(u),u=setTimeout(h,m)}function h(){var t=document.getElementById("trip-publish-live");t&&(t.hidden=!0),u&&(clearTimeout(u),u=null)}function T(t,e){t.setAttribute("aria-checked",e?"true":"false"),t.setAttribute("data-published",e?"true":"false");var i=t.closest(".trip-publish-overlay"),r=i?i.querySelector(".trip-draft-badge"):null;r&&(r.hidden=e)}function o(t,e){e?(t.setAttribute("aria-busy","true"),t.disabled=!0):(t.removeAttribute("aria-busy"),t.disabled=!1)}function y(t){if(t.getAttribute("aria-busy")!=="true"){var e=t.getAttribute("data-published")==="true",i=!e;if(!(!i&&t.getAttribute("data-active")==="true"&&!window.confirm("This is your active trip \u2014 unpublishing it also removes it from the home page. Unpublish anyway?"))){var r=t.getAttribute("data-trip-slug");o(t,!0),c("/api/v1/trip/"+encodeURIComponent(r)+"/publish",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({published:i})},null,v).then(function(){T(t,i),o(t,!1)}).catch(function(n){o(t,!1),b(p(n,"Couldn't update \u2014 try again."))})}}}function f(){document.addEventListener("click",function(t){var e=t.target.closest?t.target.closest(".trip-publish-toggle"):null;e&&(t.preventDefault(),y(e))})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",f):f();})();
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }" "build": "node scripts/gen-weather-icons.js && esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && esbuild js/src/feed-actions.js --bundle --minify --format=iife --outfile=js/feed-actions.js && esbuild js/src/trip-publish.js --bundle --minify --format=iife --outfile=js/trip-publish.js && rm -rf js/post && esbuild js/src/post-form.js --bundle --minify --format=esm --splitting --outdir=js/post && mkdir -p css-compiled fonts && { mv js/main.css css-compiled/main.css 2>/dev/null || true; } && { mv js/map.css css-compiled/map.css 2>/dev/null || true; } && { mv js/post/post-form.css css-compiled/post-form.css 2>/dev/null || true; }"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/dm-sans": "latest", "@fontsource-variable/dm-sans": "latest",
+4 -1
View File
@@ -9,7 +9,10 @@
{% set trip_route = config.site.active_trip %} {% set trip_route = config.site.active_trip %}
{% set trip = grav.pages.find(trip_route) %} {% set trip = grav.pages.find(trip_route) %}
{% if config.site.travelling %} {# An unpublished active trip falls through to the between-trips state (R16,
KTD7): trip is resolved above and `.published` reads the trip.md flag. This is
the whole home fallback — site.active_trip is not touched. #}
{% if config.site.travelling and trip and trip.published %}
{# ══════════════════════════════════════════════════════════ ACTIVE TRIP MODE #} {# ══════════════════════════════════════════════════════════ ACTIVE TRIP MODE #}
{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %} {% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
@@ -0,0 +1,27 @@
{#
Owner-only publish/unpublish switch for a /trips listing card (U3, R10/R11).
Rendered by trips.html.twig as a SIBLING of the navigating card <a> (never a
child of it), overlaid top-right on the cover, so a click on it toggles publish
state rather than following the card link (KTD6). trip-publish.js binds the
button by `.trip-publish-toggle` and drives it from the data-* below.
Params:
trip Page — the trip whose published state this controls
is_active bool — true when this trip is site.active_trip; drives the
"home page loses it" confirm in the JS (R12)
#}
{% set is_active = is_active ?? false %}
<div class="trip-publish-overlay">
<span class="trip-draft-badge"{% if trip.published %} hidden{% endif %}>Draft</span>
<button type="button"
class="trip-publish-toggle"
role="switch"
aria-checked="{{ trip.published ? 'true' : 'false' }}"
aria-label="Published — {{ trip.title }}"
data-trip-slug="{{ trip.slug }}"
data-published="{{ trip.published ? 'true' : 'false' }}"
data-active="{{ is_active ? 'true' : 'false' }}">
<span class="trip-publish-track" aria-hidden="true"><span class="trip-publish-knob"></span></span>
</button>
</div>
+20 -1
View File
@@ -3,7 +3,13 @@
{% block content %} {% block content %}
{% import 'macros/cover.html.twig' as cover %} {% import 'macros/cover.html.twig' as cover %}
<h1 class="trips-heading">Past Trips</h1> <h1 class="trips-heading">Past Trips</h1>
{% set trips = page.children.published()|sort((a, b) => a.date < b.date ? 1 : -1) %} {# Owner gate (R1): broader than trip.html.twig's owner_can_edit — publishing
works on ANY trip, not just the active one, so it's just owner identity. #}
{% set is_owner = grav.user.authenticated
and grav.user.username == grav.config.site.owner_username %}
{# Owner sees drafts (R9); everyone else published only. #}
{% set trips = (is_owner ? page.children : page.children.published())|sort((a, b) => a.date < b.date ? 1 : -1) %}
{% if is_owner %}{% do assets.addJs('theme://js/trip-publish.js', {group: 'bottom'}) %}{% endif %}
{% if trips|length == 0 %} {% if trips|length == 0 %}
<p class="feed-empty">No trips yet.</p> <p class="feed-empty">No trips yet.</p>
{% else %} {% else %}
@@ -13,6 +19,17 @@
{% set stories_page = grav.pages.find(trip.route ~ '/stories') %} {% set stories_page = grav.pages.find(trip.route ~ '/stories') %}
{% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %} {% set journal_count = dailies_page ? dailies_page.children.published()|length : 0 %}
{% set story_count = stories_page ? stories_page.children.published()|length : 0 %} {% set story_count = stories_page ? stories_page.children.published()|length : 0 %}
{# Active-trip match: site.active_trip may be a full route (/trips/x) or a
bare slug — normalise to its last segment and compare to the trip slug,
the same one-liner trip.html.twig uses, so the R12 confirm never silently
drops. #}
{% set active_trip_slug = (grav.config.site.active_trip|default(''))|split('/')|last %}
{% set is_active = active_trip_slug != '' and trip.slug == active_trip_slug %}
{# The wrapper is a positioned container so the owner toggle can overlay the
cover as a SIBLING of the navigating <a> (KTD6) — and it exists even for a
coverless draft, giving the toggle an anchor whether or not the cover macro
emits an image. #}
<div class="trip-card-wrap">
<a class="trip-card" href="{{ trip.url }}"> <a class="trip-card" href="{{ trip.url }}">
{{ cover.render(trip, trip.title, 720, 240, 'trip-card-cover', '(max-width: 700px) 100vw, 360px') }} {{ cover.render(trip, trip.title, 720, 240, 'trip-card-cover', '(max-width: 700px) 100vw, 360px') }}
<div class="trip-card-title">{{ trip.title }}</div> <div class="trip-card-title">{{ trip.title }}</div>
@@ -32,6 +49,8 @@
</span> </span>
</div> </div>
</a> </a>
{% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}