Files
Davide Scaini 2ec4d9157c Refactor: extract edit UI HTML into bincio/edit/templates/edit.html
The 285-line _HTML string literal in edit/server.py is replaced by a
template file loaded at request time. The route handler is unchanged in
behaviour — it still substitutes __SITE_URL__, __SPORT_OPTIONS__, and
__STAT_CHECKBOXES__ before returning the response.

Five new tests cover: 200 response, form presence, site_url injection,
no unresolved placeholders, and template file existence on disk.
2026-05-13 23:19:19 +02:00

284 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Activity</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090b; --surface: #18181b; --border: #27272a;
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
--accent-dim: #1d3461; --danger: #ef4444;
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body { background: var(--bg); color: var(--text); font-family: var(--font);
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.25rem; font-weight: 700; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 140px; }
.field { margin-bottom: 16px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
user-select: none; transition: border-color .15s, background .15s; }
.check-item:hover { border-color: var(--muted); }
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle-row { display: flex; gap: 16px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: border-color .15s, background .15s; }
.toggle:hover { border-color: var(--muted); }
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
.toggle input { width: auto; accent-color: var(--accent); }
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
transition: border-color .15s; margin-top: 4px; }
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; }
.image-chip button { background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
.image-chip button:hover { color: var(--danger); }
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
cursor: pointer; border: none; transition: opacity .15s; }
.btn:disabled { opacity: .4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
.status { font-size: 13px; }
.status.ok { color: #4ade80; }
.status.err { color: var(--danger); }
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
.back { font-size: 13px; color: var(--muted); }
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.card { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div style="max-width:780px;margin:0 auto">
<div class="header">
<a class="back" href="__SITE_URL__">← Back to site</a>
<h1 id="page-title">Edit Activity</h1>
</div>
<p id="meta" class="meta" style="margin-bottom:16px"></p>
<div class="card">
<form id="form" autocomplete="off">
<p class="section-title">Identity</p>
<div class="row">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
</div>
<div class="field">
<label for="sport">Sport</label>
<select id="sport" name="sport">
__SPORT_OPTIONS__
</select>
</div>
</div>
<div class="field">
<label for="gear">Gear</label>
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
</div>
<p class="section-title" style="margin-top:20px">Description</p>
<div class="field">
<label for="description">Markdown supported</label>
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
</div>
<p class="section-title" style="margin-top:20px">Display</p>
<div class="field">
<label>Hide stat panels</label>
<div class="check-group" id="hide-stats-group">
__STAT_CHECKBOXES__
</div>
</div>
<div class="field" style="margin-top:12px">
<label>Flags</label>
<div class="toggle-row">
<label class="toggle" id="toggle-highlight">
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
</label>
<label class="toggle" id="toggle-private">
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
</label>
</div>
</div>
<p class="section-title" style="margin-top:20px">Images</p>
<div class="field">
<label>Drag & drop images or click to browse</label>
<div class="drop-zone" id="drop-zone">
<span id="drop-label">Drop images here or click to upload</span>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
</div>
<div class="image-list" id="image-list"></div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
<span class="status" id="status"></span>
</div>
</form>
</div>
</div>
<script>
const id = location.pathname.split('/edit/')[1];
const api = '/api/activity/' + id;
let uploadedImages = [];
// Fetch current data
fetch(api).then(r => r.json()).then(data => {
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
document.getElementById('meta').textContent = data.started_at
? new Date(data.started_at).toLocaleString() : '';
document.getElementById('title').value = data.title || '';
document.getElementById('sport').value = data.sport || 'other';
document.getElementById('gear').value = data.gear || '';
document.getElementById('description').value = data.description || '';
if (data.highlight) setToggle('highlight', true);
if (data.private) setToggle('private', true);
(data.hide_stats || []).forEach(s => {
const cb = document.querySelector(`input[data-stat="${s}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
});
uploadedImages = data.images || [];
renderImageList();
}).catch(() => {
document.getElementById('status').textContent = 'Could not load activity data.';
document.getElementById('status').className = 'status err';
});
// Toggle active class on check items
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('.check-item').classList.toggle('active', cb.checked);
});
});
function setToggle(name, val) {
const cb = document.getElementById(name);
cb.checked = val;
document.getElementById('toggle-' + name).classList.toggle('active', val);
}
document.getElementById('highlight').addEventListener('change', e => {
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
});
document.getElementById('private').addEventListener('change', e => {
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
});
// Image upload
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
uploadFiles([...e.dataTransfer.files]);
});
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
async function uploadFiles(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(api + '/images', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
renderImageList();
// Insert markdown image reference at end of description
const ta = document.getElementById('description');
const ref = '\n![' + d.filename.replace(/\.[^.]+$/, '') + '](' + d.filename + ')';
ta.value = ta.value.trimEnd() + ref;
}
}
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function renderImageList() {
const list = document.getElementById('image-list');
list.innerHTML = uploadedImages.map(f =>
`<span class="image-chip">${escapeHtml(f)}
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
</span>`
).join('');
}
async function removeImage(filename) {
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
uploadedImages = uploadedImages.filter(f => f !== filename);
renderImageList();
}
// Save
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('save-btn');
const status = document.getElementById('status');
btn.disabled = true;
status.textContent = 'Saving…';
status.className = 'status';
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
.map(cb => cb.dataset.stat);
const payload = {
title: document.getElementById('title').value.trim(),
sport: document.getElementById('sport').value,
gear: document.getElementById('gear').value.trim(),
description: document.getElementById('description').value.trim(),
highlight: document.getElementById('highlight').checked,
private: document.getElementById('private').checked,
hide_stats: hideStats,
};
try {
const r = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
status.className = 'status ok';
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.className = 'status err';
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>