Shifting markdown to use bootstrap.
This commit is contained in:
parent
668924ba10
commit
3bca03e1bb
2 changed files with 237 additions and 126 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
<!-- link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css" -->
|
||||||
<style>
|
<style>
|
||||||
textarea.auto-md {
|
textarea.auto-md {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -51,6 +51,10 @@
|
||||||
for (const a of container.querySelectorAll('a[href]')) {
|
for (const a of container.querySelectorAll('a[href]')) {
|
||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
|
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||||
|
}
|
||||||
|
for (const t of container.querySelectorAll('table')) {
|
||||||
|
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
<!-- link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css" -->
|
||||||
<style>
|
<style>
|
||||||
textarea.auto-md {
|
textarea.auto-md {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -64,148 +64,52 @@
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<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 src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// State (kept global for compatibility with your form serialization)
|
||||||
window.newDrafts = window.newDrafts || [];
|
window.newDrafts = window.newDrafts || [];
|
||||||
window.deletedIds = window.deletedIds || [];
|
window.deletedIds = window.deletedIds || [];
|
||||||
|
|
||||||
function addNewDraft() {
|
// ---------- DRY UTILITIES ----------
|
||||||
const ta = document.getElementById('newUpdateInput');
|
function renderMarkdown(md) {
|
||||||
const text = (ta.value || '').trim()
|
// One place to parse + sanitize
|
||||||
if (!text) return;
|
const raw = marked.parse(md || "");
|
||||||
window.newDrafts.push(text);
|
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
|
||||||
ta.value = '';
|
|
||||||
renderNewDrafts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNewDraft() {
|
function enhanceLinks(root) {
|
||||||
const ta = document.getElementById('newUpdateInput');
|
if (!root) return;
|
||||||
ta.value = '';
|
for (const a of root.querySelectorAll('a[href]')) {
|
||||||
}
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
function renderNewDrafts() {
|
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
|
||||||
const wrap = document.getElementById('newDraftsList');
|
|
||||||
const ul = document.getElementById('newDraftsUl');
|
|
||||||
ul.innerHTML = '';
|
|
||||||
const drafts = window.newDrafts || [];
|
|
||||||
if (!drafts.length) {
|
|
||||||
wrap.classList.add('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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 = DOMPurify.sanitize(marked.parse(md || ''));
|
|
||||||
for (const a of left.querySelectorAll('a[href]')) {
|
|
||||||
a.setAttribute('target','_blank');
|
|
||||||
a.setAttribute('rel','noopener noreferrer nofollow');
|
|
||||||
}
|
|
||||||
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" onclick="deleteDraft(${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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDraft(idx) {
|
function enhanceTables(root) {
|
||||||
if (!Array.isArray(window.newDrafts)) return;
|
if (!root) return;
|
||||||
window.newDrafts.splice(idx, 1);
|
for (const t of root.querySelectorAll('table')) {
|
||||||
renderNewDrafts();
|
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
if (idx === -1) {
|
|
||||||
window.deletedIds.push(id);
|
|
||||||
if (btn) btn.classList.replace('btn-outline-danger', 'btn-danger');
|
|
||||||
if (btn) btn.textContent = 'Undelete';
|
|
||||||
if (container) container.style.opacity = '0.5';
|
|
||||||
} else {
|
|
||||||
window.deletedIds.splice(idx, 1);
|
|
||||||
if (btn) btn.classList.replace('btn-danger', 'btn-outline-danger');
|
|
||||||
if (btn) btn.textContent = 'Delete';
|
|
||||||
if (container) container.style.opacity = '1';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial render
|
function renderHTML(el, md) {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
if (!el) return;
|
||||||
const ids = [{% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %} {% endfor %} ];
|
el.innerHTML = renderMarkdown(md);
|
||||||
for (const id of ids) renderView(id, getMarkdown(id));
|
enhanceLinks(el);
|
||||||
});
|
enhanceTables(el);
|
||||||
|
}
|
||||||
|
|
||||||
function getMarkdown(id) {
|
function getMarkdown(id) {
|
||||||
const el = document.getElementById(`md-${id}`);
|
const el = document.getElementById(`md-${id}`);
|
||||||
return el ? JSON.parse(el.textContent) : "";
|
return el ? JSON.parse(el.textContent || '""') : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMarkdown(id, md) {
|
function setMarkdown(id, md) {
|
||||||
const el = document.getElementById(`md-${id}`);
|
const el = document.getElementById(`md-${id}`);
|
||||||
if (el) el.textContent = JSON.stringify(md ?? "");
|
if (el) el.textContent = JSON.stringify(md ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderView(id, md) {
|
function escapeForTextarea(s) {
|
||||||
const container = document.getElementById(`editContainer${id}`);
|
return (s ?? "").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
if (!container) return;
|
|
||||||
const html = marked.parse(md || "");
|
|
||||||
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
|
|
||||||
for (const a of container.querySelectorAll('a[href]')) {
|
|
||||||
a.setAttribute('target', '_blank');
|
|
||||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeMode(id) {
|
|
||||||
const container = document.getElementById(`editContainer${id}`);
|
|
||||||
const toggle = document.getElementById(`editSwitch${id}`);
|
|
||||||
if (!toggle.checked) return renderView(id, getMarkdown(id));
|
|
||||||
|
|
||||||
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" onclick="saveEdit(${id})">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit(${id})">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview(${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 saveEdit(id) {
|
|
||||||
const ta = document.getElementById(`editor${id}`);
|
|
||||||
const value = ta ? ta.value : "";
|
|
||||||
setMarkdown(id, value); // persist into the JSON tag
|
|
||||||
renderView(id, value); // show it
|
|
||||||
document.getElementById(`editSwitch${id}`).checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit(id) {
|
|
||||||
document.getElementById(`editSwitch${id}`).checked = false;
|
|
||||||
renderView(id, getMarkdown(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePreview(id) {
|
|
||||||
const ta = document.getElementById(`editor${id}`);
|
|
||||||
const preview = document.getElementById(`preview${id}`);
|
|
||||||
preview.classList.toggle('d-none');
|
|
||||||
if (!preview.classList.contains('d-none')) {
|
|
||||||
const html = marked.parse(ta ? ta.value : "");
|
|
||||||
preview.innerHTML = DOMPurify.sanitize(html);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoGrow(ta) {
|
function autoGrow(ta) {
|
||||||
|
|
@ -214,7 +118,210 @@
|
||||||
ta.style.height = (ta.scrollHeight + 5) + 'px';
|
ta.style.height = (ta.scrollHeight + 5) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeForTextarea(s) {
|
// ---------- RENDERERS ----------
|
||||||
return (s ?? "").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue