camper/web/static/camper.js

273 lines
8.6 KiB
JavaScript
Raw Normal View History

/**
* SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
* SPDX-License-Identifier: AGPL-3.0-only
*/
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',
});
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);
});
})
ready(function () {
const textareas = document.querySelectorAll('textarea.html');
if (textareas.length > 0) {
const language = document.documentElement.getAttribute('lang');
const csrfHeader = JSON.parse(document.querySelector('meta[name="csrf-header"]').getAttribute('content'));
const script = document.head.appendChild(Object.assign(document.createElement('script'), {
src: '/static/ckeditor5@40.2.0/ckeditor.js',
}));
if (language !== 'en') {
document.head.appendChild(Object.assign(document.createElement('script'), {
src: '/static/ckeditor5@40.2.0/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
}
const editorConfig = {
language,
image: {
toolbar: [
'imageTextAlternative',
'toggleImageCaption',
'|',
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'resizeImage',
],
},
simpleUpload: {
uploadUrl: '/admin/media',
headers: Object.assign(csrfHeader, {
Accept: 'application/vnd.ckeditor+json',
}),
},
};
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
ClassicEditor
.create(canvas, editorConfig)
.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();
let text = serializer.serializeToString(xml);
text = text.replace('&nbsp;', '&#xA0;');
text = text.substring(text.indexOf('>') + 1, text.lastIndexOf('<'));
textarea.value = text;
}
});
})
.catch(error => {
console.error(error);
});
}
});
}
})
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
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);
}
});
}
export function setupCampgroundMap(map) {
if (!map) {
return;
}
const prefix = "cp_";
for (const campsite of Array.from(map.querySelectorAll(`[id^="${prefix}"]`))) {
const label = campsite.id.substring(prefix.length);
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);
link.append(...campsite.childNodes);
campsite.appendChild(link);
}
}
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);
}
export function setupCalendar(calendar) {
const startDate = calendar.querySelector('input[name="start_date"]');
const endDate = calendar.querySelector('input[name="end_date"]');
const days = Array.from(calendar.querySelectorAll('time'));
const dialog = calendar.querySelector('dialog');
const clear = function () {
startDate.value = endDate.value = "";
days.forEach((e) => e.removeAttribute('aria-checked'));
}
dialog.addEventListener('close', clear);
dialog.querySelector('button').addEventListener('click', function (e) {
e.preventDefault();
dialog.close();
});
const selectDate = function (e) {
e.preventDefault();
const date = e.currentTarget.dateTime;
if (!date) {
return;
}
if (!startDate.value) {
startDate.value = date;
e.currentTarget.setAttribute('aria-checked', true);
return;
} else if (startDate.value > date) {
endDate.value = startDate.value;
startDate.value = date;
} else {
endDate.value = date;
}
for (const day of days) {
if (day.dateTime >= startDate.value && day.dateTime <= endDate.value) {
day.setAttribute('aria-checked', true);
} else {
day.removeAttribute('aria-checked');
}
}
dialog.showModal();
}
for (const day of days) {
day.addEventListener('click', selectDate);
}
clear();
}
htmx.onLoad((target) => {
if (target.tagName === 'DIALOG') {
target.showModal();
}
})
htmx.onLoad((content) => {
const sortables = Array.from(content.querySelectorAll('.sortable table tbody'));
for (const sortable of sortables) {
const sortableInstance = new Sortable(sortable, {
animation: 150,
draggable: '>tr',
handle: '.handle',
onMove: (evt) => evt.related.className.indexOf('htmx-indicator') === -1,
onEnd: function () {
this.option('disabled', true);
},
});
sortable.addEventListener('htmx:afterSwap', function () {
sortableInstance.option('disabled', false);
});
}
})