Compare commits

...
5 Commits
Author SHA1 Message Date
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
14 changed files with 579 additions and 103 deletions
@@ -97,19 +97,17 @@ class EntryScopeGuard
}
/**
* 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.
* Resolve a safe segment to the page that is a DIRECT child of $parentRoute,
* or null when the segment is unsafe, the page does not exist, or its parent
* 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)) {
return null;
}
$dailies = self::dailiesRoute($grav);
if ($dailies === null) {
return null;
}
$pages = $grav['pages'];
// In the API request context the page tree is lazily disabled; enable it
// so find() can resolve (mirrors the api plugin's own resolvePageByRoute).
@@ -117,12 +115,47 @@ class EntryScopeGuard
if (method_exists($pages, 'enablePages')) {
$pages->enablePages();
}
$page = $pages->find($dailies . '/' . $segment);
$page = $pages->find($parentRoute . '/' . $segment);
if ($page === null) {
return null;
}
$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 $page;
@@ -141,4 +141,98 @@ class EntryActionsApiController extends AbstractApiController
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:
* - 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/trip/{slug}/publish — publish/unpublish a 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
@@ -65,5 +66,10 @@ class EntryActionsPlugin extends Plugin
// 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).
$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;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #E0A458; /* warm amber — draft/unpublished */
border: 1px solid #E0A458;
color: var(--color-draft-accent); /* warm amber — draft/unpublished */
border: 1px solid var(--color-draft-accent);
border-radius: var(--radius-sm);
padding: 0.1em 0.5em;
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-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-counts {
+1
View File
@@ -14,6 +14,7 @@
--color-surface-raised: #2A2720; /* elevated surfaces: tooltips, hover */
--color-ink-inverse: #17171A; /* text on accent-coloured buttons */
--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) ── */
--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 'easymde/dist/easymde.min.css';
import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
@@ -882,29 +883,7 @@ function initPhotoEditor(route) {
if (sortable) sortable.option('disabled', b);
}
// 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. (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;
}
// apiSend / apiErrorMsg now live in ./api-utils.js (shared with trip-publish.js).
function mediaList() {
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 (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
}).then(function () { setBusy(false); });
}
@@ -1038,7 +1017,7 @@ function initPhotoEditor(route) {
);
}, function (err) {
// 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);
})
.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,
"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": {
"@fontsource-variable/dm-sans": "latest",
+4 -1
View File
@@ -9,7 +9,10 @@
{% set trip_route = config.site.active_trip %}
{% 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 #}
{% 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 %}
{% import 'macros/cover.html.twig' as cover %}
<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 %}
<p class="feed-empty">No trips yet.</p>
{% else %}
@@ -13,6 +19,17 @@
{% set stories_page = grav.pages.find(trip.route ~ '/stories') %}
{% set journal_count = dailies_page ? dailies_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 }}">
{{ cover.render(trip, trip.title, 720, 240, 'trip-card-cover', '(max-width: 700px) 100vw, 360px') }}
<div class="trip-card-title">{{ trip.title }}</div>
@@ -32,6 +49,8 @@
</span>
</div>
</a>
{% if is_owner %}{% include 'partials/trip-publish-toggle.html.twig' with { trip: trip, is_active: is_active } only %}{% endif %}
</div>
{% endfor %}
</div>
{% endif %}