Three concurrent themes from this session:
═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════
UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):
- Each generated stage stays on screen as its own editable card with
its own embedded "Don't Miss" panel. No more single rolling note
element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
before any operation (advance, finalize, persist) so inline edits
flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
background after Add-more before generation; "Stage N" with gray
after generation. Fixes the bug where the badge flipped to Stage 2
the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
1. edConsolidate (new prompt) → polished single final note that
integrates every stage chronologically (HPI / ROS / PE / ED Course /
A&P with disposition).
2. edFinalize (rewritten with full inline 2023 AMA E/M element
rubric — problems / data / risk definitions, level mapping with
concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
finalized} so resume re-renders the full state.
Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.
Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).
═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════
Multiple iterations based on Daniel's feedback:
- Layout: align-items:flex-start so action buttons stay pinned top-right
when long numbers wrap (was align-items:center → buttons drifted into
the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
so long numbers wrap within their column instead of pushing under the
buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
at-a-glance type signal (replacing an earlier 3px left stripe that
Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
-0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.
Files: public/js/extensions.js, public/css/styles.css.
═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════
Document moves (preserving git history via git mv):
BROWSER_WHISPER_SETUP.md → docs/browser-whisper-setup.md
BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
DEVELOPER_GUIDE.md → docs/developer-guide-extended.md
EMBEDDINGS_SETUP.md → docs/embeddings-setup.md
FEATURES_EXPLAINED.md → docs/features-explained.md
IMPROVEMENTS.md → docs/improvements.md
OPENID_SETUP.md → docs/openid-setup.md
TRANSCRIPTION_OPTIONS.md → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.
New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.
docs/logic/README.md — index + recommended reading order
docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
load, backend route convention,
schema, encryption, deployment
docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
pocket + calculators + PE Guide
+ suture selector
docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
admin panel + Learning Hub
(Quiz engine logic at sub-detail
only — TODO follow-up)
docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
prompts, voice/STT, helper trio
docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
session's worked example)
Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
the tree as JSON; /file?path=X validates path stays inside docs/ and
renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
revealed in auth.js when user.role==='admin' (same pattern as the
existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
activation, renders collapsible folders, persists expanded state and
last-opened path via UIState. Server-rendered HTML so no client
markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
with router.use(authMiddleware) at /api accidentally 401s every other
/api/* path (caught and fixed during testing — /api/health was 401'ing).
Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).
═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════
- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
tracking + progress dashboard) is covered at the architectural level
in docs/logic/auth-admin-learning.md but not drilled into the quiz
data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
the consolidated finalNote in the error payload, but client doesn't
surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
356 lines
16 KiB
JavaScript
356 lines
16 KiB
JavaScript
// ============================================================
|
|
// PAGERS & EXTENSIONS — personal directory with async search + soft delete
|
|
// ============================================================
|
|
(function () {
|
|
var _inited = false;
|
|
var _items = [];
|
|
var _mode = 'active'; // 'active' | 'trash'
|
|
var _editId = null; // null = creating, non-null = editing
|
|
var _searchDebounce = null;
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (e.detail.tab !== 'extensions' || _inited) return;
|
|
_inited = true;
|
|
init();
|
|
});
|
|
|
|
function init() {
|
|
document.getElementById('ext-add-btn').addEventListener('click', onAddClick);
|
|
document.getElementById('ext-cancel-btn').addEventListener('click', hideForm);
|
|
document.getElementById('ext-save-btn').addEventListener('click', saveItem);
|
|
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
|
|
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
|
|
|
var searchInp = document.getElementById('ext-search');
|
|
searchInp.addEventListener('input', function () {
|
|
clearTimeout(_searchDebounce);
|
|
_searchDebounce = setTimeout(load, 200);
|
|
});
|
|
|
|
// Keyboard: Esc cancels form
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && !document.getElementById('ext-form-wrap').classList.contains('hidden')) {
|
|
hideForm();
|
|
}
|
|
});
|
|
|
|
load();
|
|
loadTrashCount();
|
|
}
|
|
|
|
function loadTrashCount() {
|
|
fetch('/api/extensions?trash=1', { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (d.success) {
|
|
var n = (d.items || []).length;
|
|
document.getElementById('ext-trash-count').textContent = n ? '(' + n + ')' : '';
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function load() {
|
|
var q = document.getElementById('ext-search').value.trim();
|
|
var url = '/api/extensions?' + (_mode === 'trash' ? 'trash=1&' : '') + (q ? 'q=' + encodeURIComponent(q) : '');
|
|
var listEl = document.getElementById('ext-list');
|
|
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:30px;">Loading...</p>';
|
|
|
|
fetch(url, { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">' + esc(d.error || 'Load failed') + '</p>'; return; }
|
|
_items = d.items || [];
|
|
render();
|
|
refreshLocationList();
|
|
})
|
|
.catch(function (err) {
|
|
listEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:20px;">Load failed: ' + esc(err.message) + '</p>';
|
|
});
|
|
}
|
|
|
|
function refreshLocationList() {
|
|
// Populate datalist with existing unique locations for autocomplete
|
|
var dl = document.getElementById('ext-location-list');
|
|
if (!dl) return;
|
|
var seen = {};
|
|
var opts = [];
|
|
_items.forEach(function (x) {
|
|
if (!seen[x.location]) { seen[x.location] = true; opts.push(x.location); }
|
|
});
|
|
dl.innerHTML = opts.map(function (l) { return '<option value="' + esc(l) + '">'; }).join('');
|
|
}
|
|
|
|
function render() {
|
|
var listEl = document.getElementById('ext-list');
|
|
if (_items.length === 0) {
|
|
var icon = _mode === 'trash' ? 'fa-trash-can' : 'fa-phone-slash';
|
|
var msg = _mode === 'trash' ? 'Trash is empty' : 'No entries yet';
|
|
var q = document.getElementById('ext-search').value.trim();
|
|
if (q) { icon = 'fa-magnifying-glass'; msg = 'No matches for "' + esc(q) + '"'; }
|
|
listEl.innerHTML =
|
|
'<div style="text-align:center;padding:60px 20px;color:var(--g400);">' +
|
|
'<div style="font-size:48px;margin-bottom:14px;opacity:0.4;"><i class="fas ' + icon + '"></i></div>' +
|
|
'<div style="font-size:14px;font-weight:500;color:var(--g500);">' + msg + '</div>' +
|
|
'</div>';
|
|
return;
|
|
}
|
|
|
|
// Group by location → type
|
|
var byLoc = {};
|
|
_items.forEach(function (x) {
|
|
if (!byLoc[x.location]) byLoc[x.location] = { extension: [], pager: [] };
|
|
(byLoc[x.location][x.type] || byLoc[x.location].extension).push(x);
|
|
});
|
|
|
|
var html = '';
|
|
Object.keys(byLoc).sort().forEach(function (loc) {
|
|
html += '<div class="ext-loc-group" style="margin-bottom:18px;">';
|
|
html += '<h3 style="font-size:14px;color:var(--g700);margin:12px 0 8px;display:flex;align-items:center;gap:8px;"><i class="fas fa-map-marker-alt" style="color:var(--g400);"></i> ' + esc(loc) + '</h3>';
|
|
|
|
['extension', 'pager'].forEach(function (type) {
|
|
var arr = byLoc[loc][type];
|
|
if (!arr || arr.length === 0) return;
|
|
html += '<div style="margin-left:4px;">';
|
|
html += '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--g500);font-weight:600;margin:6px 0 4px;">' + (type === 'pager' ? '📟 Pagers' : '☎️ Extensions') + '</div>';
|
|
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;">';
|
|
arr.forEach(function (x, i) { html += renderCard(x, i); });
|
|
html += '</div></div>';
|
|
});
|
|
|
|
html += '</div>';
|
|
});
|
|
listEl.innerHTML = html;
|
|
|
|
// Wire per-card events
|
|
listEl.querySelectorAll('[data-ext-action]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var action = btn.dataset.extAction;
|
|
var id = parseInt(btn.dataset.extId, 10);
|
|
if (!Number.isFinite(id) || id <= 0) return;
|
|
if (action === 'edit') return startEdit(id);
|
|
if (action === 'delete') return confirmDelete(id);
|
|
if (action === 'restore') return restoreItem(id);
|
|
if (action === 'purge') return confirmPurge(id);
|
|
});
|
|
});
|
|
|
|
// Click-to-copy on the number itself. Visual feedback is a brief
|
|
// green flash via the .ext-copied class — no toast spam since the
|
|
// user is doing this repeatedly while phoning around.
|
|
listEl.querySelectorAll('.ext-number[data-copy]').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var v = btn.dataset.copy || '';
|
|
if (!v) return;
|
|
var done = function () {
|
|
btn.classList.add('ext-copied');
|
|
setTimeout(function () { btn.classList.remove('ext-copied'); }, 600);
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(v).then(done).catch(function () {
|
|
// Clipboard blocked — fall back to a textarea hack
|
|
var ta = document.createElement('textarea');
|
|
ta.value = v; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
document.body.appendChild(ta); ta.select();
|
|
try { document.execCommand('copy'); done(); } catch (e) {}
|
|
document.body.removeChild(ta);
|
|
});
|
|
} else {
|
|
done();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderCard(x, idx) {
|
|
var typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
|
|
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
|
|
var typeIcon = x.type === 'pager' ? 'fa-pager' : 'fa-phone';
|
|
var trashed = x.trashed_at != null;
|
|
// Alternating subtle backgrounds — every other card gets a slight
|
|
// off-white tint so a long list reads as distinct rows instead of a
|
|
// wall of identical white boxes. Trashed rows use their own muted bg.
|
|
var bg = trashed
|
|
? '#fafaf9'
|
|
: ((idx % 2 === 0) ? 'var(--white, #fff)' : '#fafbfc');
|
|
var border = trashed ? 'var(--g300)' : 'var(--g200)';
|
|
// Stagger entrance — each card delayed slightly so they appear in a
|
|
// gentle wave instead of a single jarring pop. Capped at 12 to keep
|
|
// long lists snappy.
|
|
var stagger = Math.min(idx == null ? 0 : idx, 12) * 35;
|
|
|
|
var actions;
|
|
if (trashed) {
|
|
actions =
|
|
'<button class="btn-sm btn-ghost ext-action" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
|
|
'<button class="btn-sm btn-ghost ext-action" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
|
|
} else {
|
|
actions =
|
|
'<button class="btn-sm btn-ghost ext-action" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
|
'<button class="btn-sm btn-ghost ext-action" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
|
|
}
|
|
|
|
var html = '';
|
|
// Card layout:
|
|
// - uniform 1px border (no left stripe — too visually bulky)
|
|
// - phone/pager icon + colored number = type signal
|
|
// - number is click-to-copy with a green flash (data-copy)
|
|
// - hover lift + shadow grow via .ext-card:hover in styles.css
|
|
// - staggered fade-in (--ext-stagger CSS var consumed by keyframes)
|
|
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px 14px;display:flex;gap:12px;align-items:flex-start;--ext-stagger:' + stagger + 'ms;">';
|
|
html += ' <div style="flex:1;min-width:0;">';
|
|
html += ' <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0;">';
|
|
html += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:14px;align-self:center;flex-shrink:0;"></i>';
|
|
html += ' <button type="button" class="ext-number" data-copy="' + esc(x.number) + '" title="Click to copy" style="font-size:20px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;line-height:1.2;word-break:break-all;min-width:0;background:none;border:0;padding:0;cursor:pointer;font-feature-settings:\'tnum\' 1;text-align:left;">' + esc(x.number) + '</button>';
|
|
html += ' <span style="font-size:10px;font-weight:600;padding:2px 7px;border-radius:6px;background:' + typeColor + '15;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.6px;flex-shrink:0;">' + typeLabel + '</span>';
|
|
html += ' </div>';
|
|
// Name: scan-target. Larger, bolder, darker, slightly tighter
|
|
// letter spacing for that "headline" feel. The number is the action;
|
|
// the name is what your eye locks onto when scrolling a list of 50.
|
|
html += ' <div class="ext-name" style="font-size:14.5px;color:var(--g900);margin-top:6px;word-break:break-word;font-weight:700;letter-spacing:-0.012em;line-height:1.35;">' + esc(x.name) + '</div>';
|
|
if (x.notes) html += ' <div style="font-size:11.5px;color:var(--g500);margin-top:3px;word-break:break-word;line-height:1.45;">' + esc(x.notes) + '</div>';
|
|
html += ' </div>';
|
|
html += ' <div style="display:flex;gap:4px;flex-shrink:0;align-self:flex-start;">' + actions + '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function onAddClick() {
|
|
_editId = null;
|
|
document.getElementById('ext-location').value = '';
|
|
document.getElementById('ext-name').value = '';
|
|
document.getElementById('ext-number').value = '';
|
|
document.getElementById('ext-type').value = 'extension';
|
|
document.getElementById('ext-notes').value = '';
|
|
document.getElementById('ext-form-status').textContent = '';
|
|
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-floppy-disk"></i> Save';
|
|
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
|
document.getElementById('ext-location').focus();
|
|
}
|
|
|
|
function hideForm() {
|
|
document.getElementById('ext-form-wrap').classList.add('hidden');
|
|
_editId = null;
|
|
}
|
|
|
|
function startEdit(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
_editId = id;
|
|
document.getElementById('ext-location').value = item.location;
|
|
document.getElementById('ext-name').value = item.name;
|
|
document.getElementById('ext-number').value = item.number;
|
|
document.getElementById('ext-type').value = item.type;
|
|
document.getElementById('ext-notes').value = item.notes || '';
|
|
document.getElementById('ext-form-status').textContent = '';
|
|
document.getElementById('ext-save-btn').innerHTML = '<i class="fas fa-check"></i> Update';
|
|
document.getElementById('ext-form-wrap').classList.remove('hidden');
|
|
document.getElementById('ext-location').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function saveItem() {
|
|
var body = {
|
|
location: document.getElementById('ext-location').value.trim(),
|
|
name: document.getElementById('ext-name').value.trim(),
|
|
number: document.getElementById('ext-number').value.trim(),
|
|
type: document.getElementById('ext-type').value,
|
|
notes: document.getElementById('ext-notes').value.trim()
|
|
};
|
|
if (!body.location || !body.name || !body.number) {
|
|
document.getElementById('ext-form-status').textContent = 'Location, name, and number are required.';
|
|
document.getElementById('ext-form-status').style.color = 'var(--red)';
|
|
return;
|
|
}
|
|
var url = _editId ? ('/api/extensions/' + _editId) : '/api/extensions';
|
|
var method = _editId ? 'PUT' : 'POST';
|
|
document.getElementById('ext-save-btn').disabled = true;
|
|
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(body) })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Save failed', 'error'); return; }
|
|
hideForm();
|
|
showToast(_editId ? 'Updated' : 'Added', 'success');
|
|
load();
|
|
})
|
|
.catch(function () { showToast('Save failed', 'error'); })
|
|
.finally(function () { document.getElementById('ext-save-btn').disabled = false; });
|
|
}
|
|
|
|
function confirmDelete(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
showConfirm(
|
|
'Move "' + item.name + ' (' + item.number + ')" to trash? You can restore it later from the trash view.',
|
|
function () {
|
|
fetch('/api/extensions/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
|
showToast('Moved to trash', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Delete failed', 'error'); });
|
|
},
|
|
{ confirmText: 'Move to trash' }
|
|
);
|
|
}
|
|
|
|
function restoreItem(id) {
|
|
fetch('/api/extensions/' + id + '/restore', { method: 'POST', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Restore failed', 'error'); return; }
|
|
showToast('Restored', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Restore failed', 'error'); });
|
|
}
|
|
|
|
function confirmPurge(id) {
|
|
var item = _items.filter(function (x) { return x.id === id; })[0];
|
|
if (!item) return;
|
|
showConfirm(
|
|
'Permanently delete "' + item.name + ' (' + item.number + ')"? This cannot be undone.',
|
|
function () {
|
|
fetch('/api/extensions/' + id + '/purge', { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Delete failed', 'error'); return; }
|
|
showToast('Permanently deleted', 'success');
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Delete failed', 'error'); });
|
|
},
|
|
{ danger: true, confirmText: 'Delete permanently' }
|
|
);
|
|
}
|
|
|
|
function toggleTrashMode() {
|
|
_mode = (_mode === 'trash') ? 'active' : 'trash';
|
|
updateModeBanner();
|
|
load();
|
|
}
|
|
function updateModeBanner() {
|
|
var banner = document.getElementById('ext-mode-banner');
|
|
var trashBtn = document.getElementById('ext-trash-btn');
|
|
if (_mode === 'trash') {
|
|
banner.classList.remove('hidden');
|
|
trashBtn.innerHTML = '<i class="fas fa-list"></i> Active items';
|
|
document.getElementById('ext-add-btn').style.display = 'none';
|
|
} else {
|
|
banner.classList.add('hidden');
|
|
trashBtn.innerHTML = '<i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span>';
|
|
document.getElementById('ext-add-btn').style.display = '';
|
|
loadTrashCount();
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
})();
|