The Create image popup showed "Generating image…" forever even after the job finished. The status poll called fetchAssistantImageJob, which was never imported, so every tick threw ReferenceError — and the catch treated that like a transient network failure and rescheduled, permanently. The import is added, a test now asserts that every api.js function the assistant calls is actually imported, and the poll distinguishes a programming error (surface it) from a transient one (retry, but not forever). Chat titles were hard-cut at 60 characters mid-word, so "Rickets Radiographic Fea" was all the Create image picker could ever show. The server already allows 160, so titles now keep whole words up to that, and each view decides its own visible length from the width it actually has rather than inheriting one cut made at save time. A single very long token still falls back to a hard cut. Extension cards led with the number at 20px with word-break:break-all, so "5616/3764/5619" wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial from. The name leads now, since that is what the eye hunts for in a list of fifty; the number follows in tabular figures and may only break between groups, never inside a run of digits. Cards share a minimum height so a grid reads as rows rather than a ragged mosaic. The collapsed rail's brand kept its expanded margin-right:auto, which pushed the stethoscope off the axis the two buttons sat on. Every child of the collapsed head is now the same centred fixed-size box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
458 lines
20 KiB
JavaScript
458 lines
20 KiB
JavaScript
// ============================================================
|
|
// PAGERS & EXTENSIONS — personal directory with async search + soft delete
|
|
// ============================================================
|
|
var _inited = false;
|
|
var _items = [];
|
|
var _mode = 'active'; // 'active' | 'trash'
|
|
var _editId = null; // null = creating, non-null = editing
|
|
var _searchDebounce = null;
|
|
var _pendingImportFile = 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-export-btn').addEventListener('click', exportItems);
|
|
document.getElementById('ext-import-btn').addEventListener('click', function () {
|
|
document.getElementById('ext-import-file').click();
|
|
});
|
|
document.getElementById('ext-import-file').addEventListener('change', importItems);
|
|
document.getElementById('ext-import-confirm').addEventListener('click', confirmImportPreview);
|
|
document.getElementById('ext-import-cancel').addEventListener('click', clearImportPreview);
|
|
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, in scan order: the NAME is what the eye hunts for in a list of
|
|
// fifty ("Blood Bank"), the number is the payload you then copy. The number
|
|
// led before, at 20px with word-break:break-all, so a multi-line entry wrapped
|
|
// mid-digit — "5616/3764/56 19" — which is unreadable and unsafe to dial.
|
|
html += '<div class="ext-card" style="--ext-stagger:' + stagger + 'ms;">';
|
|
html += ' <div class="ext-card-body">';
|
|
html += ' <div class="ext-card-head">';
|
|
html += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';"></i>';
|
|
html += ' <span class="ext-name">' + esc(x.name) + '</span>';
|
|
html += ' <span class="ext-badge" style="background:' + typeColor + '15;color:' + typeColor + ';">' + typeLabel + '</span>';
|
|
html += ' </div>';
|
|
// Numbers wrap between their separators, never inside a group of digits.
|
|
html += ' <button type="button" class="ext-number" data-copy="' + esc(x.number) + '" title="Click to copy" style="color:' + typeColor + ';">' + esc(x.number).replace(/\//g, '<wbr>/') + '</button>';
|
|
if (x.notes) html += ' <div class="ext-notes">' + esc(x.notes) + '</div>';
|
|
html += ' </div>';
|
|
html += ' <div class="ext-card-actions">' + 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 exportItems() {
|
|
fetch('/api/extensions/export', { headers: getAuthHeaders() })
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Export failed');
|
|
var count = parseInt(r.headers.get('X-Export-Count') || '', 10);
|
|
return r.blob().then(function (blob) { return { blob: blob, count: count }; });
|
|
})
|
|
.then(function (d) {
|
|
var a = document.createElement('a');
|
|
var stamp = new Date().toISOString().slice(0, 10);
|
|
a.href = URL.createObjectURL(d.blob);
|
|
a.download = 'pedscribe-extensions-' + stamp + '.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
document.body.removeChild(a);
|
|
showToast('Exported ' + (Number.isFinite(d.count) ? d.count : 'your') + ' entries', 'success');
|
|
})
|
|
.catch(function () { showToast('Export failed', 'error'); });
|
|
}
|
|
|
|
function importItems(e) {
|
|
var input = e.target;
|
|
var file = input.files && input.files[0];
|
|
if (!file) return;
|
|
previewImportFile(file, input);
|
|
}
|
|
|
|
function previewImportFile(file, input) {
|
|
var form = new FormData();
|
|
form.append('file', file);
|
|
var headers = getAuthHeaders();
|
|
delete headers['Content-Type'];
|
|
fetch('/api/extensions/import-file/preview', { method: 'POST', headers: headers, body: form })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Import preview failed', 'error'); input.value = ''; return; }
|
|
_pendingImportFile = { file: file, input: input };
|
|
renderImportPreview(d.preview && d.preview.summary);
|
|
})
|
|
.catch(function () { showToast('Import preview failed', 'error'); input.value = ''; });
|
|
}
|
|
|
|
function renderImportPreview(summary) {
|
|
summary = summary || {};
|
|
var panel = document.getElementById('ext-import-preview');
|
|
var text = document.getElementById('ext-import-preview-text');
|
|
var restore = document.getElementById('ext-import-restore-trashed');
|
|
var possible = document.getElementById('ext-import-possible');
|
|
restore.checked = false;
|
|
possible.checked = false;
|
|
restore.disabled = !summary.exactTrashed;
|
|
possible.disabled = !summary.possible;
|
|
text.innerHTML =
|
|
'<strong>Import preview:</strong> ' + esc(summary.total || 0) + ' valid entries found. ' +
|
|
esc(summary.new || 0) + ' new, ' +
|
|
esc(summary.exactActive || 0) + ' exact active duplicates, ' +
|
|
esc(summary.exactTrashed || 0) + ' exact matches in trash, ' +
|
|
esc(summary.possible || 0) + ' possible duplicates by location/number or number/type. ' +
|
|
'Exact active duplicates are always skipped.';
|
|
panel.classList.remove('hidden');
|
|
}
|
|
|
|
function clearImportPreview() {
|
|
document.getElementById('ext-import-preview').classList.add('hidden');
|
|
if (_pendingImportFile && _pendingImportFile.input) _pendingImportFile.input.value = '';
|
|
_pendingImportFile = null;
|
|
}
|
|
|
|
function confirmImportPreview() {
|
|
if (!_pendingImportFile) return;
|
|
submitImportFile(_pendingImportFile.file, _pendingImportFile.input, {
|
|
restoreTrashed: document.getElementById('ext-import-restore-trashed').checked,
|
|
importPossibleDuplicates: document.getElementById('ext-import-possible').checked
|
|
});
|
|
}
|
|
|
|
function submitImportFile(file, input, options) {
|
|
var form = new FormData();
|
|
form.append('file', file);
|
|
form.append('restoreTrashed', options && options.restoreTrashed ? 'true' : 'false');
|
|
form.append('importPossibleDuplicates', options && options.importPossibleDuplicates ? 'true' : 'false');
|
|
var headers = getAuthHeaders();
|
|
delete headers['Content-Type'];
|
|
fetch('/api/extensions/import-file', {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: form
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d.success) { showToast(d.error || 'Import failed', 'error'); return; }
|
|
showToast('Imported ' + d.imported + ' entries' + (d.restored ? ', restored ' + d.restored : '') + (d.skipped ? ', skipped ' + d.skipped : ''), 'success');
|
|
clearImportPreview();
|
|
load();
|
|
loadTrashCount();
|
|
})
|
|
.catch(function () { showToast('Import failed', 'error'); })
|
|
.finally(function () { input.value = ''; });
|
|
}
|
|
|
|
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, '"');
|
|
}
|