Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc9c9854c | ||
|
|
d4fcbecaf1 | ||
|
|
b3b4f774d0 | ||
|
|
034db29e66 | ||
|
|
7f6bf9e3f3 | ||
|
|
8db3ffeafc | ||
|
|
a7bda6ed39 | ||
|
|
7775a4ed6d | ||
|
|
7ffd75e8ec | ||
|
|
361a6b4d67 | ||
|
|
a4432d897e | ||
|
|
4fea522d5c | ||
|
|
fcf52a0e44 | ||
|
|
05db592836 | ||
|
|
ff31683075 | ||
|
|
03323bcadf | ||
|
|
cb759746d0 | ||
|
|
525b684493 | ||
|
|
f8f03faa1d | ||
|
|
16a570ca3b | ||
|
|
5752877e66 | ||
|
|
6abd08af40 | ||
|
|
b8daea217d | ||
|
|
265a06a972 | ||
|
|
c4228e9027 | ||
|
|
a2d4f81bfe | ||
|
|
4e91492f65 | ||
|
|
aa5d34717e | ||
|
|
fff5358ce2 | ||
|
|
8cf1145e6f | ||
|
|
8b037511fd | ||
|
|
1cde137daa | ||
|
|
d17d1256ba | ||
|
|
87da3e4b5b | ||
|
|
d3a520426a | ||
|
|
c70e96879b | ||
|
|
9a96d0f19e | ||
|
|
7f021ab114 | ||
|
|
bf5b5c2f3c | ||
|
|
6282528dd8 | ||
|
|
87e216594e | ||
|
|
817c20c24a |
@@ -2,8 +2,10 @@
|
|||||||
!/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
|
/accounts/testrunner.yaml
|
||||||
|
/accounts/tester.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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+7
-1
@@ -76,7 +76,13 @@ pages:
|
|||||||
- rss
|
- rss
|
||||||
- atom
|
- atom
|
||||||
append_url_extension: null
|
append_url_extension: null
|
||||||
expires: 604800
|
# expires: 0 → Cache-Control: max-age=0, so the browser (and any CDN) revalidates
|
||||||
|
# each load instead of serving up to 7 days stale. With etag on, an unchanged
|
||||||
|
# page returns a cheap 304; an edited/deleted/new entry shows immediately. This
|
||||||
|
# matters because the owner edits the live site and entry media reuses filenames
|
||||||
|
# (photo-N.jpg) across reorders. Server-side page cache (cache.enabled) is
|
||||||
|
# unaffected and still does the heavy lifting.
|
||||||
|
expires: 0
|
||||||
cache_control: null
|
cache_control: null
|
||||||
last_modified: false
|
last_modified: false
|
||||||
etag: true
|
etag: true
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
title: Stories
|
title: Stories
|
||||||
template: default
|
template: stories
|
||||||
routable: false
|
|
||||||
visible: false
|
|
||||||
published: true
|
published: true
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
---
|
---
|
||||||
title: 'Cycling Tuscany 2025'
|
title: 'Tuscany Gravel 2025'
|
||||||
template: trip
|
template: trip
|
||||||
date: '2025-10-11'
|
date: '2025-09-01'
|
||||||
date_start: '2025-10-11'
|
date_start: '2025-09-01'
|
||||||
date_end: '2025-10-16'
|
date_end: '2025-09-08'
|
||||||
cover_image: ''
|
cover_image: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+143
-21
@@ -1,87 +1,209 @@
|
|||||||
---
|
---
|
||||||
title: 'New journal entry'
|
title: 'New Entry'
|
||||||
template: post-form
|
template: post-form
|
||||||
access:
|
access:
|
||||||
site.login: true
|
site.login: true
|
||||||
|
|
||||||
|
# Parent (write target) is injected server-side from site.active_trip by the
|
||||||
|
# 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:
|
||||||
parent: /trips/italy-2026-demo/dailies
|
|
||||||
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
|
||||||
action: /post
|
action: /post
|
||||||
enctype: multipart/form-data
|
enctype: multipart/form-data
|
||||||
fields:
|
fields:
|
||||||
|
# Photos come first — they anchor what you'll write (Photos → Title →
|
||||||
|
# Content). After upload post-form.js auto-collapses this into a summary
|
||||||
|
# bar so the thumbnails don't crowd the writing area.
|
||||||
|
-
|
||||||
|
name: photos
|
||||||
|
label: Photos (1–6)
|
||||||
|
# Grav-managed filepond field. post-form.js hooks its beforeAddFile
|
||||||
|
# to convert HEIC->JPEG in the browser, then re-adds the JPEG via the
|
||||||
|
# public pond.addFile() API — FilePond keeps ownership of the upload
|
||||||
|
# and page-attach contract (U4).
|
||||||
|
type: filepond
|
||||||
|
multiple: true
|
||||||
|
destination: '@self'
|
||||||
|
# Max 6 photos per entry; at least 1 is required (enforced
|
||||||
|
# client-side in post-form.js initValidation).
|
||||||
|
limit: 6
|
||||||
|
accept:
|
||||||
|
- 'image/*'
|
||||||
|
|
||||||
-
|
-
|
||||||
name: title
|
name: title
|
||||||
label: Title
|
label: Title
|
||||||
type: text
|
type: text
|
||||||
autofocus: true
|
|
||||||
validate:
|
validate:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
-
|
-
|
||||||
name: date
|
name: date
|
||||||
label: 'Date & Time'
|
label: Date & Time
|
||||||
|
# Rendered as a native <input type="datetime-local"> by the theme
|
||||||
|
# override at templates/forms/fields/datetime/datetime.html.twig.
|
||||||
|
# No `default: now` — the stock text fallback would print the literal
|
||||||
|
# string "now" into the picker; post-form.js prefills the current
|
||||||
|
# local time instead (and the client validator requires this field).
|
||||||
type: datetime
|
type: datetime
|
||||||
default: now
|
|
||||||
format: 'Y-m-d H:i'
|
format: 'Y-m-d H:i'
|
||||||
validate:
|
validate:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
-
|
-
|
||||||
name: content
|
name: content
|
||||||
label: 'What happened today?'
|
label: "What happened today?"
|
||||||
type: textarea
|
type: textarea
|
||||||
rows: 10
|
rows: 10
|
||||||
validate:
|
validate:
|
||||||
required: true
|
required: true
|
||||||
-
|
|
||||||
name: photos
|
|
||||||
label: 'Photos (max 4)'
|
|
||||||
type: filepond
|
|
||||||
multiple: true
|
|
||||||
destination: '@self'
|
|
||||||
limit: 4
|
|
||||||
accept:
|
|
||||||
- 'image/*'
|
|
||||||
-
|
-
|
||||||
name: lat
|
name: lat
|
||||||
label: Latitude
|
label: Latitude
|
||||||
type: text
|
type: text
|
||||||
placeholder: 'tap "Get Location" below'
|
placeholder: 'tap "Get Location" below'
|
||||||
|
|
||||||
-
|
-
|
||||||
name: lng
|
name: lng
|
||||||
label: Longitude
|
label: Longitude
|
||||||
type: text
|
type: text
|
||||||
placeholder: ''
|
placeholder: ''
|
||||||
|
|
||||||
-
|
-
|
||||||
name: location_city
|
name: location_city
|
||||||
label: City
|
label: City
|
||||||
type: text
|
type: text
|
||||||
placeholder: 'e.g. Kyoto'
|
placeholder: 'e.g. Kyoto'
|
||||||
|
|
||||||
-
|
-
|
||||||
name: location_country
|
name: location_country
|
||||||
label: Country
|
label: Country
|
||||||
type: text
|
type: text
|
||||||
placeholder: 'e.g. Japan'
|
placeholder: 'e.g. Japan'
|
||||||
-
|
|
||||||
name: weather_temp_c
|
|
||||||
type: hidden
|
|
||||||
-
|
-
|
||||||
name: weather_desc
|
name: weather_desc
|
||||||
|
label: Weather Condition
|
||||||
|
type: select
|
||||||
|
default: ''
|
||||||
|
options:
|
||||||
|
'': '— none —'
|
||||||
|
'Sunny': '☀️ Sunny'
|
||||||
|
'Partly cloudy': '⛅ Partly cloudy'
|
||||||
|
'Cloudy': '☁️ Cloudy'
|
||||||
|
'Foggy': '🌫️ Foggy'
|
||||||
|
'Drizzle': '🌦️ Drizzle'
|
||||||
|
'Rain': '🌧️ Rain'
|
||||||
|
'Snow': '❄️ Snow'
|
||||||
|
'Thunderstorm': '⛈️ Thunderstorm'
|
||||||
|
|
||||||
|
-
|
||||||
|
name: weather_temp_c
|
||||||
|
label: 'Temperature (°C)'
|
||||||
|
type: number
|
||||||
|
placeholder: 'tap "Get Weather" or type it'
|
||||||
|
validate:
|
||||||
|
min: -60
|
||||||
|
max: 60
|
||||||
|
|
||||||
|
-
|
||||||
|
name: transport_mode
|
||||||
|
label: 'How I got here'
|
||||||
|
type: select
|
||||||
|
default: ''
|
||||||
|
options:
|
||||||
|
'': '— not specified —'
|
||||||
|
'walking': '🚶 Walking'
|
||||||
|
'bicycle': '🚲 Bicycle'
|
||||||
|
'bus': '🚌 Bus'
|
||||||
|
'train': '🚆 Train'
|
||||||
|
'car': '🚗 Car'
|
||||||
|
'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
|
type: hidden
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
# Advanced fields — collapsed behind "More options" (see U5).
|
||||||
|
# No hero_image field: journal entries render their hero from the first
|
||||||
|
# uploaded photo (entry-journal.html.twig uses entry.media.images|first),
|
||||||
|
# so an explicit hero filename was redundant. Stories still use hero_image
|
||||||
|
# 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
|
||||||
|
label: Force connector line
|
||||||
|
type: toggle
|
||||||
|
classes: advanced-field
|
||||||
|
highlight: 0
|
||||||
|
default: 0
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
|
||||||
|
-
|
||||||
|
name: featured
|
||||||
|
label: Featured highlight
|
||||||
|
type: toggle
|
||||||
|
classes: advanced-field
|
||||||
|
highlight: 0
|
||||||
|
default: 0
|
||||||
|
options:
|
||||||
|
1: 'Yes'
|
||||||
|
0: 'No'
|
||||||
|
validate:
|
||||||
|
type: bool
|
||||||
|
|
||||||
novalidate: true
|
novalidate: true
|
||||||
|
|
||||||
buttons:
|
buttons:
|
||||||
-
|
-
|
||||||
type: submit
|
type: submit
|
||||||
value: 'Post Daily'
|
value: Post Daily
|
||||||
classes: btn-post
|
classes: btn-post
|
||||||
|
|
||||||
process:
|
process:
|
||||||
add_page: true
|
add_page: true
|
||||||
upload: true
|
upload: true
|
||||||
message: 'Entry posted successfully!'
|
message: 'Entry posted successfully!'
|
||||||
reset: true
|
reset: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,344 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Grav\Plugin;
|
namespace Grav\Plugin;
|
||||||
|
|
||||||
|
use Grav\Common\Cache;
|
||||||
|
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';
|
||||||
|
require_once __DIR__ . '/classes/PhotoRenumberer.php';
|
||||||
|
|
||||||
|
use Grav\Plugin\Shared\EntryScopeGuard;
|
||||||
|
use Grav\Plugin\Shared\PhotoRenumberer;
|
||||||
|
|
||||||
class CacheOnSavePlugin extends Plugin
|
class CacheOnSavePlugin extends Plugin
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* onFormProcessed fires once per `process:` action (add_page, upload, message,
|
||||||
|
* reset — 4x for the post form). Photo reconciliation must run exactly once:
|
||||||
|
* the first pass renames the kept photos to photo-01..NN, so a second pass with
|
||||||
|
* the same manifest would see those renamed files as "unlisted" and delete
|
||||||
|
* them. This latches after the first run (the plugin instance persists for the
|
||||||
|
* request); the first fire is the `add_page` action, after add-page-by-form
|
||||||
|
* (priority 0) has created the page and copied files, so files are present.
|
||||||
|
*/
|
||||||
|
private bool $photosReconciled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same 4x-per-submit firing as $photosReconciled: onFormProcessed runs once
|
||||||
|
* per process action. Clearing the page-tree cache is idempotent, but doing it
|
||||||
|
* four times per post is wasted work (a full deleteAll() + system.yaml touch
|
||||||
|
* each time). Latch it so the invalidation runs exactly once per submission.
|
||||||
|
*/
|
||||||
|
private bool $cacheInvalidated = false;
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'onFormProcessed' => ['onFormProcessed', 0],
|
// Runs before add-page-by-form's onFormProcessed (page write), so it
|
||||||
|
// can inject the write target and abort the submit by failing validation.
|
||||||
|
'onFormValidationProcessed' => ['onFormValidationProcessed', 0],
|
||||||
|
// Priority -100 so this runs AFTER add-page-by-form's onFormProcessed
|
||||||
|
// (priority 0) has created the page and copied the uploaded files —
|
||||||
|
// we reorder those files, then clear the page-tree cache.
|
||||||
|
'onFormProcessed' => ['onFormProcessed', -100],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-authoritative active-trip parent injection.
|
||||||
|
*
|
||||||
|
* The post form no longer hardcodes `pageconfig.parent`; instead the write
|
||||||
|
* target is derived from `site.active_trip` at submit time and injected into
|
||||||
|
* the form data. add-page-by-form reads `$form->value()->toArray()['parent']`
|
||||||
|
* (add-page-by-form.php:521) and honours it over any pageconfig/header parent.
|
||||||
|
*
|
||||||
|
* Fail closed: if `active_trip` is unset/empty we throw a ValidationException
|
||||||
|
* so the `add_page` action never runs. Merely leaving `parent` unset is unsafe —
|
||||||
|
* with `pageconfig.parent` removed, add-page-by-form's `getParentPage('')`
|
||||||
|
* resolves to the /post page itself and the entry would silently land there.
|
||||||
|
*/
|
||||||
|
public function onFormValidationProcessed(Event $event): void
|
||||||
|
{
|
||||||
|
$form = $event['form'];
|
||||||
|
if (!$form || $form->getName() !== 'new-entry') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||||
|
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||||
|
|
||||||
|
if ($activeTrip === '') {
|
||||||
|
throw new ValidationException('No active trip is set — cannot post an entry. Set site.active_trip first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise `active_trip` to its dailies container route.
|
||||||
|
*
|
||||||
|
* Accepts either a full route ("/trips/italy-2026-demo") or a bare slug
|
||||||
|
* ("italy-2026-demo") and returns "/trips/<slug>/dailies".
|
||||||
|
*/
|
||||||
|
private function resolveDailiesParent(string $activeTrip): string
|
||||||
|
{
|
||||||
|
$trip = trim($activeTrip, '/');
|
||||||
|
if (strpos($trip, 'trips/') !== 0) {
|
||||||
|
$trip = 'trips/' . $trip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/' . $trip . '/dailies';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The photo order the user arranged in the form, sent explicitly by
|
||||||
|
* post-form.js as a JSON array of filenames in the dedicated
|
||||||
|
* data[photo_order] input. FilePond does not re-sequence its own submitted
|
||||||
|
* inputs on reorder, so this is the only reliable source of the drag order.
|
||||||
|
* Read straight from $_POST as a top-level key (not under data[]), so Grav's
|
||||||
|
* form never captures it and it never lands in the entry frontmatter.
|
||||||
|
*/
|
||||||
|
private function orderFromPost(): array
|
||||||
|
{
|
||||||
|
$raw = $_POST['photo_order'] ?? null;
|
||||||
|
if (!is_string($raw) || $raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
foreach ($decoded as $name) {
|
||||||
|
if (is_string($name) && $name !== '') {
|
||||||
|
$names[] = basename(str_replace('\\', '/', $name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
public function onFormProcessed(Event $event): void
|
public function onFormProcessed(Event $event): void
|
||||||
{
|
{
|
||||||
$form = $event['form'];
|
$form = $event['form'];
|
||||||
if (!$form) {
|
if (!$form || $form->getName() !== 'new-entry') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($form->getName() === 'new-entry') {
|
|
||||||
$this->grav['cache']->deleteAll();
|
// Reconcile the entry's photos to the order the owner arranged in the form
|
||||||
|
// (FilePond drag). On create this only renumbers the just-copied uploads;
|
||||||
|
// on edit (M2) it also removes any photo the owner dropped and renumbers
|
||||||
|
// the surviving set so the first file is the cover. Runs ONCE per submit
|
||||||
|
// (see $photosReconciled) — a second pass would delete the just-renamed
|
||||||
|
// photo-N files as "unlisted". Best-effort: any failure logs and is
|
||||||
|
// skipped so a post is never lost over cosmetics.
|
||||||
|
if (!$this->photosReconciled) {
|
||||||
|
$this->photosReconciled = true;
|
||||||
|
try {
|
||||||
|
$this->reconcilePhotos($form);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->grav['log']->warning('cache-on-save: photo reconcile skipped — ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two-part invalidation, latched to run ONCE per submit (see
|
||||||
|
// $cacheInvalidated) — the 4 process actions would otherwise repeat it.
|
||||||
|
// deleteAll() drops the Doctrine store (the tracker feed page cache etc.),
|
||||||
|
// but the page-tree INDEX is keyed on
|
||||||
|
// md5(dirs + folderHash + config->checksum() + lang) (Pages::buildRegularPages).
|
||||||
|
// With cache.check.method:folder that index can survive a create — a fresh
|
||||||
|
// entry then stays invisible to the API (GET /api/v1/pages{route} 404s), so
|
||||||
|
// opening the just-posted entry for editing shows "this entry no longer
|
||||||
|
// exists". invalidateCache() touches system.yaml, bumping config->checksum()
|
||||||
|
// so the index key changes and the tree rebuilds on the next request.
|
||||||
|
if (!$this->cacheInvalidated) {
|
||||||
|
$this->cacheInvalidated = true;
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
Cache::invalidateCache();
|
||||||
|
$this->grav['log']->info('cache-on-save: cleared page cache + invalidated page-tree index after new-entry submit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the entry's photo files to the submitted (drag) order.
|
||||||
|
*
|
||||||
|
* The published entry lists media in filename order and treats the first as
|
||||||
|
* the hero/cover (see partials/entry-journal + entry-story), so a deterministic
|
||||||
|
* photo-N naming is what makes the arranged order stick. post-form.js sends the
|
||||||
|
* final ordered set via the top-level `photo_order` POST key (orderFromPost):
|
||||||
|
* on create these are the just-uploaded client filenames; on edit (M2) the mix
|
||||||
|
* of surviving existing photos (loaded into FilePond as local items) plus any
|
||||||
|
* new uploads, in the arranged order.
|
||||||
|
*
|
||||||
|
* Create: fuzzily locate the fresh folder by its uploaded filenames, then
|
||||||
|
* renumber. Edit: locate the folder authoritatively through the page tree via
|
||||||
|
* the shared scope guard (findEntryFolder is unsafe once files are the generic
|
||||||
|
* photo-N.jpg — many entries share those names), delete any image the owner
|
||||||
|
* dropped (not in the manifest), then renumber the survivors.
|
||||||
|
*
|
||||||
|
* Fail-safe: an empty manifest reconciles nothing (photos are left untouched),
|
||||||
|
* so a missing/failed `photo_order` on edit never wipes an entry's images.
|
||||||
|
*/
|
||||||
|
private function reconcilePhotos($form): void
|
||||||
|
{
|
||||||
|
$names = $this->orderFromPost();
|
||||||
|
if (count($names) < 1) {
|
||||||
|
return; // nothing submitted — leave the entry's photos untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
$editPath = $this->editPathFromForm($form);
|
||||||
|
if ($editPath !== '') {
|
||||||
|
// EDIT — resolve the target folder through the page tree (shared guard),
|
||||||
|
// then prune dropped photos before renumbering the survivors.
|
||||||
|
$segment = EntryScopeGuard::segmentFromEditPath($editPath);
|
||||||
|
$page = EntryScopeGuard::resolveActiveDailyChild($this->grav, $segment);
|
||||||
|
if ($page === null) {
|
||||||
|
return; // out of scope / unresolvable — the save guard already ran
|
||||||
|
}
|
||||||
|
$dir = $page->path();
|
||||||
|
$this->deleteUnlistedImages($dir, $names);
|
||||||
|
} else {
|
||||||
|
// CREATE — locate the fresh folder by the set of uploaded filenames.
|
||||||
|
$activeTrip = $this->grav['config']->get('site.active_trip');
|
||||||
|
$activeTrip = is_string($activeTrip) ? trim($activeTrip) : '';
|
||||||
|
if ($activeTrip === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$slug = preg_replace('#^/?trips/#', '', trim($activeTrip, '/'));
|
||||||
|
$slug = preg_replace('#/.*$#', '', $slug);
|
||||||
|
$dir = $this->findEntryFolder($slug, $names);
|
||||||
|
if ($dir === null) {
|
||||||
|
return; // couldn't confidently locate the new entry folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoRenumberer::renumber($dir, $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every image file in $dir whose basename is not in $keep (the manifest
|
||||||
|
* of photos the owner kept). Only touches known image extensions — never the
|
||||||
|
* entry .md or any other file — and clears any Grav media sidecar so a stale
|
||||||
|
* `.meta.yaml` can't resurrect a removed image.
|
||||||
|
*/
|
||||||
|
private function deleteUnlistedImages(string $dir, array $keep): void
|
||||||
|
{
|
||||||
|
$keepSet = array_flip($keep);
|
||||||
|
$imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||||
|
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$base = basename($path);
|
||||||
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, $imageExts, true)) {
|
||||||
|
continue; // never touch .md or non-image files
|
||||||
|
}
|
||||||
|
if (isset($keepSet[$base])) {
|
||||||
|
continue; // still in the arranged set — keep it
|
||||||
|
}
|
||||||
|
@unlink($path);
|
||||||
|
if (is_file($path . '.meta.yaml')) {
|
||||||
|
@unlink($path . '.meta.yaml');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the freshly-created entry folder: the child of the active trip's
|
||||||
|
* dailies directory that contains all of the uploaded files. Matching by the
|
||||||
|
* exact uploaded filenames avoids re-deriving add-page-by-form's slug logic.
|
||||||
|
*/
|
||||||
|
private function findEntryFolder(string $slug, array $names): ?string
|
||||||
|
{
|
||||||
|
$pagesRoot = rtrim(USER_DIR, '/\\') . '/pages';
|
||||||
|
$dailies = null;
|
||||||
|
foreach (glob($pagesRoot . '/*trips*', GLOB_ONLYDIR) ?: [] as $tripsDir) {
|
||||||
|
foreach (glob($tripsDir . '/*', GLOB_ONLYDIR) ?: [] as $tripDir) {
|
||||||
|
$base = basename($tripDir);
|
||||||
|
if ($base === $slug || preg_match('/(^|\.)' . preg_quote($slug, '/') . '$/', $base)) {
|
||||||
|
$found = glob($tripDir . '/*dailies*', GLOB_ONLYDIR) ?: [];
|
||||||
|
if ($found) {
|
||||||
|
$dailies = $found[0];
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($dailies === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (glob($dailies . '/*', GLOB_ONLYDIR) ?: [] as $child) {
|
||||||
|
$allPresent = true;
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (!is_file($child . DIRECTORY_SEPARATOR . $name)) {
|
||||||
|
$allPresent = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($allPresent) {
|
||||||
|
return $child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,131 @@
|
|||||||
|
<?php
|
||||||
|
namespace Grav\Plugin\Shared;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the `photo-NN` naming invariant, shared by BOTH
|
||||||
|
* paths that establish it so their numbering can never diverge:
|
||||||
|
* - cache-on-save's create/edit reconcile (onFormProcessed), and
|
||||||
|
* - entry-actions' live reorder route (POST /entry/{slug}/photos/order).
|
||||||
|
*
|
||||||
|
* Files named in the ordered manifest are renamed to `photo-01..NN` (ZERO-PADDED)
|
||||||
|
* in that order. Zero-padding is load-bearing: the published feed lists media in
|
||||||
|
* filename order and treats the first as the cover (entry.media.images|first), and
|
||||||
|
* lexicographic order only equals numeric order past 9 photos when the index is
|
||||||
|
* padded (otherwise photo-1, photo-10, photo-2…). The pad width grows with the
|
||||||
|
* set so it stays correct for 100+ photos, while normal entries get `photo-01`.
|
||||||
|
*
|
||||||
|
* Safety: only files that are ON DISK and carry a known IMAGE extension are ever
|
||||||
|
* renamed. A crafted manifest entry naming the entry `.md`, a `.gpx`, or a
|
||||||
|
* `.meta.yaml` sidecar is silently skipped — it can never be renamed or clobbered.
|
||||||
|
* This guard lives here (not only in the callers) so every caller inherits it.
|
||||||
|
*
|
||||||
|
* Completeness: renumber() ALWAYS renumbers every image already in $dir, not just
|
||||||
|
* the manifest subset. $names only supplies the preferred ORDER; any on-disk image
|
||||||
|
* the manifest omits is appended at the end. This makes an incomplete/stale
|
||||||
|
* manifest (e.g. a second browser tab whose list predates a change) harmless —
|
||||||
|
* without it, an unlisted image left sitting at a target slot would be silently
|
||||||
|
* OVERWRITTEN (destroyed) by the second-phase rename. The reorder route trusts a
|
||||||
|
* client-supplied list, so this guard is what keeps it from losing photos.
|
||||||
|
*/
|
||||||
|
class PhotoRenumberer
|
||||||
|
{
|
||||||
|
/** Image extensions eligible for renumbering. Broad on purpose: existing
|
||||||
|
* entries may hold .heic even though new uploads are jpg/jpeg/png/webp. */
|
||||||
|
private const IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renumber every image in $dir to photo-01..NN, using $names as the preferred
|
||||||
|
* order and appending any unlisted on-disk images at the end.
|
||||||
|
*
|
||||||
|
* Two-phase via temp names so a target (photo-02.jpg) can't clobber a
|
||||||
|
* not-yet-moved source of the same name (e.g. a straight swap or the
|
||||||
|
* un-padded photo-N → photo-0N normalisation pass). Non-image and missing
|
||||||
|
* files in $names are skipped and do not consume an index; a repeated name is
|
||||||
|
* counted once. Because every on-disk image becomes a target (see the
|
||||||
|
* completeness note on the class), the surviving images are numbered
|
||||||
|
* contiguously from 01 and no untouched file is ever overwritten.
|
||||||
|
*
|
||||||
|
* Idempotent: a file already at its correct padded name is left untouched,
|
||||||
|
* so re-running with the same manifest (e.g. an auto-retried reorder) is a
|
||||||
|
* no-op.
|
||||||
|
*/
|
||||||
|
public static function renumber(string $dir, array $names): void
|
||||||
|
{
|
||||||
|
// Keep only real image files, in the requested order, de-duplicated —
|
||||||
|
// this is both the security filter and what determines the pad width.
|
||||||
|
$targets = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (!is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$base = basename(str_replace('\\', '/', $name));
|
||||||
|
if (isset($seen[$base])) {
|
||||||
|
continue; // a repeated name must not consume a second index
|
||||||
|
}
|
||||||
|
$src = $dir . DIRECTORY_SEPARATOR . $base;
|
||||||
|
if (!is_file($src)) {
|
||||||
|
continue; // not on disk — skip (idempotent for auto-retry)
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, self::IMAGE_EXTS, true)) {
|
||||||
|
continue; // never rename the entry .md, a .gpx, or a sidecar
|
||||||
|
}
|
||||||
|
$seen[$base] = true;
|
||||||
|
$targets[] = [$src, $ext ?: 'jpg'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completeness guard: append EVERY other image already in $dir that the
|
||||||
|
// manifest didn't list (natural name order), so an incomplete/stale
|
||||||
|
// manifest can't leave an unlisted image at a target slot for phase-2 to
|
||||||
|
// overwrite. Hidden files ('.'-prefixed temp/sidecar) are never targets.
|
||||||
|
$extra = [];
|
||||||
|
foreach (@scandir($dir) ?: [] as $f) {
|
||||||
|
if ($f === '' || $f[0] === '.' || isset($seen[$f])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$p = $dir . DIRECTORY_SEPARATOR . $f;
|
||||||
|
if (!is_file($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, self::IMAGE_EXTS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$extra[$f] = [$p, $ext ?: 'jpg'];
|
||||||
|
}
|
||||||
|
if ($extra) {
|
||||||
|
uksort($extra, 'strnatcasecmp');
|
||||||
|
foreach ($extra as $t) {
|
||||||
|
$targets[] = $t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = max(2, strlen((string) count($targets)));
|
||||||
|
|
||||||
|
// Unique per-call token in the temp name so two concurrent renumbers on
|
||||||
|
// the same folder can't collide on a shared '.reorder-tmp-N' path and
|
||||||
|
// overwrite one photo's bytes.
|
||||||
|
$token = bin2hex(random_bytes(4));
|
||||||
|
|
||||||
|
$planned = [];
|
||||||
|
$i = 1;
|
||||||
|
foreach ($targets as [$src, $ext]) {
|
||||||
|
$index = str_pad((string) $i, $width, '0', STR_PAD_LEFT);
|
||||||
|
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $index . '.' . $ext;
|
||||||
|
if ($src === $final) {
|
||||||
|
$i++;
|
||||||
|
continue; // already correctly named — leave it
|
||||||
|
}
|
||||||
|
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $token . '-' . $i . '.' . $ext;
|
||||||
|
@rename($src, $tmp);
|
||||||
|
$planned[] = [$tmp, $final];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
foreach ($planned as [$tmp, $final]) {
|
||||||
|
if (is_file($tmp)) {
|
||||||
|
@rename($tmp, $final);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,136 @@
|
|||||||
|
<?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 Grav\Plugin\Shared\PhotoRenumberer;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
// Shared R6 guard + the photo-NN renumber helper both live in cache-on-save (the
|
||||||
|
// always-present custom plugin); require them so save, delete and reorder enforce
|
||||||
|
// scope identically (KTD5) and share one numbering invariant.
|
||||||
|
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/EntryScopeGuard.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/cache-on-save/classes/PhotoRenumberer.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);
|
||||||
|
// Enforce the API-key scope cap (GHSA-x7hm) with the SAME permission the
|
||||||
|
// stock media/page-write endpoints require. The owner already holds it
|
||||||
|
// (their add/delete media uploads pass it), so this only caps a scoped
|
||||||
|
// key — it never blocks the legitimate owner.
|
||||||
|
$this->requirePermission($request, 'api.pages.write');
|
||||||
|
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();
|
||||||
|
// Audit trail: entry deletion is destructive and owner-only — record who
|
||||||
|
// did it and to what, so an unexpected disappearance is traceable.
|
||||||
|
$this->grav['log']->info(sprintf('entry-actions: owner "%s" deleted entry "%s"', $user->username, $slug));
|
||||||
|
|
||||||
|
return ApiResponse::noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/entry/{slug}/photos/order
|
||||||
|
*
|
||||||
|
* Body: { "order": ["photo-x.jpg", "photo-y.jpg", …] } — the entry's image
|
||||||
|
* files in the display order the owner arranged. Renames them to photo-01..NN
|
||||||
|
* so the feed cover (media.images|first) and numeric client sort follow the
|
||||||
|
* drag. Same guard chain as deleteEntry: OWNER + direct-child-of-active-dailies.
|
||||||
|
*
|
||||||
|
* Filename safety is defence in depth: unsafe segments (containing '/' or '..')
|
||||||
|
* are dropped here, and PhotoRenumberer only ever renames files that already
|
||||||
|
* exist as image media in the folder — so a crafted order body can never touch
|
||||||
|
* the entry .md, a .gpx or a .meta.yaml sidecar. An incomplete `order` (e.g. a
|
||||||
|
* stale second tab) is safe too: PhotoRenumberer renumbers every on-disk image,
|
||||||
|
* appending any the manifest omits, so no photo is lost — `order` only sorts.
|
||||||
|
*/
|
||||||
|
public function reorderPhotos(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 reorder entry photos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $this->getRouteParam($request, 'slug');
|
||||||
|
if (!is_string($slug) || !EntryScopeGuard::isSafeSegment($slug)) {
|
||||||
|
throw new ApiException(400, 'Bad Request', 'Invalid entry slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce the body's `order` to a clean list of safe basenames. Anything
|
||||||
|
// unsafe or non-string is dropped; PhotoRenumberer then keeps only the
|
||||||
|
// entries that are real image files on disk.
|
||||||
|
$body = $this->getRequestBody($request);
|
||||||
|
$order = $body['order'] ?? null;
|
||||||
|
if (!is_array($order)) {
|
||||||
|
throw new ApiException(400, 'Bad Request', 'Body must include an "order" array of filenames.');
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
foreach ($order as $name) {
|
||||||
|
if (is_string($name) && EntryScopeGuard::isSafeSegment($name)) {
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoRenumberer::renumber($path, $names);
|
||||||
|
$this->grav['cache']->deleteAll();
|
||||||
|
// Audit trail: mirror deleteEntry — record the owner mutating an entry's
|
||||||
|
// photo order (and how many files the manifest listed).
|
||||||
|
$this->grav['log']->info(sprintf('entry-actions: owner "%s" reordered %d photo(s) for entry "%s"', $user->username, count($names), $slug));
|
||||||
|
|
||||||
|
return ApiResponse::noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?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.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* - DELETE /api/v1/entry/{slug} — delete a journal entry folder
|
||||||
|
* - POST /api/v1/entry/{slug}/photos/order — reorder an entry's photos
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* rename media to the photo-NN cover order at all. Both custom routes require the
|
||||||
|
* configured site OWNER and assert the target is a direct child of the active
|
||||||
|
* trip's dailies container — sharing one guard (EntryScopeGuard) with the save
|
||||||
|
* path so the 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']);
|
||||||
|
// Reorder an entry's photos to a client-supplied order → rename to
|
||||||
|
// photo-01..NN so the feed cover (media.images|first) follows the drag.
|
||||||
|
// 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
var g=Object.create;var f=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var j=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var m=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(b,c)=>(typeof require<"u"?require:b)[c]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var n=(a,b)=>()=>(b||a((b={exports:{}}).exports,b),b.exports);var l=(a,b,c,e)=>{if(b&&typeof b=="object"||typeof b=="function")for(let d of i(b))!k.call(a,d)&&d!==c&&f(a,d,{get:()=>b[d],enumerable:!(e=h(b,d))||e.enumerable});return a};var o=(a,b,c)=>(c=a!=null?g(j(a)):{},l(b||!a||!a.__esModule?f(c,"default",{value:a,enumerable:!0}):c,a));export{m as a,n as b,o as c};
|
||||||
File diff suppressed because one or more lines are too long
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
/*
|
||||||
|
* /post form bundle CSS. U3 scope: make the EasyMDE editor usable without the
|
||||||
|
* FontAwesome dependency (toolbar glyphs supplied here). Field Notes theming,
|
||||||
|
* 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. */
|
||||||
|
.editor-toolbar .mde-btn::before {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.editor-toolbar .mde-bold::before { content: 'B'; font-weight: 700; }
|
||||||
|
.editor-toolbar .mde-italic::before { content: 'I'; font-style: italic; }
|
||||||
|
.editor-toolbar .mde-ul::before { content: '\2022\2002\2014'; } /* • — */
|
||||||
|
.editor-toolbar .mde-link::before { content: '\1F517'; } /* 🔗 */
|
||||||
|
.editor-toolbar .mde-preview::before { content: '\1F441'; } /* 👁 */
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo HEIC-conversion feedback (U4) ────────────────────
|
||||||
|
* FilePond renders the thumbnails/progress; these are the pre-FilePond
|
||||||
|
* "converting…" status line and the draft photos-reselect hint (U6).
|
||||||
|
*/
|
||||||
|
.photo-convert-status:empty { display: none; }
|
||||||
|
.photo-convert-status {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.photo-reauth-hint {
|
||||||
|
display: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.photo-reauth-hint.is-shown { display: block; }
|
||||||
|
|
||||||
|
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
|
||||||
|
|
||||||
|
/* Select fields (weather condition, transport mode) match the text inputs. */
|
||||||
|
.post-form-wrap select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2390887E' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
.post-form-wrap select:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.post-form-wrap input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.post-form-wrap input[type="number"]:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "More options" disclosure */
|
||||||
|
.more-options {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
}
|
||||||
|
.more-options__summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink);
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.more-options__summary::-webkit-details-marker { display: none; }
|
||||||
|
.more-options__summary::before {
|
||||||
|
content: '▸';
|
||||||
|
margin-right: var(--space-2);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.more-options[open] .more-options__summary::before { transform: rotate(90deg); }
|
||||||
|
.more-options[open] .more-options__summary { border-bottom: 1px solid var(--color-border); }
|
||||||
|
.more-options > .form-field { padding: 0 1rem; }
|
||||||
|
.more-options > .form-field:first-of-type { padding-top: var(--space-4); }
|
||||||
|
.more-options > .form-field:last-child { padding-bottom: var(--space-4); margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Button loading spinner (Get Location / Get Weather) */
|
||||||
|
.btn-action.is-loading { position: relative; color: transparent; pointer-events: none; }
|
||||||
|
.btn-action.is-loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: -8px 0 0 -8px;
|
||||||
|
border: 2px solid var(--color-ink-muted);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: post-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
.btn-action[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
@keyframes post-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Grav renders form status as <div class="notices success|error green|red">.
|
||||||
|
The theme doesn't style .notices, so on the dark background it was nearly
|
||||||
|
invisible — style it prominently here. */
|
||||||
|
.post-form-wrap .notices {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border-left: 4px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
.post-form-wrap .notices p { margin: 0; }
|
||||||
|
.post-form-wrap .notices.error,
|
||||||
|
.post-form-wrap .notices.red { border-left-color: var(--color-error); }
|
||||||
|
.post-form-wrap .notices.success,
|
||||||
|
.post-form-wrap .notices.green { border-left-color: var(--color-accent); }
|
||||||
|
|
||||||
|
/* Post-success confirmation CTA (injected by post-form.js). */
|
||||||
|
.post-success {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
padding: 1.1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
.post-success__title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink);
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
.post-success__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
||||||
|
.post-success__view {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-on);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.post-success__again {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide FilePond's "Powered by PQINA" credit. */
|
||||||
|
.filepond--credits { display: none !important; }
|
||||||
|
|
||||||
|
/* Field Notes dark theme for the FilePond widget — the default is a light/cream
|
||||||
|
panel that clashes with the site's warm near-black palette. Repaint the drop
|
||||||
|
zone, thumbnails and actions with the design tokens. */
|
||||||
|
.filepond--root { font-family: var(--font-ui); font-size: var(--text-base); }
|
||||||
|
.filepond--panel-root {
|
||||||
|
background-color: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.filepond--drop-label,
|
||||||
|
.filepond--drop-label label { color: var(--color-ink-muted); }
|
||||||
|
.filepond--label-action {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.filepond--item-panel {
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.filepond--drip-blob { background-color: var(--color-accent); }
|
||||||
|
.filepond--file { color: var(--color-ink); }
|
||||||
|
.filepond--file-action-button {
|
||||||
|
color: var(--color-ink);
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.filepond--file-action-button:hover { background-color: rgba(0, 0, 0, 0.65); }
|
||||||
|
|
||||||
|
/* Issue #2: FilePond's completed thumbnail carries a murky gradient tint + the
|
||||||
|
filename overlay, which reads as "something's wrong". Strip those overlays
|
||||||
|
and show a single clean green ✓ badge so a finished upload is unambiguous.
|
||||||
|
The remove (×) action button is left untouched so photos stay removable. */
|
||||||
|
.filepond--image-preview-overlay { display: none !important; }
|
||||||
|
.filepond--item[data-filepond-item-state="processing-complete"] .filepond--file-info,
|
||||||
|
.filepond--item[data-filepond-item-state="processing-complete"] .filepond--file-status {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.filepond--item[data-filepond-item-state="processing-complete"]::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo thumbnail grid (edit + create) ─────────────────────────────────
|
||||||
|
Render FilePond items as a compact square-thumbnail grid so existing photos
|
||||||
|
(loaded on edit) and new uploads are visible and reorderable without the
|
||||||
|
full-width, overflowing vertical list the default produced. Setting a fixed
|
||||||
|
item width is FilePond's supported way to switch its layout engine to a grid. */
|
||||||
|
.filepond--list.filepond--list {
|
||||||
|
/* FilePond positions items absolutely; the wrapper needs a little inset so
|
||||||
|
the 3-up grid doesn't butt against the drop-zone edge. */
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
.filepond--item {
|
||||||
|
width: calc(33.333% - 0.5rem);
|
||||||
|
}
|
||||||
|
/* Square thumbnails regardless of source aspect ratio. */
|
||||||
|
.filepond--item .filepond--panel-root,
|
||||||
|
.filepond--item .filepond--image-preview-wrapper { border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
/* Native image dragging hijacked FilePond's reorder: dragging a thumbnail
|
||||||
|
started a browser ghost-image / page scroll instead of moving the item.
|
||||||
|
Disable user-drag on the preview so FilePond owns the gesture. */
|
||||||
|
.filepond--image-preview,
|
||||||
|
.filepond--image-preview-wrapper,
|
||||||
|
.filepond--image-clip,
|
||||||
|
.filepond--image-preview canvas,
|
||||||
|
.filepond--image-bitmap,
|
||||||
|
.filepond--file img {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
-khtml-user-drag: none;
|
||||||
|
user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing photos loaded for editing sit in FilePond's 'idle' state (they are
|
||||||
|
already on the server, never uploaded). Give them the same clean look as a
|
||||||
|
completed upload — hide the filename/size overlay so the grid reads as photos,
|
||||||
|
not a list of filenames. */
|
||||||
|
.filepond--item[data-filepond-item-state="idle"] .filepond--file-info,
|
||||||
|
.filepond--item[data-filepond-item-state="idle"] .filepond--file-status {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue #3: photo section auto-collapses to a summary bar after upload — same
|
||||||
|
<details> affordance as "More options" above. */
|
||||||
|
.photos-collapse {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
}
|
||||||
|
.photos-collapse__summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink);
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.photos-collapse__summary::-webkit-details-marker { display: none; }
|
||||||
|
.photos-collapse__summary::before {
|
||||||
|
content: '▸';
|
||||||
|
margin-right: var(--space-2);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.photos-collapse[open] .photos-collapse__summary::before { transform: rotate(90deg); }
|
||||||
|
.photos-collapse[open] .photos-collapse__summary { border-bottom: 1px solid var(--color-border); }
|
||||||
|
.photos-collapse > :not(summary) { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.photos-collapse[open] .filepond--root,
|
||||||
|
.photos-collapse[open] .filepond-root { margin: var(--space-4) 0; }
|
||||||
|
.photos-collapse[open] > .photo-convert-status:last-child,
|
||||||
|
.photos-collapse[open] > .photo-reauth-hint:last-child { padding-bottom: var(--space-4); }
|
||||||
|
|
||||||
|
/* ── EasyMDE — Field Notes dark theme (U5) ─────────────────── */
|
||||||
|
.EasyMDEContainer .CodeMirror {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .CodeMirror-cursor { border-color: var(--color-ink); }
|
||||||
|
.EasyMDEContainer .CodeMirror-selected { background: var(--color-accent-light) !important; }
|
||||||
|
.EasyMDEContainer .editor-toolbar {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar button {
|
||||||
|
color: var(--color-ink) !important;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar button:hover,
|
||||||
|
.EasyMDEContainer .editor-toolbar button.active {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar i.separator { border-color: var(--color-border); }
|
||||||
|
.EasyMDEContainer .editor-preview,
|
||||||
|
.EasyMDEContainer .editor-preview-side {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
|
||||||
|
|
||||||
|
/* ── Photo editor (edit mode only) ────────────────────────────────────────
|
||||||
|
Own thumbnail grid that talks straight to the media API (add/delete/reorder
|
||||||
|
live), replacing the FilePond photo path when editing. */
|
||||||
|
.photo-editor {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
.photo-editor__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.photo-editor__label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.photo-editor__add {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.photo-editor__add:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.photo-editor__status {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
min-height: 1.2em;
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
.photo-editor__status.error { color: var(--color-error); }
|
||||||
|
|
||||||
|
.photo-editor__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.photo-editor__grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
.photo-editor__loading,
|
||||||
|
.photo-editor__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-editor__cell {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none; /* let SortableJS own the touch-drag gesture */
|
||||||
|
}
|
||||||
|
.photo-editor__cell:active { cursor: grabbing; }
|
||||||
|
.photo-editor__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none; /* the cell, not the image, is the drag handle */
|
||||||
|
}
|
||||||
|
/* First thumbnail is the feed cover. */
|
||||||
|
.photo-editor__cell:first-child::after {
|
||||||
|
content: 'Cover';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--space-1);
|
||||||
|
bottom: var(--space-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs, 0.7rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-accent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.photo-editor__del {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-1);
|
||||||
|
right: var(--space-1);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.photo-editor__del:hover { background: rgba(0, 0, 0, 0.75); }
|
||||||
|
.photo-editor__del:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* Inline delete confirm — covers the cell (option a: live delete, deliberate step). */
|
||||||
|
.photo-editor__confirm {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.photo-editor__confirm-q {
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.photo-editor__confirm button {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs, 0.72rem);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.photo-editor__confirm-yes { background: var(--color-error); color: #fff; }
|
||||||
|
.photo-editor__confirm-no { background: #fff; color: var(--color-ink); }
|
||||||
|
.photo-editor__confirm button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* SortableJS drag feedback. */
|
||||||
|
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
|
||||||
|
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+92
-1
@@ -8,9 +8,12 @@
|
|||||||
"@fontsource-variable/dm-sans": "latest",
|
"@fontsource-variable/dm-sans": "latest",
|
||||||
"@fontsource/dm-serif-display": "latest",
|
"@fontsource/dm-serif-display": "latest",
|
||||||
"@mapbox/togeojson": "^0.16.2",
|
"@mapbox/togeojson": "^0.16.2",
|
||||||
|
"easymde": "^2",
|
||||||
|
"heic-to": "^1",
|
||||||
"maplibre-gl": "^4",
|
"maplibre-gl": "^4",
|
||||||
"photoswipe": "^5",
|
"photoswipe": "^5",
|
||||||
"scrollama": "^3"
|
"scrollama": "^3",
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.21",
|
"esbuild": "^0.21",
|
||||||
@@ -523,6 +526,21 @@
|
|||||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/codemirror": {
|
||||||
|
"version": "5.60.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz",
|
||||||
|
"integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/tern": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/estree": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
@@ -555,6 +573,12 @@
|
|||||||
"@types/pbf": "*"
|
"@types/pbf": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/marked": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/pbf": {
|
"node_modules/@types/pbf": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||||
@@ -570,6 +594,15 @@
|
|||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/tern": {
|
||||||
|
"version": "0.23.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||||
|
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.13",
|
"version": "0.8.13",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||||
@@ -585,6 +618,21 @@
|
|||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "5.65.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz",
|
||||||
|
"integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/codemirror-spell-checker": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"typo-js": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
@@ -606,6 +654,19 @@
|
|||||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/easymde": {
|
||||||
|
"version": "2.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.21.0.tgz",
|
||||||
|
"integrity": "sha512-5uE7I/DEN8gvGRwxaqAv7h1PMEK2ykNXVX5zL0dK3nCYROGja3AMbdQz8eCEELnfvCfy7tRkTmLuvyJG8uSWjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/codemirror": "^5.60.10",
|
||||||
|
"@types/marked": "^4.0.7",
|
||||||
|
"codemirror": "^5.65.15",
|
||||||
|
"codemirror-spell-checker": "1.1.2",
|
||||||
|
"marked": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -683,6 +744,12 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heic-to": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==",
|
||||||
|
"license": "LGPL-3.0"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -796,6 +863,18 @@
|
|||||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
@@ -906,6 +985,12 @@
|
|||||||
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
|
"integrity": "sha512-PIPwB1kYBnbw/ezvPBJa5dCN5qEwokfpAkI3BmpZWAwcVID4nDf1qH6WV16A2fQaJmsKx0un5S/zhxN+PQeKDQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.15.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||||
|
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -936,6 +1021,12 @@
|
|||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/typo-js": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
{
|
{
|
||||||
"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 && 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; }"
|
"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",
|
||||||
"@fontsource/dm-serif-display": "latest",
|
"@fontsource/dm-serif-display": "latest",
|
||||||
"@mapbox/togeojson": "^0.16.2",
|
"@mapbox/togeojson": "^0.16.2",
|
||||||
|
"easymde": "^2",
|
||||||
|
"heic-to": "^1",
|
||||||
"maplibre-gl": "^4",
|
"maplibre-gl": "^4",
|
||||||
"photoswipe": "^5",
|
"photoswipe": "^5",
|
||||||
"scrollama": "^3"
|
"scrollama": "^3",
|
||||||
|
"sortablejs": "^1.15.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.21",
|
"esbuild": "^0.21",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# intotheeast override of Grav's datetime field.
|
||||||
|
|
||||||
|
Grav's stock forms/fields/datetime/datetime.html.twig is DEPRECATED and just
|
||||||
|
extends the text field, so `type: datetime` renders a plain <input type="text">
|
||||||
|
— the owner sees a raw box (the literal word "now" from `default: now`) with no
|
||||||
|
way to pick a date/time. Here we render a native <input type="datetime-local">
|
||||||
|
instead: a real calendar + clock picker, and an excellent one on mobile.
|
||||||
|
|
||||||
|
The value is prefilled to the current local time by post-form.js (JS knows the
|
||||||
|
traveller's timezone; the server doesn't), and the existing client-side
|
||||||
|
validator now requires this field — so an empty or invalid date can no longer
|
||||||
|
reach the server and destroy the FilePond photo list on a re-render. #}
|
||||||
|
{% extends "forms/field.html.twig" %}
|
||||||
|
|
||||||
|
{% block input_attributes %}
|
||||||
|
type="datetime-local"
|
||||||
|
{{ parent() }}
|
||||||
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
|
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
|
||||||
{% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %}
|
{% do assets.addJs('theme://js/nav.js', {group: 'bottom'}) %}
|
||||||
{% block map_assets %}{% endblock %}
|
{% block map_assets %}{% endblock %}
|
||||||
|
{% block head_assets %}{% endblock %}
|
||||||
{{ assets.css()|raw }}
|
{{ assets.css()|raw }}
|
||||||
{{ assets.js()|raw }}
|
{{ assets.js()|raw }}
|
||||||
</head>
|
</head>
|
||||||
@@ -26,6 +27,10 @@
|
|||||||
<nav class="site-nav" id="site-nav" aria-label="Main navigation">
|
<nav class="site-nav" id="site-nav" aria-label="Main navigation">
|
||||||
<a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
|
<a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
|
||||||
<a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
|
<a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
|
||||||
|
{% if grav.user.authenticated %}
|
||||||
|
<a href="{{ base_url_absolute }}/post"{% if page.template == 'post-form' %} aria-current="page"{% endif %}>New Post</a>
|
||||||
|
<a href="{{ base_url_absolute }}/gpx-manager"{% if page.template == 'gpx-manager' %} aria-current="page"{% endif %}>GPX Manager</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
caller only. Home's active-trip include omits it (`only`), so it defaults off
|
caller only. Home's active-trip include omits it (`only`), so it defaults off
|
||||||
and that header renders exactly as before (KTD4 / R12). #}
|
and that header renders exactly as before (KTD4 / R12). #}
|
||||||
{% set trip_header_extras = trip_header_extras|default(false) %}
|
{% set trip_header_extras = trip_header_extras|default(false) %}
|
||||||
|
{# 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>
|
||||||
|
|||||||
@@ -1,126 +1,28 @@
|
|||||||
{% extends 'default.html.twig' %}
|
{% extends 'default.html.twig' %}
|
||||||
|
|
||||||
|
{% block head_assets %}
|
||||||
|
{# FilePond's own CSS must load from the <head>. The filepond field registers
|
||||||
|
it via assets.addCss() during body rendering, which is too late for the
|
||||||
|
theme's head-only {{ assets.css() }} — so the widget renders unstyled
|
||||||
|
(giant overlapping tiles) unless we add it here. #}
|
||||||
|
{% do assets.addCss('plugin://form/assets/filepond/filepond.min.css') %}
|
||||||
|
{% do assets.addCss('plugin://form/assets/filepond/filepond-plugin-image-preview.min.css') %}
|
||||||
|
{% do assets.addCss('theme://css-compiled/post-form.css') %}
|
||||||
|
<script type="module" src="{{ url('theme://js/post/post-form.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="post-form-wrap">
|
<div class="post-form-wrap" data-trip-url="{{ config.site.active_trip }}">
|
||||||
<h1>New Entry</h1>
|
<h1>New Entry</h1>
|
||||||
{% include 'forms/form.html.twig' ignore missing %}
|
{% include 'forms/form.html.twig' ignore missing %}
|
||||||
|
|
||||||
<div class="form-action-row">
|
<div class="form-action-row">
|
||||||
<button type="button" id="get-location" class="btn-action">📍 Get Location</button>
|
<button type="button" id="get-location" class="btn-action">📍 Get Location</button>
|
||||||
<button type="button" id="get-weather" class="btn-action">🌤 Get Weather</button>
|
<button type="button" id="get-weather" class="btn-action" disabled>🌤 Get Weather</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="location-status" class="form-status"></p>
|
<p id="location-status" class="form-status" role="status"></p>
|
||||||
<p id="weather-status" class="form-status"></p>
|
<p id="weather-status" class="form-status" role="status"></p>
|
||||||
</div>
|
</div>
|
||||||
|
{# Get Location / Get Weather, disclosure, and validation all live in the
|
||||||
<script>
|
page-scoped bundle (js/src/post-form.js), loaded via head_assets above. #}
|
||||||
// Custom validation — form uses novalidate so we handle it here
|
|
||||||
(function() {
|
|
||||||
var REQUIRED = ['title', 'content'];
|
|
||||||
var form = document.querySelector('form[name="new-entry"]');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
function clearErrors() {
|
|
||||||
form.querySelectorAll('.field-error').forEach(function(el) { el.remove(); });
|
|
||||||
form.querySelectorAll('.field-invalid').forEach(function(el) { el.classList.remove('field-invalid'); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(field, msg) {
|
|
||||||
field.classList.add('field-invalid');
|
|
||||||
var err = document.createElement('span');
|
|
||||||
err.className = 'field-error';
|
|
||||||
err.textContent = msg;
|
|
||||||
field.parentNode.insertBefore(err, field.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
clearErrors();
|
|
||||||
var firstInvalid = null;
|
|
||||||
REQUIRED.forEach(function(name) {
|
|
||||||
var field = form.querySelector('[name="data[' + name + ']"]');
|
|
||||||
if (field && !field.value.trim()) {
|
|
||||||
showError(field, name.charAt(0).toUpperCase() + name.slice(1) + ' is required.');
|
|
||||||
if (!firstInvalid) firstInvalid = field;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (firstInvalid) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstInvalid.focus();
|
|
||||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var WMO_MAP = {
|
|
||||||
0:'Sunny',1:'Partly cloudy',2:'Partly cloudy',3:'Cloudy',
|
|
||||||
45:'Foggy',48:'Foggy',
|
|
||||||
51:'Drizzle',53:'Drizzle',55:'Drizzle',56:'Drizzle',57:'Drizzle',
|
|
||||||
61:'Rain',63:'Rain',65:'Rain',66:'Rain',67:'Rain',80:'Rain',81:'Rain',82:'Rain',
|
|
||||||
71:'Snow',73:'Snow',75:'Snow',77:'Snow',85:'Snow',86:'Snow',
|
|
||||||
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'
|
|
||||||
};
|
|
||||||
|
|
||||||
function getField(name) {
|
|
||||||
return document.querySelector('input[name="data[' + name + ']"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('get-location').addEventListener('click', function() {
|
|
||||||
var status = document.getElementById('location-status');
|
|
||||||
status.className = 'form-status';
|
|
||||||
status.textContent = 'Getting location…';
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
status.textContent = 'Geolocation not supported.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.geolocation.getCurrentPosition(function(pos) {
|
|
||||||
var lat = pos.coords.latitude.toFixed(6);
|
|
||||||
var lng = pos.coords.longitude.toFixed(6);
|
|
||||||
var latField = getField('lat');
|
|
||||||
var lngField = getField('lng');
|
|
||||||
if (latField) latField.value = lat;
|
|
||||||
if (lngField) lngField.value = lng;
|
|
||||||
status.textContent = '✓ Location captured · ' + lat + ', ' + lng;
|
|
||||||
status.classList.add('form-status--ok');
|
|
||||||
}, function(err) {
|
|
||||||
status.textContent = '✗ Could not get location: ' + err.message;
|
|
||||||
status.classList.add('form-status--err');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('get-weather').addEventListener('click', function() {
|
|
||||||
var status = document.getElementById('weather-status');
|
|
||||||
status.className = 'form-status';
|
|
||||||
var latField = getField('lat');
|
|
||||||
var lngField = getField('lng');
|
|
||||||
var lat = latField ? latField.value.trim() : '';
|
|
||||||
var lng = lngField ? lngField.value.trim() : '';
|
|
||||||
if (!lat || !lng) {
|
|
||||||
status.textContent = 'Get location first, then fetch weather.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.textContent = 'Fetching weather…';
|
|
||||||
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + lat +
|
|
||||||
'&longitude=' + lng +
|
|
||||||
'¤t=temperature_2m,weather_code&temperature_unit=celsius';
|
|
||||||
fetch(url)
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
var temp = Math.round(data.current.temperature_2m);
|
|
||||||
var code = data.current.weather_code;
|
|
||||||
var desc = WMO_MAP[code] || 'Cloudy';
|
|
||||||
var tempField = getField('weather_temp_c');
|
|
||||||
var descField = getField('weather_desc');
|
|
||||||
if (tempField) tempField.value = temp;
|
|
||||||
if (descField) descField.value = desc;
|
|
||||||
status.textContent = '✓ Weather set · ' + desc + ' · ' + temp + '°C';
|
|
||||||
status.classList.add('form-status--ok');
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
status.textContent = '✗ Could not fetch weather — enter manually if needed.';
|
|
||||||
status.classList.add('form-status--err');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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, '.', ''),
|
||||||
@@ -79,7 +94,9 @@
|
|||||||
gpx_urls: gpx_urls,
|
gpx_urls: gpx_urls,
|
||||||
gps_points: gps_points,
|
gps_points: gps_points,
|
||||||
show_sort: true,
|
show_sort: true,
|
||||||
trip_header_extras: true
|
trip_header_extras: true,
|
||||||
|
owner_can_edit: owner_can_edit,
|
||||||
|
feed_return_url: page.url
|
||||||
} only %}
|
} only %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user