335 lines
11 KiB
HTML
335 lines
11 KiB
HTML
{% set items = (field.template_ctx.instance.updates or []) %}
|
||
|
||
<div class="mt-3">
|
||
<label class="form-label">Add update</label>
|
||
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
|
||
<div class="mt-2 d-flex gap-2">
|
||
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
|
||
</div>
|
||
|
||
<div id="newDraftsList" class="mt-3 d-none">
|
||
<div class="fw-semibold mb-1">Pending new updates</div>
|
||
<ul class="list-group" id="newDraftsUl"></ul>
|
||
</div>
|
||
</div>
|
||
|
||
<ul class="list-group mt-3">
|
||
{% for n in items %}
|
||
<li class="list-group-item">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
|
||
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
|
||
|
||
<div class="d-flex flex-column align-items-end">
|
||
<div id="editView{{ n.id }}">
|
||
<small class="text-muted text-nowrap">
|
||
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
|
||
</small>
|
||
</div>
|
||
|
||
<div class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" id="editSwitch{{ n.id }}" onchange="changeMode({{ n.id }})" role="switch" aria-label="Edit mode for update {{ n.id }}">
|
||
<label for="editSwitch{{ n.id }}" class="form-check-label">Edit</label>
|
||
</div>
|
||
|
||
<div class="mt-2">
|
||
<button type="button" class="btn btn-outline-danger btn-sm" id="deleteBtn{{ n.id }}" onclick="toggleDelete({{ n.id }})">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
{% else %}
|
||
<li id="noUpdatesRow" class="list-group-item text-muted">No updates yet.</li>
|
||
{% endfor %}
|
||
</ul>
|
||
|
||
<!-- link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css" -->
|
||
<style>
|
||
textarea.auto-md {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
max-height: 60vh;
|
||
overflow: hidden;
|
||
resize: vertical;
|
||
}
|
||
|
||
@supports (field-sizing: content) {
|
||
textarea.auto-md {
|
||
field-sizing: content;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||
<script>
|
||
// State (kept global for compatibility with your form serialization)
|
||
window.newDrafts = window.newDrafts || [];
|
||
window.deletedIds = window.deletedIds || [];
|
||
|
||
// ---------- DRY UTILITIES ----------
|
||
function renderMarkdown(md) {
|
||
// One place to parse + sanitize
|
||
const raw = marked.parse(md || "");
|
||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
|
||
}
|
||
|
||
function enhanceLinks(root) {
|
||
if (!root) return;
|
||
for (const a of root.querySelectorAll('a[href]')) {
|
||
a.setAttribute('target', '_blank');
|
||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||
}
|
||
}
|
||
|
||
function enhanceTables(root) {
|
||
if (!root) return;
|
||
for (const t of root.querySelectorAll('table')) {
|
||
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||
}
|
||
}
|
||
|
||
function enhanceBlockquotes(root) {
|
||
if (!root) return;
|
||
for (const t of root.querySelectorAll('blockquote')) {
|
||
t.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
|
||
}
|
||
}
|
||
|
||
function renderHTML(el, md) {
|
||
if (!el) return;
|
||
el.innerHTML = renderMarkdown(md);
|
||
enhanceLinks(el);
|
||
enhanceTables(el);
|
||
enhanceBlockquotes(el);
|
||
}
|
||
|
||
function getMarkdown(id) {
|
||
const el = document.getElementById(`md-${id}`);
|
||
return el ? JSON.parse(el.textContent || '""') : "";
|
||
}
|
||
|
||
function setMarkdown(id, md) {
|
||
const el = document.getElementById(`md-${id}`);
|
||
if (el) el.textContent = JSON.stringify(md ?? "");
|
||
}
|
||
|
||
function escapeForTextarea(s) {
|
||
return (s ?? "").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function autoGrow(ta) {
|
||
if (!ta) return;
|
||
ta.style.height = 'auto';
|
||
ta.style.height = (ta.scrollHeight + 5) + 'px';
|
||
}
|
||
|
||
// ---------- RENDERERS ----------
|
||
function renderExistingView(id) {
|
||
const container = document.getElementById(`editContainer${id}`);
|
||
renderHTML(container, getMarkdown(id));
|
||
}
|
||
|
||
function renderEditor(id) {
|
||
const container = document.getElementById(`editContainer${id}`);
|
||
if (!container) return;
|
||
const current = getMarkdown(id);
|
||
container.innerHTML = `
|
||
<textarea class="form-control w-100 overflow-auto auto-md" id="editor${id}">${escapeForTextarea(current)}</textarea>
|
||
<div class="mt-2 d-flex gap-2">
|
||
<button type="button" class="btn btn-primary btn-sm" data-action="save-edit" data-id="${id}">Save</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-action="cancel-edit" data-id="${id}">Cancel</button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="toggle-preview" data-id="${id}">Preview</button>
|
||
</div>
|
||
<div class="mt-2 border rounded p-2 d-none" id="preview${id}" aria-live="polite"></div>
|
||
`;
|
||
const ta = document.getElementById(`editor${id}`);
|
||
autoGrow(ta);
|
||
ta.addEventListener('input', () => autoGrow(ta));
|
||
}
|
||
|
||
function renderDraftsList() {
|
||
const wrap = document.getElementById('newDraftsList');
|
||
const ul = document.getElementById('newDraftsUl');
|
||
if (!wrap || !ul) return;
|
||
|
||
ul.innerHTML = '';
|
||
const drafts = window.newDrafts || [];
|
||
if (!drafts.length) {
|
||
wrap.classList.add('d-none');
|
||
} else {
|
||
wrap.classList.remove('d-none');
|
||
drafts.forEach((md, idx) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'list-group-item d-flex justify-content-between align-items-start';
|
||
|
||
const left = document.createElement('div');
|
||
left.className = 'w-100 markdown-body';
|
||
left.innerHTML = renderMarkdown(md || '');
|
||
enhanceLinks(left);
|
||
|
||
const right = document.createElement('div');
|
||
right.className = 'ms-3 d-flex flex-column align-items-end';
|
||
right.innerHTML = `
|
||
<button type="button" class="btn btn-outline-danger btn-sm" data-action="remove-draft" data-index="${idx}">
|
||
Remove
|
||
</button>
|
||
`;
|
||
|
||
li.appendChild(left);
|
||
li.appendChild(right);
|
||
ul.appendChild(li);
|
||
});
|
||
}
|
||
|
||
const noRow = document.getElementById('noUpdatesRow');
|
||
if (noRow) {
|
||
if (drafts.length) noRow.classList.add('d-none');
|
||
else noRow.classList.remove('d-none');
|
||
}
|
||
}
|
||
|
||
// ---------- STATE MUTATORS ----------
|
||
function addNewDraft() {
|
||
const ta = document.getElementById('newUpdateInput');
|
||
const text = (ta?.value || '').trim();
|
||
if (!text) return;
|
||
window.newDrafts.push(text);
|
||
ta.value = '';
|
||
renderDraftsList();
|
||
}
|
||
|
||
function clearNewDraft() {
|
||
const ta = document.getElementById('newUpdateInput');
|
||
if (ta) ta.value = '';
|
||
}
|
||
|
||
function deleteDraft(idx) {
|
||
if (!Array.isArray(window.newDrafts)) return;
|
||
window.newDrafts.splice(idx, 1);
|
||
renderDraftsList();
|
||
}
|
||
|
||
function toggleDelete(id) {
|
||
window.deletedIds = window.deletedIds || [];
|
||
const idx = window.deletedIds.indexOf(id);
|
||
const btn = document.getElementById(`deleteBtn${id}`);
|
||
const container = document.getElementById(`editContainer${id}`);
|
||
|
||
const markDeleted = (on) => {
|
||
if (!btn || !container) return;
|
||
if (on) {
|
||
btn.classList.replace('btn-outline-danger', 'btn-danger');
|
||
btn.textContent = 'Undelete';
|
||
container.style.opacity = '0.5';
|
||
} else {
|
||
btn.classList.replace('btn-danger', 'btn-outline-danger');
|
||
btn.textContent = 'Delete';
|
||
container.style.opacity = '1';
|
||
}
|
||
};
|
||
|
||
if (idx === -1) {
|
||
window.deletedIds.push(id);
|
||
markDeleted(true);
|
||
} else {
|
||
window.deletedIds.splice(idx, 1);
|
||
markDeleted(false);
|
||
}
|
||
}
|
||
|
||
function changeMode(id) {
|
||
const toggle = document.getElementById(`editSwitch${id}`);
|
||
if (!toggle) return;
|
||
if (!toggle.checked) {
|
||
renderExistingView(id);
|
||
return;
|
||
}
|
||
renderEditor(id);
|
||
}
|
||
|
||
function saveEdit(id) {
|
||
const ta = document.getElementById(`editor${id}`);
|
||
const value = ta ? ta.value : "";
|
||
setMarkdown(id, value);
|
||
renderExistingView(id);
|
||
const toggle = document.getElementById(`editSwitch${id}`);
|
||
if (toggle) toggle.checked = false;
|
||
}
|
||
|
||
function cancelEdit(id) {
|
||
const toggle = document.getElementById(`editSwitch${id}`);
|
||
if (toggle) toggle.checked = false;
|
||
renderExistingView(id);
|
||
}
|
||
|
||
function togglePreview(id) {
|
||
const ta = document.getElementById(`editor${id}`);
|
||
const preview = document.getElementById(`preview${id}`);
|
||
if (!preview) return;
|
||
preview.classList.toggle('d-none');
|
||
if (!preview.classList.contains('d-none')) {
|
||
preview.innerHTML = renderMarkdown(ta ? ta.value : "");
|
||
}
|
||
}
|
||
|
||
// ---------- EVENT DELEGATION ----------
|
||
document.addEventListener('click', (e) => {
|
||
const t = e.target;
|
||
if (!(t instanceof Element)) return;
|
||
|
||
const action = t.getAttribute('data-action');
|
||
if (!action) return;
|
||
|
||
switch (action) {
|
||
case 'add-draft': addNewDraft(); break;
|
||
case 'clear-draft': clearNewDraft(); break;
|
||
case 'remove-draft': deleteDraft(parseInt(t.getAttribute('data-index') || '-1', 10)); break;
|
||
case 'toggle-delete': toggleDelete(parseInt(t.getAttribute('data-id') || '0', 10)); break;
|
||
case 'save-edit': saveEdit(parseInt(t.getAttribute('data-id') || '0', 10)); break;
|
||
case 'cancel-edit': cancelEdit(parseInt(t.getAttribute('data-id') || '0', 10)); break;
|
||
case 'toggle-preview': togglePreview(parseInt(t.getAttribute('data-id') || '0', 10)); break;
|
||
}
|
||
});
|
||
|
||
document.addEventListener('change', (e) => {
|
||
const t = e.target;
|
||
if (!(t instanceof Element)) return;
|
||
if (t.matches('[id^="editSwitch"]')) {
|
||
// id form: editSwitch{n.id}
|
||
const id = parseInt(t.id.replace('editSwitch', ''), 10);
|
||
changeMode(id);
|
||
}
|
||
});
|
||
|
||
// ---------- BOOT ----------
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// If you insist on leaving inline onclick attributes in HTML, fine, it won’t break.
|
||
// Still render all existing updates:
|
||
const ids = [ {% for n in items %}{{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ];
|
||
for (const id of ids) renderExistingView(id);
|
||
|
||
// Wire the top buttons to delegation without changing your HTML:
|
||
// Just add data-action attributes at runtime so you can keep your markup unchanged.
|
||
const addBtn = document.querySelector('button.btn.btn-primary.btn-sm[onclick="addNewDraft()"]');
|
||
if (addBtn) addBtn.setAttribute('data-action', 'add-draft');
|
||
|
||
const clearBtn = document.querySelector('button.btn.btn-outline-secondary.btn-sm[onclick="clearNewDraft()"]');
|
||
if (clearBtn) clearBtn.setAttribute('data-action', 'clear-draft');
|
||
|
||
// For delete buttons, add data-action so delegation can handle them too
|
||
{% for n in items %}
|
||
(function(id){
|
||
const del = document.getElementById('deleteBtn' + id);
|
||
if (del) {
|
||
del.setAttribute('data-action', 'toggle-delete');
|
||
del.setAttribute('data-id', String(id));
|
||
}
|
||
})({{ n.id }});
|
||
{% endfor %}
|
||
|
||
renderDraftsList();
|
||
});
|
||
</script>
|