2023-08-04 17:59:58 +00:00
|
|
|
/**
|
|
|
|
* SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
2023-07-26 16:52:32 +00:00
|
|
|
function ready(fn) {
|
|
|
|
if (document.readyState !== 'loading') {
|
|
|
|
fn();
|
|
|
|
} else {
|
|
|
|
document.addEventListener('DOMContentLoaded', fn);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ready(function () {
|
Add the upload form to the media picker
It makes easier to upload new images from the place where we need it,
instead of having to go to the media section each time.
It was a little messy, this one.
First of all, I realized that POSTint to /admin/media/picker to get the
new media field was wrong: i was not asking the server to “accept an
entity”, but only requesting a new HTML value, just like a GET to
/admin/media/upload requests the form to upload a new media, thus here
i should do the same, except i needed the query parameters to change the
field, which is fine—it is actually a different resource, thus a
different URL.
Then, i thought that i could not POST the upload to /admin/media,
because i returned a different HTML —the media field—, so i reused the
recently unused POST to /admin/media/picker to upload that file and
return the HTML for the field. It was wrong, because i was not
requesting the server to put the file as a subordinate of
/admin/media/picker, only /admin/media, but i did not come up with any
other solution.
Since i had two different upload functions now, i created uploadForm’s
Handle method to refactorize the duplicated logic to a single place.
Unfortunately, i did not work as i expected because uploadForm’s and
mediaPicker’s MustRender methods are different, and mediaPicker has to
embed uploadForm to render the form in the picker. That made me change
Handle’s output to a boolean and error in order for the HTTP handler
function know when to render the form with the error messages with the
proper MustRender handler.
However, I saw the opportunity of reusing that Handler method for
editMedia, that was doing mostly the same job, but had to call a
different Validate than uploadForm’s, because editMedia does not require
the uploaded file. That’s when i realized that i could use an interface
and that this interface could be reused not only within media but
throughout the application, and added HandleMultipart in form.
Had to create a different interface for multipart forms because they
need different parameters in Parse that non-multipart form, when i add
that interface, hence had to also change Parse to ParseForm to account
for the difference in signature; not a big deal.
After all that, i realized that i **could** POST to /admin/media in both
cases, because i always return “an HTML entity”, it just happens that
for the media section it is empty with a redirect, and for the picker is
the field. That made the whole Handle method a bit redundant, but i
left it nevertheless, as i find it slightly easier to read the
uploadMedia function now.
2023-09-21 23:40:22 +00:00
|
|
|
const snackBar = Object.assign(document.body.appendChild(document.createElement('section')), {
|
|
|
|
id: 'snackbar',
|
|
|
|
});
|
2023-07-26 16:52:32 +00:00
|
|
|
|
|
|
|
const errorMessage = snackBar.appendChild(document.createElement('div'));
|
|
|
|
errorMessage.setAttribute('role', 'alert');
|
|
|
|
|
|
|
|
const openClass = 'open';
|
|
|
|
const toasts = [];
|
|
|
|
let timeoutId = null;
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
toasts.push(message);
|
|
|
|
popUp();
|
|
|
|
}
|
|
|
|
|
|
|
|
function popUp() {
|
|
|
|
if (toasts.length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (errorMessage.classList.contains(openClass)) {
|
|
|
|
dismiss();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (errorMessage.innerText !== "") {
|
|
|
|
// it will show after remove calls popUp again.
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
errorMessage.innerText = toasts[0];
|
|
|
|
errorMessage.classList.add(openClass);
|
|
|
|
timeoutId = setTimeout(dismiss, 4000);
|
|
|
|
}
|
|
|
|
|
|
|
|
function dismiss() {
|
|
|
|
if (!errorMessage.classList.contains(openClass)) {
|
|
|
|
// already dismissed
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
errorMessage.classList.remove(openClass);
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
timeoutId = setTimeout(remove, 350);
|
|
|
|
}
|
|
|
|
|
|
|
|
function remove() {
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
toasts.splice(0, 1);
|
|
|
|
errorMessage.innerText = "";
|
|
|
|
popUp();
|
|
|
|
}
|
|
|
|
|
|
|
|
document.body.addEventListener('htmx:error', function (evt) {
|
|
|
|
const errorInfo = evt.detail.errorInfo;
|
|
|
|
const error = errorInfo.xhr && errorInfo.xhr.responseText || errorInfo.error;
|
|
|
|
showError(error);
|
|
|
|
});
|
|
|
|
})
|
2023-08-04 17:59:58 +00:00
|
|
|
|
|
|
|
ready(function () {
|
|
|
|
const textareas = document.querySelectorAll('textarea.html');
|
2023-08-12 03:41:34 +00:00
|
|
|
if (textareas.length > 0) {
|
|
|
|
const language = document.documentElement.getAttribute('lang');
|
|
|
|
const script = document.head.appendChild(Object.assign(document.createElement('script'), {
|
|
|
|
src: '/static/ckeditor5@39.0.1/ckeditor.js',
|
|
|
|
}));
|
|
|
|
document.head.appendChild(Object.assign(document.createElement('style'), {
|
|
|
|
innerHTML: '.ck-content { margin-left: 5rem; }',
|
|
|
|
}));
|
|
|
|
if (language !== 'en') {
|
|
|
|
document.head.appendChild(Object.assign(document.createElement('script'), {
|
|
|
|
src: '/static/ckeditor5@39.0.1/translations/' + language + '.js',
|
|
|
|
}));
|
Replace Gutenberg with GrapesJS for pages
I simply can not use Gutenberg without having it choking in its own
over-engineered architecture: using it inside a form, submits it when
clicking the button to change a paragraph’s text size; and using the
custom text size in pixels causes the paragraph component to fail.
The issue with paragraph’s custom text size is that block-editor’s
typography hook expects the font size to be a string, such as '12px' or
'1em', to call startsWith on it, but the paragraph sets an integer,
always assuming that the units are pixels. Integers do not have a
startsWith method.
Looking at the Gutenberg distributed with the current version of
WordPress, 6.3, seems that now paragraph has a selector for the units,
therefore never sets just the integer. That made me think that the
components used by the Isolated Block Editor are “mismatched”: maybe in
a previous version of block-editor it was always passed as an integer
too?
I downloaded the source code of the Isolated Block Editor and tried to
update @wordpress/block-library from version 8.14.0 to the current
version, 8.16.0, but fails with an error saying that 'core/paragraph' is
not registered, when, as far as i could check, it was. Seems that
something changed in @wordpress/blocks between version 12.14.0 and
12.16.0, so i tried to upgrade that module as well; it did not work
because @wordpress/data was not updated —do not remember the actual
error message—. Upgrading to @wordpress/data from 9.7.0 to 9.9.0 made
the registration of the 'isolated/editor' subregistry to be apparently
ignored, because the posterior select('isolated/editor') within a
withSelect hook returns undefined.
At this point, i gave up: it is obvious that the people that shit
JavaScript for Gutenberg do not care for semantic versioning, and there
are a lot of moving parts to fix just to be able to use a simple
paragraph block!
It seems, however, that there are not many open-source, block-based
_layout_ editors out there: mainly GrapesJS and Craft.JS. Craft.JS,
however, has no way to output HTML[0], requiring hacks such as using
React to generate the HTML and then pasted that shit onto the page;
totally useless for me.
I am not a fan of GrapesJS either: it seems that the “text block” is
a content-editable div, and semantic HTML can go fuck itself,
apparently. Typical webshit mentality. By strapping another huge
dependency like CKEditor, but only up to the already out-of-support
version 4, i can write headers, paragraphs and list. That’s
something, i guess.
[0]: https://github.com/prevwong/craft.js/issues/42
Part of #33.
2023-08-11 00:38:49 +00:00
|
|
|
}
|
2023-08-12 03:41:34 +00:00
|
|
|
script.addEventListener('load', function () {
|
|
|
|
for (const textarea of textareas) {
|
|
|
|
const canvas = document.createElement('div');
|
|
|
|
textarea.parentNode.insertBefore(canvas, textarea.nextSibling);
|
|
|
|
textarea.style.display = 'none';
|
Replace Gutenberg with GrapesJS for pages
I simply can not use Gutenberg without having it choking in its own
over-engineered architecture: using it inside a form, submits it when
clicking the button to change a paragraph’s text size; and using the
custom text size in pixels causes the paragraph component to fail.
The issue with paragraph’s custom text size is that block-editor’s
typography hook expects the font size to be a string, such as '12px' or
'1em', to call startsWith on it, but the paragraph sets an integer,
always assuming that the units are pixels. Integers do not have a
startsWith method.
Looking at the Gutenberg distributed with the current version of
WordPress, 6.3, seems that now paragraph has a selector for the units,
therefore never sets just the integer. That made me think that the
components used by the Isolated Block Editor are “mismatched”: maybe in
a previous version of block-editor it was always passed as an integer
too?
I downloaded the source code of the Isolated Block Editor and tried to
update @wordpress/block-library from version 8.14.0 to the current
version, 8.16.0, but fails with an error saying that 'core/paragraph' is
not registered, when, as far as i could check, it was. Seems that
something changed in @wordpress/blocks between version 12.14.0 and
12.16.0, so i tried to upgrade that module as well; it did not work
because @wordpress/data was not updated —do not remember the actual
error message—. Upgrading to @wordpress/data from 9.7.0 to 9.9.0 made
the registration of the 'isolated/editor' subregistry to be apparently
ignored, because the posterior select('isolated/editor') within a
withSelect hook returns undefined.
At this point, i gave up: it is obvious that the people that shit
JavaScript for Gutenberg do not care for semantic versioning, and there
are a lot of moving parts to fix just to be able to use a simple
paragraph block!
It seems, however, that there are not many open-source, block-based
_layout_ editors out there: mainly GrapesJS and Craft.JS. Craft.JS,
however, has no way to output HTML[0], requiring hacks such as using
React to generate the HTML and then pasted that shit onto the page;
totally useless for me.
I am not a fan of GrapesJS either: it seems that the “text block” is
a content-editable div, and semantic HTML can go fuck itself,
apparently. Typical webshit mentality. By strapping another huge
dependency like CKEditor, but only up to the already out-of-support
version 4, i can write headers, paragraphs and list. That’s
something, i guess.
[0]: https://github.com/prevwong/craft.js/issues/42
Part of #33.
2023-08-11 00:38:49 +00:00
|
|
|
|
2023-08-12 03:41:34 +00:00
|
|
|
BalloonEditor
|
|
|
|
.create(canvas, {
|
|
|
|
language,
|
|
|
|
})
|
|
|
|
.then(editor => {
|
|
|
|
const xml = document.createElement('div');
|
|
|
|
const serializer = new XMLSerializer();
|
|
|
|
editor.setData(textarea.value);
|
|
|
|
editor.ui.focusTracker.on('change:isFocused', (event, name, focused) => {
|
|
|
|
if (!focused) {
|
|
|
|
xml.innerHTML = editor.getData();
|
|
|
|
textarea.value = serializer.serializeToString(xml).replace(' ', ' ');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
})
|
|
|
|
.catch(error => {
|
|
|
|
console.error(error);
|
|
|
|
});
|
|
|
|
}
|
2023-08-04 17:59:58 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
})
|
Add the upload form to the media picker
It makes easier to upload new images from the place where we need it,
instead of having to go to the media section each time.
It was a little messy, this one.
First of all, I realized that POSTint to /admin/media/picker to get the
new media field was wrong: i was not asking the server to “accept an
entity”, but only requesting a new HTML value, just like a GET to
/admin/media/upload requests the form to upload a new media, thus here
i should do the same, except i needed the query parameters to change the
field, which is fine—it is actually a different resource, thus a
different URL.
Then, i thought that i could not POST the upload to /admin/media,
because i returned a different HTML —the media field—, so i reused the
recently unused POST to /admin/media/picker to upload that file and
return the HTML for the field. It was wrong, because i was not
requesting the server to put the file as a subordinate of
/admin/media/picker, only /admin/media, but i did not come up with any
other solution.
Since i had two different upload functions now, i created uploadForm’s
Handle method to refactorize the duplicated logic to a single place.
Unfortunately, i did not work as i expected because uploadForm’s and
mediaPicker’s MustRender methods are different, and mediaPicker has to
embed uploadForm to render the form in the picker. That made me change
Handle’s output to a boolean and error in order for the HTTP handler
function know when to render the form with the error messages with the
proper MustRender handler.
However, I saw the opportunity of reusing that Handler method for
editMedia, that was doing mostly the same job, but had to call a
different Validate than uploadForm’s, because editMedia does not require
the uploaded file. That’s when i realized that i could use an interface
and that this interface could be reused not only within media but
throughout the application, and added HandleMultipart in form.
Had to create a different interface for multipart forms because they
need different parameters in Parse that non-multipart form, when i add
that interface, hence had to also change Parse to ParseForm to account
for the difference in signature; not a big deal.
After all that, i realized that i **could** POST to /admin/media in both
cases, because i always return “an HTML entity”, it just happens that
for the media section it is empty with a redirect, and for the picker is
the field. That made the whole Handle method a bit redundant, but i
left it nevertheless, as i find it slightly easier to read the
uploadMedia function now.
2023-09-21 23:40:22 +00:00
|
|
|
|
2023-09-24 01:19:46 +00:00
|
|
|
export function camperUploadForm(el) {
|
Add the upload form to the media picker
It makes easier to upload new images from the place where we need it,
instead of having to go to the media section each time.
It was a little messy, this one.
First of all, I realized that POSTint to /admin/media/picker to get the
new media field was wrong: i was not asking the server to “accept an
entity”, but only requesting a new HTML value, just like a GET to
/admin/media/upload requests the form to upload a new media, thus here
i should do the same, except i needed the query parameters to change the
field, which is fine—it is actually a different resource, thus a
different URL.
Then, i thought that i could not POST the upload to /admin/media,
because i returned a different HTML —the media field—, so i reused the
recently unused POST to /admin/media/picker to upload that file and
return the HTML for the field. It was wrong, because i was not
requesting the server to put the file as a subordinate of
/admin/media/picker, only /admin/media, but i did not come up with any
other solution.
Since i had two different upload functions now, i created uploadForm’s
Handle method to refactorize the duplicated logic to a single place.
Unfortunately, i did not work as i expected because uploadForm’s and
mediaPicker’s MustRender methods are different, and mediaPicker has to
embed uploadForm to render the form in the picker. That made me change
Handle’s output to a boolean and error in order for the HTTP handler
function know when to render the form with the error messages with the
proper MustRender handler.
However, I saw the opportunity of reusing that Handler method for
editMedia, that was doing mostly the same job, but had to call a
different Validate than uploadForm’s, because editMedia does not require
the uploaded file. That’s when i realized that i could use an interface
and that this interface could be reused not only within media but
throughout the application, and added HandleMultipart in form.
Had to create a different interface for multipart forms because they
need different parameters in Parse that non-multipart form, when i add
that interface, hence had to also change Parse to ParseForm to account
for the difference in signature; not a big deal.
After all that, i realized that i **could** POST to /admin/media in both
cases, because i always return “an HTML entity”, it just happens that
for the media section it is empty with a redirect, and for the picker is
the field. That made the whole Handle method a bit redundant, but i
left it nevertheless, as i find it slightly easier to read the
uploadMedia function now.
2023-09-21 23:40:22 +00:00
|
|
|
const progress = el.querySelector('progress');
|
|
|
|
htmx.on(el, 'drop', function (evt) {
|
|
|
|
evt.preventDefault();
|
|
|
|
[...evt.dataTransfer.items].forEach(function (i) {
|
|
|
|
console.log(i);
|
|
|
|
i.getAsString(console.log)
|
|
|
|
});
|
|
|
|
});
|
|
|
|
htmx.on(el, 'dragover', function (evt) {
|
|
|
|
evt.preventDefault();
|
|
|
|
});
|
|
|
|
htmx.on(el, 'dragleave', function (evt) {
|
|
|
|
evt.preventDefault();
|
|
|
|
});
|
|
|
|
htmx.on(el, 'htmx:xhr:progress', function (evt) {
|
|
|
|
if (progress && evt.detail.lengthComputable) {
|
|
|
|
progress.setAttribute('value', evt.detail.loaded / evt.detail.total * 100);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
Change media picker from <div> to <dialog> and make it modal
Have to call Dialog.showModal when HTMx loaded the dialog in the DOM,
so had to add a onLoad event listened that checks whether the loaded
element is actually a DIALOG.
Had to restrict the margin: 0 for all elements (*) to exclude dialog,
because the browser sets it to auto, and i did not want to set it again
just because i was too overzealous with my “reset”.
The rest of the CSS is just to have a sticky header and footer, and see
the cancel button, that works as a “close”, all the time.
Finally, i realized that if i add the dialog at the end of the fieldset
and let HTMx inherit its hx-target and hx-swap, i no longer need to set
them in the dialog, as HTMx will always replace the fieldset, and i can
have the dialog side by side the current content of the fieldset, that
it was very confusing seeing it disappear when trying to select a new
media.
The cancel button could now just remove the dialog instead of making the
POST, but in local it makes no difference; we’lls see what happens on
production.
2023-09-22 00:11:03 +00:00
|
|
|
|
2023-09-25 10:34:05 +00:00
|
|
|
export function setupCampgroundMap(map) {
|
Add campsite map in SVG
I intend to use the same SVG file for customers and employees, so i had
to change Oriol’s design to add a class to layers that are supposed to
be only for customers, like trees. These are hidden in the admin area.
I understood that customers and employees have to click on a campsite to
select it, and then they can book or whatever they need to do to them.
Since customers and employees most certainly will need to have different
listeners on campsites, i decided to add the link with JavaScript. To
do so, i need a custom XML attribute with the campsite’s identifier.
Since i have seen that all campsites have a label, i changed the
“identifier” to the unique combination (company_id, label). The
company_id is there because different companies could have the same
label; i left the campsite_id primary key for foreign constraints.
In this case, as a test, i add an <a> element to the campsite with a
link to edit it; we’ll discuss with Oriol what exactly it needs to do.
However, the original design had the labels in a different layer, that
interfered with the link, as the numbers must be above the path and
the link must wrap the path in order to “inherit” its shape. I had no
other recourse than to move the labels in the same layer as the paths’.
2023-09-24 01:17:13 +00:00
|
|
|
if (!map) {
|
|
|
|
return;
|
|
|
|
}
|
2023-09-25 10:44:47 +00:00
|
|
|
const prefix = "cp_";
|
|
|
|
for (const campsite of Array.from(map.querySelectorAll(`[id^="${prefix}"]`))) {
|
|
|
|
const label = campsite.id.substring(prefix.length);
|
Add campsite map in SVG
I intend to use the same SVG file for customers and employees, so i had
to change Oriol’s design to add a class to layers that are supposed to
be only for customers, like trees. These are hidden in the admin area.
I understood that customers and employees have to click on a campsite to
select it, and then they can book or whatever they need to do to them.
Since customers and employees most certainly will need to have different
listeners on campsites, i decided to add the link with JavaScript. To
do so, i need a custom XML attribute with the campsite’s identifier.
Since i have seen that all campsites have a label, i changed the
“identifier” to the unique combination (company_id, label). The
company_id is there because different companies could have the same
label; i left the campsite_id primary key for foreign constraints.
In this case, as a test, i add an <a> element to the campsite with a
link to edit it; we’ll discuss with Oriol what exactly it needs to do.
However, the original design had the labels in a different layer, that
interfered with the link, as the numbers must be above the path and
the link must wrap the path in order to “inherit” its shape. I had no
other recourse than to move the labels in the same layer as the paths’.
2023-09-24 01:17:13 +00:00
|
|
|
if (!label) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const link = document.createElementNS('http://www.w3.org/2000/svg', 'a');
|
|
|
|
link.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '/admin/campsites/' + label);
|
2023-09-25 10:34:05 +00:00
|
|
|
link.append(...campsite.childNodes);
|
|
|
|
campsite.appendChild(link);
|
Add campsite map in SVG
I intend to use the same SVG file for customers and employees, so i had
to change Oriol’s design to add a class to layers that are supposed to
be only for customers, like trees. These are hidden in the admin area.
I understood that customers and employees have to click on a campsite to
select it, and then they can book or whatever they need to do to them.
Since customers and employees most certainly will need to have different
listeners on campsites, i decided to add the link with JavaScript. To
do so, i need a custom XML attribute with the campsite’s identifier.
Since i have seen that all campsites have a label, i changed the
“identifier” to the unique combination (company_id, label). The
company_id is there because different companies could have the same
label; i left the campsite_id primary key for foreign constraints.
In this case, as a test, i add an <a> element to the campsite with a
link to edit it; we’ll discuss with Oriol what exactly it needs to do.
However, the original design had the labels in a different layer, that
interfered with the link, as the numbers must be above the path and
the link must wrap the path in order to “inherit” its shape. I had no
other recourse than to move the labels in the same layer as the paths’.
2023-09-24 01:17:13 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-25 18:10:33 +00:00
|
|
|
export function setupIconInput(icon) {
|
|
|
|
if (!icon) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const input = icon.querySelector('input[type="hidden"]')
|
|
|
|
if (!input) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const buttons = Array.from(icon.querySelectorAll('button[data-icon-name]'));
|
|
|
|
const updateValue = function (iconName) {
|
|
|
|
input.value = iconName;
|
|
|
|
for (const button of buttons) {
|
|
|
|
button.setAttribute('aria-pressed', button.dataset.iconName === iconName);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const button of buttons) {
|
|
|
|
button.addEventListener('click', function (e) {
|
|
|
|
updateValue(e.target.dataset.iconName);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
updateValue(input.value);
|
|
|
|
}
|
|
|
|
|
Change media picker from <div> to <dialog> and make it modal
Have to call Dialog.showModal when HTMx loaded the dialog in the DOM,
so had to add a onLoad event listened that checks whether the loaded
element is actually a DIALOG.
Had to restrict the margin: 0 for all elements (*) to exclude dialog,
because the browser sets it to auto, and i did not want to set it again
just because i was too overzealous with my “reset”.
The rest of the CSS is just to have a sticky header and footer, and see
the cancel button, that works as a “close”, all the time.
Finally, i realized that if i add the dialog at the end of the fieldset
and let HTMx inherit its hx-target and hx-swap, i no longer need to set
them in the dialog, as HTMx will always replace the fieldset, and i can
have the dialog side by side the current content of the fieldset, that
it was very confusing seeing it disappear when trying to select a new
media.
The cancel button could now just remove the dialog instead of making the
POST, but in local it makes no difference; we’lls see what happens on
production.
2023-09-22 00:11:03 +00:00
|
|
|
htmx.onLoad((target) => {
|
|
|
|
if (target.tagName === 'DIALOG') {
|
|
|
|
target.showModal();
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|