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:
@@ -13,7 +13,10 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
// Runs before add-page-by-form's onFormProcessed (page write), so it
|
// Runs before add-page-by-form's onFormProcessed (page write), so it
|
||||||
// can inject the write target and abort the submit by failing validation.
|
// can inject the write target and abort the submit by failing validation.
|
||||||
'onFormValidationProcessed' => ['onFormValidationProcessed', 0],
|
'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';
|
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') {
|
|
||||||
|
// 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();
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -195,6 +195,33 @@
|
|||||||
/* Hide FilePond's "Powered by PQINA" credit. */
|
/* Hide FilePond's "Powered by PQINA" credit. */
|
||||||
.filepond--credits { display: none !important; }
|
.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
|
/* Issue #2: FilePond's completed thumbnail carries a murky gradient tint + the
|
||||||
filename overlay, which reads as "something's wrong". Strip those overlays
|
filename overlay, which reads as "something's wrong". Strip those overlays
|
||||||
and show a single clean green ✓ badge so a finished upload is unambiguous.
|
and show a single clean green ✓ badge so a finished upload is unambiguous.
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ function initPhotoConversion() {
|
|||||||
|
|
||||||
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
|
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
|
||||||
var collapse = buildPhotoCollapse();
|
var collapse = buildPhotoCollapse();
|
||||||
|
var orderPond = null; // set when the managed FilePond instance is found
|
||||||
|
|
||||||
|
// FilePond does NOT re-sequence its submitted data[photos][] inputs when the
|
||||||
|
// list is reordered — those stay in upload order — so the drag order never
|
||||||
|
// reaches the server on its own. Send it explicitly: on submit, write the
|
||||||
|
// current visual order into a hidden input. Its name is a TOP-LEVEL POST key
|
||||||
|
// ("photo_order", not "data[...]") so Grav's form never captures it into the
|
||||||
|
// page data — the server reads it straight from $_POST and renames the
|
||||||
|
// copied files photo-1..N to match, with nothing leaking into frontmatter.
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
if (!orderPond) return;
|
||||||
|
var files = orderPond.getFiles();
|
||||||
|
if (!files.length) return; // text-only post — send nothing
|
||||||
|
var hidden = form.querySelector('input[name="photo_order"]');
|
||||||
|
if (!hidden) {
|
||||||
|
hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.name = 'photo_order';
|
||||||
|
form.appendChild(hidden);
|
||||||
|
}
|
||||||
|
hidden.value = JSON.stringify(files.map(function (f) { return f.filename; }));
|
||||||
|
}, true);
|
||||||
|
|
||||||
function setStatus(msg, kind) {
|
function setStatus(msg, kind) {
|
||||||
var el = photoStatusEl();
|
var el = photoStatusEl();
|
||||||
@@ -244,7 +266,12 @@ function initPhotoConversion() {
|
|||||||
ponds.forEach(function (pond) {
|
ponds.forEach(function (pond) {
|
||||||
if (pond && !pond._heicHooked) {
|
if (pond && !pond._heicHooked) {
|
||||||
pond._heicHooked = true;
|
pond._heicHooked = true;
|
||||||
pond.setOptions({ beforeAddFile: makeBeforeAddFile(pond) });
|
// allowReorder: drag thumbnails to set the order. The server
|
||||||
|
// (cache-on-save) renames the copied files photo-1..N in the
|
||||||
|
// submitted order so the published entry honours it (entry media
|
||||||
|
// is filename-ordered; hero = first).
|
||||||
|
pond.setOptions({ beforeAddFile: makeBeforeAddFile(pond), allowReorder: true, itemInsertLocation: 'after' });
|
||||||
|
orderPond = pond;
|
||||||
// Update the collapse summary as files are added/uploaded/removed.
|
// Update the collapse summary as files are added/uploaded/removed.
|
||||||
['addfile', 'processfile', 'processfiles', 'removefile', 'error'].forEach(function (ev) {
|
['addfile', 'processfile', 'processfiles', 'removefile', 'error'].forEach(function (ev) {
|
||||||
try { pond.on(ev, scheduleRefresh); } catch (e) { /* older FilePond API */ }
|
try { pond.on(ev, scheduleRefresh); } catch (e) { /* older FilePond API */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user