feat(post-form): dark-theme the photo widget + drag-to-reorder that sticks

Styling: repaint FilePond's default light drop zone/thumbnails/actions with
the Field Notes dark tokens so the picker matches the site palette.

Reordering: enable FilePond drag-reorder (allowReorder, itemInsertLocation
'after'). FilePond does NOT re-sequence its submitted data[photos][] inputs on
reorder, so post-form.js sends the visual order as a top-level `photo_order`
POST key on submit. cache-on-save reads it from $_POST (after add-page-by-form
copies the files, priority -100) and renames them photo-1..N in that order —
which the entry honours since it lists media by filename and treats the first
as hero. The order key is top-level (not data[...]), so it never lands in the
entry frontmatter. Best-effort + self-idempotent: locates the new entry folder
by the uploaded filenames and no-ops if they're already renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:25:56 +02:00
co-authored by Claude Opus 4.8
parent 87da3e4b5b
commit d17d1256ba
5 changed files with 237 additions and 48 deletions
+139 -4
View File
@@ -13,7 +13,10 @@ class CacheOnSavePlugin extends Plugin
// 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],
'onFormProcessed' => ['onFormProcessed', 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],
];
}
@@ -63,14 +66,146 @@ class CacheOnSavePlugin extends Plugin
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
{
$form = $event['form'];
if (!$form) {
if (!$form || $form->getName() !== 'new-entry') {
return;
}
if ($form->getName() === 'new-entry') {
$this->grav['cache']->deleteAll();
// Reorder the just-copied photos to match the order the user arranged in
// the form (FilePond drag). Best-effort: any failure logs and is skipped
// so a post is never lost over cosmetics.
try {
$this->reorderPhotos();
} catch (\Throwable $e) {
$this->grav['log']->warning('cache-on-save: photo reorder skipped — ' . $e->getMessage());
}
$this->grav['cache']->deleteAll();
}
/**
* Rename the uploaded photos to photo-1..N in the submitted (drag) order.
*
* The published entry lists media in filename order and treats the first as
* the hero (see partials/entry-journal + entry-story), so a deterministic
* photo-N naming is what makes the arranged order stick. copyFiles() writes
* each file under its unsanitised client filename, and post-form.js sends the
* drag order via the top-level `photo_order` POST key (orderFromPost) — so we
* can map each on-disk file to its final photo-N slot.
*/
private function reorderPhotos(): void
{
$names = $this->orderFromPost();
if (count($names) < 1) {
return; // nothing uploaded
}
$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
}
// Two-phase rename via temp names so a target (photo-2.jpg) can't clobber
// a not-yet-moved source of the same name.
$planned = [];
$i = 1;
foreach ($names as $name) {
$src = $dir . DIRECTORY_SEPARATOR . $name;
if (!is_file($src)) {
continue; // skip anything not actually on disk
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)) ?: 'jpg';
$tmp = $dir . DIRECTORY_SEPARATOR . '.reorder-tmp-' . $i . '.' . $ext;
$final = $dir . DIRECTORY_SEPARATOR . 'photo-' . $i . '.' . $ext;
if ($src === $final) {
$i++;
continue; // already correctly named
}
@rename($src, $tmp);
$planned[] = [$tmp, $final];
$i++;
}
foreach ($planned as [$tmp, $final]) {
if (is_file($tmp)) {
@rename($tmp, $final);
}
}
}
/**
* 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;
}
}