Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff5358ce2 | ||
|
|
b8a7bedcda | ||
|
|
8cf1145e6f | ||
|
|
8b037511fd | ||
|
|
1cde137daa | ||
|
|
d17d1256ba | ||
|
|
2e32a8559c | ||
|
|
87da3e4b5b | ||
|
|
d3a520426a | ||
|
|
c70e96879b | ||
|
|
2b48e427fd | ||
|
|
9a96d0f19e | ||
|
|
7f021ab114 | ||
|
|
c4e9b2f6bf | ||
|
|
bf5b5c2f3c | ||
|
|
6282528dd8 | ||
|
|
87e216594e | ||
|
|
817c20c24a |
@@ -6,5 +6,6 @@
|
|||||||
/pages/01.trips/italy-2026-demo/
|
/pages/01.trips/italy-2026-demo/
|
||||||
/pages/02.post/*ui-test*/
|
/pages/02.post/*ui-test*/
|
||||||
/config/plugins/git-sync.yaml
|
/config/plugins/git-sync.yaml
|
||||||
|
/config/plugins/api-private.php
|
||||||
/config/security.yaml
|
/config/security.yaml
|
||||||
/themes/intotheeast/node_modules/
|
/themes/intotheeast/node_modules/
|
||||||
|
|||||||
+59
-3
@@ -1,4 +1,60 @@
|
|||||||
popularity:
|
enabled: true
|
||||||
salt: 671ae9ab4f792c7dc860dbe8be288f2fdebdb3b4615f3c0f43211ecb95aaeeb3
|
route: /api
|
||||||
|
version_prefix: v1
|
||||||
auth:
|
auth:
|
||||||
jwt_secret: 61a84160bdd430768c82c4fe153e151a7a6f68f993c3779a5f36d32ee9293653
|
api_keys_enabled: true
|
||||||
|
jwt_enabled: true
|
||||||
|
jwt_algorithm: HS256
|
||||||
|
jwt_expiry: 3600
|
||||||
|
jwt_refresh_expiry: 604800
|
||||||
|
session_enabled: true
|
||||||
|
cors:
|
||||||
|
enabled: true
|
||||||
|
origins: { }
|
||||||
|
methods:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
- OPTIONS
|
||||||
|
headers:
|
||||||
|
- Content-Type
|
||||||
|
- Authorization
|
||||||
|
- X-API-Key
|
||||||
|
- X-API-Token
|
||||||
|
- X-Grav-Environment
|
||||||
|
- If-Match
|
||||||
|
- If-None-Match
|
||||||
|
expose_headers:
|
||||||
|
- ETag
|
||||||
|
- X-Invalidates
|
||||||
|
- X-RateLimit-Limit
|
||||||
|
- X-RateLimit-Remaining
|
||||||
|
- X-RateLimit-Reset
|
||||||
|
max_age: 86400
|
||||||
|
credentials: false
|
||||||
|
rate_limit:
|
||||||
|
enabled: true
|
||||||
|
requests: 120
|
||||||
|
window: 60
|
||||||
|
storage: file
|
||||||
|
flex_backend:
|
||||||
|
pages: true
|
||||||
|
accounts: true
|
||||||
|
pagination:
|
||||||
|
default_per_page: 20
|
||||||
|
max_per_page: 1000
|
||||||
|
invitations:
|
||||||
|
expiration: 604800
|
||||||
|
popularity:
|
||||||
|
enabled: true
|
||||||
|
exclude_admin: true
|
||||||
|
exclude_ips: { }
|
||||||
|
history:
|
||||||
|
daily: 30
|
||||||
|
monthly: 12
|
||||||
|
visitors: 20
|
||||||
|
ignore:
|
||||||
|
- '/test*'
|
||||||
|
- /modular
|
||||||
|
salt: 37517ad926e4f900863af7f1b58718cdccc34f7265dedd09d33b6f820046c510
|
||||||
|
|||||||
+3
-2
@@ -210,7 +210,7 @@ session:
|
|||||||
domain: null
|
domain: null
|
||||||
path: null
|
path: null
|
||||||
gpm:
|
gpm:
|
||||||
releases: testing
|
releases: stable
|
||||||
official_gpm_only: true
|
official_gpm_only: true
|
||||||
http:
|
http:
|
||||||
method: curl
|
method: curl
|
||||||
@@ -237,7 +237,8 @@ flex:
|
|||||||
lifetime: 600
|
lifetime: 600
|
||||||
strict_mode:
|
strict_mode:
|
||||||
yaml_compat: false
|
yaml_compat: false
|
||||||
twig_compat: false
|
|
||||||
blueprint_compat: false
|
blueprint_compat: false
|
||||||
|
twig2_compat: false
|
||||||
|
twig3_compat: true
|
||||||
uploads_privacy:
|
uploads_privacy:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
+92
-18
@@ -4,9 +4,9 @@ template: post-form
|
|||||||
access:
|
access:
|
||||||
site.login: true
|
site.login: true
|
||||||
|
|
||||||
# Keep in sync with active_trip in user/config/site.yaml
|
# Parent (write target) is injected server-side from site.active_trip by the
|
||||||
|
# cache-on-save plugin (onFormValidationProcessed) — no manual sync needed.
|
||||||
pageconfig:
|
pageconfig:
|
||||||
parent: '/trips/italy-2026-demo/dailies'
|
|
||||||
slug_field: 'date,title'
|
slug_field: 'date,title'
|
||||||
overwrite_mode: false
|
overwrite_mode: false
|
||||||
|
|
||||||
@@ -19,19 +19,41 @@ form:
|
|||||||
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
|
||||||
@@ -44,16 +66,6 @@ form:
|
|||||||
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
|
||||||
@@ -79,12 +91,74 @@ form:
|
|||||||
placeholder: 'e.g. Japan'
|
placeholder: 'e.g. Japan'
|
||||||
|
|
||||||
-
|
-
|
||||||
name: weather_temp_c
|
name: weather_desc
|
||||||
type: hidden
|
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_desc
|
name: weather_temp_c
|
||||||
type: hidden
|
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'
|
||||||
|
|
||||||
|
# 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: 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace Grav\Plugin;
|
namespace Grav\Plugin;
|
||||||
|
|
||||||
|
use Grav\Common\Data\ValidationException;
|
||||||
use Grav\Common\Plugin;
|
use Grav\Common\Plugin;
|
||||||
use RocketTheme\Toolbox\Event\Event;
|
use RocketTheme\Toolbox\Event\Event;
|
||||||
|
|
||||||
@@ -9,18 +10,202 @@ class CacheOnSavePlugin extends Plugin
|
|||||||
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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,325 @@
|
|||||||
|
/*
|
||||||
|
* /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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
/*
|
||||||
|
* /post journal form bundle — page-scoped (loaded only by post-form.html.twig).
|
||||||
|
* Kept out of the global main.js so EasyMDE (+ the U4 HEIC converter, lazy-loaded)
|
||||||
|
* never ship on any other page. Built as ESM with code-splitting so the heic-to
|
||||||
|
* dynamic import becomes a separately-fetched chunk (see package.json / KTD2).
|
||||||
|
*
|
||||||
|
* Every init function presence-guards its own DOM, mirroring main.js's
|
||||||
|
* initTripStats pattern, so this is a no-op if an element is absent.
|
||||||
|
*/
|
||||||
|
import EasyMDE from 'easymde';
|
||||||
|
import 'easymde/dist/easymde.min.css';
|
||||||
|
import './post-form.css';
|
||||||
|
|
||||||
|
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
||||||
|
function initEditor() {
|
||||||
|
var textarea = document.querySelector('textarea[name="data[content]"]');
|
||||||
|
if (!textarea) return null;
|
||||||
|
|
||||||
|
var editor = new EasyMDE({
|
||||||
|
element: textarea,
|
||||||
|
spellChecker: false,
|
||||||
|
autoDownloadFontAwesome: false, // toolbar glyphs come from post-form.css, no FA CDN
|
||||||
|
status: false,
|
||||||
|
placeholder: 'What happened today?',
|
||||||
|
minHeight: '180px',
|
||||||
|
// Minimal toolbar; custom classNames drive the CSS glyphs in post-form.css.
|
||||||
|
toolbar: [
|
||||||
|
{ name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn mde-bold', title: 'Bold' },
|
||||||
|
{ name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn mde-italic', title: 'Italic' },
|
||||||
|
{ name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn mde-ul', title: 'Bulleted list' },
|
||||||
|
{ name: 'link', action: EasyMDE.drawLink, className: 'mde-btn mde-link', title: 'Insert link' },
|
||||||
|
'|',
|
||||||
|
{ name: 'preview', action: EasyMDE.togglePreview, className: 'mde-btn mde-preview', title: 'Toggle preview' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// KTD3 / R15: keep the underlying <textarea> in sync with the editor so the
|
||||||
|
// form's custom required-field validator (which reads [name="data[content]"])
|
||||||
|
// and the submitted payload both see the live value.
|
||||||
|
editor.codemirror.on('change', function () { editor.codemirror.save(); });
|
||||||
|
|
||||||
|
var form = textarea.closest('form');
|
||||||
|
if (form) {
|
||||||
|
// Capture phase fires before the inline bubble-phase validator, so the
|
||||||
|
// textarea already holds the current value when validation reads it.
|
||||||
|
form.addEventListener('submit', function () { editor.codemirror.save(); }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo picker + client-side HEIC→JPEG (U4) ───────────────
|
||||||
|
* We replaced Grav's managed filepond field with a plain, controlled picker
|
||||||
|
* (see the `photos` field template) because filepond instant-uploads each file
|
||||||
|
* the moment it is added and its FilePond.create config is not ours to hook —
|
||||||
|
* so HEIC could never be converted before upload. Here we own the flow: sniff,
|
||||||
|
* lazy-convert HEIC, then POST each web-safe JPEG to Grav's AJAX file-upload
|
||||||
|
* route (into the form flash, exactly as filepond's server.process did) so
|
||||||
|
* add-page-by-form's copyFiles() picks them up on submit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ISO-BMFF 'ftyp' brands that indicate HEIC/HEIF. Sniffed from bytes, never
|
||||||
|
// from the filename/MIME (R16) — iOS sometimes hands us .heic with an image/*
|
||||||
|
// MIME, and a renamed .jpg can still be HEIC underneath.
|
||||||
|
var HEIC_BRANDS = ['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1', 'heim', 'heis', 'hevm', 'hevs'];
|
||||||
|
|
||||||
|
function looksLikeHeic(file) {
|
||||||
|
return file.slice(0, 64).arrayBuffer().then(function (buf) {
|
||||||
|
var b = new Uint8Array(buf);
|
||||||
|
if (b.length < 12) return false;
|
||||||
|
// bytes 4..8 must be the 'ftyp' box type
|
||||||
|
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return false;
|
||||||
|
// scan major brand + compatible brands (4-byte codes from offset 8)
|
||||||
|
var brands = '';
|
||||||
|
for (var i = 8; i + 4 <= b.length; i += 4) {
|
||||||
|
brands += String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]).toLowerCase();
|
||||||
|
}
|
||||||
|
return HEIC_BRANDS.some(function (brand) { return brands.indexOf(brand) !== -1; });
|
||||||
|
}).catch(function () { return false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyJpegName(name) {
|
||||||
|
var base = String(name || 'photo').replace(/\.[^.]+$/, '');
|
||||||
|
base = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
return (base || 'photo') + '.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert (once) a status line just after the FilePond field for conversion
|
||||||
|
// feedback, returning it. FilePond owns the per-thumbnail chrome; this line
|
||||||
|
// carries the pre-FilePond "converting…" / error state.
|
||||||
|
function photoStatusEl() {
|
||||||
|
var root = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
if (!root || !root.parentNode) return null;
|
||||||
|
var el = root.parentNode.querySelector('.photo-convert-status');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('p');
|
||||||
|
el.className = 'photo-convert-status form-status';
|
||||||
|
el.setAttribute('role', 'status');
|
||||||
|
root.parentNode.insertBefore(el, root.nextSibling);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the photo picker in a native <details> so it can auto-collapse to a
|
||||||
|
// live summary bar once uploads settle (issue #3: thumbnails otherwise crowd
|
||||||
|
// the writing area). The field's own <label> is hidden — the <summary> becomes
|
||||||
|
// the section header. Returns { details, summary } or null when no picker.
|
||||||
|
function buildPhotoCollapse() {
|
||||||
|
var root = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
if (!root) return null;
|
||||||
|
var wrapper = root.closest('.form-field');
|
||||||
|
if (!wrapper) return null;
|
||||||
|
|
||||||
|
// Grav wraps the field label in a .form-label div (not a bare <label>);
|
||||||
|
// hide it so the <summary> is the section's only header.
|
||||||
|
var fieldLabel = wrapper.querySelector('.form-label');
|
||||||
|
|
||||||
|
var details = document.createElement('details');
|
||||||
|
details.className = 'photos-collapse';
|
||||||
|
details.open = true;
|
||||||
|
var summary = document.createElement('summary');
|
||||||
|
summary.className = 'photos-collapse__summary';
|
||||||
|
summary.textContent = 'Photos (1–6)';
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
// Relocate the existing control (+ label/status) into the details body.
|
||||||
|
while (wrapper.firstChild) details.appendChild(wrapper.firstChild);
|
||||||
|
wrapper.appendChild(details);
|
||||||
|
if (fieldLabel) fieldLabel.style.display = 'none';
|
||||||
|
|
||||||
|
return { details: details, summary: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPhotoConversion() {
|
||||||
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
|
||||||
|
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) {
|
||||||
|
var el = photoStatusEl();
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || '';
|
||||||
|
el.className = 'photo-convert-status form-status' + (kind ? ' form-status--' + kind : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live summary + auto-collapse for the photo section. Reads FilePond's DOM
|
||||||
|
// item states (robust across FilePond versions) plus the pre-FilePond HEIC
|
||||||
|
// `converting` counter, so the section stays open while anything is in
|
||||||
|
// flight and collapses to "✓ N ready" only once everything has settled.
|
||||||
|
function refreshCollapse() {
|
||||||
|
if (!collapse) return;
|
||||||
|
var items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
|
||||||
|
var total = items.length;
|
||||||
|
var done = 0;
|
||||||
|
Array.prototype.forEach.call(items, function (el) {
|
||||||
|
if (el.getAttribute('data-filepond-item-state') === 'processing-complete') done++;
|
||||||
|
});
|
||||||
|
if (converting > 0 || (total > 0 && done < total)) {
|
||||||
|
collapse.summary.textContent = converting > 0
|
||||||
|
? 'Converting ' + converting + ' photo' + (converting > 1 ? 's' : '') + '…'
|
||||||
|
: 'Uploading ' + total + ' photo' + (total > 1 ? 's' : '') + '…';
|
||||||
|
collapse.details.open = true; // keep visible while working
|
||||||
|
} else if (total > 0) {
|
||||||
|
collapse.summary.textContent = '✓ ' + total + ' photo' + (total > 1 ? 's' : '') + ' ready — tap to review';
|
||||||
|
collapse.details.open = false; // auto-collapse when settled
|
||||||
|
} else {
|
||||||
|
collapse.summary.textContent = 'Photos (1–6)';
|
||||||
|
collapse.details.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilePond fires processfile/processfiles a tick before it flips the item's
|
||||||
|
// data-filepond-item-state to "processing-complete", so a refresh bound
|
||||||
|
// straight to the event reads a stale state. Re-check on trailing timeouts
|
||||||
|
// so the summary settles on the final DOM state.
|
||||||
|
function scheduleRefresh() {
|
||||||
|
refreshCollapse();
|
||||||
|
setTimeout(refreshCollapse, 150);
|
||||||
|
setTimeout(refreshCollapse, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGate() {
|
||||||
|
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
|
||||||
|
if (btn) btn.disabled = converting > 0;
|
||||||
|
if (converting > 0) {
|
||||||
|
setStatus('Converting ' + converting + ' photo' + (converting > 1 ? 's' : '') + '…');
|
||||||
|
} else {
|
||||||
|
var el = photoStatusEl();
|
||||||
|
if (el && /Converting/.test(el.textContent)) setStatus('');
|
||||||
|
}
|
||||||
|
refreshCollapse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard the submit while a HEIC is still converting (it hasn't entered
|
||||||
|
// FilePond yet, so FilePond's own processing gate wouldn't cover it). R17.
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (converting > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus('Hang on — a photo is still converting.', 'err');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// FilePond hook: reject a HEIC item, convert it to JPEG via the lazy heic-to
|
||||||
|
// chunk (KTD4), then re-add the JPEG through pond.addFile() so FilePond
|
||||||
|
// uploads and page-attaches it via its own (correct) contract.
|
||||||
|
function makeBeforeAddFile(pond) {
|
||||||
|
return function (item) {
|
||||||
|
var file = item && item.file;
|
||||||
|
if (!file) return true;
|
||||||
|
return looksLikeHeic(file).then(function (isHeic) {
|
||||||
|
if (!isHeic) return true; // web format (R9) — FilePond handles it
|
||||||
|
converting++;
|
||||||
|
updateGate();
|
||||||
|
import('heic-to').then(function (mod) {
|
||||||
|
var heicTo = mod.heicTo || (mod.default && mod.default.heicTo);
|
||||||
|
return heicTo({ blob: file, type: 'image/jpeg', quality: 0.85 });
|
||||||
|
}).then(function (jpeg) {
|
||||||
|
var jpegFile = new File([jpeg], slugifyJpegName(file.name), { type: 'image/jpeg' });
|
||||||
|
pond.addFile(jpegFile);
|
||||||
|
}).catch(function () {
|
||||||
|
// Fail closed (R16): the original HEIC is never added/uploaded.
|
||||||
|
setStatus('A photo couldn’t be converted and was skipped — the others are fine.', 'err');
|
||||||
|
}).then(function () {
|
||||||
|
converting--;
|
||||||
|
updateGate();
|
||||||
|
});
|
||||||
|
return false; // reject the original HEIC
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The managed FilePond instance is created by the form plugin's
|
||||||
|
// filepond-handler (DOMContentLoaded). Poll briefly, then attach the hook.
|
||||||
|
var tries = 0;
|
||||||
|
(function attach() {
|
||||||
|
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
|
||||||
|
? window.GravFilePond.getInstances() : [];
|
||||||
|
if (!ponds.length) {
|
||||||
|
if (tries++ < 120) setTimeout(attach, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ponds.forEach(function (pond) {
|
||||||
|
if (pond && !pond._heicHooked) {
|
||||||
|
pond._heicHooked = true;
|
||||||
|
// 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.
|
||||||
|
['addfile', 'processfile', 'processfiles', 'removefile', 'error'].forEach(function (ev) {
|
||||||
|
try { pond.on(ev, scheduleRefresh); } catch (e) { /* older FilePond API */ }
|
||||||
|
});
|
||||||
|
refreshCollapse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field helper ────────────────────────────────────────── */
|
||||||
|
// Attribute selector matches input, select AND textarea — unlike the old
|
||||||
|
// input[...] lookup, which broke once weather_desc became a <select> (U2).
|
||||||
|
function field(name) {
|
||||||
|
return document.querySelector('[name="data[' + name + ']"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "More options" disclosure (U5, KTD5) ─────────────────────
|
||||||
|
* The blueprint renders the advanced fields (force_connect/featured) flat;
|
||||||
|
* here we relocate their .form-field wrappers into a native <details> so
|
||||||
|
* they collapse by default with full keyboard/AT support. Auto-opens if any
|
||||||
|
* advanced field already carries a value (edit / draft restore).
|
||||||
|
*/
|
||||||
|
function initDisclosure() {
|
||||||
|
var flagged = Array.prototype.slice.call(document.querySelectorAll('.advanced-field'));
|
||||||
|
if (!flagged.length) return;
|
||||||
|
|
||||||
|
var wrappers = [];
|
||||||
|
flagged.forEach(function (el) {
|
||||||
|
var w = el.closest('.form-field');
|
||||||
|
if (w && wrappers.indexOf(w) === -1) wrappers.push(w);
|
||||||
|
});
|
||||||
|
if (!wrappers.length) return;
|
||||||
|
|
||||||
|
var details = document.createElement('details');
|
||||||
|
details.className = 'more-options';
|
||||||
|
var summary = document.createElement('summary');
|
||||||
|
summary.className = 'more-options__summary';
|
||||||
|
summary.textContent = 'More options';
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
wrappers[0].parentNode.insertBefore(details, wrappers[0]);
|
||||||
|
wrappers.forEach(function (w) { details.appendChild(w); });
|
||||||
|
|
||||||
|
var hasValue = wrappers.some(function (w) {
|
||||||
|
var text = w.querySelector('input[type="text"]');
|
||||||
|
if (text && text.value.trim()) return true;
|
||||||
|
var toggle = w.querySelector('input[type="radio"]:checked');
|
||||||
|
if (toggle && toggle.value && toggle.value !== '0') return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (hasValue) details.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Get Location / Get Weather (U5, R18) ─────────────────────
|
||||||
|
* Migrated out of the template's inline scripts so it can read the now-<select>
|
||||||
|
* weather_desc field and expose idle/loading/success/error states.
|
||||||
|
*/
|
||||||
|
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 setStatus(el, msg, kind) {
|
||||||
|
if (!el) return;
|
||||||
|
el.className = 'form-status' + (kind ? ' form-status--' + kind : '');
|
||||||
|
el.textContent = msg || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGeo() {
|
||||||
|
var locBtn = document.getElementById('get-location');
|
||||||
|
var wxBtn = document.getElementById('get-weather');
|
||||||
|
if (!locBtn && !wxBtn) return;
|
||||||
|
|
||||||
|
var locStatus = document.getElementById('location-status');
|
||||||
|
var wxStatus = document.getElementById('weather-status');
|
||||||
|
|
||||||
|
function coords() {
|
||||||
|
var latEl = field('lat');
|
||||||
|
var lngEl = field('lng');
|
||||||
|
var lat = latEl ? latEl.value.trim() : '';
|
||||||
|
var lng = lngEl ? lngEl.value.trim() : '';
|
||||||
|
return (lat && lng) ? { lat: lat, lng: lng } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Weather is meaningless without coordinates — gate it (R18).
|
||||||
|
function syncWeatherEnabled() {
|
||||||
|
if (!wxBtn) return;
|
||||||
|
var ok = !!coords();
|
||||||
|
wxBtn.disabled = !ok;
|
||||||
|
wxBtn.title = ok ? '' : 'Get location first';
|
||||||
|
}
|
||||||
|
syncWeatherEnabled();
|
||||||
|
|
||||||
|
// Reverse-geocode the captured coordinates into City + Country via
|
||||||
|
// BigDataCloud's free client endpoint (no API key, CORS-enabled). Only
|
||||||
|
// fills fields the traveller left blank — never clobbers a manual entry —
|
||||||
|
// and appends the resolved place to the location status. Best-effort: a
|
||||||
|
// failure leaves the coordinates (and the manual city/country fields) intact.
|
||||||
|
function reverseGeocode(lat, lng) {
|
||||||
|
var cityEl = field('location_city');
|
||||||
|
var countryEl = field('location_country');
|
||||||
|
// Nothing to fill if the traveller already typed both.
|
||||||
|
if ((!cityEl || cityEl.value.trim()) && (!countryEl || countryEl.value.trim())) return;
|
||||||
|
|
||||||
|
var url = 'https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=' +
|
||||||
|
encodeURIComponent(lat) + '&longitude=' + encodeURIComponent(lng) + '&localityLanguage=en';
|
||||||
|
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
|
||||||
|
// BigDataCloud's `city` is often empty in rural areas; `locality`
|
||||||
|
// is the more reliably-populated place name, so fall back to it.
|
||||||
|
var city = (data.city || data.locality || '').trim();
|
||||||
|
var country = (data.countryName || '').trim();
|
||||||
|
if (cityEl && !cityEl.value.trim() && city) cityEl.value = city;
|
||||||
|
if (countryEl && !countryEl.value.trim() && country) countryEl.value = country;
|
||||||
|
var place = [city, country].filter(Boolean).join(', ');
|
||||||
|
if (place) {
|
||||||
|
setStatus(locStatus, '✓ Location captured · ' + place, 'ok');
|
||||||
|
}
|
||||||
|
}).catch(function () { /* keep the coordinates-only status */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locBtn) {
|
||||||
|
locBtn.addEventListener('click', function () {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setStatus(locStatus, 'Geolocation is not supported on this device.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locBtn.classList.add('is-loading');
|
||||||
|
locBtn.disabled = true;
|
||||||
|
setStatus(locStatus, 'Getting location…');
|
||||||
|
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||||
|
var lat = pos.coords.latitude.toFixed(6);
|
||||||
|
var lng = pos.coords.longitude.toFixed(6);
|
||||||
|
var latEl = field('lat');
|
||||||
|
var lngEl = field('lng');
|
||||||
|
if (latEl) latEl.value = lat;
|
||||||
|
if (lngEl) lngEl.value = lng;
|
||||||
|
locBtn.classList.remove('is-loading');
|
||||||
|
locBtn.disabled = false;
|
||||||
|
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||||||
|
syncWeatherEnabled();
|
||||||
|
reverseGeocode(lat, lng); // fill City/Country in the background
|
||||||
|
}, function (err) {
|
||||||
|
locBtn.classList.remove('is-loading');
|
||||||
|
locBtn.disabled = false;
|
||||||
|
setStatus(locStatus, '✗ ' + (err && err.message ? err.message : 'Could not get location') + ' — enter coordinates manually if needed.', 'err');
|
||||||
|
}, { enableHighAccuracy: true, timeout: 15000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxBtn) {
|
||||||
|
wxBtn.addEventListener('click', function () {
|
||||||
|
var c = coords();
|
||||||
|
if (!c) {
|
||||||
|
setStatus(wxStatus, 'Get location first, then fetch weather.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wxBtn.classList.add('is-loading');
|
||||||
|
wxBtn.disabled = true;
|
||||||
|
setStatus(wxStatus, 'Fetching weather…');
|
||||||
|
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + c.lat +
|
||||||
|
'&longitude=' + c.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 desc = WMO_MAP[data.current.weather_code] || 'Cloudy';
|
||||||
|
var tempEl = field('weather_temp_c');
|
||||||
|
var descEl = field('weather_desc');
|
||||||
|
if (tempEl) tempEl.value = temp;
|
||||||
|
if (descEl) descEl.value = desc; // works for <select> too (R6)
|
||||||
|
wxBtn.classList.remove('is-loading');
|
||||||
|
syncWeatherEnabled();
|
||||||
|
setStatus(wxStatus, '✓ Weather set · ' + desc + ' · ' + temp + '°C (edit above if needed)', 'ok');
|
||||||
|
}).catch(function () {
|
||||||
|
wxBtn.classList.remove('is-loading');
|
||||||
|
syncWeatherEnabled();
|
||||||
|
setStatus(wxStatus, '✗ Could not fetch weather — set it manually above.', 'err');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Blocking required-field validation (U5, R19) ─────────────
|
||||||
|
* Replaces the template's inline validator. Reads the EasyMDE-synced content
|
||||||
|
* value (initEditor keeps the textarea current) and shows per-field messages.
|
||||||
|
*/
|
||||||
|
// Current local wall-clock time as the value a datetime-local input expects
|
||||||
|
// ("YYYY-MM-DDTHH:MM"). Uses the browser's timezone — the server has no idea
|
||||||
|
// where the traveller is — and strips seconds so the value round-trips cleanly.
|
||||||
|
function localNowDatetime() {
|
||||||
|
var d = new Date();
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||||
|
return d.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initValidation() {
|
||||||
|
var REQUIRED = { title: 'Title', date: 'Date & time', content: 'Content' };
|
||||||
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
// Prefill the date/time picker with "now" (local) unless draft restore
|
||||||
|
// already put a value there. initDraft runs before initValidation in boot(),
|
||||||
|
// so an empty field here means there was no saved draft date to keep.
|
||||||
|
var dateEl = field('date');
|
||||||
|
if (dateEl && !String(dateEl.value).trim()) {
|
||||||
|
dateEl.value = localNowDatetime();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(el, msg) {
|
||||||
|
el.classList.add('field-invalid');
|
||||||
|
var err = document.createElement('span');
|
||||||
|
err.className = 'field-error';
|
||||||
|
err.textContent = msg;
|
||||||
|
el.parentNode.insertBefore(err, el.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one photo is required. The picker is first in the form, so if it
|
||||||
|
// is empty this is the first invalid field — reveal the (possibly collapsed)
|
||||||
|
// section, show the message under its header, and return an element to
|
||||||
|
// scroll to. Uses the same .field-error class so clearErrors() cleans it up.
|
||||||
|
function showPhotoError(msg) {
|
||||||
|
var collapse = form.querySelector('.photos-collapse');
|
||||||
|
var host, anchor;
|
||||||
|
if (collapse) {
|
||||||
|
collapse.open = true; // reveal the drop zone + message
|
||||||
|
anchor = collapse.querySelector('.photos-collapse__summary');
|
||||||
|
host = collapse;
|
||||||
|
} else {
|
||||||
|
host = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
anchor = host;
|
||||||
|
}
|
||||||
|
if (!anchor) return host || null;
|
||||||
|
var err = document.createElement('span');
|
||||||
|
err.className = 'field-error';
|
||||||
|
err.textContent = msg;
|
||||||
|
anchor.insertAdjacentElement('afterend', err);
|
||||||
|
return host || anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubble phase: runs after initEditor's capture-phase codemirror.save(), so
|
||||||
|
// the content textarea already holds the live value.
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
clearErrors();
|
||||||
|
var firstInvalid = null;
|
||||||
|
|
||||||
|
// ≥1 photo (photos are the first field). Count any present FilePond item.
|
||||||
|
if (document.querySelectorAll('.filepond--item').length < 1) {
|
||||||
|
firstInvalid = showPhotoError('Add at least one photo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(REQUIRED).forEach(function (name) {
|
||||||
|
var el = field(name);
|
||||||
|
if (el && !String(el.value).trim()) {
|
||||||
|
showError(el, REQUIRED[name] + ' is required.');
|
||||||
|
if (!firstInvalid) firstInvalid = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (firstInvalid) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
|
||||||
|
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Draft persistence (U6, R19 preserve-on-failure + R20, KTD6) ──
|
||||||
|
* Text field values are mirrored to localStorage so a failed submit or an
|
||||||
|
* expired session mid-compose never loses the writing. Photos can't be
|
||||||
|
* serialized (File/Blob) so they are NOT persisted — an inline hint says so
|
||||||
|
* on restore. The draft is cleared ONLY when the server confirms a successful
|
||||||
|
* post (<div class="notices success">), which is exactly what guarantees the
|
||||||
|
* text survives every non-success outcome, independent of failure-type sniffing.
|
||||||
|
*/
|
||||||
|
var DRAFT_KEY = 'intotheeast:new-entry-draft';
|
||||||
|
|
||||||
|
function draftEls(form) {
|
||||||
|
return Array.prototype.slice.call(form.querySelectorAll('[name^="data["]'))
|
||||||
|
.filter(function (el) {
|
||||||
|
if (el.type === 'file') return false;
|
||||||
|
var n = el.name;
|
||||||
|
return n.indexOf('data[_json') !== 0 && n.indexOf('data[photos') !== 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReauthHint() {
|
||||||
|
// The filepond-root div is server-rendered, so it exists even before
|
||||||
|
// FilePond enhances it. Place the hint right after the photo field.
|
||||||
|
var root = document.querySelector('.filepond-root, .form-input-file');
|
||||||
|
if (!root || !root.parentNode) return;
|
||||||
|
if (root.parentNode.querySelector('.photo-reauth-hint')) return;
|
||||||
|
var p = document.createElement('p');
|
||||||
|
p.className = 'photo-reauth-hint is-shown';
|
||||||
|
p.textContent = 'Your text was restored — photos need re-selecting (they can’t be saved in a draft).';
|
||||||
|
root.parentNode.insertBefore(p, root.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDraft() {
|
||||||
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
|
if (!form) return; // e.g. session expired → login form shown; draft is left intact
|
||||||
|
|
||||||
|
// A confirmed successful post: clear the draft and do NOT restore onto the
|
||||||
|
// freshly reset form. This runs before any save, so process.reset can't
|
||||||
|
// round-trip blank fields back into storage.
|
||||||
|
if (document.querySelector('.notices.success')) {
|
||||||
|
try { localStorage.removeItem(DRAFT_KEY); } catch (e) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var data = {};
|
||||||
|
draftEls(form).forEach(function (el) {
|
||||||
|
if (el.type === 'radio') { if (el.checked) { data[el.name] = el.value; } }
|
||||||
|
else { data[el.name] = el.value; }
|
||||||
|
});
|
||||||
|
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(data)); } catch (e) { /* quota / private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = null;
|
||||||
|
try { raw = localStorage.getItem(DRAFT_KEY); } catch (e) { raw = null; }
|
||||||
|
if (raw) {
|
||||||
|
var data = null;
|
||||||
|
try { data = JSON.parse(raw); } catch (e) { data = null; }
|
||||||
|
if (data) {
|
||||||
|
var restoredSomething = false;
|
||||||
|
Object.keys(data).forEach(function (name) {
|
||||||
|
var val = data[name];
|
||||||
|
if (val != null && String(val).trim()) { restoredSomething = true; }
|
||||||
|
if (name === 'data[content]') { return; } // restored via the editor below
|
||||||
|
var radios = form.querySelectorAll('input[type="radio"][name="' + name + '"]');
|
||||||
|
if (radios.length) {
|
||||||
|
radios.forEach(function (r) { r.checked = (r.value === val); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var el = form.querySelector('[name="' + name + '"]');
|
||||||
|
if (el && el.type !== 'file') { el.value = val; }
|
||||||
|
});
|
||||||
|
if (window.postFormEditor && data['data[content]'] != null) {
|
||||||
|
window.postFormEditor.value(data['data[content]']);
|
||||||
|
}
|
||||||
|
if (restoredSomething) { showReauthHint(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save text on every edit (change covers selects/toggles; input covers text).
|
||||||
|
form.addEventListener('input', save);
|
||||||
|
form.addEventListener('change', save);
|
||||||
|
if (window.postFormEditor) {
|
||||||
|
window.postFormEditor.codemirror.on('change', save);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Post-success confirmation (issue: message was off-screen) ─────
|
||||||
|
* After a successful submit the page re-renders with Grav's success notice at
|
||||||
|
* the top of a long, reset form — easy to miss on mobile. Scroll it into view
|
||||||
|
* and add a "View your journal" / "Post another" CTA the owner can act on.
|
||||||
|
*/
|
||||||
|
function initSuccessState() {
|
||||||
|
var wrap = document.querySelector('.post-form-wrap');
|
||||||
|
var notice = document.querySelector('.post-form-wrap .notices.success, .post-form-wrap .notices.green');
|
||||||
|
if (!wrap || !notice) return;
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'post-success';
|
||||||
|
|
||||||
|
var title = document.createElement('p');
|
||||||
|
title.className = 'post-success__title';
|
||||||
|
title.textContent = '✓ Saved to your journal.';
|
||||||
|
panel.appendChild(title);
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'post-success__actions';
|
||||||
|
|
||||||
|
var tripUrl = wrap.getAttribute('data-trip-url');
|
||||||
|
if (tripUrl) {
|
||||||
|
var view = document.createElement('a');
|
||||||
|
view.className = 'post-success__view';
|
||||||
|
view.href = tripUrl;
|
||||||
|
view.textContent = 'View your journal →';
|
||||||
|
actions.appendChild(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
var again = document.createElement('a');
|
||||||
|
again.className = 'post-success__again';
|
||||||
|
again.href = window.location.pathname; // reload /post fresh
|
||||||
|
again.textContent = 'Post another';
|
||||||
|
actions.appendChild(again);
|
||||||
|
|
||||||
|
panel.appendChild(actions);
|
||||||
|
notice.parentNode.insertBefore(panel, notice.nextSibling);
|
||||||
|
|
||||||
|
// Issue #1: after a successful post, show the confirmation *only* — hide the
|
||||||
|
// (now-reset) form and the location/weather controls so a stray empty form
|
||||||
|
// doesn't render below the success panel. The .notices message and the
|
||||||
|
// injected panel live in .form-wrapper *before* the <form>, so hiding the
|
||||||
|
// form leaves them visible.
|
||||||
|
['form', '.form-action-row', '#location-status', '#weather-status'].forEach(function (sel) {
|
||||||
|
var el = wrap.querySelector(sel);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boot ────────────────────────────────────────────────── */
|
||||||
|
function boot() {
|
||||||
|
// Exposed for U6 draft restore and the Playwright specs; null when this
|
||||||
|
// bundle loads on a page without the content field.
|
||||||
|
window.postFormEditor = initEditor();
|
||||||
|
initSuccessState();
|
||||||
|
// Restore before disclosure/geo so their on-load checks (auto-open,
|
||||||
|
// weather-button enable) see the restored values.
|
||||||
|
initDraft();
|
||||||
|
initPhotoConversion();
|
||||||
|
initDisclosure();
|
||||||
|
initGeo();
|
||||||
|
initValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
Generated
+84
@@ -8,6 +8,8 @@
|
|||||||
"@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"
|
||||||
@@ -523,6 +525,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 +572,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 +593,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 +617,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 +653,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 +743,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 +862,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",
|
||||||
@@ -936,6 +1014,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,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"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 && 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"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user