diff --git a/README.md b/README.md index 0cf62d9..4c43880 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ data/ site.yml Name, Pronomen, Links, Skills, "Gerade dran" journey.yml Stationen für den Werdegang homelab.yml Hardware und Dienste - pages/ Werdegang und Homelab als .de.md / .en.md + pages/ Werdegang und Homelab als .de.md / .en.md, Bilder in pages/images/ projects// meta.yml, de.md, en.md und die Bilder ``` @@ -48,6 +48,8 @@ Unter derselben Adresse zeigt `/de/` die ganze Seite mit Entwürfen, als Vorscha Hochgeladene Bilder werden auf höchstens 2400 px verkleinert und neu gespeichert, dabei fallen EXIF-Daten wie der Aufnahmeort weg. +In die Textfelder für Projekte, Werdegang und Homelab lassen sich Bilder mit Strg+V oder per Drag & Drop einfügen. Sie werden sofort hochgeladen und als Markdown eingesetzt, bei Projekten landen sie im Projektordner, sonst in `pages/images/`. Bei einem neuen Projekt geht das erst nach dem ersten Speichern. + ## Deployment Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager. diff --git a/client/js/admin.js b/client/js/admin.js index 122bd08..97c47d4 100644 --- a/client/js/admin.js +++ b/client/js/admin.js @@ -29,8 +29,98 @@ function rowIndexes(field) { 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; diff --git a/src/admin/files.js b/src/admin/files.js index 5d99e22..0f45c1c 100644 --- a/src/admin/files.js +++ b/src/admin/files.js @@ -196,5 +196,27 @@ export function createFiles({ contentDir, dataDir }) { await rm(projectDir(slug), { recursive: true, force: true }); } - return { readYaml, writeYaml, readMarkdown, writeMarkdown, removeFile, readProject, saveProject, deleteProject }; + // Images pasted into a text. Returns the path for the Markdown, relative to the text file. + async function saveProjectImage(slug, upload) { + return saveImage(projectDir(slug), upload); + } + + async function savePageImage(upload) { + const dir = path.join(dataDir, 'pages', 'images'); + await mkdir(dir, { recursive: true }); + return `images/${await saveImage(dir, upload)}`; + } + + return { + readYaml, + writeYaml, + readMarkdown, + writeMarkdown, + removeFile, + readProject, + saveProject, + deleteProject, + saveProjectImage, + savePageImage, + }; } diff --git a/src/admin/routes.js b/src/admin/routes.js index 2160bfe..b5d53f5 100644 --- a/src/admin/routes.js +++ b/src/admin/routes.js @@ -23,6 +23,10 @@ export function adminRouter({ config, content, stores }) { { name: 'cover', maxCount: 1 }, { name: 'images', maxCount: 30 }, ]); + const textImage = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024, files: 1, fields: 5 }, + }).single('bild'); const reload = () => Promise.all(stores.map((store) => store.reload())); function view(res, template, data = {}) { @@ -81,6 +85,33 @@ export function adminRouter({ config, content, stores }) { }); }; + /* Images pasted or dropped into a text field, admin.js inserts the returned path as Markdown */ + + const withTextImage = (req, res, next) => { + textImage(req, res, (err) => { + if (!err) return next(); + if (!(err instanceof multer.MulterError)) return next(err); + res.status(413).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'Das Bild ist größer als 25 MB.' : 'Bitte ein Bild nach dem anderen.' }); + }); + }; + + async function saveTextImage(req, res, save) { + if (!req.file) return res.status(422).json({ error: 'Es kam kein Bild an.' }); + try { + res.json({ src: await save(req.file) }); + } catch (err) { + if (!(err instanceof forms.InputError)) throw err; + res.status(422).json({ error: err.message }); + } + } + + router.post('/admin/projekte/:slug/bild', withTextImage, csrf, async (req, res) => { + if (!(await files.readProject(req.params.slug))) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + await saveTextImage(req, res, (upload) => files.saveProjectImage(req.params.slug, upload)); + }); + + router.post('/admin/seiten/bild', withTextImage, csrf, (req, res) => saveTextImage(req, res, files.savePageImage)); + router.post('/admin/logout', urlencoded, csrf, (req, res) => { auth.logout(req, res); res.redirect(303, '/admin/login'); diff --git a/src/views/admin/homelab.njk b/src/views/admin/homelab.njk index d22e288..e389b79 100644 --- a/src/views/admin/homelab.njk +++ b/src/views/admin/homelab.njk @@ -8,7 +8,7 @@ Vorschau -
+
@@ -17,7 +17,7 @@ {{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }} {{ pair('Einleitung', 'lead', form.lead, rows=2) }} - {{ pair('Text unter Hardware und Diensten', 'body', form.body, rows=7, hint='Markdown geht, z. B. „## Was ich dabei gelernt habe“ als Überschrift.') }} + {{ pair('Text unter Hardware und Diensten', 'body', form.body, rows=7, hint='Markdown geht, z. B. „## Was ich dabei gelernt habe“ als Überschrift. Bilder mit Strg+V oder per Drag & Drop einfügen.', images=true) }}
diff --git a/src/views/admin/journey.njk b/src/views/admin/journey.njk index 839d451..778d75a 100644 --- a/src/views/admin/journey.njk +++ b/src/views/admin/journey.njk @@ -8,12 +8,12 @@ Vorschau - +

Über mich

- {{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional.') }} + {{ pair('Text', 'about', form.about, rows=7, hint='Markdown geht. Englisch ist optional. Bilder mit Strg+V oder per Drag & Drop einfügen.', images=true) }}
diff --git a/src/views/admin/macros.njk b/src/views/admin/macros.njk index 970166c..cc102e5 100644 --- a/src/views/admin/macros.njk +++ b/src/views/admin/macros.njk @@ -8,10 +8,11 @@ {%- endmacro %} -{% macro area(label, name, value='', rows=3, hint='', mono=false) -%} +{# images: pasted or dropped images are uploaded to the form's data-image-upload and inserted as Markdown #} +{% macro area(label, name, value='', rows=3, hint='', mono=false, images=false) -%} {%- endmacro %} @@ -35,7 +36,7 @@ {%- endmacro %} {# Same field in German and English side by side #} -{% macro pair(label, name, value, rows=0, hint='', mono=false) -%} +{% macro pair(label, name, value, rows=0, hint='', mono=false, images=false) -%}
{{ label }}
@@ -44,7 +45,7 @@
- +
@@ -26,14 +26,14 @@

Texte

-

Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder aus der Galerie bindest du mit ![Beschreibung](dateiname.jpg) ein.

+

Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder kannst du mit Strg+V oder per Drag & Drop direkt in den Text einfügen{% if isNew %}, sobald das Projekt einmal gespeichert ist{% endif %}. Bilder aus der Galerie bindest du mit ![Beschreibung](dateiname.jpg) ein.

{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}

{{ label }}

{{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }} {{ area('Kurzbeschreibung', lang + '[summary]', project[lang].summary, 2) }} - {{ area('Text', lang + '[body]', project[lang].body, 16, mono=true) }} + {{ area('Text', lang + '[body]', project[lang].body, 16, mono=true, images=true) }}
{% endfor %}
diff --git a/test/admin.test.js b/test/admin.test.js index 71d3443..4519d53 100644 --- a/test/admin.test.js +++ b/test/admin.test.js @@ -229,6 +229,36 @@ test('journey, profile and homelab', async () => { assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft'); }); +test('images pasted into a text are saved next to it', async () => { + const paste = async (url, file, name = 'bild', token = csrf) => { + const body = new FormData(); + body.set('_csrf', token); + body.set('bild', new Blob([file]), name); + return request(url, { method: 'POST', body }); + }; + + let res = await paste('/admin/projekte/alpha/bild', await image('png')); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { src: 'bild.png' }); + assert.equal((await sharp(path.join(dataDir, 'projects/alpha/bild.png')).metadata()).width, 2400); + assert.deepEqual(await (await paste('/admin/projekte/alpha/bild', await image('png'))).json(), { src: 'bild-2.png' }); + + res = await paste('/admin/seiten/bild', await image('jpeg'), 'Rack Foto.jpg'); + assert.deepEqual(await res.json(), { src: 'images/rack-foto.jpg' }); + res = await request('/admin/homelab', { + method: 'POST', + body: form({ visible: 'on', 'body[de]': 'Mein Rack:\n\n![Das Rack](images/rack-foto.jpg)' }), + }); + assert.equal(res.status, 303); + assert.match(publicStore.get().pages.homelab.de.html, / { assert.equal(clientKey('79.221.147.164'), '79.221.147.164'); assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');