Files
photog/templates/manage.html
T
puttaalu bf75c9fe8e Fix manage handlers ignoring multipart form bodies
The /manage page posts with FormData, i.e. multipart/form-data, but the edit,
delete, tags, restore and empty-trash handlers parsed with r.ParseForm, which
only reads urlencoded bodies. So every field came back empty: delete returned
200 with deleted:0 and moved nothing, edits saved nothing — while curl -d
(urlencoded) worked, which is why tests and manual checks missed it.

Parse with ParseMultipartForm (tolerating ErrNotMultipart so urlencoded still
works), via a shared parseForm helper used by all manage handlers. handleUpload
already did this. Also stop the UI reporting deleted:0 / updated:0 as success,
and add a multipart regression test.
2026-10-18 22:32:18 +02:00

346 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage — Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>
.manage-page { max-width: 760px; margin: 0 auto; padding: 28px 20px 80px; }
.manage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.manage-head h2 { font-family: var(--serif); font-size: 22px; font-weight: 600; color: var(--fg); }
.manage-head a { font-family: monospace; font-size: 13px; color: var(--fg4); text-decoration: none; }
.manage-head a:hover { color: var(--fg2); }
#count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-bottom: 20px; }
/* Bulk action bar, sticky at the top once posts are selected. */
.bulkbar {
position: sticky; top: 0; z-index: 5;
display: none; flex-wrap: wrap; align-items: center; gap: 10px;
background: var(--bg1); border: 1px solid var(--bg2);
padding: 12px 14px; margin-bottom: 20px;
}
.bulkbar.show { display: flex; }
.bulkbar .sel { font-family: monospace; font-size: 12px; color: var(--fg2); margin-right: auto; }
.bulkbar input[type="text"] {
background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
font-family: var(--serif); font-size: 14px; padding: 7px 9px; width: 150px; outline: none;
}
.bulkbar input:focus { border-color: var(--fg4); }
.btn {
background: var(--bg1); border: 1px solid var(--bg2); color: var(--fg2);
font-family: var(--serif); font-size: 14px; padding: 7px 12px; cursor: pointer;
transition: background .2s, color .2s;
}
.btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.btn:disabled { opacity: .35; cursor: not-allowed; }
.btn.danger { color: #fb4934; border-color: #5a2828; }
.btn.danger:hover:not(:disabled) { background: #2e1d1d; color: #fb4934; }
.row {
display: grid; grid-template-columns: 22px 72px 1fr auto; gap: 14px;
align-items: start; padding: 16px 0; border-top: 1px solid var(--bg1);
}
.row input[type="checkbox"] { margin-top: 28px; width: 16px; height: 16px; accent-color: var(--aqua, #8ec07c); }
.row .thumb { width: 72px; height: 72px; object-fit: cover; background: var(--bg1); display: block; }
.fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.fields label { font-size: 11px; color: var(--fg4); font-family: monospace; text-transform: uppercase; letter-spacing: .06em; }
.fields input {
width: 100%; background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
font-size: 15px; padding: 8px 10px; outline: none; box-sizing: border-box;
}
.fields input[type="text"] { font-family: var(--serif); }
.fields input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
.fields input:focus { border-color: var(--fg4); }
.rowactions { display: flex; flex-direction: column; gap: 8px; }
@media (max-width: 560px) {
.row { grid-template-columns: 22px 56px 1fr; }
.row .rowactions { grid-column: 2 / 4; flex-direction: row; }
.row input[type="checkbox"] { margin-top: 20px; }
}
.trashpanel { display: none; border: 1px solid var(--bg2); background: var(--bg0); padding: 14px; margin-bottom: 24px; }
.trashpanel.show { display: block; }
.trashhead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.trashhead strong { font-family: var(--serif); color: var(--fg); }
.trashhead #trash-count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-right: auto; }
.trashrow { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--bg1); }
.trashrow .meta { margin-right: auto; min-width: 0; }
.trashrow .slug { font-family: var(--serif); color: var(--fg2); }
.trashrow .when { font-family: monospace; font-size: 11px; color: var(--fg4); }
#trash-empty-msg { color: var(--fg4); font-family: var(--serif); padding: 8px 0; }
#toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
display: none; font-family: monospace; font-size: 13px; padding: 10px 16px; max-width: 90%;
}
#toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
#toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
#empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; }
</style>
</head>
<body>
<div class="manage-page">
<div class="manage-head">
<h2>Manage posts</h2>
<span><a href="/upload">+ Upload</a> &nbsp; <a href="#" id="trash-toggle">Trash</a> &nbsp; <a href="/">← Gallery</a></span>
</div>
<div id="count">Loading…</div>
<div class="trashpanel" id="trashpanel">
<div class="trashhead">
<strong>Trash</strong>
<span id="trash-count"></span>
<button class="btn danger" id="btn-empty-all">Empty trash</button>
</div>
<div id="trashlist"></div>
<div id="trash-empty-msg" style="display:none">Trash is empty.</div>
</div>
<div class="bulkbar" id="bulkbar">
<span class="sel" id="sel">0 selected</span>
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off">
<button class="btn" id="btn-add">Add</button>
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off">
<button class="btn" id="btn-remove">Remove</button>
<button class="btn danger" id="btn-del-sel">Delete selected</button>
</div>
<div id="list"></div>
<div id="empty" style="display:none">No posts yet.</div>
</div>
<div id="toast"></div>
<script>
const list = document.getElementById('list');
const countEl = document.getElementById('count');
const emptyEl = document.getElementById('empty');
const bulkbar = document.getElementById('bulkbar');
const selEl = document.getElementById('sel');
const toast = document.getElementById('toast');
let toastTimer;
function flash(msg, ok) {
toast.textContent = msg;
toast.className = ok ? 'ok' : 'err';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = ''; }, 3500);
}
function selectedSlugs() {
return [...list.querySelectorAll('input.pick:checked')].map(c => c.dataset.slug);
}
function refreshBulk() {
const n = selectedSlugs().length;
selEl.textContent = n + ' selected';
bulkbar.classList.toggle('show', n > 0);
}
function row(p) {
const el = document.createElement('div');
el.className = 'row';
el.dataset.slug = p.slug;
el.innerHTML = `
<input type="checkbox" class="pick" data-slug="${p.slug}" aria-label="Select">
<a href="${p.url}" target="_blank" rel="noopener"><img class="thumb" src="${p.thumb}" alt=""></a>
<div class="fields">
<label>Caption</label>
<input type="text" class="f-caption" value="">
<label>Date</label>
<input type="date" class="f-date" value="${p.date}">
<label>Tags (comma-separated)</label>
<input type="text" class="f-tags" value="">
</div>
<div class="rowactions">
<button class="btn f-save">Save</button>
<button class="btn danger f-del">Delete</button>
</div>`;
// Set values via property to avoid HTML-escaping issues in attributes.
el.querySelector('.f-caption').value = p.caption;
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
el.querySelector('.pick').addEventListener('change', refreshBulk);
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
el.querySelector('.f-del').addEventListener('click', () => delOne(el, p.slug));
return el;
}
async function load() {
try {
const res = await fetch('/api/photos', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const photos = await res.json();
list.innerHTML = '';
photos.forEach(p => list.appendChild(row(p)));
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
emptyEl.style.display = photos.length ? 'none' : 'block';
refreshBulk();
} catch (err) {
countEl.textContent = '';
flash('Could not load posts: ' + err.message, false);
}
}
async function post(url, body) {
const res = await fetch(url, { method: 'POST', body });
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
return res;
}
async function save(el, slug) {
const form = new FormData();
form.append('slug', slug);
form.append('caption', el.querySelector('.f-caption').value);
form.append('date', el.querySelector('.f-date').value);
form.append('tags', el.querySelector('.f-tags').value);
try {
await post('/api/edit', form);
flash('Saved ' + slug, true);
} catch (err) { flash('Save failed: ' + err.message, false); }
}
async function delOne(el, slug) {
if (!confirm('Move "' + slug + '" to trash?')) return;
const form = new FormData();
form.append('slug', slug);
try {
const res = await post('/api/delete', form);
const j = await res.json().catch(() => ({}));
if (!j.deleted) { flash('Not deleted — "' + slug + '" not found on the server', false); return; }
el.remove();
flash('Moved ' + slug + ' to trash', true);
refreshBulk();
updateCount();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
function updateCount() {
const n = list.querySelectorAll('.row').length;
countEl.textContent = n + (n === 1 ? ' post' : ' posts');
emptyEl.style.display = n ? 'none' : 'block';
}
document.getElementById('btn-del-sel').addEventListener('click', async () => {
const slugs = selectedSlugs();
if (!slugs.length) return;
if (!confirm('Move ' + slugs.length + ' post(s) to trash?')) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
try {
const res = await post('/api/delete', form);
const j = await res.json().catch(() => ({}));
if (!j.deleted) { flash('Nothing deleted — slugs not found on the server', false); return; }
flash('Moved ' + j.deleted + ' to trash', true);
await load();
} catch (err) { flash('Delete failed: ' + err.message, false); }
});
async function bulkTags(field, label) {
const slugs = selectedSlugs();
const val = document.getElementById(field).value.trim();
if (!slugs.length || !val) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
form.append(label, val);
try {
const res = await post('/api/tags', form);
const j = await res.json().catch(() => ({}));
if (!j.updated) { flash('No posts updated', false); return; }
document.getElementById(field).value = '';
flash('Updated tags on ' + j.updated + ' post(s)', true);
await load();
} catch (err) { flash('Tag update failed: ' + err.message, false); }
}
document.getElementById('btn-add').addEventListener('click', () => bulkTags('bulk-add', 'add'));
document.getElementById('btn-remove').addEventListener('click', () => bulkTags('bulk-remove', 'remove'));
// ── Trash ────────────────────────────────────────────────────────────────
const trashpanel = document.getElementById('trashpanel');
const trashlist = document.getElementById('trashlist');
const trashCount = document.getElementById('trash-count');
const trashEmpty = document.getElementById('trash-empty-msg');
function trashRow(it) {
const el = document.createElement('div');
el.className = 'trashrow';
const parts = [];
if (it.image) parts.push('image');
if (it.hasMeta) parts.push('caption/tags');
el.innerHTML = `
<div class="meta">
<div class="slug"></div>
<div class="when">deleted ${it.deletedAt} · ${parts.join(' + ') || 'empty'}</div>
</div>
<button class="btn t-restore">Restore</button>
<button class="btn danger t-del">Delete forever</button>`;
el.querySelector('.slug').textContent = it.slug;
el.querySelector('.t-restore').addEventListener('click', () => restore(it));
el.querySelector('.t-del').addEventListener('click', () => deleteForever(it));
return el;
}
async function loadTrash() {
try {
const res = await fetch('/api/trash', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const items = await res.json();
trashlist.innerHTML = '';
items.forEach(it => trashlist.appendChild(trashRow(it)));
trashCount.textContent = items.length + (items.length === 1 ? ' item' : ' items');
trashEmpty.style.display = items.length ? 'none' : 'block';
} catch (err) {
flash('Could not load trash: ' + err.message, false);
}
}
async function restore(it) {
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/restore', form);
flash('Restored ' + it.slug, true);
await Promise.all([load(), loadTrash()]);
} catch (err) { flash('Restore failed: ' + err.message, false); }
}
async function deleteForever(it) {
if (!confirm('Permanently delete "' + it.slug + '"? This cannot be undone.')) return;
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/trash/empty', form);
flash('Deleted ' + it.slug + ' forever', true);
await loadTrash();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
document.getElementById('trash-toggle').addEventListener('click', (e) => {
e.preventDefault();
const showing = trashpanel.classList.toggle('show');
if (showing) loadTrash();
});
document.getElementById('btn-empty-all').addEventListener('click', async () => {
if (!confirm('Permanently delete everything in the trash? This cannot be undone.')) return;
const form = new FormData();
form.append('all', '1');
try {
await post('/api/trash/empty', form);
flash('Trash emptied', true);
await loadTrash();
} catch (err) { flash('Empty failed: ' + err.message, false); }
});
load();
</script>
</body>
</html>