Compare commits
12
Commits
fff5358ce2
...
16a570ca3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a570ca3b | ||
|
|
93370033ea | ||
|
|
5752877e66 | ||
|
|
6abd08af40 | ||
|
|
2840018372 | ||
|
|
b8daea217d | ||
|
|
265a06a972 | ||
|
|
c4228e9027 | ||
|
|
a2d4f81bfe | ||
|
|
4e91492f65 | ||
|
|
4aa9ae9b23 | ||
|
|
aa5d34717e |
@@ -2,10 +2,14 @@
|
|||||||
!/plugins/.gitkeep
|
!/plugins/.gitkeep
|
||||||
!/plugins/cache-on-save/
|
!/plugins/cache-on-save/
|
||||||
!/plugins/story-blocks/
|
!/plugins/story-blocks/
|
||||||
|
!/plugins/entry-actions/
|
||||||
/data/
|
/data/
|
||||||
|
/accounts/testrunner.yaml
|
||||||
/pages/01.trips/italy-2026-demo/
|
/pages/01.trips/italy-2026-demo/
|
||||||
/pages/02.post/*ui-test*/
|
/pages/02.post/*ui-test*/
|
||||||
/config/plugins/git-sync.yaml
|
/config/plugins/git-sync.yaml
|
||||||
/config/plugins/api-private.php
|
/config/plugins/api-private.php
|
||||||
/config/security.yaml
|
/config/security.yaml
|
||||||
|
/config/security-private.php
|
||||||
|
/config/versions.yaml
|
||||||
/themes/intotheeast/node_modules/
|
/themes/intotheeast/node_modules/
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ admin_next:
|
|||||||
preferences:
|
preferences:
|
||||||
pluginsViewMode: cards
|
pluginsViewMode: cards
|
||||||
colorMode: dark
|
colorMode: dark
|
||||||
accentHue: 271
|
accentHue: 38
|
||||||
accentSaturation: 91
|
accentSaturation: 92
|
||||||
fontFamily: inter
|
fontFamily: public-sans
|
||||||
pagesViewMode: tree
|
pagesViewMode: tree
|
||||||
|
fontSize: large
|
||||||
|
editorMode: normal
|
||||||
content_editor: ''
|
content_editor: ''
|
||||||
groups: { }
|
groups: { }
|
||||||
access:
|
access:
|
||||||
|
|||||||
@@ -57,4 +57,3 @@ popularity:
|
|||||||
ignore:
|
ignore:
|
||||||
- '/test*'
|
- '/test*'
|
||||||
- /modular
|
- /modular
|
||||||
salt: 37517ad926e4f900863af7f1b58718cdccc34f7265dedd09d33b6f820046c510
|
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ metadata:
|
|||||||
description: 'A travel blog by Mischa'
|
description: 'A travel blog by Mischa'
|
||||||
active_trip: /trips/us-canada-mex-2024
|
active_trip: /trips/us-canada-mex-2024
|
||||||
travelling: false
|
travelling: false
|
||||||
|
# Single source of truth for the site owner's account username. Backs both the
|
||||||
|
# front-end edit/delete UI gate and the server-side scope guards (KTD8): only
|
||||||
|
# this user (not merely any authenticated/super-admin account) may edit, delete,
|
||||||
|
# or see drafts on the active trip's feed.
|
||||||
|
owner_username: mischa
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
core:
|
|
||||||
grav:
|
|
||||||
version: 1.7.53
|
|
||||||
schema: 1.7.53_2026-06-09_0
|
|
||||||
history:
|
|
||||||
- { version: 1.7.53, date: '2026-06-17 22:27:48' }
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Auto-generated private secret. Do NOT commit to version control.
|
||||||
|
// Used to sign and verify API JWTs. Regenerate by deleting this file; the
|
||||||
|
// next request will write a new value (invalidating all existing tokens).
|
||||||
|
|
||||||
|
return 'b24a28b949941d16ef856a957f051831184a82ec15229dcd630183518bd0bca2';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
enabled: true
|
||||||
|
folders:
|
||||||
|
- pages
|
||||||
|
- config
|
||||||
|
- themes
|
||||||
|
local_repository: ''
|
||||||
|
repository: 'https://git.gorinskat.nl/m038/intotheeast-com-content.git'
|
||||||
|
no_user: false
|
||||||
|
user: into-the-east-grav-sync
|
||||||
|
webhook: /_git-sync
|
||||||
|
webhook_enabled: 1
|
||||||
|
webhook_secret: '/RX{_*lU]dF&y_Pd!h+EZWjMP1{Np>$z>\%+G''Jo'
|
||||||
|
branch: main
|
||||||
|
logging: false
|
||||||
|
password: gitsync-def502005b5427cc6afd28ea66682788711e72b3e31bcb6bd302db4232e48a8783900ed76bf2cf891b3e4545dd925a5f5276fa683bf033797b5c6074f7ef0c520c455e34e27392be46615378e779cba61fe4a7f710fcbf157fb9709142794bce7d785e3738de84dd6887e0287f2923e96ae12ce446e4e61285a3ce48
|
||||||
|
sync:
|
||||||
|
direction: both
|
||||||
|
on_save: true
|
||||||
|
on_delete: true
|
||||||
|
on_media: true
|
||||||
|
cron_enable: false
|
||||||
|
cron_at: '0 12,23 * * *'
|
||||||
|
remote:
|
||||||
|
name: origin
|
||||||
|
branch: main
|
||||||
|
git:
|
||||||
|
author: gituser
|
||||||
|
message: '(Grav GitSync) Automatic Commit'
|
||||||
|
name: GitSync
|
||||||
|
email: mischa@gorinskat.nl
|
||||||
|
bin: git
|
||||||
|
ignore: ''
|
||||||
|
private_key: ''
|
||||||
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
// Used for CSRF nonce signing and admin rate-limit hashing. Regenerate by
|
// Used for CSRF nonce signing and admin rate-limit hashing. Regenerate by
|
||||||
// deleting this file; the next request will write a new value.
|
// deleting this file; the next request will write a new value.
|
||||||
|
|
||||||
return 'lsUHWFkCwvGZrL';
|
return '7b82d740b30aafea06c98ee539ff5c2f1800cbd60f07754d131db7d80e687068';
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Production-only Grav config overrides.
|
||||||
|
#
|
||||||
|
# Deep-merged OVER the committed user/config/system.yaml via Grav's
|
||||||
|
# per-environment config mechanism: on the server this file is deployed to
|
||||||
|
# <webroot>/user/env/<hostname>/config/system.yaml
|
||||||
|
# and Grav's `environment://config` stream (keyed on the request hostname)
|
||||||
|
# layers it on top of `user://config`.
|
||||||
|
#
|
||||||
|
# These values are deliberately NOT in the committed system.yaml because they
|
||||||
|
# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false
|
||||||
|
# so theme edits take effect immediately). Prod is the only place they apply.
|
||||||
|
#
|
||||||
|
# Deploy with: make remote-apply-env-prod
|
||||||
|
# The user/env/ tree is outside the content repo's tracked folders, so it is
|
||||||
|
# NOT restored by content-push / git-sync / remote-fetch-content — re-run the
|
||||||
|
# target above after any fresh install.
|
||||||
|
twig:
|
||||||
|
cache: true
|
||||||
|
debug: false
|
||||||
|
auto_reload: false
|
||||||
|
|
||||||
|
# Compression / connection handling.
|
||||||
|
#
|
||||||
|
# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown
|
||||||
|
# "early connection close" falls back to emitting `Content-Encoding: identity`
|
||||||
|
# to ask the webserver not to compress. But Apache's mod_deflate compresses
|
||||||
|
# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers —
|
||||||
|
# the browser can't decode the body and renders raw gzip bytes (a garbage
|
||||||
|
# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it
|
||||||
|
# does not help. The real fix is to disable the early-close path, so Grav never
|
||||||
|
# emits the bogus header and mod_deflate compresses cleanly (single header).
|
||||||
|
debugger:
|
||||||
|
shutdown:
|
||||||
|
close_connection: false
|
||||||
|
# Let the webserver own gzip; Grav does not compress or double-label.
|
||||||
|
cache:
|
||||||
|
gzip: false
|
||||||
|
allow_webserver_gzip: false
|
||||||
@@ -6,13 +6,21 @@ access:
|
|||||||
|
|
||||||
# Parent (write target) is injected server-side from site.active_trip by the
|
# Parent (write target) is injected server-side from site.active_trip by the
|
||||||
# cache-on-save plugin (onFormValidationProcessed) — no manual sync needed.
|
# cache-on-save plugin (onFormValidationProcessed) — no manual sync needed.
|
||||||
|
#
|
||||||
|
# overwrite_mode is toggled per submit by cache-on-save (KTD1, Alt B): edit when
|
||||||
|
# the hidden edit_path field is filled (write back in place), false when empty
|
||||||
|
# (create a fresh dated folder via slug_field). The static value here is the
|
||||||
|
# create-safe fallback for if cache-on-save doesn't run — add-page-by-form stays
|
||||||
|
# stock (no fork), so a static `edit` would break create (empty edit_path -> '.').
|
||||||
pageconfig:
|
pageconfig:
|
||||||
slug_field: 'date,title'
|
slug_field: 'date,title'
|
||||||
overwrite_mode: false
|
overwrite_mode: false
|
||||||
|
|
||||||
|
# published is NOT a static pagefrontmatter default anymore — it is an
|
||||||
|
# authoritative form field (below) so every submit (create AND edit) writes the
|
||||||
|
# owner's chosen publish state. Only `template` stays static here.
|
||||||
pagefrontmatter:
|
pagefrontmatter:
|
||||||
template: entry
|
template: entry
|
||||||
published: true
|
|
||||||
|
|
||||||
form:
|
form:
|
||||||
name: new-entry
|
name: new-entry
|
||||||
@@ -129,11 +137,36 @@ form:
|
|||||||
'car': '🚗 Car'
|
'car': '🚗 Car'
|
||||||
'plane': '✈️ Plane'
|
'plane': '✈️ Plane'
|
||||||
|
|
||||||
|
# Hidden edit target. Empty on create (add-page-by-form falls through to
|
||||||
|
# slug_field and writes a fresh dated folder); on edit, post-form.js sets
|
||||||
|
# it to the entry's path so overwrite_mode:edit writes back in place.
|
||||||
|
-
|
||||||
|
name: edit_path
|
||||||
|
type: hidden
|
||||||
|
default: ''
|
||||||
|
|
||||||
# Advanced fields — collapsed behind "More options" (see U5).
|
# Advanced fields — collapsed behind "More options" (see U5).
|
||||||
# No hero_image field: journal entries render their hero from the first
|
# No hero_image field: journal entries render their hero from the first
|
||||||
# uploaded photo (entry-journal.html.twig uses entry.media.images|first),
|
# uploaded photo (entry-journal.html.twig uses entry.media.images|first),
|
||||||
# so an explicit hero filename was redundant. Stories still use hero_image
|
# so an explicit hero filename was redundant. Stories still use hero_image
|
||||||
# but they aren't posted through this form.
|
# but they aren't posted through this form.
|
||||||
|
-
|
||||||
|
name: published
|
||||||
|
label: Published
|
||||||
|
# Authoritative publish state (replaces the removed static
|
||||||
|
# pagefrontmatter.published). Default ON so new entries publish; the
|
||||||
|
# owner flips it OFF to save/keep a draft, or to unpublish on edit.
|
||||||
|
# validate.type:bool keeps it a real boolean in frontmatter (not '0').
|
||||||
|
type: toggle
|
||||||
|
classes: advanced-field
|
||||||
|
highlight: 1
|
||||||
|
default: 1
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
|
||||||
-
|
-
|
||||||
name: force_connect
|
name: force_connect
|
||||||
label: Force connector line
|
label: Force connector line
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use Grav\Common\Data\ValidationException;
|
|||||||
use Grav\Common\Plugin;
|
use Grav\Common\Plugin;
|
||||||
use RocketTheme\Toolbox\Event\Event;
|
use RocketTheme\Toolbox\Event\Event;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/classes/EntryScopeGuard.php';
|
||||||
|
|
||||||
|
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||||
|
|
||||||
class CacheOnSavePlugin extends Plugin
|
class CacheOnSavePlugin extends Plugin
|
||||||
{
|
{
|
||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
@@ -48,6 +52,63 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
}
|
}
|
||||||
|
|
||||||
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
|
$form->setData('parent', $this->resolveDailiesParent($activeTrip));
|
||||||
|
|
||||||
|
// One shared /post form drives both create and edit (KTD1). add-page-by-form
|
||||||
|
// reads overwrite_mode from the /post page header's pageconfig (not form
|
||||||
|
// data), so we toggle it here per submit.
|
||||||
|
$editPath = $this->editPathFromForm($form);
|
||||||
|
|
||||||
|
if ($editPath === '') {
|
||||||
|
// CREATE — left untouched (any site.login user): overwrite_mode:false
|
||||||
|
// so stock add-page-by-form falls through to slug_field (date,title)
|
||||||
|
// and writes a fresh dated folder.
|
||||||
|
$this->setOverwriteMode('false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EDIT — enforce R6 server-side (KTD6) BEFORE allowing an in-place write.
|
||||||
|
// Fail closed (ValidationException) so the add_page action never runs.
|
||||||
|
// The UI only renders Edit for the owner on the active trip, but that gate
|
||||||
|
// is cosmetic; this is the authoritative check.
|
||||||
|
if (!EntryScopeGuard::isOwner($this->grav)) {
|
||||||
|
throw new ValidationException('You are not allowed to edit journal entries.');
|
||||||
|
}
|
||||||
|
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
|
||||||
|
if (EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment) === null) {
|
||||||
|
// Unsafe/traversal segment, no active trip, missing page, or a target
|
||||||
|
// outside the active trip's dailies — all rejected identically.
|
||||||
|
throw new ValidationException('That entry is not editable here — it is not in the active trip.');
|
||||||
|
}
|
||||||
|
$this->setOverwriteMode('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hidden edit_path form field, trimmed. Empty string when creating a new
|
||||||
|
* entry; the entry's `<route>/entry.md` path when editing (set by post-form.js).
|
||||||
|
*/
|
||||||
|
private function editPathFromForm($form): string
|
||||||
|
{
|
||||||
|
$value = $form->value('edit_path');
|
||||||
|
return is_string($value) ? trim($value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override add-page-by-form's overwrite_mode by mutating the current (/post)
|
||||||
|
* page header's pageconfig. add-page-by-form reads
|
||||||
|
* `$grav['page']->header()->pageconfig['overwrite_mode']` (add-page-by-form.php
|
||||||
|
* :385) from the same page singleton, and Page::header() returns a cached
|
||||||
|
* instance, so this write is visible when its onFormProcessed runs afterwards.
|
||||||
|
*/
|
||||||
|
private function setOverwriteMode(string $mode): void
|
||||||
|
{
|
||||||
|
$page = $this->grav['page'] ?? null;
|
||||||
|
if (!$page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$header = $page->header();
|
||||||
|
$pageconfig = (isset($header->pageconfig) && is_array($header->pageconfig)) ? $header->pageconfig : [];
|
||||||
|
$pageconfig['overwrite_mode'] = $mode;
|
||||||
|
$header->pageconfig = $pageconfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
namespace Grav\Plugin\Shared;
|
||||||
|
|
||||||
|
use Grav\Common\Grav;
|
||||||
|
use Grav\Common\Page\Interfaces\PageInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the R6 server-side scope guard, shared by BOTH
|
||||||
|
* enforcement points so they cannot diverge (KTD5):
|
||||||
|
* - the save/publish path (cache-on-save onFormValidationProcessed), and
|
||||||
|
* - the delete path (entry-actions API route).
|
||||||
|
*
|
||||||
|
* Two independent checks, both required:
|
||||||
|
* 1. isOwner() — the acting user is the configured site owner, NOT merely any
|
||||||
|
* authenticated account (the super-admin `tester` also authenticates).
|
||||||
|
* 2. resolveActiveDailyChild() — the target resolves, through the page tree,
|
||||||
|
* to a real DIRECT child of the active trip's `dailies` container. Resolving
|
||||||
|
* via $pages->find() + a parent-route assertion (never raw path concatenation)
|
||||||
|
* closes the traversal hole where a string like `/…/dailies/../other/entry.md`
|
||||||
|
* prefix-matches the active dailies but points elsewhere.
|
||||||
|
*/
|
||||||
|
class EntryScopeGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Active trip's dailies container route ("/trips/<slug>/dailies"), or null
|
||||||
|
* when site.active_trip is unset. Accepts a full route or a bare slug.
|
||||||
|
*/
|
||||||
|
public static function dailiesRoute(Grav $grav): ?string
|
||||||
|
{
|
||||||
|
$active = $grav['config']->get('site.active_trip');
|
||||||
|
$active = is_string($active) ? trim($active) : '';
|
||||||
|
if ($active === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$trip = trim($active, '/');
|
||||||
|
if (strpos($trip, 'trips/') !== 0) {
|
||||||
|
$trip = 'trips/' . $trip;
|
||||||
|
}
|
||||||
|
return '/' . $trip . '/dailies';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when the current user is authenticated AND their username equals
|
||||||
|
* site.owner_username. Gating on authentication alone would grant rights to
|
||||||
|
* every account, including the super-admin `tester` (KTD8).
|
||||||
|
*/
|
||||||
|
public static function isOwner(Grav $grav): bool
|
||||||
|
{
|
||||||
|
$user = $grav['user'] ?? null;
|
||||||
|
if (!$user || empty($user->authenticated)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return self::isOwnerUser($grav, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner check for an explicit user object — used by the API delete route,
|
||||||
|
* whose authenticated user comes from the request (api_user attribute), not
|
||||||
|
* $grav['user']. Same rule: username must equal site.owner_username.
|
||||||
|
*/
|
||||||
|
public static function isOwnerUser(Grav $grav, $user): bool
|
||||||
|
{
|
||||||
|
if (!$user || !isset($user->username)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$owner = $grav['config']->get('site.owner_username');
|
||||||
|
return is_string($owner) && $owner !== '' && $user->username === $owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A safe single path segment: non-empty, no separators, no dot-traversal.
|
||||||
|
*/
|
||||||
|
public static function isSafeSegment(string $segment): bool
|
||||||
|
{
|
||||||
|
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (strpbrk($segment, '/\\') !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return strpos($segment, '..') === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder segment carried by a hidden edit_path value. post-form.js sets
|
||||||
|
* edit_path to "<entry-route>/entry.md", so basename(dirname()) is the entry's
|
||||||
|
* own folder name (its route's last segment) — the same value stock
|
||||||
|
* add-page-by-form derives for the in-place write.
|
||||||
|
*/
|
||||||
|
public static function segmentFromEditPath(string $editPath): string
|
||||||
|
{
|
||||||
|
$editPath = trim($editPath);
|
||||||
|
if ($editPath === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return basename(dirname($editPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
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).
|
||||||
|
// Idempotent — a no-op in the frontend save-path context.
|
||||||
|
if (method_exists($pages, 'enablePages')) {
|
||||||
|
$pages->enablePages();
|
||||||
|
}
|
||||||
|
$page = $pages->find($dailies . '/' . $segment);
|
||||||
|
if ($page === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$parent = $page->parent();
|
||||||
|
if ($parent === null || $parent->route() !== $dailies) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: Entry Actions
|
||||||
|
version: 0.1.0
|
||||||
|
description: Owner-only, active-trip-scoped journal entry actions (M1: delete) via the Grav API.
|
||||||
|
icon: trash
|
||||||
|
author:
|
||||||
|
name: Mischa
|
||||||
|
homepage: https://intotheeast.com
|
||||||
|
keywords: api, journal, delete
|
||||||
|
bugs: https://intotheeast.com
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- { name: grav, version: '>=2.0.0' }
|
||||||
|
- { name: api }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
namespace Grav\Plugin\EntryActions;
|
||||||
|
|
||||||
|
use Grav\Common\Filesystem\Folder;
|
||||||
|
use Grav\Plugin\Api\Controllers\AbstractApiController;
|
||||||
|
use Grav\Plugin\Api\Exceptions\ApiException;
|
||||||
|
use Grav\Plugin\Api\Exceptions\ForbiddenException;
|
||||||
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
||||||
|
use Grav\Plugin\Api\Response\ApiResponse;
|
||||||
|
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
// Shared R6 guard lives in cache-on-save (the always-present custom plugin);
|
||||||
|
// require it so save and delete enforce scope identically (KTD5).
|
||||||
|
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/entry/{slug}
|
||||||
|
*
|
||||||
|
* Deletes a journal entry folder, but only when ALL hold:
|
||||||
|
* - the request is the authenticated site OWNER (not merely any login/admin);
|
||||||
|
* - {slug} is a safe single segment (no '/', no '..');
|
||||||
|
* - it resolves through the page tree to a DIRECT child of the ACTIVE trip's
|
||||||
|
* dailies container.
|
||||||
|
* Otherwise: 401 (anon), 403 (non-owner), 400 (bad slug), 404 (out of scope /
|
||||||
|
* not found). On success the folder is removed and the page-tree cache cleared.
|
||||||
|
*/
|
||||||
|
class EntryActionsApiController extends AbstractApiController
|
||||||
|
{
|
||||||
|
public function deleteEntry(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
// Authenticated OWNER only (KTD8). getUser() throws 401 for anonymous.
|
||||||
|
$user = $this->getUser($request);
|
||||||
|
if (!EntryScopeGuard::isOwnerUser($this->grav, $user)) {
|
||||||
|
throw new ForbiddenException('Only the site owner can delete journal entries.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $this->getRouteParam($request, 'slug');
|
||||||
|
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
||||||
|
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve via $pages->find() + parent-route assertion (never raw path
|
||||||
|
// concatenation) — same shared check the save path uses.
|
||||||
|
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $slug);
|
||||||
|
if ($page === null) {
|
||||||
|
throw new NotFoundException('Entry not found in the active trip.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $page->path();
|
||||||
|
if (!is_string($path) || $path === '' || !is_dir($path)) {
|
||||||
|
throw new NotFoundException('Entry folder not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Folder::delete($path);
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
|
||||||
|
return ApiResponse::noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
namespace Grav\Plugin;
|
||||||
|
|
||||||
|
use Grav\Common\Plugin;
|
||||||
|
use RocketTheme\Toolbox\Event\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry Actions — a thin, purpose-built API surface for owner-only, active-trip
|
||||||
|
* scoped journal-entry actions that the stock Grav API cannot express safely.
|
||||||
|
*
|
||||||
|
* M1 registers exactly one route: DELETE /api/v1/entry/{slug}. The stock
|
||||||
|
* DELETE /api/v1/pages<route> only checks write-permission (no trip scope, and
|
||||||
|
* any admin passes), which violates R6. This route requires the configured site
|
||||||
|
* OWNER and asserts the target is a direct child of the active trip's dailies
|
||||||
|
* container — sharing one guard (EntryScopeGuard) with the save path so the two
|
||||||
|
* R6 enforcement points cannot diverge (KTD5).
|
||||||
|
*
|
||||||
|
* Custom-in-repo (NOT GPM-managed): tracked via a `!` negation in user/.gitignore
|
||||||
|
* and deployed with the content push, like cache-on-save. Never in plugins.txt.
|
||||||
|
*/
|
||||||
|
class EntryActionsPlugin extends Plugin
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'onPluginsInitialized' => ['onPluginsInitialized', 0],
|
||||||
|
'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a lazy PSR-4 autoloader for this plugin's classes. The api router
|
||||||
|
* dispatches from a CACHED route map and instantiates the controller directly
|
||||||
|
* (ApiRouter::handleRoute → `new $controllerClass`) WITHOUT re-firing
|
||||||
|
* onApiRegisterRoutes, so requiring the class only there would leave it
|
||||||
|
* unloaded on cached-route requests. Lazy autoloading fires exactly when the
|
||||||
|
* router constructs the controller — by which point the api plugin's own
|
||||||
|
* autoloader (for AbstractApiController) is already registered.
|
||||||
|
*/
|
||||||
|
public function onPluginsInitialized(): void
|
||||||
|
{
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
$prefix = 'Grav\\Plugin\\EntryActions\\';
|
||||||
|
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$rel = substr($class, strlen($prefix));
|
||||||
|
$file = __DIR__ . '/classes/' . str_replace('\\', '/', $rel) . '.php';
|
||||||
|
if (is_file($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onApiRegisterRoutes(Event $event): void
|
||||||
|
{
|
||||||
|
$routes = $event['routes'];
|
||||||
|
$routes->delete('/entry/{slug}', [EntryActions\EntryActionsApiController::class, 'deleteEntry']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
enabled: true
|
||||||
File diff suppressed because one or more lines are too long
@@ -269,6 +269,103 @@ body::after {
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Owner card controls (U4): Draft badge + Edit/Delete ─────────────────── */
|
||||||
|
.journal-post-titlerow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap; /* phone-first: actions wrap below a long title */
|
||||||
|
gap: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.journal-post-titlerow .journal-post-title { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.journal-draft-badge {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #E0A458; /* warm amber — draft/unpublished */
|
||||||
|
border: 1px solid #E0A458;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.1em 0.5em;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-post-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.entry-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px; /* D8 — phone-first tap target */
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-2);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.entry-action:hover,
|
||||||
|
.entry-action:focus-visible {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* [hidden] must win over the inline-flex display above (used to swap
|
||||||
|
Delete <-> Cancel/Confirm from feed-actions.js). */
|
||||||
|
.entry-action[hidden] { display: none; }
|
||||||
|
.entry-action--confirm {
|
||||||
|
color: #E5786A; /* soft red — destructive confirm */
|
||||||
|
border-color: #E5786A;
|
||||||
|
}
|
||||||
|
.entry-action--confirm:hover,
|
||||||
|
.entry-action--confirm:focus-visible {
|
||||||
|
background: #E5786A;
|
||||||
|
color: var(--color-ink-inverse);
|
||||||
|
}
|
||||||
|
.entry-delete-confirm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.entry-delete-confirm[hidden] { display: none; }
|
||||||
|
.entry-delete-msg {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: #E5786A;
|
||||||
|
}
|
||||||
|
.entry-delete-msg:empty { display: none; }
|
||||||
|
|
||||||
|
/* Visually-hidden but screen-reader-available (feed-actions live region). */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.journal-photo-wrap {
|
.journal-photo-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
(()=>{function m(){var e=document.getElementById("feed-actions-live");return e||(e=document.createElement("div"),e.id="feed-actions-live",e.setAttribute("aria-live","polite"),e.setAttribute("role","status"),e.className="sr-only",document.body.appendChild(e)),e}function s(e){m().textContent=e}function v(e){var n=String(e||"").replace(/\/+$/,"").split("/");return n[n.length-1]||""}function u(e,n){var i=e.querySelector("[data-delete-start]"),r=e.querySelector(".entry-delete-confirm");if(i&&(i.hidden=n),r&&(r.hidden=!n),!n){var t=e.querySelector(".entry-delete-msg");t&&(t.textContent="")}}function g(e){var n=e.getAttribute("data-entry-route"),i=v(n),r=e.querySelector("[data-delete-cancel]"),t=e.querySelector("[data-delete-confirm]"),a=e.querySelector(".entry-delete-msg"),l=e.closest(".journal-post");t&&(t.disabled=!0,t.textContent="Deleting\u2026"),r&&(r.disabled=!0),a&&(a.textContent=""),s("Deleting entry\u2026"),fetch("/api/v1/entry/"+encodeURIComponent(i),{method:"DELETE",credentials:"include",headers:{Accept:"application/json"}}).then(function(d){if(!(d.status===204||d.ok))throw new Error("HTTP "+d.status);for(var o=l?l.nextElementSibling:null;o&&!o.classList.contains("journal-post");)o=o.nextElementSibling;var c=o||(l&&l.previousElementSibling&&l.previousElementSibling.classList&&l.previousElementSibling.classList.contains("journal-post")?l.previousElementSibling:null)||document.querySelector(".home-trip-name, .home-feed-col h1, .feed");if(l&&l.remove(),c){c.hasAttribute("tabindex")||c.setAttribute("tabindex","-1");try{c.focus({preventScroll:!1})}catch{}}s("Entry deleted.")}).catch(function(){t&&(t.disabled=!1,t.textContent="Confirm delete"),r&&(r.disabled=!1),a&&(a.textContent="Could not delete \u2014 please try again."),s("Delete failed.")})}function f(){document.addEventListener("click",function(e){var n=e.target.closest?e.target.closest("[data-delete-start]"):null,i=e.target.closest?e.target.closest("[data-delete-cancel]"):null,r=e.target.closest?e.target.closest("[data-delete-confirm]"):null;if(!(!n&&!i&&!r)){var t=(n||i||r).closest(".journal-post-actions");if(t){if(e.preventDefault(),n){u(t,!0);return}if(i){u(t,!1);return}if(r){var a=t.querySelector("[data-delete-confirm]");if(a&&a.disabled)return;g(t)}}}})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",f):f();})();
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
* feed-actions.js (U6) — owner delete wiring for journal feed cards.
|
||||||
|
*
|
||||||
|
* Loaded only on the trip / home active-trip feed for the owner. The Edit link is
|
||||||
|
* a plain navigation (no JS). Delete is a two-step inline confirm (no browser
|
||||||
|
* dialog): Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>
|
||||||
|
* (session cookie), then the card is removed and focus moves to the next card.
|
||||||
|
*
|
||||||
|
* Markup (rendered by partials/entry-journal.html.twig):
|
||||||
|
* article.journal-post[data-entry-route]
|
||||||
|
* .journal-post-actions[data-entry-route]
|
||||||
|
* button[data-delete-start] (Delete)
|
||||||
|
* .entry-delete-confirm[hidden]
|
||||||
|
* button[data-delete-cancel] (Cancel)
|
||||||
|
* button[data-delete-confirm] (Confirm delete)
|
||||||
|
* .entry-delete-msg[aria-live] (inline error slot)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// One page-level polite live region for cross-card announcements (D4) — the
|
||||||
|
// per-card .entry-delete-msg vanishes with the card it belongs to.
|
||||||
|
function liveRegion() {
|
||||||
|
var el = document.getElementById('feed-actions-live');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = 'feed-actions-live';
|
||||||
|
el.setAttribute('aria-live', 'polite');
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
el.className = 'sr-only';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
function announce(msg) { liveRegion().textContent = msg; }
|
||||||
|
|
||||||
|
function slugFromRoute(route) {
|
||||||
|
var parts = String(route || '').replace(/\/+$/, '').split('/');
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfirm(actions, show) {
|
||||||
|
var del = actions.querySelector('[data-delete-start]');
|
||||||
|
var confirm = actions.querySelector('.entry-delete-confirm');
|
||||||
|
if (del) del.hidden = show;
|
||||||
|
if (confirm) confirm.hidden = !show;
|
||||||
|
if (!show) {
|
||||||
|
var msg = actions.querySelector('.entry-delete-msg');
|
||||||
|
if (msg) msg.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performDelete(actions) {
|
||||||
|
var route = actions.getAttribute('data-entry-route');
|
||||||
|
var slug = slugFromRoute(route);
|
||||||
|
var cancelBtn = actions.querySelector('[data-delete-cancel]');
|
||||||
|
var confirmBtn = actions.querySelector('[data-delete-confirm]');
|
||||||
|
var msg = actions.querySelector('.entry-delete-msg');
|
||||||
|
var card = actions.closest('.journal-post');
|
||||||
|
|
||||||
|
// D2: lock both buttons immediately so a mobile double-tap can't fire two
|
||||||
|
// DELETEs (the second would 404 on an already-removed folder).
|
||||||
|
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting…'; }
|
||||||
|
if (cancelBtn) cancelBtn.disabled = true;
|
||||||
|
if (msg) msg.textContent = '';
|
||||||
|
announce('Deleting entry…');
|
||||||
|
|
||||||
|
fetch('/api/v1/entry/' + encodeURIComponent(slug), {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!(r.status === 204 || r.ok)) throw new Error('HTTP ' + r.status);
|
||||||
|
|
||||||
|
// D4: move focus to the next journal card (or the feed heading) BEFORE
|
||||||
|
// removing this one, then announce.
|
||||||
|
var next = card ? card.nextElementSibling : null;
|
||||||
|
while (next && !next.classList.contains('journal-post')) next = next.nextElementSibling;
|
||||||
|
var focusTarget = next
|
||||||
|
|| (card && card.previousElementSibling && card.previousElementSibling.classList && card.previousElementSibling.classList.contains('journal-post') ? card.previousElementSibling : null)
|
||||||
|
|| document.querySelector('.home-trip-name, .home-feed-col h1, .feed');
|
||||||
|
if (card) card.remove();
|
||||||
|
if (focusTarget) {
|
||||||
|
if (!focusTarget.hasAttribute('tabindex')) focusTarget.setAttribute('tabindex', '-1');
|
||||||
|
try { focusTarget.focus({ preventScroll: false }); } catch (e) { /* older browsers */ }
|
||||||
|
}
|
||||||
|
announce('Entry deleted.');
|
||||||
|
}).catch(function () {
|
||||||
|
// D7: restore the control + inline error, constrained to the card.
|
||||||
|
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm delete'; }
|
||||||
|
if (cancelBtn) cancelBtn.disabled = false;
|
||||||
|
if (msg) msg.textContent = 'Could not delete — please try again.';
|
||||||
|
announce('Delete failed.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFeedActions() {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var start = e.target.closest ? e.target.closest('[data-delete-start]') : null;
|
||||||
|
var cancel = e.target.closest ? e.target.closest('[data-delete-cancel]') : null;
|
||||||
|
var confirm = e.target.closest ? e.target.closest('[data-delete-confirm]') : null;
|
||||||
|
if (!start && !cancel && !confirm) return;
|
||||||
|
|
||||||
|
var actions = (start || cancel || confirm).closest('.journal-post-actions');
|
||||||
|
if (!actions) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (start) { showConfirm(actions, true); return; }
|
||||||
|
if (cancel) { showConfirm(actions, false); return; }
|
||||||
|
if (confirm) {
|
||||||
|
var btn = actions.querySelector('[data-delete-confirm]');
|
||||||
|
if (btn && btn.disabled) return; // already in flight (D2)
|
||||||
|
performDelete(actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initFeedActions);
|
||||||
|
} else {
|
||||||
|
initFeedActions();
|
||||||
|
}
|
||||||
@@ -4,6 +4,19 @@
|
|||||||
* mobile layout, and the "More options" disclosure land in U5.
|
* mobile layout, and the "More options" disclosure land in U5.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* Edit-mode prefill-failure banner (U5, D7): shown between the heading and the
|
||||||
|
first field when the entry can't be loaded for editing. */
|
||||||
|
.post-edit-error {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #E5786A;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(229, 120, 106, 0.12);
|
||||||
|
color: #E5786A;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* EasyMDE toolbar glyphs — replace the FontAwesome icons EasyMDE expects. */
|
/* EasyMDE toolbar glyphs — replace the FontAwesome icons EasyMDE expects. */
|
||||||
.editor-toolbar .mde-btn::before {
|
.editor-toolbar .mde-btn::before {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
@@ -527,7 +527,9 @@ function initValidation() {
|
|||||||
var firstInvalid = null;
|
var firstInvalid = null;
|
||||||
|
|
||||||
// ≥1 photo (photos are the first field). Count any present FilePond item.
|
// ≥1 photo (photos are the first field). Count any present FilePond item.
|
||||||
if (document.querySelectorAll('.filepond--item').length < 1) {
|
// Skipped in edit mode (KTD9): photos are untouched in M1, so an empty
|
||||||
|
// FilePond on an edit submit keeps the entry's existing images.
|
||||||
|
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
|
||||||
firstInvalid = showPhotoError('Add at least one photo.');
|
firstInvalid = showPhotoError('Add at least one photo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +582,7 @@ function showReauthHint() {
|
|||||||
function initDraft() {
|
function initDraft() {
|
||||||
var form = document.querySelector('form[name="new-entry"]');
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
if (!form) return; // e.g. session expired → login form shown; draft is left intact
|
if (!form) return; // e.g. session expired → login form shown; draft is left intact
|
||||||
|
if (EDIT_MODE) return; // editing an existing entry: don't restore/overwrite with a create draft
|
||||||
|
|
||||||
// A confirmed successful post: clear the draft and do NOT restore onto the
|
// A confirmed successful post: clear the draft and do NOT restore onto the
|
||||||
// freshly reset form. This runs before any save, so process.reset can't
|
// freshly reset form. This runs before any save, so process.reset can't
|
||||||
@@ -684,14 +687,146 @@ function initSuccessState() {
|
|||||||
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Edit mode (U5, KTD4/KTD9): prefill from the API, adapt the form ──────────
|
||||||
|
* The Edit control on a journal card links to /post?edit=<entry-route>. We detect
|
||||||
|
* that param, disable the form (D1), fetch the entry via the session-auth Grav
|
||||||
|
* API (credentials:include — the gpx-manager pattern), populate every field, set
|
||||||
|
* the hidden edit_path so the save writes back in place (cache-on-save toggles
|
||||||
|
* overwrite_mode:edit server-side), hide the photos section and relax the
|
||||||
|
* >=1-photo rule (photos are untouched in M1 — an empty FilePond leaves existing
|
||||||
|
* images intact), and switch the chrome to "Edit entry" / "Save changes" (D6).
|
||||||
|
*/
|
||||||
|
var EDIT_MODE = false;
|
||||||
|
|
||||||
|
function qparam(name) {
|
||||||
|
return new URLSearchParams(window.location.search).get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSetText(name, value) {
|
||||||
|
var el = field(name);
|
||||||
|
if (el) el.value = (value === undefined || value === null) ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggles (published/featured/force_connect) render as a 1/0 radio pair.
|
||||||
|
function editSetToggle(name, truthy) {
|
||||||
|
var want = truthy ? '1' : '0';
|
||||||
|
var radios = document.querySelectorAll('[name="data[' + name + ']"]');
|
||||||
|
Array.prototype.forEach.call(radios, function (r) { r.checked = (String(r.value) === want); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSetContent(value) {
|
||||||
|
var text = (value === undefined || value === null) ? '' : String(value);
|
||||||
|
if (window.postFormEditor && typeof window.postFormEditor.value === 'function') {
|
||||||
|
window.postFormEditor.value(text);
|
||||||
|
} else {
|
||||||
|
var ta = field('content');
|
||||||
|
if (ta) ta.value = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable/enable the form's own fields + submit (get-location/weather live
|
||||||
|
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
|
||||||
|
function editFormDisabled(form, disabled) {
|
||||||
|
var els = form.querySelectorAll('input, textarea, select, button');
|
||||||
|
Array.prototype.forEach.call(els, function (el) { el.disabled = disabled; });
|
||||||
|
if (window.postFormEditor && window.postFormEditor.codemirror) {
|
||||||
|
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSubmitLabel(btn, text) {
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn.tagName === 'INPUT') btn.value = text; else btn.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editShowError(wrap, msg) {
|
||||||
|
var existing = wrap.querySelector('.post-edit-error');
|
||||||
|
if (existing) { existing.textContent = msg; return; }
|
||||||
|
var banner = document.createElement('div');
|
||||||
|
banner.className = 'post-edit-error';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.textContent = msg;
|
||||||
|
var h1 = wrap.querySelector('h1');
|
||||||
|
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEditMode() {
|
||||||
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
|
var wrap = document.querySelector('.post-form-wrap');
|
||||||
|
if (!form || !wrap) return;
|
||||||
|
|
||||||
|
var route = qparam('edit');
|
||||||
|
if (!route) return; // create mode — nothing to do
|
||||||
|
EDIT_MODE = true;
|
||||||
|
|
||||||
|
var h1 = wrap.querySelector('h1');
|
||||||
|
if (h1) h1.textContent = 'Edit entry'; // D6
|
||||||
|
var submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||||
|
var origLabel = submitBtn ? (submitBtn.tagName === 'INPUT' ? submitBtn.value : submitBtn.textContent) : 'Save changes';
|
||||||
|
|
||||||
|
// KTD9: hide the photos section (photos untouched in M1). The >=1-photo rule
|
||||||
|
// is skipped while EDIT_MODE (see initValidation).
|
||||||
|
var photoField = form.querySelector('.photos-collapse') || form.querySelector('.filepond-root, .form-input-file');
|
||||||
|
var photoWrapper = photoField ? (photoField.closest('.form-field') || photoField) : null;
|
||||||
|
if (photoWrapper) photoWrapper.style.display = 'none';
|
||||||
|
|
||||||
|
// D1: no typing before prefill lands — disable + loading label.
|
||||||
|
editFormDisabled(form, true);
|
||||||
|
editSubmitLabel(submitBtn, 'Loading entry…');
|
||||||
|
|
||||||
|
// D3/D5: carry the edit context on the form action so a server re-render
|
||||||
|
// (once the Form 9.1.10 filepond regression is fixed) re-enters edit mode and
|
||||||
|
// the return target survives the round-trip.
|
||||||
|
var ret = qparam('return') || wrap.getAttribute('data-trip-url') || '';
|
||||||
|
form.setAttribute('action', '/post?edit=' + encodeURIComponent(route) + (ret ? '&return=' + encodeURIComponent(ret) : ''));
|
||||||
|
|
||||||
|
fetch('/api/v1/pages' + route, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(function (json) {
|
||||||
|
var d = (json && json.data) || {};
|
||||||
|
var h = d.header || {};
|
||||||
|
editSetText('title', h.title != null ? h.title : d.title);
|
||||||
|
editSetText('date', h.date ? String(h.date).replace(' ', 'T') : ''); // datetime-local wants a T separator
|
||||||
|
editSetContent(d.content);
|
||||||
|
editSetText('lat', h.lat);
|
||||||
|
editSetText('lng', h.lng);
|
||||||
|
editSetText('location_city', h.location_city);
|
||||||
|
editSetText('location_country', h.location_country);
|
||||||
|
editSetText('weather_desc', h.weather_desc);
|
||||||
|
editSetText('weather_temp_c', h.weather_temp_c);
|
||||||
|
editSetText('transport_mode', h.transport_mode);
|
||||||
|
editSetToggle('featured', h.featured);
|
||||||
|
editSetToggle('force_connect', h.force_connect);
|
||||||
|
editSetToggle('published', h.published !== undefined ? h.published : d.published);
|
||||||
|
|
||||||
|
var editPathEl = field('edit_path');
|
||||||
|
if (editPathEl) editPathEl.value = route + '/entry.md';
|
||||||
|
|
||||||
|
editFormDisabled(form, false);
|
||||||
|
editSubmitLabel(submitBtn, 'Save changes'); // D6
|
||||||
|
|
||||||
|
var more = form.querySelector('.more-options');
|
||||||
|
if (more) more.open = true; // reveal Published/Featured/Connector
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// D7: inline error between heading and first field; keep the form
|
||||||
|
// disabled and empty rather than leaving a half-filled state.
|
||||||
|
editShowError(wrap, 'Sorry — this entry could not be loaded for editing. Go back to the journal and try again.');
|
||||||
|
editSubmitLabel(submitBtn, origLabel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Boot ────────────────────────────────────────────────── */
|
/* ── Boot ────────────────────────────────────────────────── */
|
||||||
function boot() {
|
function boot() {
|
||||||
// Exposed for U6 draft restore and the Playwright specs; null when this
|
// Exposed for U6 draft restore and the Playwright specs; null when this
|
||||||
// bundle loads on a page without the content field.
|
// bundle loads on a page without the content field.
|
||||||
window.postFormEditor = initEditor();
|
window.postFormEditor = initEditor();
|
||||||
initSuccessState();
|
initSuccessState();
|
||||||
|
// Edit mode first: sets EDIT_MODE (so initDraft skips and initValidation
|
||||||
|
// relaxes the photo rule), disables the form and starts the async prefill.
|
||||||
|
initEditMode();
|
||||||
// Restore before disclosure/geo so their on-load checks (auto-open,
|
// Restore before disclosure/geo so their on-load checks (auto-open,
|
||||||
// weather-button enable) see the restored values.
|
// weather-button enable) see the restored values. No-op in edit mode.
|
||||||
initDraft();
|
initDraft();
|
||||||
initPhotoConversion();
|
initPhotoConversion();
|
||||||
initDisclosure();
|
initDisclosure();
|
||||||
|
|||||||
@@ -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 && 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 && 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",
|
||||||
|
|||||||
@@ -14,11 +14,20 @@
|
|||||||
|
|
||||||
{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
|
{% set dailies_page = grav.pages.find(trip_route ~ '/dailies') %}
|
||||||
{% set stories_page = grav.pages.find(trip_route ~ '/stories') %}
|
{% set stories_page = grav.pages.find(trip_route ~ '/stories') %}
|
||||||
|
{# published-only — feeds the map, stats and counts (drafts excluded, R5) #}
|
||||||
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
||||||
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
||||||
|
|
||||||
|
{# This branch IS the active trip, so the owner gate is just owner identity
|
||||||
|
(KTD8). The super-admin tester authenticates too, so gate on owner_username. #}
|
||||||
|
{% set owner_can_edit = grav.user.authenticated
|
||||||
|
and grav.user.username == grav.config.site.owner_username %}
|
||||||
|
{# Owner-aware feed list: owner sees drafts; everyone else published only #}
|
||||||
|
{% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %}
|
||||||
|
{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %}
|
||||||
|
|
||||||
{% set all_items = [] %}
|
{% set all_items = [] %}
|
||||||
{% for e in journal_entries %}
|
{% for e in journal_feed %}
|
||||||
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for s in story_entries %}
|
{% for s in story_entries %}
|
||||||
@@ -38,7 +47,8 @@
|
|||||||
|
|
||||||
{% set map_entries = [] %}
|
{% set map_entries = [] %}
|
||||||
{% for item in all_items %}
|
{% for item in all_items %}
|
||||||
{% if item.type == 'journal' and item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
{# drafts render as a feed card only — never a map marker (R5) #}
|
||||||
|
{% if item.type == 'journal' and item.page.published and item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
||||||
{% set map_entries = map_entries|merge([{
|
{% set map_entries = map_entries|merge([{
|
||||||
'lat': item.page.header.lat|number_format(6, '.', ''),
|
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||||
'lng': item.page.header.lng|number_format(6, '.', ''),
|
'lng': item.page.header.lng|number_format(6, '.', ''),
|
||||||
@@ -87,7 +97,9 @@
|
|||||||
has_gpx: home_gpx_urls|length > 0,
|
has_gpx: home_gpx_urls|length > 0,
|
||||||
gpx_urls: home_gpx_urls,
|
gpx_urls: home_gpx_urls,
|
||||||
gps_points: gps_points,
|
gps_points: gps_points,
|
||||||
show_sort: false
|
show_sort: false,
|
||||||
|
owner_can_edit: owner_can_edit,
|
||||||
|
feed_return_url: page.url
|
||||||
} only %}
|
} only %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
{% include 'partials/weather-icons.html.twig' %}
|
{% include 'partials/weather-icons.html.twig' %}
|
||||||
<article class="journal-post" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
|
{% set owner_can_edit = owner_can_edit ?? false %}
|
||||||
|
{% set feed_return_url = feed_return_url ?? trip_page.url ?? '/' %}
|
||||||
|
<article class="journal-post{% if not entry.published %} is-draft{% endif %}" id="entry-{{ entry.slug }}" data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}"{% if owner_can_edit %} data-entry-route="{{ entry.route }}"{% endif %}>
|
||||||
<header class="journal-post-header">
|
<header class="journal-post-header">
|
||||||
<h2 class="journal-post-title">{{ entry.title }}</h2>
|
<div class="journal-post-titlerow">
|
||||||
|
<h2 class="journal-post-title">{{ entry.title }}{% if not entry.published %} <span class="journal-draft-badge">Draft</span>{% endif %}</h2>
|
||||||
|
{% if owner_can_edit %}
|
||||||
|
<div class="journal-post-actions" data-entry-route="{{ entry.route }}">
|
||||||
|
<a class="entry-action entry-action--edit" href="/post?edit={{ entry.route|url_encode }}&return={{ feed_return_url|url_encode }}">Edit</a>
|
||||||
|
<button class="entry-action entry-action--delete" type="button" data-delete-start>Delete</button>
|
||||||
|
<span class="entry-delete-confirm" hidden>
|
||||||
|
<button class="entry-action entry-action--cancel" type="button" data-delete-cancel>Cancel</button>
|
||||||
|
<button class="entry-action entry-action--confirm" type="button" data-delete-confirm>Confirm delete</button>
|
||||||
|
</span>
|
||||||
|
<span class="entry-delete-msg" role="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<p class="journal-post-meta">
|
<p class="journal-post-meta">
|
||||||
<a class="journal-post-permalink" href="{{ entry.url }}">
|
<a class="journal-post-permalink" href="{{ entry.url }}">
|
||||||
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{% import 'macros/stats.html.twig' as stats_m %}
|
{% import 'macros/stats.html.twig' as stats_m %}
|
||||||
{% import 'macros/cycling.html.twig' as cycling_m %}
|
{% import 'macros/cycling.html.twig' as cycling_m %}
|
||||||
|
{# owner_can_edit gates the Draft badge + Edit/Delete controls on each card
|
||||||
|
(threaded into entry-journal below). Default false so any caller that doesn't
|
||||||
|
pass it renders a read-only feed. #}
|
||||||
|
{% set owner_can_edit = owner_can_edit ?? false %}
|
||||||
<div class="home-feed-col">
|
<div class="home-feed-col">
|
||||||
<div class="home-trip-header">
|
<div class="home-trip-header">
|
||||||
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
|
||||||
|
|||||||
@@ -9,11 +9,25 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% set dailies_page = grav.pages.find(page.route ~ '/dailies') %}
|
{% set dailies_page = grav.pages.find(page.route ~ '/dailies') %}
|
||||||
{% set stories_page = grav.pages.find(page.route ~ '/stories') %}
|
{% set stories_page = grav.pages.find(page.route ~ '/stories') %}
|
||||||
|
{# journal_entries stays published-only — it feeds the map, stats and counts,
|
||||||
|
which must never include drafts (R5). #}
|
||||||
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
{% set journal_entries = dailies_page ? dailies_page.children.published() : [] %}
|
||||||
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
{% set story_entries = stories_page ? stories_page.children.published() : [] %}
|
||||||
|
|
||||||
|
{# Owner gate (KTD8): the site owner (not merely any login — the super-admin
|
||||||
|
tester also authenticates) viewing the ACTIVE trip. Drives draft visibility
|
||||||
|
in the feed and the Edit/Delete controls (threaded to the card partial). #}
|
||||||
|
{% set active_trip_slug = (grav.config.site.active_trip|default(''))|split('/')|last %}
|
||||||
|
{% set owner_can_edit = grav.user.authenticated
|
||||||
|
and grav.user.username == grav.config.site.owner_username
|
||||||
|
and page.slug == active_trip_slug %}
|
||||||
|
{# Feed list is owner-aware: the owner sees drafts (unpublished) too; everyone
|
||||||
|
else (and every non-active-trip view) sees published only (R5, KTD7). #}
|
||||||
|
{% set journal_feed = (owner_can_edit and dailies_page) ? dailies_page.children : journal_entries %}
|
||||||
|
{% if owner_can_edit %}{% do assets.addJs('theme://js/feed-actions.js', {group: 'bottom'}) %}{% endif %}
|
||||||
|
|
||||||
{% set all_items = [] %}
|
{% set all_items = [] %}
|
||||||
{% for e in journal_entries %}
|
{% for e in journal_feed %}
|
||||||
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
{% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for s in story_entries %}
|
{% for s in story_entries %}
|
||||||
@@ -41,7 +55,8 @@
|
|||||||
|
|
||||||
{% set map_entries = [] %}
|
{% set map_entries = [] %}
|
||||||
{% for item in all_items %}
|
{% for item in all_items %}
|
||||||
{% if item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
{# drafts render as a feed card only — never a map marker (R5) #}
|
||||||
|
{% if item.page.published and item.page.header.lat is not empty and item.page.header.lng is not empty %}
|
||||||
{% set map_entries = map_entries|merge([{
|
{% set map_entries = map_entries|merge([{
|
||||||
'type': item.type,
|
'type': item.type,
|
||||||
'lat': item.page.header.lat|number_format(6, '.', ''),
|
'lat': item.page.header.lat|number_format(6, '.', ''),
|
||||||
@@ -78,7 +93,9 @@
|
|||||||
has_gpx: has_gpx,
|
has_gpx: has_gpx,
|
||||||
gpx_urls: gpx_urls,
|
gpx_urls: gpx_urls,
|
||||||
gps_points: gps_points,
|
gps_points: gps_points,
|
||||||
show_sort: true
|
show_sort: true,
|
||||||
|
owner_can_edit: owner_can_edit,
|
||||||
|
feed_return_url: page.url
|
||||||
} only %}
|
} only %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user