Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/access-router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import adminApp from "./admin-v5";
import adminApp from "./admin-v7";

const MANAGE_PATH = "/library/manage";
const MANAGE_API_PATTERN = /^[a-z0-9-]+$/;
Expand Down
95 changes: 95 additions & 0 deletions src/admin-ui-v8.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { adminHtml as durationAdminHtml } from "./admin-ui-v7";

const LONG_VISIT_CSS = `
#longvisitmodal .modal{width:min(560px,100%)}.lvbulk{display:flex;align-items:end;gap:8px;margin-top:14px;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--subtle)}.lvbulk .field{flex:0 0 130px}.lvbulknote{flex:1;color:var(--quiet);font-size:11px;line-height:1.35}
.lvlist{max-height:min(48vh,360px);overflow:auto;margin-top:12px;border:1px solid var(--line);border-radius:8px}.lvrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 12px;border-bottom:1px solid var(--line)}.lvrow:last-child{border-bottom:0}.lvname{font-size:12.5px;font-weight:650}.lvmeta{margin-top:3px;color:var(--muted);font-size:11px}.lvfield{display:flex;align-items:center;gap:6px;color:var(--quiet);font-size:11px}.lvminutes{width:82px;text-align:right;font-variant-numeric:tabular-nums}
.lvhint{margin-top:10px;color:var(--muted);font-size:11.5px;line-height:1.4}
`;

const LONG_VISIT_MODAL = `
<div class="modalbg" id="longvisitmodal"><div class="modal"><div class="modalhead"><strong>Did they forget to check out?</strong><button class="x" id="longvisitclose">×</button></div><div class="modalbody"><div class="confirmcopy" id="longvisitcopy"></div><div class="lvbulk"><div class="field"><label for="longvisitall">Set all to</label><input id="longvisitall" type="number" min="0" max="1440" step="1" inputmode="numeric" placeholder="minutes"></div><button class="btn secondary" id="longvisitapply" type="button">Apply to all</button><div class="lvbulknote">Nobody is capped above the time they have already been checked in.</div></div><div class="lvlist" id="longvisitlist"></div><div class="lvhint">Check-in times stay the same. Everyone still in the library is checked out, and the times you correct here are what history, stats, and exports will show.</div></div><div class="modalactions"><button class="btn secondary" id="longvisitcancel">Cancel</button><button class="btn" id="longvisitconfirm">Check everyone out</button></div></div></div>
`;

const LONG_VISIT_SCRIPT = `<script>
(function(){
const LONG_VISIT_MINUTES=30;
const CLEAR_TITLE='Check everyone out?';
const CLEAR_COPY='This will check out every student who is currently in the library. Use this at closing time or when the live roster needs to be cleared.';
let reviewing=[];
function elapsedMinutes(v){const t=new Date(v.checkedInAt).getTime();return Number.isFinite(t)?Math.max(0,Math.floor((Date.now()-t)/60000)):null}
function longVisits(){return (state.current||[]).filter(v=>{const m=elapsedMinutes(v);return m!=null&&m>LONG_VISIT_MINUTES})}
function inputs(){return Array.from($('longvisitlist').querySelectorAll('.lvminutes'))}
function closeLong(){reviewing=[];$('longvisitmodal').classList.remove('open')}
function plainClear(){
confirmDo(CLEAR_TITLE,CLEAR_COPY,'Check everyone out',async()=>{
await post('/api/library/clear',{method:'clear_all'});
toast('Everyone was checked out.');
await loadCurrent();
loadOverview();
});
}
function openLong(rows){
reviewing=rows.map(v=>({visitId:v.visitId,minutes:elapsedMinutes(v)}));
$('longvisitcopy').textContent=rows.length+' students have been checked in for more than '+LONG_VISIT_MINUTES+' minutes. If any of them forgot to scan out, correct how long the visit really lasted before you check everyone out.';
$('longvisitlist').innerHTML=rows.map((v,i)=>{
const here=elapsedMinutes(v),name=v.firstName+' '+v.lastName;
return '<div class="lvrow"><div><div class="lvname">'+esc(name)+'</div><div class="lvmeta">'+esc('Checked in '+ft(v.checkedInAt)+' · here '+dur(here))+'</div></div><div class="lvfield"><input class="lvminutes" type="number" min="0" max="'+here+'" step="1" inputmode="numeric" value="'+here+'" data-index="'+i+'" aria-label="'+esc('Minutes for '+name)+'"><span>min</span></div></div>';
}).join('');
$('longvisitall').value='';
$('longvisitmodal').classList.add('open');
}
function applyToAll(){
const raw=$('longvisitall').value.trim();
if(raw===''){toast('Enter the number of minutes to use for everyone.');$('longvisitall').focus();return}
const value=Number(raw);
if(!Number.isInteger(value)||value<0){toast('Enter a whole number of minutes.');$('longvisitall').focus();return}
let capped=0;
inputs().forEach(input=>{const max=Number(input.max);if(value>max)capped++;input.value=String(Math.min(value,max))});
toast(capped?(value+' min applied · '+capped+' kept at their time in the library.'):('Every visit set to '+value+' min.'));
}
function readRows(){
const out=[];
for(const input of inputs()){
const row=reviewing[Number(input.dataset.index)];
if(!row)continue;
const raw=input.value.trim(),max=Number(input.max),value=Number(raw);
if(raw===''||!Number.isInteger(value)||value<0||value>max){
toast('Enter a whole number of minutes from 0 to '+max+'.');
input.focus();
return null;
}
out.push({visitId:row.visitId,durationMinutes:value,original:row.minutes});
}
return out;
}
async function confirmLong(){
const rows=readRows();
if(!rows)return;
const adjustments=rows.filter(r=>r.durationMinutes!==r.original).map(r=>({visitId:r.visitId,durationMinutes:r.durationMinutes}));
$('longvisitconfirm').disabled=true;
try{
await post('/api/library/admin-clear-with-durations',{adjustments:adjustments});
toast(adjustments.length?('Everyone was checked out · '+adjustments.length+' time'+(adjustments.length===1?'':'s')+' corrected.'):'Everyone was checked out.');
closeLong();
await loadCurrent();
loadOverview();
}catch(e){toast(e.message)}finally{$('longvisitconfirm').disabled=false}
}
$('clearall').onclick=()=>{const rows=longVisits();if(rows.length>1)openLong(rows);else plainClear()};
$('longvisitclose').onclick=closeLong;
$('longvisitcancel').onclick=closeLong;
$('longvisitapply').onclick=applyToAll;
$('longvisitconfirm').onclick=confirmLong;
$('longvisitall').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();applyToAll()}});
$('longvisitmodal').onclick=e=>{if(e.target===$('longvisitmodal'))closeLong()};
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('longvisitmodal').classList.contains('open')){e.stopPropagation();closeLong()}},true);
})();
</script>
`;

export function adminHtml(): string {
return durationAdminHtml()
.replace("</style>", LONG_VISIT_CSS + "</style>")
.replace('<div class="toast" id="toast"></div>', LONG_VISIT_MODAL + '<div class="toast" id="toast"></div>')
.replace("</body>", LONG_VISIT_SCRIPT + "</body>");
}
133 changes: 133 additions & 0 deletions src/admin-ui-v9.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { adminHtml as longVisitAdminHtml } from "./admin-ui-v8";

const HISTORY_HEAD_OLD = `<thead><tr><th>Student</th><th>Reason</th><th>Check in</th><th>Check out</th><th>Duration</th><th>Checkout</th><th></th></tr></thead>`;
const HISTORY_HEAD_NEW = `<thead><tr><th class="selcol"><input class="rowselect" id="selall" type="checkbox" aria-label="Select every editable visit on this page"></th><th>Student</th><th>Reason</th><th>Check in</th><th>Check out</th><th>Duration</th><th>Checkout</th><th></th></tr></thead>`;

const HISTORY_SECTION_OLD = `<div class="meta" id="historynote">Loading visits…</div></div></div><div class="tablewrap">`;
const HISTORY_SECTION_NEW = `<div class="meta" id="historynote">Loading visits…</div></div></div><div class="selbar" id="selbar" hidden><strong id="selcount">0 visits selected</strong><button class="btn small" id="selduration" type="button">Edit duration</button><button class="btn ghost small" id="selclear" type="button">Clear selection</button></div><div class="tablewrap">`;

const SELECTION_CSS = `
th.selcol,td.selcell{width:1%;padding-right:0;text-align:center}.rowselect{width:16px;height:16px;min-height:0;margin:0;accent-color:var(--accent)}#selall[hidden]{display:none}
.selbar{display:flex;align-items:center;gap:10px;padding:9px 15px;border-bottom:1px solid var(--line);background:var(--subtle);font-size:12.5px}.selbar[hidden]{display:none}.selbar strong{margin-right:auto;font-size:12.5px}
#bulkdurationmodal .modal{width:min(560px,100%)}
`;

const BULK_DURATION_MODAL = `
<div class="modalbg" id="bulkdurationmodal"><div class="modal"><div class="modalhead"><strong>Edit visit durations</strong><button class="x" id="bulkdurationclose">×</button></div><div class="modalbody"><div class="confirmcopy" id="bulkdurationcopy"></div><div class="lvbulk"><div class="field"><label for="bulkdurationall">Set all to</label><input id="bulkdurationall" type="number" min="0" max="1440" step="1" inputmode="numeric" placeholder="minutes"></div><button class="btn secondary" id="bulkdurationapply" type="button">Apply to all</button><div class="lvbulknote">Nobody goes past the time that has passed since they checked in.</div></div><div class="lvlist" id="bulkdurationlist"></div><div class="lvhint">Check-in times stay the same. Flow moves each checkout time so totals, stats, and exports use the corrected durations.</div></div><div class="modalactions"><button class="btn secondary" id="bulkdurationcancel">Cancel</button><button class="btn" id="bulkdurationsave">Save durations</button></div></div></div>
`;

const SELECTION_SCRIPT = `<script>
(function(){
const selected=new Set();
let editing=[];
function editable(visit){return !!visit&&!!visit.checkedOutAt&&!visit.archivedAt}
function maxMinutes(visit){const t=new Date(visit.checkedInAt).getTime();return Number.isFinite(t)?Math.min(1440,Math.max(0,Math.floor((Date.now()-t)/60000))):1440}
function boxes(){return Array.from(document.querySelectorAll('#historybody .rowselect'))}
function selectedVisits(){return (state.history||[]).filter(v=>selected.has(String(v.visitId)))}
function refreshBar(){const n=selected.size;$('selcount').textContent=n+' visit'+(n===1?'':'s')+' selected';$('selbar').hidden=n===0}
function syncSelectAll(){
const all=boxes(),checked=all.filter(b=>b.checked);
$('selall').hidden=historyArchived()||all.length===0;
$('selall').checked=all.length>0&&checked.length===all.length;
$('selall').indeterminate=checked.length>0&&checked.length<all.length;
}
function decorateSelection(){
const archived=historyArchived();
Array.from(document.querySelectorAll('#historybody tr')).forEach((row,index)=>{
if(row.querySelector('.selcell'))return;
const visit=state.history[index],cell=document.createElement('td');
cell.className='selcell';
if(!archived&&editable(visit)){
cell.innerHTML='<input class="rowselect" type="checkbox" data-id="'+esc(visit.visitId)+'" aria-label="'+esc('Select the '+visit.firstName+' '+visit.lastName+' visit')+'">';
}
row.insertBefore(cell,row.firstChild);
});
}
function historyRendered(){selected.clear();decorateSelection();syncSelectAll();refreshBar()}
function closeBulk(){editing=[];$('bulkdurationmodal').classList.remove('open')}
function openBulk(){
const visits=selectedVisits();
if(!visits.length){toast('Select at least one visit first.');return}
editing=visits.map(v=>({visitId:v.visitId,minutes:Math.max(0,Number(v.durationMinutes||0))}));
$('bulkdurationcopy').textContent='Correct how long '+(visits.length===1?'this visit':'these '+visits.length+' visits')+' really lasted.';
$('bulkdurationlist').innerHTML=visits.map((v,i)=>{
const name=v.firstName+' '+v.lastName,current=Math.max(0,Number(v.durationMinutes||0));
return '<div class="lvrow"><div><div class="lvname">'+esc(name)+'</div><div class="lvmeta">'+esc('Checked in '+fdt(v.checkedInAt)+' · recorded '+dur(current))+'</div></div><div class="lvfield"><input class="bdminutes" type="number" min="0" max="'+maxMinutes(v)+'" step="1" inputmode="numeric" value="'+current+'" data-index="'+i+'" aria-label="'+esc('Minutes for '+name)+'"><span>min</span></div></div>';
}).join('');
$('bulkdurationall').value='';
$('bulkdurationmodal').classList.add('open');
}
function fields(){return Array.from($('bulkdurationlist').querySelectorAll('.bdminutes'))}
function applyToAll(){
const raw=$('bulkdurationall').value.trim();
if(raw===''){toast('Enter the number of minutes to use for everyone.');$('bulkdurationall').focus();return}
const value=Number(raw);
if(!Number.isInteger(value)||value<0){toast('Enter a whole number of minutes.');$('bulkdurationall').focus();return}
let capped=0;
fields().forEach(input=>{const max=Number(input.max);if(value>max)capped++;input.value=String(Math.min(value,max))});
toast(capped?(value+' min applied · '+capped+' kept shorter so the checkout stays in the past.'):('Every visit set to '+value+' min.'));
}
function readFields(){
const out=[];
for(const input of fields()){
const row=editing[Number(input.dataset.index)];
if(!row)continue;
const raw=input.value.trim(),max=Number(input.max),value=Number(raw);
if(raw===''||!Number.isInteger(value)||value<0||value>max){
toast('Enter a whole number of minutes from 0 to '+max+'.');
input.focus();
return null;
}
out.push({visitId:row.visitId,durationMinutes:value,original:row.minutes});
}
return out;
}
async function saveBulk(){
const rows=readFields();
if(!rows)return;
const updates=rows.filter(r=>r.durationMinutes!==r.original).map(r=>({visitId:r.visitId,durationMinutes:r.durationMinutes}));
if(!updates.length){toast('No durations were changed.');return}
$('bulkdurationsave').disabled=true;
try{
await post('/api/library/admin-update-visit-durations',{updates:updates});
toast(updates.length+' visit duration'+(updates.length===1?'':'s')+' updated.');
closeBulk();
await loadHistory();
loadOverview();
}catch(e){toast(e.message)}finally{$('bulkdurationsave').disabled=false}
}
new MutationObserver(historyRendered).observe($('historybody'),{childList:true});
historyRendered();
$('historybody').addEventListener('click',e=>{
const box=e.target.closest('.rowselect');
if(!box)return;
if(box.checked)selected.add(box.dataset.id);else selected.delete(box.dataset.id);
syncSelectAll();
refreshBar();
});
$('selall').addEventListener('change',()=>{
boxes().forEach(box=>{box.checked=$('selall').checked;if(box.checked)selected.add(box.dataset.id);else selected.delete(box.dataset.id)});
syncSelectAll();
refreshBar();
});
$('selclear').onclick=()=>{selected.clear();boxes().forEach(box=>{box.checked=false});syncSelectAll();refreshBar()};
$('selduration').onclick=openBulk;
$('bulkdurationclose').onclick=closeBulk;
$('bulkdurationcancel').onclick=closeBulk;
$('bulkdurationapply').onclick=applyToAll;
$('bulkdurationsave').onclick=saveBulk;
$('bulkdurationall').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();applyToAll()}});
$('bulkdurationmodal').onclick=e=>{if(e.target===$('bulkdurationmodal'))closeBulk()};
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('bulkdurationmodal').classList.contains('open')){e.stopPropagation();closeBulk()}},true);
})();
</script>
`;

export function adminHtml(): string {
return longVisitAdminHtml()
.replace(HISTORY_HEAD_OLD, HISTORY_HEAD_NEW)
.replace(HISTORY_SECTION_OLD, HISTORY_SECTION_NEW)
.replace("</style>", SELECTION_CSS + "</style>")
.replace('<div class="toast" id="toast"></div>', BULK_DURATION_MODAL + '<div class="toast" id="toast"></div>')
.replace("</body>", SELECTION_SCRIPT + "</body>");
}
2 changes: 1 addition & 1 deletion src/admin-ui.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { adminHtml } from "./admin-ui-v7";
export { adminHtml } from "./admin-ui-v9";
Loading