Compare commits
1
Commits
55da834396
...
064f0f0c52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064f0f0c52 |
@@ -127,4 +127,36 @@ class EntryScopeGuard
|
|||||||
}
|
}
|
||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
if (!self::isSafeSegment($slug)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pages = $grav['pages'];
|
||||||
|
// In the API request context the page tree is lazily disabled; enable it
|
||||||
|
// so find() can resolve (mirrors resolveActiveDailyChild). Idempotent.
|
||||||
|
if (method_exists($pages, 'enablePages')) {
|
||||||
|
$pages->enablePages();
|
||||||
|
}
|
||||||
|
$page = $pages->find('/trips/' . $slug);
|
||||||
|
if ($page === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$parent = $page->parent();
|
||||||
|
if ($parent === null || $parent->route() !== '/trips') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,4 +141,84 @@ 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.
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
if (function_exists('apcu_clear_cache')) {
|
||||||
|
apcu_clear_cache();
|
||||||
|
}
|
||||||
|
$this->grav['pages']->reset();
|
||||||
|
$this->grav['cache']->clearCache('standard');
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: #E0A458; /* warm amber — matches .journal-draft-badge */
|
||||||
|
background: var(--color-canvas);
|
||||||
|
border: 1px solid #E0A458;
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* 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-trip-route, 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var TOAST_TIMEOUT_MS = 5000;
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 401/403 almost always means the login session lapsed — say so; anything else
|
||||||
|
// is a generic retry (mirrors post-form.js editErrorMsg copy).
|
||||||
|
function errorMessage(status) {
|
||||||
|
return (status === 401 || status === 403)
|
||||||
|
? 'Your login session expired — sign in again, then retry.'
|
||||||
|
: "Couldn't update — try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation fetch that resolves on success and rejects with a status-bearing Error
|
||||||
|
// otherwise (modelled on post-form.js apiSend), so the caller can tell an expired
|
||||||
|
// login apart from a generic failure. Cookies auto-included.
|
||||||
|
function apiSend(url, opts) {
|
||||||
|
return fetch(url, Object.assign({ credentials: 'include' }, opts)).then(function (r) {
|
||||||
|
if (r.ok) return r; // 204 is within r.ok
|
||||||
|
var e = new Error('HTTP ' + r.status);
|
||||||
|
e.status = r.status;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}).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(errorMessage(err && err.status));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(()=>{var o=5e3,r=null;function d(){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",l),t.appendChild(e),t.appendChild(i),document.body.appendChild(t)}return t}function c(t){var e=d();e.querySelector(".trip-publish-toast__msg").textContent=t,e.hidden=!1,r&&clearTimeout(r),r=setTimeout(l,o)}function l(){var t=document.getElementById("trip-publish-live");t&&(t.hidden=!0),r&&(clearTimeout(r),r=null)}function p(t){return t===401||t===403?"Your login session expired \u2014 sign in again, then retry.":"Couldn't update \u2014 try again."}function f(t,e){return fetch(t,Object.assign({credentials:"include"},e)).then(function(i){if(i.ok)return i;var a=new Error("HTTP "+i.status);throw a.status=i.status,a})}function h(t,e){t.setAttribute("aria-checked",e?"true":"false"),t.setAttribute("data-published",e?"true":"false");var i=t.closest(".trip-publish-overlay"),a=i?i.querySelector(".trip-draft-badge"):null;a&&(a.hidden=e)}function n(t,e){e?(t.setAttribute("aria-busy","true"),t.disabled=!0):(t.removeAttribute("aria-busy"),t.disabled=!1)}function m(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 a=t.getAttribute("data-trip-slug");n(t,!0),f("/api/v1/trip/"+encodeURIComponent(a)+"/publish",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({published:i})}).then(function(){h(t,i),n(t,!1)}).catch(function(u){n(t,!1),c(p(u&&u.status))})}}}function s(){document.addEventListener("click",function(t){var e=t.target.closest?t.target.closest(".trip-publish-toggle"):null;e&&(t.preventDefault(),m(e))})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",s):s();})();
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,18 @@
|
|||||||
{% 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 %}
|
||||||
|
{# Robust active-trip match: site.active_trip may be a full route
|
||||||
|
(/trips/x) or a bare slug — normalise both sides before comparing so the
|
||||||
|
R12 confirm never silently drops (both forms exist in helpers.js /
|
||||||
|
cache-on-save). #}
|
||||||
|
{% set active = (config.site.active_trip ?? '')|trim('/') %}
|
||||||
|
{% set trip_route = trip.route|trim('/') %}
|
||||||
|
{% set is_active = active != '' and (active == trip_route or active == ('trips/' ~ trip.slug) or 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 +50,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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user