copy paste images in admin

This commit is contained in:
Harlekin committed 2026-10-25 22:07:13 +01:00
1 parent bef622b61f
commit 42ca51d58b
9 files changed
+189 -13

No files matched your search

+3 -1
View File
@@ -28,7 +28,7 @@ data/
site.yml Name, Pronomen, Links, Skills, "Gerade dran" site.yml Name, Pronomen, Links, Skills, "Gerade dran"
journey.yml Stationen für den Werdegang journey.yml Stationen für den Werdegang
homelab.yml Hardware und Dienste homelab.yml Hardware und Dienste
pages/ Werdegang und Homelab als <name>.de.md / <name>.en.md pages/ Werdegang und Homelab als <name>.de.md / <name>.en.md, Bilder in pages/images/
projects/<slug>/ meta.yml, de.md, en.md und die Bilder projects/<slug>/ 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. 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 ## Deployment
Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager. Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager.
+90
View File
@@ -29,8 +29,98 @@ function rowIndexes(field) {
return indexes; 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) => { document.addEventListener('submit', (event) => {
const form = event.target; 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)) { if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
event.preventDefault(); event.preventDefault();
return; return;
+23 -1
View File
@@ -196,5 +196,27 @@ export function createFiles({ contentDir, dataDir }) {
await rm(projectDir(slug), { recursive: true, force: true }); 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,
};
} }
+31
View File
@@ -23,6 +23,10 @@ export function adminRouter({ config, content, stores }) {
{ name: 'cover', maxCount: 1 }, { name: 'cover', maxCount: 1 },
{ name: 'images', maxCount: 30 }, { 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())); const reload = () => Promise.all(stores.map((store) => store.reload()));
function view(res, template, data = {}) { 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) => { router.post('/admin/logout', urlencoded, csrf, (req, res) => {
auth.logout(req, res); auth.logout(req, res);
res.redirect(303, '/admin/login'); res.redirect(303, '/admin/login');
+2 -2
View File
@@ -8,7 +8,7 @@
<a class="link" href="/de/homelab">Vorschau</a> <a class="link" href="/de/homelab">Vorschau</a>
</div> </div>
<form class="form" method="post" action="/admin/homelab"> <form class="form" method="post" action="/admin/homelab" data-image-upload="/admin/seiten/bild">
<input type="hidden" name="_csrf" value="{{ csrf }}"> <input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel"> <section class="panel">
@@ -17,7 +17,7 @@
{{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }} {{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }}
</div> </div>
{{ pair('Einleitung', 'lead', form.lead, rows=2) }} {{ 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) }}
</section> </section>
<section class="panel"> <section class="panel">
+2 -2
View File
@@ -8,12 +8,12 @@
<a class="link" href="/de/werdegang">Vorschau</a> <a class="link" href="/de/werdegang">Vorschau</a>
</div> </div>
<form class="form" method="post" action="/admin/werdegang"> <form class="form" method="post" action="/admin/werdegang" data-image-upload="/admin/seiten/bild">
<input type="hidden" name="_csrf" value="{{ csrf }}"> <input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel"> <section class="panel">
<h2 class="panel__title">Über mich</h2> <h2 class="panel__title">Über mich</h2>
{{ 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) }}
</section> </section>
<section class="panel"> <section class="panel">
+5 -4
View File
@@ -8,10 +8,11 @@
</label> </label>
{%- endmacro %} {%- 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) -%}
<label class="field"> <label class="field">
<span class="field__label">{{ label }}</span> <span class="field__label">{{ label }}</span>
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %} rows="{{ rows }}">{{ value }}</textarea> <textarea class="input{% if mono %} input--mono{% endif %}" name="{{ name }}"{% if '#' in name %} data-name="{{ name }}"{% endif %}{% if images %} data-images{% endif %} rows="{{ rows }}">{{ value }}</textarea>
{% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %} {% if hint %}<span class="field__hint">{{ hint }}</span>{% endif %}
</label> </label>
{%- endmacro %} {%- endmacro %}
@@ -35,7 +36,7 @@
{%- endmacro %} {%- endmacro %}
{# Same field in German and English side by side #} {# 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) -%}
<fieldset class="pair"> <fieldset class="pair">
<legend class="field__label">{{ label }}</legend> <legend class="field__label">{{ label }}</legend>
<div class="pair__fields"> <div class="pair__fields">
@@ -44,7 +45,7 @@
<label class="pair__field"> <label class="pair__field">
<span class="pair__lang">{{ lang | upper }}</span> <span class="pair__lang">{{ lang | upper }}</span>
{% if rows %} {% if rows %}
<textarea class="input{% if mono %} input--mono{% endif %}" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} rows="{{ rows }}">{{ value[lang] }}</textarea> <textarea class="input{% if mono %} input--mono{% endif %}" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %}{% if images %} data-images{% endif %} rows="{{ rows }}">{{ value[lang] }}</textarea>
{% else %} {% else %}
<input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}"> <input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}">
{% endif %} {% endif %}
+3 -3
View File
@@ -8,7 +8,7 @@
{% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %} {% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %}
</div> </div>
<form class="form" method="post" action="{{ '/admin/projekte/neu' if isNew else '/admin/projekte/' + savedSlug }}" enctype="multipart/form-data"> <form class="form" method="post" action="{{ '/admin/projekte/neu' if isNew else '/admin/projekte/' + savedSlug }}" enctype="multipart/form-data"{% if not isNew %} data-image-upload="/admin/projekte/{{ savedSlug }}/bild"{% endif %}>
<input type="hidden" name="_csrf" value="{{ csrf }}"> <input type="hidden" name="_csrf" value="{{ csrf }}">
<section class="panel"> <section class="panel">
@@ -26,14 +26,14 @@
<section class="panel"> <section class="panel">
<h2 class="panel__title">Texte</h2> <h2 class="panel__title">Texte</h2>
<p class="panel__hint">Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder aus der Galerie bindest du mit <code>![Beschreibung](dateiname.jpg)</code> ein.</p> <p class="panel__hint">Englisch ist optional, ohne zeigt die englische Seite den deutschen Text. Im Text geht Markdown. Bilder kannst du mit Strg+V oder per Drag &amp; Drop direkt in den Text einfügen{% if isNew %}, sobald das Projekt einmal gespeichert ist{% endif %}. Bilder aus der Galerie bindest du mit <code>![Beschreibung](dateiname.jpg)</code> ein.</p>
<div class="langs"> <div class="langs">
{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %} {% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}
<div class="lang"> <div class="lang">
<h3 class="lang__title">{{ label }}</h3> <h3 class="lang__title">{{ label }}</h3>
{{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }} {{ field('Titel', lang + '[title]', project[lang].title, attrs=('data-title' if lang == 'de' else '')) }}
{{ area('Kurzbeschreibung', lang + '[summary]', project[lang].summary, 2) }} {{ 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) }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+30
View File
@@ -229,6 +229,36 @@ test('journey, profile and homelab', async () => {
assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft'); 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, /<img src="\/media\/pages\/images\/rack-foto\.jpg\?w=1600&[^"]+" srcset=/);
res = await paste('/admin/seiten/bild', 'not an image', 'virus.exe');
assert.equal(res.status, 422);
assert.match((await res.json()).error, /kein Bild/);
assert.equal((await paste('/admin/projekte/gibts-nicht/bild', await image('png'))).status, 404);
assert.equal((await paste('/admin/seiten/bild', await image('png'), 'bild', 'falsch')).status, 403);
});
test('clients are counted by address, IPv6 by /64', () => { test('clients are counted by address, IPv6 by /64', () => {
assert.equal(clientKey('79.221.147.164'), '79.221.147.164'); assert.equal(clientKey('79.221.147.164'), '79.221.147.164');
assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20'); assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');