@@ -3,7 +3,7 @@
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > Add to library < / title >
< title > Room for Books < / title >
< style >
: root {
--bg : #f6f5f1 ; --card : #ffffff ; --ink : #1d2320 ; --muted : #5b655f ; --line : #dcdad2 ;
@@ -53,6 +53,7 @@
background : var ( - - card ) ; color : var ( - - ink ) ; min-width : 0 ; }
. hit { background : var ( - - card ) ; border : 1 px solid var ( - - line ) ; border-radius : 12 px ; padding : 12 px 14 px ; margin : 10 px 0 ; }
. hit . t { font-weight : 700 ; font-size : 1.05 rem ; overflow-wrap : anywhere ; }
a { color : var ( - - accent ) ; }
. hit a { color : var ( - - accent ) ; }
. copy { display : flex ; flex-wrap : wrap ; gap : 6 px 10 px ; align-items : center ; padding : 6 px 0 0 ; }
. chip { border-radius : 999 px ; padding : 1 px 10 px ; font-size : .88 rem ; font-weight : 600 ; border : 1 px solid var ( - - line ) ; }
@@ -93,28 +94,188 @@
border-radius : 8 px ; background : var ( - - card ) ; color : var ( - - ink ) ; }
details . settings input [ type = number ] { width : 6 em ; }
. result-note { margin-top : 8 px ; padding : 8 px 10 px ; border-radius : 8 px ; background : var ( - - ok - bg ) ; border : 1 px solid var ( - - ok ) ; }
. filters { display : flex ; flex-wrap : wrap ; gap : 8 px ; margin : 8 px 0 14 px ; }
. filters select { font : inherit ; padding : 8 px 10 px ; border : 1 px solid var ( - - line ) ; border-radius : 8 px ; background : var ( - - card ) ; color : var ( - - ink ) ; max-width : 100 % ; }
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 128 px , 1 fr ) ) ; gap : 14 px 12 px ; margin : 6 px 0 16 px ; }
. tile { display : block ; text-decoration : none ; color : inherit ; }
. tile . art { position : relative ; aspect-ratio : 2 / 3 ; border-radius : 6 px ; overflow : hidden ; border : 1 px solid var ( - - line ) ;
display : flex ; align-items : flex-end ; padding : 8 px ; font-weight : 700 ; font-size : .88 rem ; line-height : 1.2 ;
color : #fff ; background : var ( - - tint , #5b6f63 ) ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .18 ) ; overflow-wrap : anywhere ; }
. tile . art img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ; background : var ( - - card ) ; }
. tile . art img [ hidden ] { display : none ; }
. tile : hover . art , . tile : focus-visible . art { outline : 3 px solid var ( - - accent ) ; outline-offset : 2 px ; }
. tile . tt { margin-top : 6 px ; font-weight : 600 ; font-size : .9 rem ; line-height : 1.25 ; overflow-wrap : anywhere ;
display : -webkit- box ; -webkit- line-clamp : 2 ; -webkit- box-orient : vertical ; overflow : hidden ; }
. tile . aa { color : var ( - - muted ) ; font-size : .82 rem ; overflow-wrap : anywhere ; }
. tile . flag { position : absolute ; top : 6 px ; right : 6 px ; z-index : 1 ; font-size : .72 rem ; padding : 1 px 8 px ; border-radius : 999 px ;
background : var ( - - warn - bg ) ; color : var ( - - warn ) ; border : 1 px solid var ( - - warn ) ; font-weight : 700 ; }
. tile . num { position : absolute ; top : 6 px ; left : 6 px ; z-index : 1 ; font-size : .72 rem ; padding : 1 px 7 px ; border-radius : 999 px ;
background : rgba ( 0 , 0 , 0 , .65 ) ; color : #fff ; font-weight : 700 ; }
. bookhead { display : flex ; gap : 16 px ; flex-wrap : wrap ; margin : 8 px 0 14 px ; }
. bookhead . art { width : 140 px ; aspect-ratio : 2 / 3 ; border-radius : 6 px ; overflow : hidden ; border : 1 px solid var ( - - line ) ; position : relative ;
background : var ( - - tint , #5b6f63 ) ; color : #fff ; font-weight : 700 ; padding : 10 px ; display : flex ; align-items : flex-end ; flex : none ; overflow-wrap : anywhere ; }
. bookhead . art img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ; background : var ( - - card ) ; }
. bookhead . art img [ hidden ] { display : none ; }
. bookhead h2 { margin : 0 0 4 px ; font-size : 1.4 rem ; }
. copybox { background : var ( - - card ) ; border : 1 px solid var ( - - line ) ; border-radius : 12 px ; padding : 12 px 14 px ; margin : 10 px 0 ; }
. copybox . row { display : flex ; flex-wrap : wrap ; gap : 8 px 10 px ; align-items : center ; margin-top : 8 px ; }
. copybox select , . copybox input [ type = text ] { font : inherit ; padding : 7 px 9 px ; border : 1 px solid var ( - - line ) ; border-radius : 8 px ; background : var ( - - card ) ; color : var ( - - ink ) ; min-width : 0 ; }
button . danger { color : var ( - - bad ) ; border-color : var ( - - bad ) ; }
. person { display : flex ; flex-wrap : wrap ; gap : 8 px 12 px ; align-items : center ; padding : 10 px 2 px ; border-bottom : 1 px solid var ( - - line ) ; }
. person . t { flex : 1 ; min-width : 180 px ; }
. loan { display : flex ; flex-wrap : wrap ; gap : 6 px 10 px ; align-items : center ; padding : 8 px 0 ; border-bottom : 1 px solid var ( - - line ) ; }
. loan . t { flex : 1 ; min-width : 160 px ; overflow-wrap : anywhere ; }
. chip . late { background : var ( - - bad - bg ) ; border-color : var ( - - bad ) ; color : var ( - - bad ) ; }
. libcard { width : 85.6 mm ; height : 54 mm ; border : 1 px solid #888 ; border-radius : 3 mm ; padding : 5 mm ; background : #fff ; color : #000 ;
display : flex ; flex-direction : column ; justify-content : space-between ; font-family : system-ui , sans-serif ; }
. libcard . lib { font-size : 11 pt ; font-weight : 700 ; } . libcard . nm { font-size : 14 pt ; font-weight : 700 ; }
. libcard . codes { display : flex ; gap : 3 mm ; align-items : center ; } . libcard . bc { flex : 1 ; min-width : 0 ; }
. libcard . bc svg { width : 100 % ; height : 14 mm ; display : block ; } . libcard img . qr { width : 19 mm ; height : 19 mm ; flex : none ; }
. libcard . no { font-size : 9 pt ; text-align : center ; letter-spacing : .15 em ; }
. cardsheet { display : grid ; grid-template-columns : repeat ( 2 , 85.6 mm ) ; gap : 6 mm ; justify-content : start ; }
. cardsheet . libcard { break-inside : avoid ; }
@ media print { header , . noprint , nav { display : none !important ; } body { background : #fff ; } main { padding : 0 ; max-width : none ; } . libcard { border : 1 px solid #000 ; } }
# live { position : fixed ; inset : 0 ; z-index : 50 ; background : rgba ( 0 , 0 , 0 , .92 ) ; display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; gap : 12 px ; padding : 12 px ; }
# live [ hidden ] { display : none ; }
# live video { width : min ( 100 % , 640 px ) ; max-height : 60 vh ; border-radius : 12 px ; background : #000 ; object-fit : cover ; }
# live . frame { position : relative ; width : min ( 100 % , 640 px ) ; }
# live . frame :: after { content : '' ; position : absolute ; inset : 18 % 12 % ; border : 3 px solid rgba ( 255 , 255 , 255 , .85 ) ; border-radius : 12 px ; pointer-events : none ; }
# live . msg { color : #fff ; font-weight : 600 ; text-align : center ; min-height : 3 em ; max-width : 640 px ; }
. sr { position : absolute ; width : 1 px ; height : 1 px ; overflow : hidden ; clip : rect ( 0 0 0 0 ) ; }
< / style >
< / head >
< body >
< main >
< header >
< h1 id = "title" > Add to library < / h1 >
< h1 id = "title" > Room for Books < / h1 >
< nav class = "tabs" aria-label = "Sections" >
< a id = "tab-add " href = "#add " class = "on" > Add books < / a >
< a id = "tab-f ind " href = "#f ind " > Find a book< / a >
< a id = "tab-library " href = "#library " class = "on" > Library < / a >
< a id = "tab-m ine " href = "#m ine " hidden > My books < / a >
< a id = "tab-lend" href = "#lend" > Lend / return< / a >
< a id = "tab-people" href = "#people" > People< / a >
< a id = "tab-add" href = "#add" > Add books< / a >
< a id = "lnk-staff" class = "ext" href = "#" target = "_blank" rel = "noopener" > Manage< / a >
< a id = "lnk-opac" class = "ext" href = "#" target = "_blank" rel = "noopener" > Public catalogue< / a >
< / nav >
< / header >
< section id = "view-find " hidden >
< section id = "view-library " hidden >
< form class = "find-form" id = "findform" autocomplete = "off" >
< label class = "sr" for = "q" > Title, author or ISBN< / label >
< input id = "q" type = "search" placeholder = "T itle, author or ISBN" enterkeyhint = "search" >
< button class = "primary" type = "submit" > Find < / button >
< input id = "q" type = "search" placeholder = "Search t itle, author or ISBN" enterkeyhint = "search" >
< button class = "primary" type = "submit" > Search < / button >
< / form >
< div id = "found" aria-live = "polite" > < / div >
< div class = "filters" >
< label class = "sr" for = "f-shelf" > Shelf< / label > < select id = "f-shelf" > < / select >
< label class = "sr" for = "f-tag" > Tag< / label > < select id = "f-tag" > < / select >
< label class = "sr" for = "f-coll" > Collection< / label > < select id = "f-coll" > < / select >
< label class = "sr" for = "f-type" > Format< / label > < select id = "f-type" > < / select >
< label class = "sr" for = "f-status" > Status< / label > < select id = "f-status" >
< option value = "" > Any status< / option > < option value = "in" > On the shelf< / option > < option value = "out" > Checked out< / option > < / select >
< label class = "sr" for = "f-sort" > Sort< / label > < select id = "f-sort" >
< option value = "title" > Sort: title< / option > < option value = "author" > Sort: author< / option > < option value = "new" > Sort: newest first< / option > < / select >
< / div >
< div id = "libstatus" class = "small" aria-live = "polite" > < / div >
< div class = "grid" id = "grid" > < / div >
< div class = "actions" > < button type = "button" id = "more" hidden > Show more< / button > < / div >
< details class = "settings" id = "csvtools" >
< summary > Export / import< / summary >
< div class = "tool" > < strong > Export your books< / strong >
< div class = "small" > A spreadsheet (CSV) with one row per copy: title, author, ISBN, barcode, shelf, collection, status and any reading level on file.< / div >
< div class = "actions" > < a href = "api/export.csv" download > < button type = "button" > Download library.csv< / button > < / a > < / div > < / div >
< div class = "tool" > < strong > Import reading levels< / strong >
< div class = "small" > A CSV with a header row and columns < code > isbn< / code > , < code > level< / code > , < code > points< / code > (and optionally < code > quiz< / code > ). Existing levels for the same ISBN are updated.< / div >
< div class = "actions" > < input type = "file" id = "arfile" accept = ".csv,text/csv" > < button type = "button" id = "arimport" > Import< / button > < / div >
< div id = "arresult" class = "small" aria-live = "polite" > < / div > < / div >
< / details >
< / section >
< section id = "view-lend" hidden >
< div class = "copybox" >
< strong > Return a book< / strong >
< form class = "scan" id = "retform" autocomplete = "off" style = "margin-top:8px" >
< label class = "sr" for = "ret" > Barcode to return< / label >
< input id = "ret" inputmode = "numeric" placeholder = "Scan the book's barcode" autocomplete = "off" >
< button class = "primary" type = "submit" > Return< / button >
< / form >
< div class = "cam-row" > < button type = "button" id = "retcam" > Return with camera< / button > < button type = "button" id = "retlive" hidden > Live return< / button > < / div >
< div id = "retresult" role = "status" aria-live = "polite" class = "small" style = "margin-top:8px" > < / div >
< / div >
< div class = "copybox" >
< strong > Check out to… < / strong >
< form class = "find-form" id = "whoform" autocomplete = "off" >
< label class = "sr" for = "who" > Name or library card number< / label >
< input id = "who" type = "search" placeholder = "Name, or scan the library card" enterkeyhint = "search" autocomplete = "off" >
< button type = "submit" > Find< / button >
< / form >
< div class = "cam-row" > < button type = "button" id = "whocam" > Scan card or phone QR with camera< / button > < button type = "button" id = "wholive" hidden > Live scan< / button >
< span class = "small" > Photograph the library card, or the QR picture on the borrower's phone.< / span > < / div >
< div id = "whostatus" class = "small" > < / div >
< div id = "whoresults" > < / div >
< div id = "patronbox" > < / div >
< / div >
< h2 > Checked out right now < span id = "outcount" class = "small" > < / span > < / h2 >
< div id = "outlist" > < / div >
< / section >
< section id = "view-people" hidden >
< details class = "settings" id = "newperson" open >
< summary > Add a person< / summary >
< form class = "manual" id = "personform" autocomplete = "off" >
< input name = "firstname" placeholder = "First name" aria-label = "First name" >
< input name = "surname" placeholder = "Last name" required aria-label = "Last name" >
< input name = "phone" placeholder = "Phone (optional)" aria-label = "Phone" >
< input name = "email" type = "email" placeholder = "Email (optional)" aria-label = "Email" >
< div class = "actions" > < button class = "primary" type = "submit" > Add person< / button > < / div >
< / form >
< div id = "personresult" aria-live = "polite" > < / div >
< / details >
< p > < a href = "#cards" > < button type = "button" > Print everyone's cards< / button > < / a >
< button type = "button" id = "alllogins" > Give everyone a sign-in< / button > < / p >
< details class = "settings" > < summary > Sign-in passwords for new people< / summary >
< fieldset > < label class = "opt" > < input type = "radio" name = "pwmode" value = "generated" > A different easy password for each person (like maple482)< / label >
< label class = "opt" > < input type = "radio" name = "pwmode" value = "same" > The same password for everyone:
< input type = "text" id = "pwsame" maxlength = "40" placeholder = "e.g. readmore" style = "font:inherit;padding:4px 8px" > < / label >
< div class = "small" > People sign in to see their own books with their library card number and this password; they can change it themselves. Existing passwords are never changed by this setting.< / div >
< div class = "actions" > < button type = "button" id = "pwsave" > Save< / button > < span id = "pwsaved" class = "small" > < / span > < / div > < / fieldset >
< / details >
< div id = "loginresult" aria-live = "polite" > < / div >
< form class = "find-form" id = "peopleform" autocomplete = "off" >
< label class = "sr" for = "pq" > Search people< / label >
< input id = "pq" type = "search" placeholder = "Search by name or card number" enterkeyhint = "search" >
< button type = "submit" > Search< / button >
< / form >
< div id = "peoplelist" > < / div >
< / section >
< section id = "view-card" hidden >
< p class = "noprint" > < a href = "#people" > ← Back to people< / a > < button type = "button" id = "printcard" class = "primary" > Print this card< / button > < button type = "button" id = "savecard" > Save as picture (to send to a phone)< / button > < / p >
< div id = "cardbox" > < / div >
< / section >
< section id = "view-mine" hidden >
< div class = "copybox" > < strong id = "mename" > < / strong > < span class = "badge" id = "mecard" > < / span >
< div class = "small" > What you have borrowed, and when it is due.< / div > < / div >
< div id = "meloans" aria-live = "polite" > < / div >
< details class = "settings" id = "mepw" > < summary > Change my password< / summary >
< form class = "manual" id = "mepwform" autocomplete = "off" >
< input type = "password" name = "old" placeholder = "Current password" autocomplete = "current-password" required aria-label = "Current password" >
< input type = "password" name = "new" placeholder = "New password (4 or more characters)" autocomplete = "new-password" required minlength = "4" aria-label = "New password" >
< div class = "actions" > < button class = "primary" type = "submit" > Change password< / button > < / div >
< / form >
< div id = "mepwresult" class = "small" aria-live = "polite" > < / div >
< / details >
< / section >
< section id = "view-cards" hidden >
< p class = "noprint" > < a href = "#people" > ← Back to people< / a > < button type = "button" id = "printcards" class = "primary" > Print these cards< / button >
< span class = "small" id = "cardsnote" > < / span > < / p >
< div class = "cardsheet" id = "cardsheet" > < / div >
< / section >
< section id = "view-book" hidden >
< p > < a href = "#library" id = "back" > ← Back to the library< / a > < / p >
< div id = "bookbox" aria-live = "polite" > < / div >
< / section >
< section id = "view-add" >
@@ -132,6 +293,7 @@
< / form >
< div class = "cam-row" >
< button type = "button" id = "cam" > Scan with camera< / button >
< button type = "button" id = "livecam" hidden > Live scan< / button >
< input type = "file" id = "camfile" accept = "image/*" capture = "environment" hidden >
< span class = "small" > Opens your phone's camera: fill the frame with the barcode and hold steady.< / span >
< / div >
@@ -179,6 +341,11 @@
< ul class = "list" id = "earlier" > < / ul >
< / section >
< / main >
< div id = "live" hidden role = "dialog" aria-modal = "true" aria-label = "Camera scanner" >
< div class = "frame" > < video id = "livevideo" playsinline muted > < / video > < / div >
< div class = "msg" id = "livemsg" aria-live = "polite" > < / div >
< button type = "button" id = "livestop" class = "primary" > Done< / button >
< / div >
< script >
( ( ) => {
@@ -211,8 +378,10 @@
} ;
async function api ( path , body ) {
const r = await fetch ( path . replace ( /^\// , '' ) , body ? { method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) } : { } ) ;
// X-Quickadd marks our own requests: a staff-site login (cookie) is only accepted for changes
// that carry it, which a page on another website cannot add (cross-site request forgery).
const r = await fetch ( path . replace ( /^\// , '' ) , body ? { method : 'POST' , headers : { 'Content-Type' : 'application/json' , 'X-Quickadd' : '1' } ,
body : JSON . stringify ( body ) } : { headers : { 'X-Quickadd' : '1' } } ) ;
return r . json ( ) ;
}
@@ -320,16 +489,17 @@
}
// Opens the camera, resolves with the codes found ([] if none, null if cancelled).
function takePhoto ( kind ) {
function takePhoto ( kind , statusEl ) {
statusEl = statusEl || $ ( 'status' ) ;
return new Promise ( resolve => {
const input = $ ( 'camfile' ) ;
input . onchange = async ( ) => {
const f = input . files [ 0 ] ; input . value = '' ;
if ( ! f ) return resolve ( null ) ;
$ ( 'status' ) . textContent = 'Reading the barcode…' ;
statusEl . textContent = 'Reading the barcode…' ;
try {
const blob = await shrink ( f ) ;
const r = await fetch ( 'api/scan?kind=' + kind , { method : 'POST' , headers : { 'Content-Type' : 'image/jpeg' } , body : blob } ) ;
const r = await fetch ( 'api/scan?kind=' + kind , { method : 'POST' , headers : { 'Content-Type' : 'image/jpeg' , 'X-Quickadd' : '1' } , body : blob } ) ;
resolve ( r . ok ? ( await r . json ( ) ) . codes : [ ] ) ;
} catch ( e ) { resolve ( [ ] ) ; }
} ;
@@ -337,6 +507,57 @@
} ) ;
}
// ---- live camera: works where the browser allows camera access (HTTPS, or the machine itself).
// Chrome/Android read codes in the browser (BarcodeDetector); everything else sends small frames to
// the server's decoder a couple of times a second. Plain-HTTP pages fall back to the photo buttons.
const liveOK = ! ! ( window . isSecureContext && navigator . mediaDevices && navigator . mediaDevices . getUserMedia ) ;
let liveStop = null ;
// onCode(code) may return text to show; continuous keeps scanning, otherwise the first code closes it.
async function liveScan ( kind , title , onCode , continuous ) {
const box = $ ( 'live' ) , video = $ ( 'livevideo' ) , msg = $ ( 'livemsg' ) ;
let stream ;
try { stream = await navigator . mediaDevices . getUserMedia ( { video : { facingMode : { ideal : 'environment' } , width : { ideal : 1280 } } , audio : false } ) ; }
catch ( e ) { alert ( 'The camera is not available (' + ( e . name === 'NotAllowedError' ? 'permission was refused' : e . message ) + '). Use the photo button instead.' ) ; return ; }
box . hidden = false ; msg . textContent = title ; video . srcObject = stream ; await video . play ( ) . catch ( ( ) => { } ) ;
let alive = true , busy = false , lastCode = '' , lastAt = 0 ;
const detector = 'BarcodeDetector' in window ? new BarcodeDetector ( { formats : [ 'qr_code' , 'code_39' , 'code_128' , 'ean_13' , 'ean_8' , 'upc_a' , 'upc_e' ] } ) : null ;
const canvas = document . createElement ( 'canvas' ) ;
const finish = ( ) => { alive = false ; stream . getTracks ( ) . forEach ( t => t . stop ( ) ) ; video . srcObject = null ; box . hidden = true ; liveStop = null ; } ;
liveStop = finish ; $ ( 'livestop' ) . onclick = finish ;
const accept = async code => {
if ( ! code || busy ) return ;
if ( kind === 'isbn' ) { code = clean ( code ) ; if ( ! code ) return ; }
const now = Date . now ( ) ;
if ( code === lastCode && now - lastAt < 4000 ) return ; // the same book still in view
lastCode = code ; lastAt = now ; busy = true ;
beep ( SOUND . added ) ;
try { const t = await onCode ( code ) ; if ( t ) msg . textContent = t ; } catch ( e ) { }
busy = false ;
if ( ! continuous ) finish ( ) ;
} ;
const clean = c => { c = String ( c ) . replace ( /[\s-]/g , '' ) . toUpperCase ( ) ; if ( ! /^(\d{8,14}|\d{9}X)$/ . test ( c ) ) return '' ; return c . length === 13 && c [ 0 ] === '0' ? c . slice ( 1 ) : c ; } ;
while ( alive ) {
try {
if ( video . readyState >= 2 && ! busy ) {
if ( detector ) {
const found = await detector . detect ( video ) ;
if ( found . length ) await accept ( found [ 0 ] . rawValue ) ;
} else {
const k = Math . min ( 1 , 1280 / Math . max ( video . videoWidth , video . videoHeight ) ) ;
canvas . width = Math . round ( video . videoWidth * k ) ; canvas . height = Math . round ( video . videoHeight * k ) ;
canvas . getContext ( '2d' ) . drawImage ( video , 0 , 0 , canvas . width , canvas . height ) ;
const blob = await new Promise ( ok => canvas . toBlob ( ok , 'image/jpeg' , 0.85 ) ) ;
const r = await fetch ( 'api/scan?kind=' + ( kind === 'isbn' ? 'isbn' : 'sticker' ) , { method : 'POST' , headers : { 'Content-Type' : 'image/jpeg' , 'X-Quickadd' : '1' } , body : blob } ) ;
const codes = r . ok ? ( await r . json ( ) ) . codes : [ ] ;
if ( codes . length && alive ) await accept ( codes [ 0 ] ) ;
}
}
} catch ( e ) { }
await new Promise ( ok => setTimeout ( ok , detector ? 200 : 650 ) ) ;
}
}
window . addEventListener ( 'hashchange' , ( ) => { if ( liveStop ) liveStop ( ) ; } ) ;
function noBarcode ( ) {
beep ( SOUND . notfound ) ;
$ ( 'status' ) . textContent = '' ;
@@ -344,6 +565,8 @@
el ( 'div' , { className : 'meta' } , 'Move closer so the barcode fills the frame, keep the phone steady and the barcode flat and well lit, then try again. You can also type the number.' ) ) ) ;
}
$ ( 'livecam' ) . hidden = ! liveOK ;
$ ( 'livecam' ) . onclick = ( ) => liveScan ( 'isbn' , 'Hold each book\'s barcode in the frame' , async code => { await add ( code ) ; return 'Added - next book' ; } , true ) ;
$ ( 'cam' ) . onclick = async ( ) => {
const codes = await takePhoto ( 'isbn' ) ;
if ( codes === null ) { $ ( 'status' ) . textContent = '' ; return ; }
@@ -455,43 +678,542 @@
// ---- under the staff site (/add/) everything is same-origin
const embedded = location . pathname . startsWith ( '/add/' ) ;
let viewer = false ; // the read-only guest login: search only
let viewer = false , isPatron = false ; // the read-only guest login: search only
// ---- views: Add b ooks / Find a book
function showView ( ) {
const find = viewer || location . hash === '#find' ;
$ ( 'tab-add' ) . hidden = viewer ;
$ ( 'view-add' ) . hidden = find ; $ ( 'view-find' ) . hidden = ! find ;
$ ( 'tab-add' ) . classList . toggle ( 'on' , ! find ) ; $ ( 'tab-find' ) . classList . toggle ( 'on' , find ) ;
$ ( 'title' ) . textContent = find ? 'Find a book' : 'Add to library' ;
document . title = find ? 'Find a book' : 'Add to library' ;
if ( find ) $ ( 'q' ) . focus ( ) ; else $ ( 'code' ) . focus ( ) ;
// ---- views: Library / B ook / Add books
const TINTS = [ '#5b6f63' , '#6b5b73' , '#73655b' , '#4f6a7a' , '#7a5252' , '#6a6f3f' , '#4a5f7a' , '#7a6a3a' ] ;
const tint = t => TINTS [ [ ... ( t || '' ) ] . reduce ( ( n , c ) => ( n * 31 + c . charCodeAt ( 0 ) ) % 997 , 7 ) % TINTS . length ] ;
function art ( b , cls ) {
const a = el ( 'span' , { className : cls || 'art' } , b . title ) ;
a . style . setProperty ( '--tint' , tint ( b . title ) ) ;
if ( b . isbn && /^\d{10,13}$/ . test ( b . isbn . replace ( /\D/g , '' ) . slice ( 0 , 13 ) ) ) {
const img = el ( 'img' , { alt : '' , hidden : true , loading : 'lazy' } ) ;
img . onload = ( ) => { if ( img . naturalWidth > 1 ) img . hidden = false ; } ;
img . src = 'https://covers.openlibrary.org/b/isbn/' + b . isbn . replace ( /\D/g , '' ) . slice ( 0 , 13 ) + '-M.jpg?default=false' ;
a . append ( img ) ;
}
return a ;
}
window . addEventListener ( 'hashchange' , showView ) ;
async function find ( term ) {
const box = $ ( 'found' ) ;
if ( term . trim ( ) . length < 2 ) { box . replaceChildren ( el ( 'div' , { className : 'small' } , 'Type at least two letters.' ) ) ; return ; }
box . replaceChildren ( el ( 'div' , { className : 'small' } , 'Searching… ') ) ;
let libPage = 1 , libShelves = [ ] , libCollections = [ ] , libLoaded = false , filtersReady = false ;
const filters = ( ) => ( { q : $ ( 'q' ) . value . trim ( ) , shelf : $ ( 'f-shelf' ) . value , collection : $ ( 'f-coll' ) . value , tag : $ ( 'f-tag' ) . value , type : $ ( 'f-type' ) . value ,
status : $ ( 'f-status' ) . value , sort : $ ( 'f-sort' ) . value } ) ;
const filterQuery = ( ) => { const f = filters ( ) ; return Object . entries ( f ) . filter ( ( [ , v ] ) => v ) . map ( ( [ k , v ] ) => k + '=' + encodeURIComponent ( v ) ) . join ( '& ') ; } ;
function tile ( b ) {
const a = el ( 'a' , { className : 'tile' , href : '#book/' + b . id } ) ;
const picture = art ( b ) ;
if ( b . copies > 1 ) picture . append ( el ( 'span' , { className : 'num' } , '× ' + b . copies ) ) ;
if ( ! viewer && b . out ) picture . append ( el ( 'span' , { className : 'flag' } , b . out === b . copies ? 'Out' : b . out + ' out' ) ) ;
else if ( viewer && b . out === b . copies && b . copies ) picture . append ( el ( 'span' , { className : 'flag' } , 'Out' ) ) ;
a . append ( picture , el ( 'div' , { className : 'tt' } , b . title ) , el ( 'div' , { className : 'aa' } , b . author ) ) ;
return a ;
}
function fillTagFilter ( tags ) {
const cur = $ ( 'f-tag' ) . value ;
$ ( 'f-tag' ) . replaceChildren ( el ( 'option' , { value : '' } , 'Any tag' ) , ... tags . map ( t => el ( 'option' , { value : t . tag } , t . tag + ' (' + t . count + ')' ) ) ) ;
$ ( 'f-tag' ) . value = [ ... $ ( 'f-tag' ) . options ] . some ( o => o . value === cur ) ? cur : '' ;
}
let libSeq = 0 ;
async function loadLibrary ( reset ) {
const seq = ++ libSeq ; // a newer search makes an older, slower answer irrelevant
if ( reset ) libPage = 1 ;
const grid = $ ( 'grid' ) ;
if ( reset ) grid . replaceChildren ( ) ;
$ ( 'libstatus' ) . textContent = 'Loading…' ;
let r ;
try { r = await api ( '/api/search?q= ' + encodeURIComponent ( term ) ) ; } catch ( e ) { r = { status : 'error' } ; }
if ( ! r . results ) { box . replaceChildren ( el ( 'div' , { className : 'card warn' } , el ( 'div' , { className : 'body' } , r . message || 'Search failed.' ) ) ) ; return ; }
if ( ! r . results . length ) { box . replaceChildren ( el ( 'div' , { className : 'small' } , 'No matches for "' + term + '".' ) ) ; return ; }
box . replaceChildren ( ... r . results . map ( b => el ( 'div' , { className : 'hit' } ,
el ( 'div' , { className : 't' } , b . title ) , el ( 'div' , { className : 'meta' } , [ b . author , b . year ] . filter ( Boolean ) . join ( ' · ' ) ) ,
... ( b . copies . length ? b . copies . map ( c => el ( 'div' , { className : 'copy' } ,
c . barcode ? el ( 'span' , { className : 'badge' } , c . barcode ) : '' ,
el ( 'span' , { } , c . location || 'no shelf set' ) , c . callnumber ? el ( 'span' , { className : 'meta' } , c . callnumber ) : '' ,
el ( 'spa n' , { className : 'chip ' + ( c . status === 'On shelf' ? 'in' : 'out' ) } , c . status ) ) )
: [ el ( 'div ', { className : 'small' } , 'No copies recorded.' ) ] ) ,
viewer ? '' : el ( 'div ', { className : 'small' , style : 'margin-top:6px' } ,
el ( 'a' , { href : staffRoot ( ) + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b . biblio _id , target : '_blank' , rel : 'noopener' } , 'Open full record' ) ) ) ) ) ;
try { r = await api ( '/api/library? ' + filterQuery ( ) + '&page=' + libPage ) ; } catch ( e ) { r = { } ; }
if ( seq !== libSeq ) return ;
if ( ! r . books ) { $ ( 'libstatus' ) . textContent = r . message || 'Koha is not answering yet - try again in a minute.' ; return ; }
if ( r . collections ) libCollections = r . collections ;
if ( r . tags ) fillTagFilter ( r . tags ) ;
if ( ! filtersReady && r . shelves ) {
filtersReady = true ;
libShelves = r . shelves ;
$ ( 'f-coll' ) . replaceChildren ( el ( 'optio n' , { value : '' } , 'Any collection' ) , el ( 'option' , { value : '-' } , '(no collection)' ) ,
... ( r . collections || [ ] ) . map ( l => el ( 'option ', { value : l . value } , l . label ) ) ) ;
$ ( 'f-shelf' ) . replaceChildren ( el ( 'option ', { value : '' } , 'Any shelf' ) , el ( 'option' , { value : '-' } , '(no shelf set)' ) ,
... r . shelves . map ( l => el ( 'option' , { value : l . value } , l . label ) ) ) ;
$ ( 'f-type' ) . replaceChildren ( el ( 'option' , { value : '' } , 'Any format' ) ,
... Object . entries ( r . types || { } ) . map ( ( [ k , v ] ) => el ( 'option' , { value : k } , v ) ) ) ;
}
grid . append ( ... r . books . map ( tile ) ) ;
$ ( 'more' ) . hidden = ! r . more ;
const shown = grid . children . length ;
const f = filters ( ) ;
$ ( 'libstatus' ) . textContent = shown ? shown + ( r . more ? '+' : '' ) + ( shown === 1 ? ' book' : ' books' ) + ( f . q || f . shelf || f . collection || f . tag || f . type || f . status ? ' match' : '' ) :
( f . q || f . shelf || f . collection || f . tag || f . type || f . status ? 'No books match.' : ( viewer ? 'Nothing here yet.' : 'Nothing here yet - open Add books and scan an ISBN.' ) ) ;
}
$ ( 'findf orm ' ) . onsubmit = ev => { ev . preventDefault ( ) ; find ( $ ( 'q' ) . value ) ; } ;
$ ( 'arimp ort ' ) . onclick = async ( ) => {
const f = $ ( 'arfile' ) . files [ 0 ] ; if ( ! f ) { $ ( 'arresult' ) . textContent = 'Choose a CSV file first.' ; return ; }
const r = await api ( '/api/ar/import' , { csv : await f . text ( ) } ) ;
$ ( 'arresult' ) . textContent = r . status === 'imported' ? 'Imported ' + r . count + ' reading level' + ( r . count === 1 ? '' : 's' ) + ( r . skipped ? ' (' + r . skipped + ' rows skipped: no valid ISBN or level)' : '' ) + '.' : ( r . message || 'Import failed.' ) ;
} ;
$ ( 'more' ) . onclick = ( ) => { libPage += 1 ; loadLibrary ( false ) ; } ;
$ ( 'findform' ) . onsubmit = ev => { ev . preventDefault ( ) ; loadLibrary ( true ) ; } ;
for ( const id of [ 'f-shelf' , 'f-coll' , 'f-tag' , 'f-type' , 'f-status' , 'f-sort' ] ) $ ( id ) . onchange = ( ) => loadLibrary ( true ) ;
// ---- one book
async function loadBook ( id ) {
const box = $ ( 'bookbox' ) ;
box . replaceChildren ( el ( 'div' , { className : 'small' } , 'Loading…' ) ) ;
let b ;
try { b = await api ( '/api/book/' + id ) ; } catch ( e ) { b = { } ; }
if ( ! b . copies ) { box . replaceChildren ( el ( 'div' , { className : 'card warn' } , el ( 'div' , { className : 'body' } , b . message || 'Book not found.' ) ) ) ; return ; }
document . title = b . title ;
if ( b . shelves && b . shelves . length ) libShelves = b . shelves ;
if ( b . collections ) libCollections = b . collections ;
const facts = [ b . author , b . year , b . publisher , b . pages , b . edition , b . series ] . filter ( Boolean ) ;
const head = el ( 'div' , { className : 'bookhead' } , art ( b ) ,
el ( 'div' , { className : 'body' } , el ( 'h2' , { } , b . title ) , b . subtitle ? el ( 'div' , { className : 'meta' } , b . subtitle ) : '' ,
el ( 'div' , { className : 'meta' } , facts . join ( ' · ' ) ) , b . isbn ? el ( 'div' , { className : 'small' } , 'ISBN ' + b . isbn ) : '' ,
b . summary ? el ( 'p' , { } , b . summary ) : '' ) ) ;
box . replaceChildren ( head ) ;
const refresh = ( ) => loadBook ( id ) ;
box . append ( tagBox ( b ) ) ;
const moreBox = el ( 'div' , { className : 'copybox' } , el ( 'div' , { className : 'small' } , 'Looking for more about this book…' ) ) ;
box . append ( moreBox ) ;
api ( '/api/more/' + id ) . then ( m => fillMore ( moreBox , b , m ) ) . catch ( ( ) => moreBox . remove ( ) ) ;
for ( const c of b . copies ) box . append ( copyBox ( b , c , refresh ) ) ;
if ( ! b . copies . length ) box . append ( el ( 'div' , { className : 'small' } , 'No copies recorded.' ) ) ;
if ( ! viewer ) {
const more = el ( 'button' , { type : 'button' } , 'Add another copy' ) ;
more . onclick = async ( ) => { more . disabled = true ; const r = await api ( '/api/item/copy' , { biblio _id : b . id } ) ; if ( r . status === 'added' ) refresh ( ) ; else more . disabled = false ; } ;
const del = el ( 'button' , { type : 'button' , className : 'danger' } , 'Delete this book' ) ;
del . onclick = async ( ) => {
if ( ! confirm ( 'Delete "' + b . title + '" and all its copies from the library?' ) ) return ;
const r = await api ( '/api/book/delete' , { biblio _id : b . id } ) ;
if ( r . status === 'deleted' ) location . hash = '#library' ; else alert ( r . message || 'Could not delete it.' ) ;
} ;
const full = el ( 'a' , { href : staffRoot ( ) + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b . id , target : '_blank' , rel : 'noopener' } , 'Open the full record in Koha' ) ;
box . append ( el ( 'div' , { className : 'actions' } , more , del ) , el ( 'p' , { className : 'small' } , full , ' (edit title, author and other details there)' ) ) ;
}
}
// A shelf/collection chooser whose last entry adds a new one (Koha's own screens are not needed).
function valueSelect ( kind , label , none , list , current ) {
const sel = el ( 'select' , { 'aria-label' : label } ) ;
const fill = ( items , cur ) => { sel . replaceChildren ( el ( 'option' , { value : '' } , none ) , ... items . map ( l => el ( 'option' , { value : l . value } , l . label ) ) ,
el ( 'option' , { value : '__new' } , '+ New ' + kind + '…' ) ) ; sel . value = cur || '' ; } ;
fill ( list , current ) ;
let last = current || '' ;
sel . onchange = async ( ) => {
if ( sel . value !== '__new' ) { last = sel . value ; return ; }
const name = prompt ( 'Name of the new ' + kind + ':' ) ;
if ( ! name || ! name . trim ( ) ) { sel . value = last ; return ; }
const r = await api ( '/api/value/add' , { kind , name } ) ;
if ( r . status !== 'added' ) { alert ( r . message || 'Could not add that.' ) ; sel . value = last ; return ; }
const items = ( kind === 'shelf' ? libShelves : libCollections ) ; items . push ( { value : r . value , label : r . label } ) ;
fill ( items , r . value ) ; last = r . value ;
} ;
return sel ;
}
// Reading level (AR): typed in here or imported from a CSV. AR BookFind itself is only a link: it has no
// public feed and its terms forbid automated look-ups.
function arBox ( b , m ) {
const ar = m . ar || { } ;
const f = ( ph , v , w ) => el ( 'input' , { type : 'text' , placeholder : ph , value : v || '' , 'aria-label' : ph , inputMode : 'decimal' , style : 'width:' + w + 'em;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' } ) ;
const lvl = f ( 'Level' , ar . level , 5 ) , pts = f ( 'Points' , ar . points , 5 ) , quiz = f ( 'Quiz no.' , ar . quiz , 7 ) , inter = f ( 'Interest (LG/MG/UG)' , ar . interest , 9 ) ;
const wrap = el ( 'div' , { className : 'small' , style : 'margin-top:10px;border-top:1px solid var(--line);padding-top:8px' } ) ;
const copy = el ( 'button' , { type : 'button' } , 'Copy title' ) ;
copy . onclick = async ( ) => { try { await navigator . clipboard . writeText ( b . title ) ; copy . textContent = 'Copied ✓' ; } catch ( e ) { copy . textContent = 'Copy failed' ; } setTimeout ( ( ) => copy . textContent = 'Copy title' , 1500 ) ; } ;
const open = el ( 'a' , { href : 'https://www.arbookfind.com/' , target : '_blank' , rel : 'noopener noreferrer' } , 'Open AR BookFind' ) ;
const copyTitle = async ( ) => { try { await navigator . clipboard . writeText ( b . title ) ; } catch ( e ) { } } ;
const arPop = el ( 'button' , { type : 'button' } , 'Open beside this page' ) ;
arPop . onclick = async ( ) => { await copyTitle ( ) ;
const w = Math . min ( 560 , Math . floor ( screen . availWidth / 2 ) ) , h = screen . availHeight - 80 ;
window . open ( 'https://www.arbookfind.com/' , 'arbookfind' , 'popup=yes,width=' + w + ',height=' + h + ',left=' + ( screen . availWidth - w ) + ',top=40' ) ; } ;
const arHolder = el ( 'div' , { } ) ;
const arFrame = el ( 'button' , { type : 'button' } , 'Show it here' ) ;
arFrame . onclick = async ( ) => { await copyTitle ( ) ;
if ( arHolder . firstChild ) { arHolder . replaceChildren ( ) ; arFrame . textContent = 'Show it here' ; return ; }
arFrame . textContent = 'Hide it' ;
arHolder . replaceChildren ( el ( 'iframe' , { src : 'https://www.arbookfind.com/' , title : 'AR BookFind' , loading : 'lazy' , referrerPolicy : 'no-referrer' ,
style : 'width:100%;height:520px;border:1px solid var(--line);border-radius:8px;margin-top:8px;background:#fff' } ) ,
el ( 'div' , { className : 'small' } , 'If it keeps asking who you are, your browser is blocking its cookies inside another page: use "Open beside this page" instead.' ) ) ; } ;
if ( viewer ) { wrap . append ( el ( 'strong' , { } , 'Reading level: ' ) , ar . level ? 'AR ' + ar . level + ( ar . points ? ', ' + ar . points + ' points' : '' ) + ( ar . interest ? ', interest ' + ar . interest : '' ) : 'not recorded' ) ; return wrap ; }
const save = el ( 'button' , { type : 'button' , className : 'primary' } , 'Save' ) ;
const msg = el ( 'span' , { } ) ;
save . onclick = async ( ) => {
const r = await api ( '/api/ar/set' , { isbn : m . isbn , level : lvl . value , points : pts . value , quiz : quiz . value , interest : inter . value } ) ;
msg . textContent = r . status === 'saved' ? ' Saved ✓' : ' ' + ( r . message || 'Could not save.' ) ;
} ;
// Easiest path: on AR BookFind's book page select everything (Ctrl/Cmd+A), copy, and paste it here.
const paste = el ( 'textarea' , { rows : 2 , placeholder : 'Paste the AR BookFind book page here (select all, copy, paste)' , 'aria-label' : 'Pasted AR BookFind text' ,
style : 'width:100%;margin-top:6px;font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)' } ) ;
paste . oninput = ( ) => {
const t = paste . value , g = re => ( t . match ( re ) || [ ] ) [ 1 ] || '' ;
const found = { level : g ( /Book\s*Level:?\s*([0-9]{1,2}(?:\.[0-9]{1,2})?)/i ) , points : g ( /(?:AR\s*)?Points:?\s*([0-9]{1,3}(?:\.[0-9]{1,2})?)/i ) ,
quiz : g ( /Quiz\s*(?:No\.?|Number|#)\s*:?\s*([0-9]{3,6})/i ) , interest : g ( /Interest\s*Level:?\s*(LG|MG\+?|UG)/i ) } ;
if ( found . level ) lvl . value = found . level ; if ( found . points ) pts . value = found . points ; if ( found . quiz ) quiz . value = found . quiz ; if ( found . interest ) inter . value = found . interest ;
msg . textContent = ( found . level || found . points || found . quiz ) ? ' Filled in from what you pasted - check it, then Save.' : ( t . trim ( ) ? " Couldn't find a level or points in that text." : '' ) ;
} ;
wrap . append ( el ( 'strong' , { } , 'Reading level (AR): ' ) , lvl , ' ' , pts , ' ' , quiz , ' ' , inter , ' ' , save , msg , paste ,
el ( 'div' , { style : 'margin-top:6px' } , open , ' ' , arPop , ' ' , arFrame , ' ' , copy ) ,
el ( 'div' , { className : 'small' } , 'Search for the title there, select the whole book page, copy, and paste it below. (The title is copied for you when you open it.)' ) , arHolder ) ;
return wrap ;
}
// Tags: any number per book, free words ("to read", "kids", "signed"...). Click one to see every book with it.
function tagBox ( b ) {
const box = el ( 'div' , { className : 'copybox' } ) ;
let tags = [ ... ( b . tags || [ ] ) ] ;
const all = ( b . all _tags || [ ] ) . map ( t => t . tag ) ;
const draw = ( ) => {
const chips = tags . map ( t => {
const c = el ( 'span' , { className : 'chip' , style : 'margin:0 6px 6px 0;display:inline-block' } ) ;
const a = el ( 'a' , { href : '#library' , style : 'text-decoration:none' } , t ) ;
a . onclick = ( ) => { setTimeout ( ( ) => { $ ( 'f-tag' ) . value = t ; loadLibrary ( true ) ; } , 0 ) ; } ;
c . append ( a ) ;
if ( ! viewer ) { const x = el ( 'button' , { type : 'button' , 'aria-label' : 'Remove tag ' + t , style : 'border:0;background:none;padding:0 0 0 6px;cursor:pointer;color:inherit' } , '× ' ) ;
x . onclick = ( ) => save ( tags . filter ( y => y !== t ) ) ; c . append ( x ) ; }
return c ;
} ) ;
const parts = [ el ( 'strong' , { } , 'Tags: ' ) , ... ( chips . length ? chips : [ el ( 'span' , { className : 'small' } , viewer ? 'none' : 'none yet ' ) ] ) ] ;
if ( ! viewer ) {
const list = el ( 'datalist' , { id : 'taglist' } , ... all . filter ( t => ! tags . includes ( t ) ) . map ( t => el ( 'option' , { value : t } ) ) ) ;
const inp = el ( 'input' , { type : 'text' , placeholder : 'Add a tag, press Enter' , 'aria-label' : 'Add a tag' , maxLength : 30 ,
style : 'font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);min-width:12em' } ) ;
inp . setAttribute ( 'list' , 'taglist' ) ;
inp . onkeydown = ev => { if ( ev . key === 'Enter' ) { ev . preventDefault ( ) ; const v = inp . value . trim ( ) ; if ( v ) save ( [ ... tags , v ] ) ; } } ;
parts . push ( list , inp ) ;
}
box . replaceChildren ( ... parts ) ;
} ;
const save = async next => {
const r = await api ( '/api/tags/set' , { biblio _id : b . id , tags : next } ) ;
if ( r . status === 'saved' ) { tags = r . tags ; draw ( ) ; fillTagFilter ( r . all || [ ] ) ; }
} ;
draw ( ) ;
return box ;
}
function fillMore ( box , b , m ) {
const parts = [ ] ;
if ( m . description && ! b . summary ) parts . push ( el ( 'p' , { } , m . description ) , el ( 'div' , { className : 'small' } , 'Description from Open Library.' ) ) ;
if ( m . subjects && m . subjects . length ) parts . push ( el ( 'div' , { style : 'margin:8px 0' } , ... m . subjects . map ( t => el ( 'span' , { className : 'chip' , style : 'margin:0 6px 6px 0;display:inline-block' } , t ) ) ) ) ;
if ( m . first _published ) parts . push ( el ( 'div' , { className : 'small' } , 'First published: ' + m . first _published ) ) ;
const row = el ( 'div' , { className : 'small' , style : 'margin-top:8px' } , 'Look it up: ' ) ;
( m . links || [ ] ) . forEach ( ( l , i ) => { if ( i ) row . append ( ' · ' ) ; row . append ( el ( 'a' , { href : l . url , target : '_blank' , rel : 'noopener noreferrer' } , l . label ) ) ; } ) ;
parts . push ( row ) ;
if ( ( m . links || [ ] ) . some ( l => l . note ) ) parts . push ( arBox ( b , m ) ) ;
box . replaceChildren ( ... parts ) ;
}
function copyBox ( b , c , refresh ) {
const box = el ( 'div' , { className : 'copybox' } ) ;
const out = c . status === 'Checked out' ;
const status = el ( 'span' , { className : 'chip ' + ( c . status === 'On shelf' ? 'in' : 'out' ) } , c . status +
( out && c . out ? ( c . out . who ? ' to ' + c . out . who : '' ) + ( c . out . due ? ', due ' + c . out . due : '' ) : '' ) ) ;
box . append ( el ( 'div' , { } , c . barcode ? el ( 'span' , { className : 'badge' } , c . barcode ) : el ( 'span' , { className : 'small' } , 'no barcode yet' ) , ' ' , status ) ) ;
if ( viewer ) {
box . append ( el ( 'div' , { className : 'meta' } , [ c . shelf _label || 'no shelf set' , c . callnumber && 'Call no. ' + c . callnumber ] . filter ( Boolean ) . join ( ' · ' ) ) ) ;
return box ;
}
const sel = valueSelect ( 'shelf' , 'Shelf' , '(no shelf set)' , libShelves , c . shelf ) ;
const csel = valueSelect ( 'collection' , 'Collection' , '(no collection)' , libCollections , c . collection ) ;
const cn = el ( 'input' , { type : 'text' , value : c . callnumber , placeholder : 'Call number' , 'aria-label' : 'Call number' , maxLength : 80 } ) ;
const save = el ( 'button' , { type : 'button' , className : 'primary' } , 'Save' ) ;
save . onclick = async ( ) => {
save . disabled = true ;
const r = await api ( '/api/item/update' , { item _id : c . item _id , shelf : sel . value , collection : csel . value , callnumber : cn . value } ) ;
save . textContent = r . status === 'saved' ? 'Saved ✓' : 'Failed' ; setTimeout ( ( ) => { save . textContent = 'Save' ; save . disabled = false ; } , 1500 ) ;
} ;
const rm = el ( 'button' , { type : 'button' , className : 'danger' } , 'Remove copy' ) ;
rm . disabled = out ;
rm . onclick = async ( ) => {
if ( ! confirm ( 'Remove this copy' + ( c . barcode ? ' (barcode ' + c . barcode + ')' : '' ) + '?' ) ) return ;
const r = await api ( '/api/item/delete' , { item _id : c . item _id , biblio _id : b . id } ) ;
if ( r . status === 'undone' ) { if ( b . copies . length <= 1 ) location . hash = '#library' ; else refresh ( ) ; }
} ;
const lab = el ( 'a' , { href : printLink ( c . barcode , c . barcode ) , target : '_blank' , rel : 'noopener' } , el ( 'button' , { type : 'button' } , 'Print label' ) ) ;
box . append ( el ( 'div' , { className : 'row' } , sel , csel , cn , save , c . barcode ? lab : '' , rm ) ) ;
return box ;
}
// ---- lend / return
const fmtDue = d => d ? new Date ( d + 'T12:00:00' ) . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' , year : 'numeric' } ) : '' ;
let curPatron = null , lendBusy = false ;
function say ( box , kind , text ) { box . replaceChildren ( el ( 'div' , { className : 'card ' + kind } , el ( 'div' , { className : 'body' } , text ) ) ) ; }
async function doReturn ( code , box ) {
box . textContent = 'Returning…' ;
let r ; try { r = await api ( '/api/return' , { barcode : code } ) ; } catch ( e ) { r = { status : 'error' , message : 'Could not reach the service.' } ; }
if ( r . status === 'returned' ) { beep ( SOUND . added ) ; say ( box , 'ok' , 'Returned: ' + ( r . title || code ) + ( r . patron ? ' (from ' + r . patron + ')' : '' ) ) ; }
else if ( r . status === 'not_out' ) { beep ( SOUND . duplicate ) ; say ( box , 'warn' , ( r . title || code ) + ' was not checked out.' ) ; }
else if ( r . status === 'unknown_barcode' ) { beep ( SOUND . notfound ) ; say ( box , 'warn' , 'No book has the barcode ' + code + '.' ) ; }
else { beep ( SOUND . error ) ; say ( box , 'bad' , r . message || 'Could not return that.' ) ; }
loadOut ( ) ; if ( curPatron ) openPatron ( curPatron . id , true ) ;
return r ;
}
$ ( 'retform' ) . onsubmit = async ev => { ev . preventDefault ( ) ; const c = $ ( 'ret' ) . value . trim ( ) ; $ ( 'ret' ) . value = '' ; if ( c ) { await doReturn ( c , $ ( 'retresult' ) ) ; $ ( 'ret' ) . focus ( ) ; } } ;
$ ( 'retlive' ) . hidden = $ ( 'wholive' ) . hidden = ! liveOK ;
$ ( 'retlive' ) . onclick = ( ) => liveScan ( 'sticker' , 'Scan each returned book' , async code => {
const r = await doReturn ( code , $ ( 'retresult' ) ) ; return $ ( 'retresult' ) . innerText ; } , true ) ;
async function pickByCode ( code ) {
const r = await api ( '/api/patrons?q=' + encodeURIComponent ( code ) ) ;
const hit = ( r . patrons || [ ] ) . filter ( p => p . card === code ) ;
if ( hit . length === 1 ) { location . hash = '#lend/' + hit [ 0 ] . id ; return true ; }
return false ;
}
$ ( 'wholive' ) . onclick = ( ) => liveScan ( 'sticker' , 'Hold the library card or the phone QR in the frame' , async code => {
if ( await pickByCode ( code ) ) return ; beep ( SOUND . notfound ) ; $ ( 'whostatus' ) . textContent = 'No library card matches ' + code + '.' ; } , false ) ;
$ ( 'retcam' ) . onclick = async ( ) => {
const codes = await takePhoto ( 'sticker' , $ ( 'retresult' ) ) ;
if ( codes === null ) { $ ( 'retresult' ) . textContent = '' ; return ; }
if ( ! codes . length ) { $ ( 'retresult' ) . textContent = "Couldn't read a barcode from that photo - move closer and try again." ; return ; }
doReturn ( codes [ 0 ] , $ ( 'retresult' ) ) ;
} ;
$ ( 'whocam' ) . onclick = async ( ) => {
const codes = await takePhoto ( 'sticker' , $ ( 'whostatus' ) ) ;
if ( codes === null ) { $ ( 'whostatus' ) . textContent = '' ; return ; }
$ ( 'whostatus' ) . textContent = '' ;
if ( ! codes . length ) { $ ( 'whostatus' ) . textContent = "Couldn't read a code from that photo - move closer and try again." ; return ; }
const r = await api ( '/api/patrons?q=' + encodeURIComponent ( codes [ 0 ] ) ) ;
const hit = ( r . patrons || [ ] ) . filter ( p => p . card === codes [ 0 ] ) ;
if ( hit . length === 1 ) { beep ( SOUND . added ) ; location . hash = '#lend/' + hit [ 0 ] . id ; }
else { beep ( SOUND . notfound ) ; $ ( 'whostatus' ) . textContent = 'No library card matches ' + codes [ 0 ] + '.' ; }
} ;
async function loadOut ( ) {
let r ; try { r = await api ( '/api/out' ) ; } catch ( e ) { return ; }
if ( ! r . loans ) return ;
$ ( 'outcount' ) . textContent = r . loans . length ? '(' + r . loans . length + ')' : '' ;
const late = r . loans . filter ( l => l . overdue ) . length ;
if ( ! r . loans . length ) { $ ( 'outlist' ) . replaceChildren ( el ( 'div' , { className : 'small' } , 'Nothing is checked out.' ) ) ; return ; }
$ ( 'outlist' ) . replaceChildren ( ... ( late ? [ el ( 'div' , { className : 'small' } , late + ' overdue' ) ] : [ ] ) , ... r . loans . map ( l => loanRow ( l , true ) ) ) ;
}
function loanRow ( l , showWho ) {
const ret = el ( 'button' , { type : 'button' } , 'Return' ) ;
ret . onclick = async ( ) => { ret . disabled = true ; await doReturn ( l . barcode , $ ( 'retresult' ) ) ; } ;
const ren = el ( 'button' , { type : 'button' } , 'Renew' ) ;
ren . onclick = async ( ) => {
ren . disabled = true ; const r = await api ( '/api/renew' , { checkout _id : l . checkout _id } ) ;
if ( r . status === 'renewed' ) { loadOut ( ) ; if ( curPatron ) openPatron ( curPatron . id , true ) ; } else { ren . disabled = false ; alert ( r . message ) ; }
} ;
const a = el ( 'a' , { href : '#book/' + l . biblio _id } , l . title ) ;
return el ( 'div' , { className : 'loan' } , el ( 'span' , { className : 'badge' } , l . barcode ) ,
el ( 'span' , { className : 't' } , a , showWho ? el ( 'div' , { className : 'small' } , l . who ) : '' ) ,
el ( 'span' , { className : 'chip ' + ( l . overdue ? 'late' : 'in' ) } , ( l . overdue ? 'Overdue, was due ' : 'Due ' ) + fmtDue ( l . due ) ) , ret , ren ) ;
}
async function openPatron ( id , quiet ) {
let p ; try { p = await api ( '/api/patron/' + id ) ; } catch ( e ) { p = { } ; }
if ( ! p . name ) { $ ( 'patronbox' ) . textContent = 'Could not load that person.' ; return ; }
curPatron = p ;
$ ( 'whoresults' ) . replaceChildren ( ) ;
const scan = el ( 'input' , { placeholder : 'Scan a book to check out' , inputMode : 'numeric' , autocomplete : 'off' , 'aria-label' : 'Barcode to check out' } ) ;
const form = el ( 'form' , { className : 'scan' } , scan , el ( 'button' , { className : 'primary' , type : 'submit' } , 'Check out' ) ) ;
const out = ( quiet && document . getElementById ( 'coresult' ) ) || el ( 'div' , { id : 'coresult' , role : 'status' , className : 'small' , style : 'margin-top:8px' } ) ;
form . onsubmit = async ev => {
ev . preventDefault ( ) ; const code = scan . value . trim ( ) ; scan . value = '' ; if ( ! code || lendBusy ) return ; lendBusy = true ;
try {
const r = await api ( '/api/checkout' , { patron _id : p . id , barcode : code } ) ;
if ( r . status === 'checked_out' ) { beep ( SOUND . added ) ; say ( out , 'ok' , ( r . title || code ) + ' - due ' + fmtDue ( r . due ) ) ; }
else if ( r . status === 'already_out' ) {
beep ( SOUND . duplicate ) ;
const rb = el ( 'button' , { type : 'button' , className : 'primary' } , 'Return it, then check it out' ) ;
rb . onclick = async ( ) => { rb . disabled = true ; await doReturn ( code , out ) ; const r2 = await api ( '/api/checkout' , { patron _id : p . id , barcode : code } ) ;
if ( r2 . status === 'checked_out' ) say ( out , 'ok' , ( r2 . title || code ) + ' - due ' + fmtDue ( r2 . due ) ) ; await openPatron ( p . id , true ) ; loadOut ( ) ; } ;
out . replaceChildren ( el ( 'div' , { className : 'card warn' } , el ( 'div' , { className : 'body' } , ( r . title || code ) + ' is already checked out. ' , rb ) ) ) ;
} else { beep ( SOUND . notfound ) ; say ( out , 'bad' , r . message || 'That did not work.' ) ; }
} catch ( e ) { say ( out , 'bad' , 'Could not reach the service.' ) ; }
lendBusy = false ; await openPatron ( p . id , true ) ; loadOut ( ) ; document . querySelector ( '#patronbox form input' ) . focus ( ) ;
} ;
const bookCam = el ( 'button' , { type : 'button' } , 'Scan book with camera' ) ;
bookCam . onclick = async ( ) => {
const codes = await takePhoto ( 'sticker' , out ) ;
if ( codes === null ) { out . textContent = '' ; return ; }
if ( ! codes . length ) { out . textContent = "Couldn't read a barcode from that photo - move closer and try again." ; return ; }
scan . value = codes [ 0 ] ; form . requestSubmit ( ) ;
} ;
const bookLive = el ( 'button' , { type : 'button' , hidden : ! liveOK } , 'Live scan books' ) ;
bookLive . onclick = ( ) => liveScan ( 'sticker' , 'Scan each book to check out to ' + p . name , async code => {
const r = await api ( '/api/checkout' , { patron _id : p . id , barcode : code } ) ;
openPatron ( p . id , true ) ; loadOut ( ) ;
if ( r . status === 'checked_out' ) return ( r . title || code ) + ' - due ' + fmtDue ( r . due ) ;
beep ( SOUND . notfound ) ; return ( r . title ? r . title + ': ' : '' ) + ( r . message || 'That did not work.' ) ;
} , true ) ;
const loans = p . loans . length ? p . loans . map ( l => loanRow ( l , false ) ) : [ el ( 'div' , { className : 'small' } , 'Nothing checked out to ' + p . name + ' yet.' ) ] ;
$ ( 'patronbox' ) . replaceChildren ( el ( 'div' , { className : 'title' , style : 'margin-top:10px' } , p . name , ' ' , el ( 'span' , { className : 'badge' } , p . card || 'no card' ) ) ,
form , el ( 'div' , { className : 'cam-row' } , bookCam , ' ' , bookLive ) , out , el ( 'h2' , { } , 'Has out' ) , ... loans ,
el ( 'div' , { className : 'small' , style : 'margin-top:8px' } , el ( 'a' , { href : '#card/' + p . id } , 'Print library card' ) , ' · ' ,
el ( 'a' , { href : '#lend' } , 'Choose someone else' ) ) ) ;
if ( ! quiet ) scan . focus ( ) ;
}
async function findPeople ( term , into , withActions ) {
let r ; try { r = await api ( '/api/patrons?q=' + encodeURIComponent ( term ) ) ; } catch ( e ) { r = { } ; }
if ( ! r . patrons ) { into . textContent = ( r && r . message ) || 'Search failed.' ; return [ ] ; }
if ( ! r . patrons . length ) { into . replaceChildren ( el ( 'div' , { className : 'small' } , term ? 'Nobody matches "' + term + '".' : 'No people yet.' ) ) ; return [ ] ; }
into . replaceChildren ( ... r . patrons . map ( p => el ( 'div' , { className : 'person' } ,
el ( 'span' , { className : 't' } , el ( 'strong' , { } , p . name ) , el ( 'div' , { className : 'small' } , [ p . card , p . phone , p . email ] . filter ( Boolean ) . join ( ' · ' ) ) ) ,
... withActions ( p ) ) ) ) ;
return r . patrons ;
}
$ ( 'whoform' ) . onsubmit = async ev => {
ev . preventDefault ( ) ; const term = $ ( 'who' ) . value . trim ( ) ; if ( ! term ) return ;
const list = await findPeople ( term , $ ( 'whoresults' ) , p => { const b = el ( 'button' , { type : 'button' , className : 'primary' } , 'Choose' ) ;
b . onclick = ( ) => { location . hash = '#lend/' + p . id ; } ; return [ b ] ; } ) ;
const exact = list . filter ( p => p . card === term ) ;
if ( exact . length === 1 ) { $ ( 'who' ) . value = '' ; location . hash = '#lend/' + exact [ 0 ] . id ; } // a scanned card goes straight through
} ;
// ---- people
async function loadPeople ( term ) {
await findPeople ( term , $ ( 'peoplelist' ) , p => {
const lend = el ( 'a' , { href : '#lend/' + p . id } , el ( 'button' , { type : 'button' } , 'Lend / returns' ) ) ;
const card = el ( 'a' , { href : '#card/' + p . id } , el ( 'button' , { type : 'button' } , 'Print card' ) ) ;
const reset = el ( 'button' , { type : 'button' } , 'New password' ) ;
reset . onclick = async ( ) => { if ( ! confirm ( 'Give ' + p . name + ' a new sign-in password?' ) ) return ; const r = await api ( '/api/patron/reset-login' , { patron _id : p . id } ) ;
$ ( 'loginresult' ) . replaceChildren ( el ( 'div' , { className : 'result-note' } , r . status === 'saved' ? r . name + ': sign in with card number ' + r . card + ' and password ' : 'Could not do that.' , r . password ? el ( 'strong' , { } , r . password ) : '' ) ) ; } ;
return [ lend , card , reset ] ;
} ) ;
}
$ ( 'alllogins' ) . onclick = async ( ) => {
if ( ! confirm ( 'Create a sign-in for everyone who does not have one yet? You will see each password once.' ) ) return ;
const r = await api ( '/api/logins/all' , { } ) ;
if ( r . status !== 'done' ) { $ ( 'loginresult' ) . textContent = r . message || 'Could not do that.' ; return ; }
$ ( 'loginresult' ) . replaceChildren ( r . made . length ? el ( 'div' , { className : 'result-note' } , el ( 'strong' , { } , r . made . length + ' sign-ins created - write these down or print this page:' ) ,
... r . made . map ( x => el ( 'div' , { } , x . name + ' - card ' + x . card + ' - password ' , el ( 'strong' , { } , x . password ) ) ) ) : el ( 'div' , { className : 'small' } , 'Everyone already has a sign-in.' ) ) ;
} ;
let pwModeLoaded = false ;
async function loadPwSettings ( ) {
if ( pwModeLoaded ) return ; const v = await api ( '/api/settings' ) ; const st = v . settings || { } ; pwModeLoaded = true ;
document . querySelectorAll ( 'input[name=pwmode]' ) . forEach ( i => { i . checked = i . value === ( st . patron _password _mode || 'generated' ) ; } ) ;
$ ( 'pwsame' ) . value = st . patron _password || '' ;
}
$ ( 'pwsave' ) . onclick = async ( ) => {
const mode = document . querySelector ( 'input[name=pwmode]:checked' ) . value ;
const r = await api ( '/api/settings' , { patron _password _mode : mode , patron _password : $ ( 'pwsame' ) . value } ) ;
$ ( 'pwsaved' ) . textContent = r . settings && r . settings . patron _password _mode === mode ? 'Saved ✓' + ( mode === 'same' && ! r . settings . patron _password ? ' (enter a password of 4+ characters)' : '' ) : 'Could not save.' ;
} ;
$ ( 'peopleform' ) . onsubmit = ev => { ev . preventDefault ( ) ; loadPeople ( $ ( 'pq' ) . value . trim ( ) ) ; } ;
$ ( 'personform' ) . onsubmit = async ev => {
ev . preventDefault ( ) ;
const f = ev . target , confirm = f . dataset . confirm === '1' ; delete f . dataset . confirm ;
const r = await api ( '/api/patron/add' , Object . assign ( Object . fromEntries ( new FormData ( f ) ) , confirm ? { confirm : true } : { } ) ) ;
if ( r . status === 'added' ) {
f . reset ( ) ;
$ ( 'personresult' ) . replaceChildren ( el ( 'div' , { className : 'result-note' } , r . name + ' added with library card number ' , el ( 'span' , { className : 'badge' } , r . card ) , r . password ? ' (sign-in: card number + password ' : '' , r . password ? el ( 'strong' , { } , r . password ) : '' , r . password ? ')' : '' , '. ' ,
el ( 'a' , { href : '#card/' + r . id } , 'Print the card' ) , ' or ' , el ( 'a' , { href : '#lend/' + r . id } , 'check something out' ) ) ) ;
loadPeople ( '' ) ;
} else if ( r . status === 'duplicate' ) {
const again = el ( 'button' , { type : 'button' , className : 'primary' } , 'Add another person with this name' ) ;
again . onclick = ( ) => { f . requestSubmit && ( f . dataset . confirm = '1' ) ; f . requestSubmit ( ) ; } ;
$ ( 'personresult' ) . replaceChildren ( el ( 'div' , { className : 'card warn' } , el ( 'div' , { className : 'body' } , r . message + ' ' , again ) ) ) ;
} else $ ( 'personresult' ) . replaceChildren ( el ( 'div' , { className : 'card bad' } , el ( 'div' , { className : 'body' } , r . message || 'Could not add that person.' ) ) ) ;
} ;
// ---- printable library card (Code 39 barcode drawn as SVG)
const C39 = { '0' : 'nnnwwnwnn' , '1' : 'wnnwnnnnw' , '2' : 'nnwwnnnnw' , '3' : 'wnwwnnnnn' , '4' : 'nnnwwnnnw' , '5' : 'wnnwwnnnn' ,
'6' : 'nnwwwnnnn' , '7' : 'nnnwnnwnw' , '8' : 'wnnwnnwnn' , '9' : 'nnwwnnwnn' , '*' : 'nwnnwnwnn' } ;
function code39 ( text ) {
const W = 3 ; let x = 10 , bars = '' ;
for ( const ch of '*' + text + '*' ) {
const pat = C39 [ ch ] ; if ( ! pat ) continue ;
for ( let i = 0 ; i < 9 ; i ++ ) { const w = pat [ i ] === 'w' ? W : 1 ; if ( i % 2 === 0 ) bars += '<rect x="' + x + '" y="0" width="' + w + '" height="50"/>' ; x += w ; }
x += 1 ; // narrow gap between characters
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + ( x + 9 ) + ' 50" preserveAspectRatio="none" fill="#000" role="img" aria-label="Barcode ' + text + '">' + bars + '</svg>' ;
}
function cardEl ( p ) {
const svg = el ( 'div' , { className : 'bc' } ) ;
svg . innerHTML = code39 ( p . card ) ;
const qr = el ( 'img' , { className : 'qr' , alt : 'QR code ' + p . card , src : 'api/qr?t=' + encodeURIComponent ( p . card ) } ) ;
return el ( 'div' , { className : 'libcard' } , el ( 'div' , { className : 'lib' } , libraryDisplay || 'Library card' ) , el ( 'div' , { className : 'nm' } , p . name ) ,
el ( 'div' , { } , el ( 'div' , { className : 'codes' } , svg , qr ) , el ( 'div' , { className : 'no' } , p . card ) ) ) ;
}
async function loadCard ( id ) {
let p ; try { p = await api ( '/api/patron/' + id ) ; } catch ( e ) { p = { } ; }
if ( ! p . name ) { $ ( 'cardbox' ) . textContent = 'Could not load that person.' ; return ; }
curCard = p ;
$ ( 'cardbox' ) . replaceChildren ( cardEl ( p ) ) ;
}
let curCard = null ;
async function loadCards ( ) {
let r ; try { r = await api ( '/api/patrons?q=' + encodeURIComponent ( ( $ ( 'pq' ) . value || '' ) . trim ( ) ) ) ; } catch ( e ) { r = { } ; }
const list = ( r . patrons || [ ] ) . filter ( p => p . card ) ;
$ ( 'cardsheet' ) . replaceChildren ( ... list . map ( cardEl ) ) ;
$ ( 'cardsnote' ) . textContent = list . length ? list . length + ' card' + ( list . length === 1 ? '' : 's' ) + ( ( $ ( 'pq' ) . value || '' ) . trim ( ) ? ' (matching your search)' : '' ) : 'Nobody to print yet.' ;
}
$ ( 'printcards' ) . onclick = ( ) => window . print ( ) ;
// A picture of the QR (name + number under it) the person can keep on their phone and hold up to be scanned.
$ ( 'savecard' ) . onclick = async ( ) => {
if ( ! curCard ) return ;
const svgText = await ( await fetch ( 'api/qr?t=' + encodeURIComponent ( curCard . card ) , { headers : { 'X-Quickadd' : '1' } } ) ) . text ( ) ;
const img = new Image ( ) ; await new Promise ( ( ok , no ) => { img . onload = ok ; img . onerror = no ; img . src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent ( svgText ) ; } ) ;
const c = document . createElement ( 'canvas' ) ; c . width = 640 ; c . height = 780 ; const g = c . getContext ( '2d' ) ;
g . fillStyle = '#fff' ; g . fillRect ( 0 , 0 , c . width , c . height ) ; g . fillStyle = '#000' ; g . textAlign = 'center' ;
g . font = 'bold 34px sans-serif' ; g . fillText ( libraryDisplay || 'Library card' , 320 , 60 ) ;
g . imageSmoothingEnabled = false ; g . drawImage ( img , 70 , 90 , 500 , 500 ) ;
g . font = 'bold 40px sans-serif' ; g . fillText ( curCard . name , 320 , 650 ) ; g . font = '34px monospace' ; g . fillText ( curCard . card , 320 , 705 ) ;
const a = document . createElement ( 'a' ) ; a . href = c . toDataURL ( 'image/png' ) ; a . download = 'library-card-' + curCard . card + '.png' ; a . click ( ) ;
} ;
$ ( 'printcard' ) . onclick = ( ) => window . print ( ) ;
async function loadMine ( ) {
let r ; try { r = await api ( '/api/me' ) ; } catch ( e ) { r = { } ; }
if ( ! r . loans ) { $ ( 'meloans' ) . textContent = r . message || 'Could not load your books.' ; return ; }
$ ( 'mename' ) . textContent = r . name ; $ ( 'mecard' ) . textContent = r . card ;
const today = new Date ( ) ; today . setHours ( 12 , 0 , 0 , 0 ) ;
const rows = r . loans . map ( l => {
const days = Math . round ( ( new Date ( l . due + 'T12:00:00' ) - today ) / 86400000 ) ;
const when = l . overdue ? 'Overdue by ' + ( - days ) + ( days === - 1 ? ' day' : ' days' ) : days === 0 ? 'Due today' : 'Due ' + fmtDue ( l . due ) + ' (in ' + days + ( days === 1 ? ' day)' : ' days)' ) ;
const ren = el ( 'button' , { type : 'button' } , 'Renew' ) ;
ren . onclick = async ( ) => { ren . disabled = true ; const x = await api ( '/api/me/renew' , { checkout _id : l . checkout _id } ) ;
if ( x . status === 'renewed' ) loadMine ( ) ; else { ren . disabled = false ; alert ( x . message || 'That one cannot be renewed.' ) ; } } ;
return el ( 'div' , { className : 'loan' } , el ( 'span' , { className : 't' } , el ( 'a' , { href : '#book/' + l . biblio _id } , l . title ) , el ( 'div' , { className : 'small' } , l . author ) ) ,
el ( 'span' , { className : 'chip ' + ( l . overdue ? 'late' : 'in' ) } , when ) , ren ) ;
} ) ;
$ ( 'meloans' ) . replaceChildren ( ... ( rows . length ? rows : [ el ( 'div' , { className : 'card ok' } , el ( 'div' , { className : 'body' } , 'You have nothing checked out right now.' ) ) ] ) ) ;
}
$ ( 'mepwform' ) . onsubmit = async ev => {
ev . preventDefault ( ) ; const f = ev . target ; const r = await api ( '/api/me/password' , Object . fromEntries ( new FormData ( f ) ) ) ;
$ ( 'mepwresult' ) . textContent = r . status === 'saved' ? 'Password changed. Next time your browser asks, use the new one.' : ( r . message || 'Could not change it.' ) ;
if ( r . status === 'saved' ) f . reset ( ) ;
} ;
function route ( ) {
const h = location . hash . replace ( /^#/ , '' ) ;
let m , view = 'library' ;
if ( ( m = h . match ( /^book\/(\d+)$/ ) ) ) view = 'book' ;
else if ( h === 'mine' && isPatron ) view = 'mine' ;
else if ( viewer ) view = 'library' ;
else if ( h === 'add' ) view = 'add' ;
else if ( ( m = h . match ( /^lend(?:\/(\d+))?$/ ) ) ) view = 'lend' ;
else if ( h === 'people' ) view = 'people' ;
else if ( ( m = h . match ( /^card\/(\d+)$/ ) ) ) view = 'card' ;
else if ( h === 'cards' ) view = 'cards' ;
for ( const v of [ 'library' , 'book' , 'add' , 'lend' , 'people' , 'card' , 'cards' , 'mine' ] ) $ ( 'view-' + v ) . hidden = v !== view ;
const tab = { library : 'library' , book : 'library' , add : 'add' , lend : 'lend' , people : 'people' , card : 'people' , cards : 'people' , mine : 'mine' } [ view ] ;
for ( const t of [ 'library' , 'add' , 'lend' , 'people' , 'mine' ] ) $ ( 'tab-' + t ) . classList . toggle ( 'on' , t === tab ) ;
$ ( 'tab-mine' ) . hidden = ! isPatron ;
for ( const t of [ 'add' , 'lend' , 'people' ] ) $ ( 'tab-' + t ) . hidden = viewer ;
$ ( 'csvtools' ) . hidden = viewer ;
const names = { library : 'The library' , book : 'The library' , add : 'Add books' , lend : 'Lend / return' , people : 'People' , card : 'Library card' , cards : 'Library cards' , mine : 'My books' } ;
document . title = names [ view ] + ' - Room for Books' ;
if ( view === 'book' ) loadBook ( m [ 1 ] ) ;
else if ( view === 'mine' ) loadMine ( ) ;
else if ( view === 'add' ) $ ( 'code' ) . focus ( ) ;
else if ( view === 'lend' ) { loadOut ( ) ; curPatron = null ; $ ( 'patronbox' ) . replaceChildren ( ) ;
if ( m && m [ 1 ] ) openPatron ( m [ 1 ] ) ; else { $ ( 'whoresults' ) . replaceChildren ( ) ; $ ( 'who' ) . focus ( ) ; } }
else if ( view === 'people' ) { loadPeople ( $ ( 'pq' ) . value . trim ( ) ) ; loadPwSettings ( ) ; }
else if ( view === 'card' ) loadCard ( m [ 1 ] ) ;
else if ( view === 'cards' ) loadCards ( ) ;
else { if ( ! libLoaded ) { libLoaded = true ; loadLibrary ( true ) ; } if ( h === 'find' ) $ ( 'q' ) . focus ( ) ; }
}
window . addEventListener ( 'hashchange' , route ) ;
let libraryDisplay = '' ;
( async ( ) => {
const s = await api ( '/api/state' ) ;
viewer = s . role === 'viewer ' ;
viewer = s . role !== 'admin' ; isPatron = s . role === 'patron ' ;
types = s . types || { } ; if ( ! types [ type ] ) type = 'BOOK' ; if ( ! viewer ) typeButtons ( ) ;
staffPort = s . staff _port ; settings = s . settings || { } ; shelf = settings . location || '' ;
staffBase = s . staff _url || '' ;
@@ -512,7 +1234,8 @@
}
if ( ! viewer ) api ( '/api/settings' ) . then ( applySettings ) ;
$ ( 'earlier' ) . replaceChildren ( ... s . recent . slice ( 0 , 25 ) . map ( e => rowFor ( e , false ) ) ) ;
showView ( ) ;
libraryDisplay = s . library _name || '' ;
route ( ) ;
} ) ( ) ;
} ) ( ) ;
< / script >