Shifting markdown to use bootstrap.

This commit is contained in:
Yaro Kasear 2025-10-14 11:50:47 -05:00
parent 668924ba10
commit 3bca03e1bb
2 changed files with 237 additions and 126 deletions

View file

@ -9,7 +9,7 @@
</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>
textarea.auto-md {
box-sizing: border-box;
@ -51,6 +51,10 @@
for (const a of container.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');
}
for (const t of container.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}

View file

@ -44,7 +44,7 @@
{% endfor %}
</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>
textarea.auto-md {
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/dompurify/dist/purify.min.js"></script>
<script>
// State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
function addNewDraft() {
const ta = document.getElementById('newUpdateInput');
const text = (ta.value || '').trim()
if (!text) return;
window.newDrafts.push(text);
ta.value = '';
renderNewDrafts();
// ---------- DRY UTILITIES ----------
function renderMarkdown(md) {
// One place to parse + sanitize
const raw = marked.parse(md || "");
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
}
function clearNewDraft() {
const ta = document.getElementById('newUpdateInput');
ta.value = '';
}
function renderNewDrafts() {
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 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 deleteDraft(idx) {
if (!Array.isArray(window.newDrafts)) return;
window.newDrafts.splice(idx, 1);
renderNewDrafts();
}
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';
function enhanceTables(root) {
if (!root) return;
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
// Initial render
document.addEventListener('DOMContentLoaded', () => {
const ids = [{% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %} {% endfor %} ];
for (const id of ids) renderView(id, getMarkdown(id));
});
function renderHTML(el, md) {
if (!el) return;
el.innerHTML = renderMarkdown(md);
enhanceLinks(el);
enhanceTables(el);
}
function getMarkdown(id) {
const el = document.getElementById(`md-${id}`);
return el ? JSON.parse(el.textContent) : "";
return el ? JSON.parse(el.textContent || '""') : "";
}
function setMarkdown(id, md) {
const el = document.getElementById(`md-${id}`);
if (el) el.textContent = JSON.stringify(md ?? "");
}
function renderView(id, md) {
const container = document.getElementById(`editContainer${id}`);
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 escapeForTextarea(s) {
return (s ?? "").replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function autoGrow(ta) {
@ -214,7 +118,210 @@
ta.style.height = (ta.scrollHeight + 5) + 'px';
}
function escapeForTextarea(s) {
return (s ?? "").replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ---------- 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 wont 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>