// 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], `![](${src})`); } 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, ''); }); }