156 lines
6.3 KiB
JavaScript
156 lines
6.3 KiB
JavaScript
// Repeated rows (links, timeline entries, services ...) can be added, removed and moved.
|
|
// Their fields use "#" as index placeholder in data-name, the real names are set on submit.
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const button = event.target.closest('[data-repeat-add], [data-repeat-remove], [data-repeat-up], [data-repeat-down]');
|
|
if (!button) return;
|
|
|
|
if (button.hasAttribute('data-repeat-add')) {
|
|
const repeat = button.closest('[data-repeat]');
|
|
const template = repeat.querySelector(':scope > template[data-repeat-template]');
|
|
const list = repeat.querySelector(':scope > [data-repeat-list]');
|
|
list.append(template.content.cloneNode(true));
|
|
list.lastElementChild?.querySelector('input:not([type="hidden"]), textarea, select')?.focus();
|
|
return;
|
|
}
|
|
|
|
const row = button.closest('[data-repeat-row]');
|
|
if (button.hasAttribute('data-repeat-remove')) row.remove();
|
|
else if (button.hasAttribute('data-repeat-up')) row.previousElementSibling?.before(row);
|
|
else row.nextElementSibling?.after(row);
|
|
});
|
|
|
|
function rowIndexes(field) {
|
|
const indexes = [];
|
|
for (let row = field.closest('[data-repeat-row]'); row; row = row.parentElement.closest('[data-repeat-row]')) {
|
|
const siblings = [...row.parentElement.children].filter((el) => el.matches('[data-repeat-row]'));
|
|
indexes.unshift(siblings.indexOf(row));
|
|
}
|
|
return indexes;
|
|
}
|
|
|
|
// Images pasted or dropped into a text field are uploaded right away and inserted as Markdown.
|
|
// The form says where they go (data-image-upload), the text field opts in with data-images.
|
|
|
|
let uploading = 0;
|
|
let placeholders = 0;
|
|
|
|
async function uploadImage(form, file, name) {
|
|
const body = new FormData();
|
|
body.set('_csrf', form.elements._csrf.value);
|
|
body.set('bild', file, name);
|
|
const res = await fetch(form.dataset.imageUpload, { method: 'POST', body });
|
|
const type = res.headers.get('content-type') ?? '';
|
|
const data = type.includes('json') ? await res.json() : null;
|
|
if (res.ok && data?.src) return data.src;
|
|
const message = data?.error ?? (type.startsWith('text/plain') ? await res.text() : '');
|
|
throw new Error(message || 'Das Bild konnte nicht hochgeladen werden.');
|
|
}
|
|
|
|
function replaceText(area, search, replacement) {
|
|
const start = area.value.indexOf(search);
|
|
if (start !== -1) area.setRangeText(replacement, start, start + search.length, 'preserve');
|
|
}
|
|
|
|
async function insertImages(area, files, pasted) {
|
|
if (!area.form.dataset.imageUpload) {
|
|
window.alert('Speichere das Projekt einmal, danach kannst du Bilder in den Text einfügen.');
|
|
return;
|
|
}
|
|
// Each image gets a paragraph of its own, glued to the text around it would break headings
|
|
const before = area.value.slice(0, area.selectionStart);
|
|
const after = area.value.slice(area.selectionEnd);
|
|
const lead = !before || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
|
|
const trail = !after || after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
|
|
const marks = files.map(() => `![Bild ${++placeholders} wird hochgeladen …]()`);
|
|
area.setRangeText(lead + marks.join('\n\n') + trail, area.selectionStart, area.selectionEnd, 'end');
|
|
|
|
// One after the other, so two pasted screenshots don't race for the same file name
|
|
uploading++;
|
|
try {
|
|
for (const [i, file] of files.entries()) {
|
|
try {
|
|
const src = await uploadImage(area.form, file, pasted ? 'bild' : file.name);
|
|
replaceText(area, marks[i], ``);
|
|
} catch (err) {
|
|
replaceText(area, marks[i], '');
|
|
window.alert(err.message);
|
|
}
|
|
}
|
|
} finally {
|
|
uploading--;
|
|
}
|
|
}
|
|
|
|
const imagesIn = (list) => [...(list?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
|
const closest = (event, selector) => (event.target instanceof Element ? event.target.closest(selector) : null);
|
|
const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
|
|
|
|
document.addEventListener('paste', (event) => {
|
|
const area = closest(event, 'textarea[data-images]');
|
|
const files = imagesIn(event.clipboardData);
|
|
// Office programs put a picture of copied text on the clipboard too, then the text wins
|
|
if (!area || !files.length || event.clipboardData.getData('text/plain').trim()) return;
|
|
event.preventDefault();
|
|
insertImages(area, files, true);
|
|
});
|
|
|
|
// Without this a file dropped next to a text field opens in the tab and the form is gone
|
|
document.addEventListener('dragover', (event) => {
|
|
if (!hasFiles(event) || closest(event, 'input[type="file"]')) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = closest(event, 'textarea[data-images]') ? 'copy' : 'none';
|
|
});
|
|
|
|
document.addEventListener('drop', (event) => {
|
|
if (!hasFiles(event) || closest(event, 'input[type="file"]')) return;
|
|
event.preventDefault();
|
|
const area = closest(event, 'textarea[data-images]');
|
|
const files = imagesIn(event.dataTransfer);
|
|
if (!area || !files.length) return;
|
|
// The cursor doesn't follow a dropped file, so without one placed in the field the image goes at the end
|
|
if (document.activeElement !== area) area.setSelectionRange(area.value.length, area.value.length);
|
|
area.focus();
|
|
insertImages(area, files, false);
|
|
});
|
|
|
|
document.addEventListener('submit', (event) => {
|
|
const form = event.target;
|
|
if (uploading) {
|
|
event.preventDefault();
|
|
window.alert('Einen Moment noch, die Bilder werden noch hochgeladen.');
|
|
return;
|
|
}
|
|
if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
for (const field of form.querySelectorAll('[data-name]')) {
|
|
const indexes = rowIndexes(field);
|
|
let i = 0;
|
|
field.name = field.dataset.name.replace(/#/g, () => indexes[i++] ?? 0);
|
|
}
|
|
});
|
|
|
|
// New project: fill the URL name from the German title until it is edited by hand
|
|
const slug = document.querySelector('[data-slug]');
|
|
const title = document.querySelector('[data-title]');
|
|
if (slug && title && !slug.value) {
|
|
let touched = false;
|
|
slug.addEventListener('input', () => (touched = true));
|
|
title.addEventListener('input', () => {
|
|
if (touched) return;
|
|
slug.value = title.value
|
|
.toLowerCase()
|
|
.replace(/ä/g, 'ae')
|
|
.replace(/ö/g, 'oe')
|
|
.replace(/ü/g, 'ue')
|
|
.replace(/ß/g, 'ss')
|
|
.normalize('NFKD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.slice(0, 60)
|
|
.replace(/^-+|-+$/g, '');
|
|
});
|
|
}
|