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"
journey.yml Stationen für den Werdegang
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
```
@@ -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.
+90
View File
@@ -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;
+23 -1
View File
@@ -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,
};
}
+31
View File
@@ -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');
+2 -2
View File
@@ -8,7 +8,7 @@
<a class="link" href="/de/homelab">Vorschau</a>
</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 }}">
<section class="panel">
@@ -17,7 +17,7 @@
{{ check('Homelab-Seite öffentlich zeigen', 'visible', form.visible) }}
</div>
{{ 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 class="panel">
+2 -2
View File
@@ -8,12 +8,12 @@
<a class="link" href="/de/werdegang">Vorschau</a>
</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 }}">
<section class="panel">
<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 class="panel">
+5 -4
View File
@@ -8,10 +8,11 @@
</label>
{%- 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">
<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 %}
</label>
{%- 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) -%}
<fieldset class="pair">
<legend class="field__label">{{ label }}</legend>
<div class="pair__fields">
@@ -44,7 +45,7 @@
<label class="pair__field">
<span class="pair__lang">{{ lang | upper }}</span>
{% 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 %}
<input class="input" type="text" name="{{ full }}"{% if '#' in full %} data-name="{{ full }}"{% endif %} value="{{ value[lang] }}">
{% endif %}
+3 -3
View File
@@ -8,7 +8,7 @@
{% if not isNew %}<a class="link" href="/de/portfolio/{{ savedSlug }}">Vorschau</a>{% endif %}
</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 }}">
<section class="panel">
@@ -26,14 +26,14 @@
<section class="panel">
<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">
{% for lang, label in [['de', 'Deutsch'], ['en', 'English']] %}
<div class="lang">
<h3 class="lang__title">{{ label }}</h3>
{{ 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) }}
</div>
{% endfor %}
</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');
});
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', () => {
assert.equal(clientKey('79.221.147.164'), '79.221.147.164');
assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');