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.
This commit is contained in:
+3
-290
@@ -48,293 +48,7 @@ from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
|||||||
from bincio.shared.images import unique_image_name as _unique_image_name
|
from bincio.shared.images import unique_image_name as _unique_image_name
|
||||||
|
|
||||||
|
|
||||||
# ── HTML UI ───────────────────────────────────────────────────────────────────
|
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "edit.html"
|
||||||
|
|
||||||
_HTML = """\
|
|
||||||
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -359,13 +73,12 @@ async def edit_page(activity_id: str) -> str:
|
|||||||
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
||||||
for s in STAT_PANELS
|
for s in STAT_PANELS
|
||||||
)
|
)
|
||||||
html = (
|
return (
|
||||||
_HTML
|
_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||||
.replace("__SITE_URL__", site_url)
|
.replace("__SITE_URL__", site_url)
|
||||||
.replace("__SPORT_OPTIONS__", sport_opts)
|
.replace("__SPORT_OPTIONS__", sport_opts)
|
||||||
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
||||||
)
|
)
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/activity/{activity_id}")
|
@app.get("/api/activity/{activity_id}")
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
+1
-1
@@ -490,7 +490,7 @@ def test_activity_geojson_missing_geometry(client, tmp_path, authenticated_sessi
|
|||||||
| # | Step | Status |
|
| # | Step | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
|
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
|
||||||
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Not started |
|
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done |
|
||||||
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Not started |
|
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Not started |
|
||||||
| 4 | Narrow broad `except Exception:` catches | Not started |
|
| 4 | Narrow broad `except Exception:` catches | Not started |
|
||||||
|
|
||||||
|
|||||||
@@ -156,3 +156,34 @@ class TestDemEndpoint:
|
|||||||
monkeypatch.setattr(edit_server, "dem_url", "https://api.open-elevation.com")
|
monkeypatch.setattr(edit_server, "dem_url", "https://api.open-elevation.com")
|
||||||
r = CLIENT.post("/api/activity/../../evil/recalculate-elevation/dem")
|
r = CLIENT.post("/api/activity/../../evil/recalculate-elevation/dem")
|
||||||
assert r.status_code in (400, 404, 422)
|
assert r.status_code in (400, 404, 422)
|
||||||
|
|
||||||
|
|
||||||
|
# ── /edit/{activity_id} HTML template ────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestEditPage:
|
||||||
|
AID = "2024-01-01T080000Z-my-ride"
|
||||||
|
|
||||||
|
def test_returns_200_html(self):
|
||||||
|
r = CLIENT.get(f"/edit/{self.AID}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
|
|
||||||
|
def test_contains_form(self):
|
||||||
|
r = CLIENT.get(f"/edit/{self.AID}")
|
||||||
|
assert '<form id="form"' in r.text
|
||||||
|
|
||||||
|
def test_site_url_placeholder_replaced(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(edit_server, "site_url", "http://localhost:9999")
|
||||||
|
r = CLIENT.get(f"/edit/{self.AID}")
|
||||||
|
assert "http://localhost:9999" in r.text
|
||||||
|
assert "__SITE_URL__" not in r.text
|
||||||
|
|
||||||
|
def test_no_unresolved_placeholders(self):
|
||||||
|
r = CLIENT.get(f"/edit/{self.AID}")
|
||||||
|
for token in ("__SITE_URL__", "__SPORT_OPTIONS__", "__STAT_CHECKBOXES__"):
|
||||||
|
assert token not in r.text, f"Unresolved placeholder: {token}"
|
||||||
|
|
||||||
|
def test_template_file_exists_on_disk(self):
|
||||||
|
from pathlib import Path
|
||||||
|
template = Path(edit_server.__file__).parent / "templates" / "edit.html"
|
||||||
|
assert template.exists(), f"Template not found at {template}"
|
||||||
|
|||||||
Reference in New Issue
Block a user