1// js/views/plexus-awards.js â the Plexus Week hub's AWARDS tab (design/AWARDS-SPEC.md §Admin). 2// 3// No artboard source; built from the hub's own vocabulary â the blockStats() stat strip, the 4// money.js table helpers and the studio.js tool-drawer â so it reads as one screen with the 5// MEETUPS tab beside it. 6// 7// WHY ITS OWN MODULE: js/views/plexus.js is already 1.9k lines and owns four screens. The awards 8// tab is a fifth, with its own data, its own drawer and its own eight handlers, so it lives here 9// and plexus.js delegates: one import, one line in TAB_ORDER, one branch in template(), and the 10// handler map merged into the hub's. Everything shared â the edition chip, the read-only rule, 11// the tab strip â stays exactly where it already was. 12// 13// Permission section `plexus-awards` (server.js mirrors the id and maps /api/v2/awards-ops to 14// it); without it the tab renders the locked state, the same way MEETUPS does. 15import { api } from '../api.js'; 16import { session } from '../state.js'; 17import { ui, esc, fmt } from '../ui.js'; 18import { perms } from '../perms.js'; 19 20export const AW_SECTION = 'plexus-awards'; 21 22// ---- COPY: every string that may change in a revision ------------------------------------------ 23export const COPY_AW = { 24 tab: 'AWARDS', tabLocked: 'AWARDS · LOCKED', 25 locked: 'The awards are locked for you.', 26 stats: { 27 entries: 'ENTRIES', entriesSub: n => `${n} candidate${n === 1 ? '' : 's'} after grouping`, 28 shortlist: 'SHORTLISTED', shortlistSub: 'across all four awards', 29 laureates: 'LAUREATES', laureatesSub: (n, m) => `${n} chosen · ${m} seat${m === 1 ? '' : 's'} on the gala list`, 30 panel: 'PANEL READ', panelSub: n => `${n} reader${n === 1 ? '' : 's'} on the panel`, 31 held: 'HELD FOR REVIEW', heldSub: 'waiting on your approve/reject email' 32 }, 33 cats: { 34 title: 'THE FOUR AWARDS', sub: 'nominations, the reading panel, and who takes the stage', 35 windows: { none: 'ORGANIZERS CHOOSE', before: 'NOT OPEN YET', open: 'OPEN', closed: 'CLOSED' }, 36 closes: n => n == null ? '' : (n < 0 ? 'closed' : n === 0 ? 'closes today' : `${n} day${n === 1 ? '' : 's'} to close`), 37 opensOn: (a, b) => `opens ${a}${b ? ' · closes ' + b : ''}`, closedOn: d => `closed ${d}`, 38 counts: (e, c) => `${e} entr${e === 1 ? 'y' : 'ies'} · ${c} candidate${c === 1 ? '' : 's'}`, 39 open: 'OPEN', ranking: 'RANKING', settings: 'SETTINGS', add: 'ADD LAUREATE', notify: 'DECIDE & NOTIFY', csv: 'CSV', 40 empty: 'No awards on this edition yet â they seed themselves on the next boot.' 41 }, 42 panel: { 43 title: 'ENTRIES', back: 'â ALL FOUR AWARDS', 44 filterAll: 'ALL', search: 'Search a name, an institutionâ¦', 45 head: { name: 'CANDIDATE', who: 'WHO PUT THEM FORWARD', inst: 'INSTITUTION', score: 'MEAN', n: 'READ', status: 'STATUS' }, 46 noms: n => n === 1 ? '1 nomination' : `${n} nominations`, 47 self: 'SELF', merged: 'MERGED', 48 empty: 'Nothing here yet.', 49 emptyWhy: 'Entries appear as people send them â the public page is the only way in.' 50 }, 51 acts: { 52 open: 'OPEN', eligible: 'ELIGIBLE', ineligible: 'NOT ELIGIBLE', shortlist: 'SHORTLIST', 53 winner: 'CHOOSE AS WINNER', decline: 'DECLINE', merge: 'MERGE', pdf: 'PDF', note: 'SAVE NOTE', close: 'CLOSE' 54 }, 55 status: { 56 received: 'RECEIVED', eligible: 'ELIGIBLE', ineligible: 'NOT ELIGIBLE', shortlisted: 'SHORTLISTED', 57 winner: 'WINNER', declined: 'DECLINED', withdrawn: 'WITHDRAWN', 'pending-review': 'HELD' 58 }, 59 drawer: { 60 entry: 'THE ENTRY', scores: 'WHAT THE PANEL SAID', nominators: 'PUT FORWARD BY', notes: 'YOUR NOTE', 61 noScores: 'Nobody has read this one yet.', 62 conflictNote: n => n ? `${n} reader${n === 1 ? '' : 's'} declared a conflict â left out of every number.` : '', 63 mean: (m, n) => m == null ? 'not scored' : `${m} mean · ${n} reader${n === 1 ? '' : 's'}`, 64 spread: s => s == null ? '' : `spread ${s}`, 65 saved: 'NOTE SAVED', statusDone: s => `MOVED TO ${s}`, mergeAsk: (a, b) => `Merge â${a}â into â${b}â? Their nominations become nominations of the surviving name â nothing is deleted.`, 66 mergeOk: 'MERGE', mergeKeep: 'KEEP BOTH', mergePick: 'PICK THE SURVIVING CANDIDATE FIRST', 67 pdfMissing: 'That attachment is not on file storage â nothing to download.' 68 }, 69 rank: { 70 title: 'RANKING', sub: 'mean of every score, conflicts excluded',
71 head: { rank: '#', name: 'CANDIDATE', mean: 'MEAN', spread: 'SPREAD', n: 'READ', conf: 'CONFLICTS' }, 72 empty: 'Nothing to rank yet â the panel has not scored anything in this award.', 73 note: 'A conflict-flagged score counts for nothing: not in the mean, not in the spread, not in n.', 74 winner: 'WINNER', shortlist: 'SHORTLIST' 75 }, 76 reviewers: { 77 title: 'THE READING PANEL', sub: 'a handful of people, one link each, no login', 78 add: '+ ADD A READER', name: 'Name', email: 'Email', cats: 'WHICH AWARDS', 79 send: 'ADD & SEND THE INVITATION', sent: 'INVITATION SENT', 80 resend: 'RESEND', revoke: 'REVOKE', copy: 'COPY LINK', copied: 'LINK COPIED', 81 progress: (a, b) => `${a} of ${b} read`, 82 empty: 'Nobody on the panel yet.', 83 emptyWhy: 'Add two or three people you trust to read. Each gets one private link â no account, no password.', 84 revokeAsk: n => `Revoke ${n}? Their link stops working at once; the scores they already gave stay in the ranking.`, 85 revokeOk: 'REVOKE', revokeKeep: 'KEEP', 86 nameFirst: 'A NAME AND AN EMAIL FIRST', catFirst: 'PICK AT LEAST ONE AWARD', 87 statusTag: { invited: 'INVITED', active: 'READING', revoked: 'REVOKED' } 88 }, 89 notify: { 90 title: 'DECIDE & NOTIFY', sub: 'every letter waits in the Outbox for your approval', 91 winners: 'Winners', shortlisted: 'Shortlisted, not selected', declined: 'Everyone else, kindly', nominators: 'Thank-you to nominators', 92 preview: 'PREVIEW', stage: 'STAGE THEM IN THE OUTBOX', 93 staged: n => `${n} LETTER${n === 1 ? '' : 'S'} WAITING IN THE OUTBOX â NOTHING HAS BEEN SENT`, 94 none: 'NOTHING TO STAGE â CHOOSE A WINNER FIRST', 95 note: 'Nothing leaves Med&X from this screen. The drafts land under Inbox â Email & Outbox, and only APPROVE & SEND there puts them on the wire.' 96 }, 97 laureate: { 98 title: 'ADD A LAUREATE', sub: 'the Lifetime Bridge is chosen here, not nominated', 99 name: 'Full name', inst: 'Institution', cite: 'Citation (read out at the Gala)', photo: 'Photo URL', email: 'Email (for the letter)', 100 save: 'ADD THE LAUREATE', added: 'LAUREATE ADDED', nameFirst: 'TYPE THE NAME FIRST', 101 remove: 'REMOVE', removeAsk: n => `Remove ${n} from the laureates? The entry itself stays exactly as it is.`, 102 removeOk: 'REMOVE', removeKeep: 'KEEP', removed: 'LAUREATE REMOVED', 103 presents: 'SPEAKS', confirmed: 'CONFIRMED', unconfirmed: 'NOT CONFIRMED', slides: 'SLIDES ON FILE', noSlides: 'NO SLIDES', 104 seat: 'GALA SEAT · COMP', noSeat: 'NO SEAT YET' 105 }, 106 settings: { 107 title: 'SETTINGS', sub: 'names, criteria, dates and the rubric â all live', 108 name: 'Name (English)', nameHr: 'Naziv (hrvatski)', cite: 'Citation', citeHr: 'Obrazloženje (hrvatski)', 109 opens: 'Opens (Zagreb time)', closes: 'Closes (Zagreb time)', max: 'How many laureates', 110 self: 'Self-nominations allowed', crit: 'Criteria (English)', critHr: 'Kriteriji (hrvatski)', 111 save: 'SAVE', saved: 'SETTINGS SAVED â THE PUBLIC PAGE READS THEM LIVE' 112 }, 113 roster: { title: 'GALA ROSTER', csv: 'ROSTER CSV', sheet: 'PRINTABLE ONE-PAGER', sub: 'for the seating team and the MC' }, 114 err: 'Could not load the awards.' 115}; 116 117// ---- the hub's shared inline vocabulary (same values js/views/plexus.js uses) ------------------- 118const HAIR = 'rgba(32,27,22,.14)', HAIR12 = 'rgba(32,27,22,.12)', HAIR07 = 'rgba(32,27,22,.07)'; 119const MICRO = 'font:600 9px Inter,sans-serif;letter-spacing:.12em;color:#6d6459'; 120const BTN_GHOST = 'padding:7px 11px;border:1px solid rgba(32,27,22,.2);font:600 9px Inter,sans-serif;letter-spacing:.12em;cursor:pointer;color:#201b16;white-space:nowrap'; 121const INPUT2 = 'border:1px solid rgba(32,27,22,.25);background:#f6f2ea;padding:8px 10px;font:400 12.5px Inter,sans-serif;color:#201b16'; 122const TONE = { 123 received: ['#f6f2ea', '#6d6459'], eligible: ['#f8f1e2', '#7a6432'], ineligible: ['#f6f2ea', '#9a9086'], 124 shortlisted: ['#1f4f7a', '#fff'], winner: ['#1e6e42', '#fff'], declined: ['#f6f2ea', '#9a9086'], 125 withdrawn: ['#f6f2ea', '#9a9086'], 'pending-review': ['#9b1b22', '#fff'] 126}; 127 128function tbl(headers, bodyRows, minWidth) { 129 return ` 130 <div class="mxp-scroll" style="overflow-x:auto"> 131 <table style="width:100%;border-collapse:collapse;min-width:${minWidth || 900}px"> 132 <thead><tr>${headers.map(h => `<th style="text-align:${h.r ? 'right' : 'left'};padding:9px 10px;${MICRO};border-bottom:1px solid ${HAIR12};white-space:nowrap">${h.t}</th>`).join('')}</tr></thead> 133 <tbody>${bodyRows}</tbody> 134 </table> 135 </div>`; 136} 137const td = (v, extra) => `<td style="font-size:12.5px;padding:9px 10px;border-bottom:1px solid ${HAIR07};vertical-align:top;${extra || ''}">${v}</td>`;
138const tdNum = v => td(`<span style="font-family:Fraunces,serif;font-size:14px;white-space:nowrap">${v}</span>`, 'text-align:right'); 139const tdActs = acts => td(`<span style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">${acts}</span>`, 'text-align:right'); 140const act = (a, id, label, extra) => `<span data-act="${a}" data-id="${esc(id)}"${extra || ''} style="${BTN_GHOST}" data-hover="border-color:#201b16">${label}</span>`; 141const chip = (text, bg, fg) => `<span style="background:${bg};color:${fg};font:600 8.5px Inter,sans-serif;letter-spacing:.12em;padding:3px 7px;white-space:nowrap">${esc(text)}</span>`; 142const fLab = t => `<span style="${MICRO}">${t}</span>`; 143const fText = (role, label, value, ph, span) => 144 `<label style="display:flex;flex-direction:column;gap:5px;min-width:0${span ? ';grid-column:1 / -1' : ''}">${fLab(esc(label))}<input data-role="${role}" type="text" value="${esc(value == null ? '' : value)}" placeholder="${esc(ph || '')}" aria-label="${esc(label)}" style="width:100%;box-sizing:border-box;${INPUT2}"></label>`; 145const fArea = (role, label, value, rows) => 146 `<label style="display:flex;flex-direction:column;gap:5px;min-width:0;grid-column:1 / -1">${fLab(esc(label))}<textarea data-role="${role}" rows="${rows || 4}" aria-label="${esc(label)}" style="width:100%;box-sizing:border-box;resize:vertical;${INPUT2}">${esc(value == null ? '' : value)}</textarea></label>`; 147 148async function fetchBlob(path) { 149 const res = await fetch(api.url(path), { headers: { Authorization: 'Bearer ' + session.token } }); 150 if (!res.ok) { let j = null; try { j = JSON.parse(await res.text()); } catch (e) {} throw new Error((j && (j.message || j.error)) || ('The export failed (HTTP ' + res.status + ').')); } 151 return res.blob(); 152} 153const dl = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
153 a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); }; 154// Same anchor trick for a URL the server already signed. The presigned S3 link carries 155// Content-Disposition: attachment, so this downloads instead of navigating away. 156const dlHref = (href, name) => { const a = document.createElement('a'); a.href = href; if (name) a.download = name; a.rel = 'noopener'; a.click(); }; 157 158// ---- state -------------------------------------------------------------------------------------- 159// H is the host contract js/views/plexus.js hands us once per render: how to repaint, which 160// edition is chosen, and whether that edition is read-only. Nothing else crosses the line. 161let H = null, A = null, S = null; 162 163export function initAwards(host) { 164 H = host; 165 S = { cat: null, data: null, busy: false, filter: '', term: '', drawer: null, entry: null, rank: null, revs: null, notify: null, mergeFrom: null }; 166} 167export function setAwardsData(d) { A = d || null; } 168export const awardsLoaded = () => !!A; 169export const canAwards = () => perms.can(AW_SECTION); 170const readOnly = () => !!(H && H.readOnly && H.readOnly()) || !!(A && A.read_only); 171const cats = () => (A && Array.isArray(A.categories)) ? A.categories : []; 172const catById = (id) => cats().find(c => String(c.id) === String(id)) || null; 173const stats = () => (A && A.stats) || {}; 174const reviewers = () => (A && Array.isArray(A.reviewers)) ? A.reviewers : []; 175const opsUrl = (tail) => '/api/v2/awards-ops' + tail; 176const edQ = () => { const id = H && H.editionId && H.editionId(); return id ? '?edition=' + encodeURIComponent(id) : ''; }; 177 178// ---- data --------------------------------------------------------------------------------------- 179export function loadAwards(editionId) { 180 return api.get(opsUrl('/overview') + (editionId ? '?edition=' + encodeURIComponent(editionId) : '')); 181} 182async function reloadOverview() { 183 try { A = await loadAwards(H && H.editionId && H.editionId()); } catch (e) { ui.toast(e.message, { kind: 'error' }); } 184} 185async function reloadCategory(id) { 186 S.busy = true; 187 paintPanel(); 188 try { S.data = await api.get(opsUrl('/categories/' + encodeURIComponent(id) + '/entries')); } 189 catch (e) { ui.toast(e.message, { kind: 'error' }); S.data = { candidates: [], laureates: [], withdrawn: [] }; } 190 S.busy = false; 191 paintPanel(); 192} 193function paintAll() { if (H && H.paint) H.paint(); } 194function paintPanel() { if (H && H.paintPart) H.paintPart('[data-block="awBody"]', blockAwBody()); } 195function paintDrawer() { if (H && H.paintPart) H.paintPart('[data-block="awDrawer"]', blockAwDrawer()); } 196 197// ================================================================ BLOCKS 198function blockAwStats() { 199 const s = stats(), c = COPY_AW.stats; 200 const seats = cats().reduce((n, x) => n + (x.laureate_rows || []).filter(l => l.gala_registration_id).length, 0); 201 const cell = (k, v, sub, last) => ` 202 <div style="padding:16px 20px;${last ? '' : 'border-right:1px solid rgba(32,27,22,.1)'}"> 203 <div style="font:600 9px Inter,sans-serif;letter-spacing:.15em;color:#6d6459">${k}</div>
204 <div class="mx-display-30" style="font-family:Fraunces,serif;font-size:30px;margin-top:3px">${esc(v)}</div> 205 <div style="font-size:11px;color:${sub && /HELD/.test(k) && Number(s.pending_review) ? '#9b1b22' : '#6d6459'}">${esc(sub)}</div> 206 </div>`; 207 return ` 208 <!-- v2: "AWARDS â stat strip" (the Plexus hub stat-strip markup, five cells) --> 209 <div data-block="awStats" data-v2="awards stats" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 210 <div class="mx-kpi" style="display:grid;grid-template-columns:repeat(5,1fr)"> 211 ${cell(c.entries, fmt.num(s.entries || 0), c.entriesSub(s.candidates || 0))} 212 ${cell(c.shortlist, fmt.num(s.shortlisted || 0), c.shortlistSub)} 213 ${cell(c.laureates, fmt.num(s.laureates || 0), c.laureatesSub(s.winners || 0, seats))} 214 ${cell(c.panel, (s.reviewer_percent || 0) + '%', c.panelSub(s.reviewers || 0))} 215 ${cell(c.held, fmt.num(s.pending_review || 0), c.heldSub, true)} 216 </div> 217 </div> 218 <!-- /v2 -->`; 219} 220 221function catRow(c) { 222 const t = COPY_AW.cats; 223 const ro = readOnly(); 224 const wTone = { none: ['#f6f2ea', '#6d6459'], before: ['#f8f1e2', '#7a6432'], open: ['#1e6e42', '#fff'], closed: ['#f6f2ea', '#9a9086'] }[c.window] || ['#f6f2ea', '#6d6459']; 225 const laur = (c.laureate_rows || []); 226 // two fixed rows: the working buttons, then CSV · SETTINGS as quiet text links â six ghost buttons in one 227 // cell wrapped 3 + 3 on one award and 4 + 1 on the next, SETTINGS alone on its own line 228 const quiet = (a, label) => `<span data-act="${a}" data-id="${esc(c.id)}" style="font:600 8.5px Inter,sans-serif;letter-spacing:.13em;color:#6d6459;cursor:pointer;white-space:nowrap" data-hover="color:#201b16">${label}</span>`; 229 const main = [ 230 act('awOpen', c.id, t.open), 231 act('awRank', c.id, t.ranking), 232 c.intake === 'none' && !ro ? act('awLaureate', c.id, t.add) : '', 233 ro ? '' : act('awNotify', c.id, t.notify)
234 ].filter(Boolean).join(''); 235 const links = [quiet('awCsv', t.csv), ro ? '' : quiet('awSettings', t.settings)].filter(Boolean).join(''); 236 const acts = `<span style="display:flex;flex-direction:column;gap:9px;align-items:flex-end"><span class="mxp-aw-acts" style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:nowrap">${main}</span><span style="display:flex;gap:16px;justify-content:flex-end">${links}</span></span>`; 237 const day = v => fmt.dayShort(String(v || '').slice(0, 10)); 238 const when = c.window === 'open' ? t.closes(c.days_to_close) 239 : c.window === 'before' && c.opens_at ? t.opensOn(day(c.opens_at), c.closes_at ? day(c.closes_at) : '') 240 : c.window === 'closed' && c.closes_at ? t.closedOn(day(c.closes_at)) : ''; 241 return ` 242 <tr data-row="aw-${esc(c.id)}"> 243 ${td(`<span style="display:flex;flex-direction:column;gap:3px;min-width:0"> 244 <span style="font-size:13px;font-weight:600">${esc(c.name)}</span> 245 <span style="font-size:11.5px;color:#6d6459">${esc(c.citation || '')}</span> 246 ${laur.length ? `<span style="display:flex;gap:7px;flex-wrap:wrap;margin-top:3px">${laur.map(l => chip(l.name, '#1e6e42', '#fff')).join('')}</span>` : ''} 247 </span>`, 'min-width:280px')} 248 ${td(`<span style="display:flex;flex-direction:column;gap:3px">${chip(t.windows[c.window] || String(c.window || '').toUpperCase(), wTone[0], wTone[1])} 249 <span style="font-size:11px;color:#6d6459;white-space:nowrap">${esc(when)}</span></span>`)} 250 ${td(`<span style="font-size:12px;color:#6d6459">${esc(t.counts(c.counts.entries, c.counts.candidates))}</span>`)} 251 ${tdNum(c.counts.shortlisted || '<span style="color:#9a9086">â</span>')} 252 ${tdNum(c.laureates ? `${c.laureates} / ${c.laureates_max}` : `<span style="color:#9a9086">0 / ${c.laureates_max}</span>`)} 253 ${td(c.counts.pending_review ? chip(String(c.counts.pending_review) + ' HELD', '#9b1b22', '#fff') : '<span style="color:#9a9086">â</span>')} 254 ${tdActs(acts)} 255 </tr>`; 256} 257 258function blockAwCats() { 259 const t = COPY_AW.cats; 260 const rows = cats(); 261 const headers = [{ t: 'AWARD' }, { t: 'INTAKE' }, { t: 'ENTRIES' }, { t: 'SHORT', r: 1 }, { t: 'LAUREATES', r: 1 }, { t: 'HELD' }, { t: '', r: 1 }]; 262 return ` 263 <div data-block="awCats" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 264 <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 265 <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${t.title}</span> 266 <span style="font-size:11.5px;color:#9a9086">${t.sub}</span> 267 <div style="flex:1"></div> 268 ${act('awReviewers', 'panel', COPY_AW.reviewers.title)} 269 ${act('awRoster', 'roster', COPY_AW.roster.csv)} 270 ${act('awOnePager', 'sheet', COPY_AW.roster.sheet)} 271 </div> 272 ${rows.length ? tbl(headers, rows.map(catRow).join(''), 900) /* 900: below 1180 the actions sit 2 Ã 2 (plexus-hub.css), so the table fits the card down to ~960 px; wider, it fills the card */ 273 : `<div class="empty" style="padding:30px 20px"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${t.empty}</span></div>`} 274 </div>`; 275} 276 277// ---- one award opened ----------------------------------------------------------------------- 278function candRow(c) { 279 const p = COPY_AW.panel; 280 const tone = TONE[c.status] || TONE.received; 281 const who = c.self_nominated && !c.nominators.length 282 ? p.self 283 : (c.nominators.length ? c.nominators.map(n => n.name || n.email).filter(Boolean).slice(0, 3).join(', ') + (c.nominators.length > 3 ? ` +${c.nominators.length - 3}` : '') : 'â'); 284 return ` 285 <tr data-row="awc-${esc(c.key)}"> 286 ${td(`<span style="display:flex;flex-direction:column;gap:2px;min-width:0"> 287 <span style="font-size:13px;font-weight:600">${esc(c.name)}</span> 288 <span style="display:flex;gap:8px;flex-wrap:wrap;align-items:baseline">
289 <span style="${MICRO};color:#c9a962;white-space:nowrap">${esc(p.noms(c.nominations))}</span> 290 ${c.self_nominated ? `<span style="${MICRO};color:#9a9086">${p.self}</span>` : ''} 291 ${c.email ? `<span style="font-size:11px;color:#6d6459">${esc(c.email)}</span>` : ''} 292 </span></span>`, 'min-width:230px')} 293 ${td(`<span style="font-size:12px;color:#6d6459">${esc(who)}</span>`)} 294 ${td(`<span style="font-size:12px">${esc(c.institution || 'â')}</span>`)} 295 ${tdNum(c.scores.mean == null ? '<span style="color:#9a9086">â</span>' : esc(String(c.scores.mean)))} 296 ${tdNum(c.scores.n ? esc(String(c.scores.n)) : '<span style="color:#9a9086">0</span>')} 297 ${td(chip(COPY_AW.status[c.status] || String(c.status).toUpperCase(), tone[0], tone[1]))} 298 ${tdActs(act('awEntry', c.lead_entry_id, COPY_AW.acts.open) + (readOnly() ? '' : act('awMerge', c.key, COPY_AW.acts.merge)))} 299 </tr>`; 300} 301 302function laureateStrip(cat) { 303 const t = COPY_AW.laureate; 304 const rows = (S.data && S.data.laureates) || []; 305 if (!rows.length) return ''; 306 return ` 307 <div style="padding:14px 20px;border-top:1px solid ${HAIR12};background:#fdfbf6"> 308 <div style="${MICRO};color:#6d6459">LAUREATES</div> 309 ${rows.map(l => ` 310 <div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;padding:8px 0;border-bottom:1px solid ${HAIR07}">
311 <span style="font-family:Fraunces,serif;font-size:16px">${esc(l.name)}</span> 312 ${l.institution ? `<span style="font-size:12px;color:#6d6459">${esc(l.institution)}</span>` : ''} 313 ${l.present_minutes ? chip(`${t.presents} · ${l.present_minutes} MIN`, '#f8f1e2', '#7a6432') : ''} 314 ${l.present_minutes ? chip(l.presentation_confirmed ? t.confirmed : t.unconfirmed, l.presentation_confirmed ? '#1e6e42' : '#f6f2ea', l.presentation_confirmed ? '#fff' : '#9a9086') : ''} 315 ${l.present_minutes ? chip(l.slides_name ? t.slides : t.noSlides, '#f6f2ea', '#6d6459') : ''} 316 ${chip(l.gala_registration_id ? t.seat : t.noSeat, l.gala_registration_id ? '#1f4f7a' : '#f6f2ea', l.gala_registration_id ? '#fff' : '#9a9086')} 317 <div style="flex:1"></div> 318 ${l.slides_url ? act('awCopy', l.slides_url, 'SLIDES LINK') : ''} 319 ${readOnly() ? '' : act('awDelLaureate', l.id, t.remove, ` data-name="${esc(l.name)}"`)} 320 </div>`).join('')} 321 </div>`; 322} 323 324function blockAwPanel() { 325 const cat = catById(S.cat); 326 if (!cat) return ''; 327 const p = COPY_AW.panel; 328 const all = (S.data && S.data.candidates) || []; 329 const rows = all 330 .filter(c => !S.filter || c.status === S.filter) 331 .filter(c => !S.term || [c.name, c.email, c.institution, c.position].filter(Boolean).some(v => String(v).toLowerCase().includes(S.term.toLowerCase()))); 332 const headers = [{ t: p.head.name }, { t: p.head.who }, { t: p.head.inst }, { t: p.head.score, r: 1 }, { t: p.head.n, r: 1 }, { t: p.head.status }, { t: '', r: 1 }]; 333 const filters = ['', 'pending-review', 'received', 'eligible', 'shortlisted', 'winner', 'declined', 'ineligible'].map(s => { 334 const on = S.filter === s; 335 return `<span data-act="awFilter" data-id="${esc(s)}" style="padding:5px 9px;font:600 9px Inter,sans-serif;letter-spacing:.12em;cursor:pointer;border:1px solid ${on ? '#201b16' : 'rgba(32,27,22,.2)'};background:${on ? '#201b16' : 'transparent'};color:${on ? '#f6f2ea' : '#6d6459'};white-space:nowrap" data-hover="border-color:#201b16">${esc(s ? (COPY_AW.status[s] || s.toUpperCase()) : p.filterAll)}</span>`; 336 }).join(''); 337 return ` 338 <div data-block="awPanel" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 339 <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 340 <span data-act="awClose" style="${BTN_GHOST}" data-hover="border-color:#201b16">${p.back}</span> 341 <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${esc(cat.name)}</span> 342 <span style="font-size:11.5px;color:#9a9086">${esc(COPY_AW.cats.counts(cat.counts.entries, cat.counts.candidates))}</span> 343 <div style="flex:1"></div> 344 ${act('awRank', cat.id, COPY_AW.cats.ranking)} 345 ${readOnly() ? '' : act('awNotify', cat.id, COPY_AW.cats.notify)} 346 </div> 347 <div style="padding:12px 20px;border-bottom:1px solid ${HAIR12};display:flex;gap:8px;align-items:center;flex-wrap:wrap"> 348 ${filters} 349 <div style="flex:1"></div> 350 <input data-role="awSearch" type="search" value="${esc(S.term)}" placeholder="${esc(p.search)}" aria-label="${esc(p.search)}
350" style="min-width:200px;${INPUT2}"> 351 </div> 352 ${S.busy ? `<div style="padding:26px 20px;font-size:12.5px;color:#6d6459">â¦</div>` 353 : rows.length ? tbl(headers, rows.map(candRow).join(''), 1040) 354 : `<div class="empty" style="padding:30px 20px"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${p.empty}</span><span class="empty-why">${p.emptyWhy}</span></div>`} 355 ${laureateStrip(cat)} 356 </div>`; 357} 358 359// ---- the drawer (the studio.js tool-drawer idiom: one slot, per-tool body) --------------------- 360function drawerShell(title, sub, body) { 361 return ` 362 <div data-block="awDrawer" id="awDrawer" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 363 <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 364 <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${esc(title)}</span> 365 <span style="font-size:11.5px;color:#9a9086">${esc(sub || '')}</span> 366 <div style="flex:1"></div> 367 <span data-act="awDrawerClose" style="${BTN_GHOST}" data-hover="border-color:#201b16">${COPY_AW.acts.close}</span> 368 </div> 369 <div style="padding:18px 20px">${body}</div> 370 </div>`; 371} 372 373function drawerEntry() { 374 const d = S.entry; 375 if (!d) return drawerShell(COPY_AW.drawer.entry, '', '<div style="font-size:12.5px;color:#6d6459">â¦</div>'); 376 const e = d.entry, sc = d.scores || { per_reviewer: [] }; 377 const dd = COPY_AW.drawer; 378 const ro = readOnly(); 379 const field = (k, v) => v ? `<div style="display:flex;gap:12px;padding:7px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"><span style="min-width:120px;${MICRO}">${esc(k)}</span><span style="min-width:0;color:#201b16;white-space:pre-wrap">${esc(v)}</span></div>` : ''; 380 const long = (k, v) => v ? `<div style="margin-top:14px"><div style="${MICRO}">${esc(k)}</div><div style="font-size:13px;line-height:1.7;color:#4a4239;margin-top:5px;white-space:pre-wrap">${esc(v)}</div></div>` : ''; 381 const statusBtn = (s, label) => ro || e.status === s ? '' : `<span data-act="awStatus" data-id="${esc(e.id)}" data-status="${s}" style="${BTN_GHOST}" data-hover="border-color:#201b16">${label}</span>`; 382 return drawerShell(dd.entry, e.name, ` 383 <div style="display:grid;grid-template-columns:1.4fr 1fr;gap:26px;align-items:start" class="mx-two"> 384 <div style="min-width:0"> 385 ${field('STATUS', COPY_AW.status[e.status] || e.status)} 386 ${field('KIND', e.kind)} 387 ${field('EMAIL', e.nominee_email)} 388 ${field('INSTITUTION', e.nominee_institution)} 389 ${field('POSITION', e.nominee_position)} 390 ${field('COUNTRY', e.nominee_country)} 391 ${field('BORN', e.nominee_birth_year)}
392 ${field('SCHOOL', [e.school, e.study_year].filter(Boolean).join(' · '))} 393 ${field('WILLING TO PRESENT', e.willing_to_present ? 'yes' : '')} 394 ${field('CONSENT TO PUBLISH', e.consent_publish ? 'yes' : 'no')} 395 ${field('HELD BECAUSE', e.gate_reason)} 396 ${e.links && e.links.length ? `<div style="margin-top:10px;${MICRO}">LINKS</div><div style="font-size:12px;margin-top:4px">${e.links.map(u => `<a href="${esc(u)}" target="_blank" rel="noopener noreferrer nofollow">${esc(u)}</a>`).join('<br>')}</div>` : ''} 397 ${long('STATEMENT', e.statement)} 398 ${long('THE CHALLENGE', e.challenge)} 399 ${long('THEIR SOLUTION', e.solution)} 400 ${long('WHY THEM', e.why_you)} 401 ${e.attachment ? `<div style="margin-top:14px">${act('awPdf', e.id, `${COPY_AW.acts.pdf} · ${esc(e.attachment.name)}`)}</div>` : ''} 402 <div style="margin-top:18px"> 403 <div style="${MICRO}">${dd.notes}</div> 404 <textarea data-role="awNote" rows="3" style="width:100%;box-sizing:border-box;margin-top:5px;${INPUT2}">${esc(e.admin_notes || '')}</textarea> 405 ${ro ? '' : `<span data-act="awSaveNote" data-id="${esc(e.id)}" style="${BTN_GHOST};margin-top:8px;display:inline-block" data-hover="border-color:#201b16">${COPY_AW.acts.note}</span>`} 406 </div> 407 </div> 408 <div style="min-width:0"> 409 <div style="${MICRO}">${dd.nominators}</div> 410 ${(d.nominators || []).length ? (d.nominators || []).map(n => ` 411 <div style="padding:7px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"> 412 <div style="font-weight:600">${esc(n.name || n.email || 'â')}</div> 413 <div style="font-size:11.5px;color:#6d6459">${esc([n.email, n.relation].filter(Boolean).join(' · ') || '')}</div> 414 </div>`).join('') : `<div style="font-size:12px;color:#6d6459;font-style:italic;padding:6px 0">${e.kind === 'application' ? 'A studentâs own application.' : 'A self-nomination.'}</div>`} 415 416 <div style="${MICRO};margin-top:18px">${dd.scores}</div> 417 <div style="font-size:13px;margin-top:4px">${esc(dd.mean(sc.mean, sc.n))}${sc.spread != null ? ` · ${esc(dd.spread(sc.spread))}` : ''}</div> 418 ${sc.conflicts ? `<div style="font-size:11.5px;color:#9b1b22;margin-top:3px">${esc(dd.conflictNote(sc.conflicts))}</div>` : ''} 419 ${(sc.per_reviewer || []).length ? (sc.per_reviewer || []).map(r => ` 420 <div style="padding:8px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"> 421 <div style="display:flex;gap:8px;align-items:baseline"><span style="font-weight:600">${esc(r.reviewer)}</span>${r.conflict ? chip('CONFLICT', '#9b1b22', '#fff') : `<span style="font-family:
421Fraunces,serif;font-size:15px">${esc(String(r.total))}</span>`}</div> 422 ${r.conflict ? '' : `<div style="font-size:11px;color:#6d6459">${esc(Object.keys(r.scores || {}).map(k => `${k} ${r.scores[k]}`).join(' · '))}</div>`} 423 ${r.comment ? `<div style="font-size:12px;color:#4a4239;margin-top:4px;line-height:1.6">${esc(r.comment)}</div>` : ''} 424 </div>`).join('') : `<div style="font-size:12px;color:#6d6459;font-style:italic;padding:6px 0">${dd.noScores}</div>`} 425 426 <div style="display:flex;gap:7px;flex-wrap:wrap;margin-top:18px"> 427 ${statusBtn('eligible', COPY_AW.acts.eligible)} 428 ${statusBtn('ineligible', COPY_AW.acts.ineligible)} 429 ${statusBtn('shortlisted', COPY_AW.acts.shortlist)} 430 ${statusBtn('winner', COPY_AW.acts.winner)} 431 ${statusBtn('declined', COPY_AW.acts.decline)} 432 </div> 433 </div> 434 </div>`); 435} 436 437function drawerRank() { 438 const r = S.rank; 439 const c = COPY_AW.rank; 440 if (!r) return drawerShell(c.title, '', '<div style="font-size:12.5px;color:#6d6459">â¦</div>'); 441 const ro = readOnly(); 442 const headers = [{ t: c.head.rank }, { t: c.head.name }, { t: c.head.mean, r: 1 }, { t: c.head.spread, r: 1 }, { t: c.head.n, r: 1 }, { t: c.head.conf, r: 1 }, { t: '', r: 1 }]; 443 const rows = (r.ranking || []).map(x => ` 444 <tr>
445 ${td(`<span style="font-family:Fraunces,serif;font-size:16px">${x.rank}</span>`)} 446 ${td(`<span style="font-size:13px;font-weight:600">${esc(x.entry.name)}</span><div style="font-size:11.5px;color:#6d6459">${esc(x.entry.nominee_institution || '')}</div>`)} 447 ${tdNum(x.mean == null ? '<span style="color:#9a9086">â</span>' : `${esc(String(x.mean))} <span style="font-size:11px;color:#6d6459">/ ${esc(String(r.max_total))}</span>`)} 448 ${tdNum(x.spread == null ? '<span style="color:#9a9086">â</span>' : esc(String(x.spread)))} 449 ${tdNum(esc(String(x.n)))} 450 ${tdNum(x.conflicts ? `<span style="color:#9b1b22">${esc(String(x.conflicts))}</span>` : '<span style="color:#9a9086">0</span>')} 451 ${tdActs(ro ? '' : (`<span data-act="awStatus" data-id="${esc(x.entry.id)}" data-status="shortlisted" style="${BTN_GHOST}" data-hover="border-color:#201b16">${c.shortlist}</span>` 452 + `<span data-act="awStatus" data-id="${esc(x.entry.id)}" data-status="winner" style="${BTN_GHOST}" data-hover="border-color:#201b16">${c.winner}</span>`))} 453 </tr>`).join(''); 454 return drawerShell(c.title, `${r.category.name} · ${c.sub}`, ` 455 ${(r.ranking || []).length ? tbl(headers, rows, 820) : `<div style="font-size:12.5px;color:#6d6459;font-style:italic">${c.empty}</div>`} 456 <div style="font-size:11.5px;color:#6d6459;margin-top:12px">${c.note}</div>`); 457} 458 459function drawerReviewers() { 460 const c = COPY_AW.reviewers; 461 const ro = readOnly(); 462 const list = reviewers(); 463 const catOpts = cats().map(x => `<label style="display:flex;gap:7px;align-items:center;font-size:12.5px;white-space:nowrap"><input type="checkbox" data-role="awRevCat" value="${esc(x.id)}" style="accent-color:#9b1b22">${esc(x.name)}</label>`).join(''); 464 const rows = list.map(r => { 465 const tone = { invited: ['#f8f1e2', '#7a6432'], active: ['#1e6e42', '#fff'], revoked: ['#f6f2ea', '#9a9086'] }[r.status] || ['#f6f2ea', '#6d6459']; 466 return ` 467 <div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;padding:10px 0;border-bottom:1px solid ${HAIR07}"> 468 <span style="font-size:13px;font-weight:600">${esc(r.name)}</span> 469 <span style="font-size:11.5px;color:#6d6459">${esc(r.email)}</span> 470 ${chip(c.statusTag[r.status] || String(r.status).toUpperCase(), tone[0], tone[1])} 471 <span style="font-size:12px;color:#6d6459">${esc(c.progress(r.scored, r.total))}</span> 472 <span style="flex:1;min-width:60px;height:4px;background:#f6f2ea;display:inline-block"><span style="display:block;height:4px;width:${Math.max(0, Math.min(100, r.percent))}%;background:#1e6e42"></span></span> 473 ${act('awCopy', r.room_url, c.copy)} 474 ${ro || r.status === 'revoked' ? '' : act('awResend', r.id, c.resend)} 475 ${ro || r.status === 'revoked' ? '' : act('awRevoke', r.id, c.revoke, ` data-name="${esc(r.name)}"`)} 476 </div>`; 477 }).join(''); 478 return drawerShell(c.title, c.sub, ` 479 ${list.length ? rows : `<div class="empty" style="padding:16px 0"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${c.empty}</span><span class="empty-why">${c.emptyWhy}</span></div>`} 480 ${ro ? '' : ` 481 <div style="margin-top:18px;padding-top:16px;border-top:1px solid ${HAIR12}"> 482 <div style="${MICRO}">${c.add}</div> 483 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px"> 484 ${fText('awRevName', c.name, '')} 485 ${fText('awRevEmail', c.email, '')} 486 </div> 487 <div style="${MICRO};margin-top:12px">${c.cats}</div> 488 <div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:6px">${catOpts}</div> 489 <span data-act="awAddReviewer" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.send}</span> 490 </div>`}`); 491} 492 493function drawerNotify() { 494 const c = COPY_AW.notify; 495 const n = S.notify || {}; 496 const cat = catById(n.catId) || {}; 497 const box = (role, label, on) => `<label style="display:flex;gap:8px;align-items:center;font-size:13px;padding:6px 0"><input type="checkbox" data-role="${role}" ${on ? 'checked' : ''} style="accent-color:#9b1b22;width:15px;height:15px">${esc(label)}</label>`; 498 return drawerShell(c.title, `${cat.name || ''} · ${c.sub}`, ` 499 ${box('awNWin', c.winners, true)} 500 ${box('awNShort', c.shortlisted, true)} 501 ${box('awNDecl', c.declined, true)} 502 ${box('awNNoms', c.nominators, true)} 503 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px"> 504 ${act('awNotifyPreview', cat.id || '', c.preview)}
505 <span data-act="awNotifyStage" data-id="${esc(cat.id || '')}" style="padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.stage}</span> 506 </div> 507 ${n.drafts ? `<div style="margin-top:16px;border-top:1px solid ${HAIR12};padding-top:12px"> 508 <div style="${MICRO}">${esc(n.drafts.length + ' LETTER' + (n.drafts.length === 1 ? '' : 'S'))}</div> 509 ${n.drafts.map(d => `<div style="display:flex;gap:10px;padding:6px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"><span style="${MICRO};min-width:130px">${esc(d.kind.replace(/_/g, ' ').toUpperCase())}</span><span>${esc(d.to)}</span><span style="color:#6d6459;min-width:0">${esc(d.subject)}</span></div>`).join('')} 510 </div>` : ''} 511 <div style="font-size:11.5px;color:#6d6459;margin-top:14px">${c.note}</div>`); 512} 513 514function drawerLaureate() { 515 const c = COPY_AW.laureate; 516 const cat = catById(S.drawerCat) || {}; 517 return drawerShell(c.title, `${cat.name || ''} · ${c.sub}`, ` 518 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> 519 ${fText('awLName', c.name, '')} 520 ${fText('awLInst', c.inst, '')} 521 ${fText('awLEmail', c.email, '')} 522 ${fText('awLPhoto', c.photo, '')} 523 ${fArea('awLCite', c.cite, cat.citation || '', 3)} 524 </div> 525 <span data-act="awSaveLaureate" data-id="${esc(cat.id || '')}" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.save}</span>`); 526} 527 528function drawerSettings() { 529 const c = COPY_AW.settings; 530 const cat = catById(S.drawerCat); 531 if (!cat) return ''; 532 return drawerShell(c.title, cat.name, ` 533 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> 534 ${fText('awSName', c.name, cat.name)} 535 ${fText('awSNameHr', c.nameHr, cat.name_hr)} 536 ${fText('awSCite', c.cite, cat.citation)} 537 ${fText('awSCiteHr', c.citeHr, cat.citation_hr)} 538 ${fText('awSOpens', c.opens, cat.opens_at, '2026-10-01T09:00')} 539 ${fText('awSCloses', c.closes, cat.closes_at, '2026-11-01T23:59')} 540 ${fText('awSMax', c.max, cat.laureates_max)} 541 <label style="display:flex;gap:8px;align-items:center;font-size:12.5px;margin-top:22px"><input type="checkbox" data-role="awSSelf" ${cat.allow_self ? 'checked' : ''} style="accent-color:#9b1b22;width:15px;height:15px">${esc(c.self)}</label> 542 ${fArea('awSCrit', c.crit, cat.criteria_md, 6)} 543 ${fArea('awSCritHr', c.critHr, cat.criteria_md_hr, 6)} 544 </div> 545 <span data-act="awSaveSettings" data-id="${esc(cat.id)}" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.save}</span>`); 546} 547 548function blockAwDrawer() { 549 if (!S.drawer) return '<div data-block="awDrawer"></div>'; 550 if (S.drawer === 'entry') return drawerEntry(); 551 if (S.drawer === 'rank') return drawerRank(); 552 if (S.drawer === 'reviewers') return drawerReviewers(); 553 if (S.drawer === 'notify') return drawerNotify(); 554 if (S.drawer === 'laureate') return drawerLaureate(); 555 if (S.drawer === 'settings') return drawerSettings(); 556 return '<div data-block="awDrawer"></div>'; 557} 558 559function blockAwBody() { 560 return `<div data-block="awBody">${S.cat ? blockAwPanel() : blockAwCats()}</div>`; 561} 562 563/** The whole AWARDS tab. js/views/plexus.js calls exactly this. */ 564export function blockAwards(error) { 565 if (!canAwards()) { 566 return ` 567 <div data-block="awTable" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 568 <div style="padding:14px 20px;border-bottom:1px solid ${HAIR12}"><span style="font:600 11px Inter,sans-serif;letter-spacing:.15em">${COPY_AW.cats.title}</span></div> 569 <div style="padding:10px 0">${ui.lockedBlock(perms.label(AW_SECTION))}</div> 570 </div>`; 571 } 572 if (error) { 573 return ` 574 <div data-block="awTable" style="border:1px solid ${HAIR};background:#fff;margin-top:22px"> 575 <div style="padding:14px 20px;border-bottom:1px solid ${HAIR12}">
575<span style="font:600 11px Inter,sans-serif;letter-spacing:.15em">${COPY_AW.cats.title}</span></div> 576 <div style="padding:10px 0">${error.isLocked ? ui.lockedBlock(perms.label(error.section)) : `<div style="padding:16px 20px;font-size:12.5px;color:#9b1b22">${esc(error.message || COPY_AW.err)}</div>`}</div> 577 </div>`; 578 } 579 return `${blockAwStats()}${blockAwBody()}${blockAwDrawer()}`; 580} 581 582// ================================================================ HANDLERS 583const val = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return el ? String(el.value || '').trim() : ''; }; 584const raw = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return el ? String(el.value || '') : ''; }; 585const checked = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return !!(el && el.checked); }; 586async function copyText(t) { 587 try { await navigator.clipboard.writeText(t); return true; }
588 catch (e) { 589 try { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); return true; } 590 catch (e2) { return false; } 591 } 592} 593function scrollDrawer() { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector('#awDrawer'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } 594 595/** Read-only-safe actions: everything that only LOOKS. plexus.js merges this into its RO set. */ 596export const AW_RO_SAFE = ['awOpen', 'awClose', 'awRank', 'awEntry', 'awCsv', 'awRoster', 'awOnePager', 597 'awFilter', 'awDrawerClose', 'awCopy', 'awPdf', 'awReviewers', 'awNotifyPreview']; 598 599export const awardsHandlers = { 600 awOpen: async (el) => { S.cat = el.dataset.id; S.filter = ''; S.term = ''; S.drawer = null; paintAll(); await reloadCategory(S.cat); }, 601 awClose: () => { S.cat = null; S.data = null; S.drawer = null; paintAll(); }, 602 awFilter: (el) => { S.filter = el.dataset.id || ''; paintPanel(); }, 603 604 awEntry: async (el) => { 605 S.drawer = 'entry'; S.entry = null; paintDrawer(); scrollDrawer(); 606 try { S.entry = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id))); } 607 catch (e) { ui.toast(e.message, { kind: 'error' }); S.drawer = null; } 608 paintDrawer(); 609 }, 610 611 awStatus: async (el) => { 612 const status = el.dataset.status; 613 try { 614 const r = await api.post(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/status'), { status }); 615 ui.toast(COPY_AW.drawer.statusDone(COPY_AW.status[status] || status.toUpperCase())); 616 if (r && r.gala_seat && r.gala_seat.created) ui.toast('GALA SEAT CREATED â COMP, ON THE GUEST LIST'); 617 await reloadOverview(); 618 if (S.cat) await reloadCategory(S.cat); 619 if (S.drawer === 'entry' && S.entry) { S.entry = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id))); } 620 if (S.drawer === 'rank' && S.rank) { S.rank = await api.get(opsUrl('/categories/' + encodeURIComponent(S.rank.category.id) + '/ranking')); } 621 paintAll(); 622 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 623 }, 624 625 awSaveNote: async (el) => { 626 try { 627 await api.post(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/notes'), { notes: raw('awNote') }); 628 ui.toast(COPY_AW.drawer.saved); 629 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 630 }, 631 632 // window.open cannot carry the Bearer header, so it answered 401 and opened a blank tab of JSON 633 // error. Ask the route for the signed link instead (?json=1) and hand THAT to the browser. 634 awPdf: async (el) => { 635 try { 636 const r = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/attachment') + '?json=1'); 637 if (!r || !r.url) throw new Error(COPY_AW.drawer.pdfMissing); 638 dlHref(r.url, r.name || 'entry.pdf'); 639 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 640 }, 641 642 awMerge: async (el) => { 643 const key = el.dataset.id; 644 if (!S.mergeFrom) { S.mergeFrom = key; ui.toast(COPY_AW.drawer.mergePick); return; } 645 if (S.mergeFrom === key) { S.mergeFrom = null; return; } 646 const from = (S.data.candidates || []).find(c => c.key === S.mergeFrom); 647 const into = (S.data.candidates || []).find(c => c.key === key); 648 const ok = await ui.confirm({ title: COPY_AW.drawer.mergeAsk((from && from.name) || S.mergeFrom, (into && into.name) || key), ok: COPY_AW.drawer.mergeOk, cancel: COPY_AW.drawer.mergeKeep }); 649 if (!ok) { S.mergeFrom = null; return; } 650 try { 651 await api.post(opsUrl('/categories/' + encodeURIComponent(S.cat) + '/merge'), { from: S.mergeFrom, into: key }); 652 S.mergeFrom = null; 653 await reloadOverview(); await reloadCategory(S.cat); paintAll(); 654 } catch (e) { S.mergeFrom = null; ui.toast(e.message, { kind: 'error' }); } 655 }, 656 657 awRank: async (el) => { 658 S.drawer = 'rank'; S.rank = null; paintDrawer(); scrollDrawer(); 659 try { S.rank = await api.get(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/ranking')); } 660 catch (e) { ui.toast(e.message, { kind: 'error' }); S.drawer = null; } 661 paintDrawer(); 662 }, 663 664 awSettings: (el) => { S.drawer = 'settings'; S.drawerCat = el.dataset.id; paintDrawer(); scrollDrawer(); }, 665 awSaveSettings: async (el) => { 666 const max = parseInt(val('awSMax'), 10); 667 try { 668 await api.put(opsUrl('/categories/' + encodeURIComponent(el.dataset.id)), { 669 name: val('awSName'), name_hr: val('awSNameHr'), 670 citation: val('awSCite'), citation_hr: val('awSCiteHr'), 671 opens_at: val('awSOpens'), closes_at: val('awSCloses'), 672 laureates_max: Number.isFinite(max) ? max : 2, 673 allow_self: checked('awSSelf'), 674 criteria_md: raw('awSCrit'), criteria_md_hr: raw('awSCritHr') 675 }); 676 ui.toast(COPY_AW.settings.saved); 677 await reloadOverview(); paintAll(); 678 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 679 }, 680 681 awLaureate: (el) => { S.drawer = 'laureate'; S.drawerCat = el.dataset.id; paintDrawer(); scrollDrawer(); }, 682 awSaveLaureate: async (el) => { 683 const name = val('awLName'); 684 if (!name) { ui.toast(COPY_AW.laureate.nameFirst); return; } 685 try { 686 await api.post(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/laureates'), { 687 name, institution: val('awLInst'), email: val('awLEmail'), photo_url: val('awLPhoto'), citation: raw('awLCite') 688 }); 689 ui.toast(COPY_AW.laureate.added); 690 S.drawer = null; 691 await reloadOverview(); 692 if (S.cat) await reloadCategory(S.cat); 693 paintAll(); 694 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 695 }, 696 awDelLaureate: async (el) => { 697 const ok = await ui.confirm({ title: COPY_AW.laureate.removeAsk(el.dataset.name || 'this laureate'), ok: COPY_AW.laureate.removeOk, cancel: COPY_AW.laureate.removeKeep }); 698 if (!ok) return; 699 try { 700 await api.del(opsUrl('/laureates/' + encodeURIComponent(el.dataset.id))); 701 ui.toast(COPY_AW.laureate.removed); 702 await reloadOverview(); 703 if (S.cat) await reloadCategory(S.cat); 704 paintAll(); 705 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 706 }, 707 708 awNotify: (el) => { S.drawer = 'notify'; S.notify = { catId: el.dataset.id, drafts: null }; paintDrawer(); scrollDrawer(); }, 709 awNotifyPreview: async (el) => { await stageNotify(el.dataset.id, true); }, 710 awNotifyStage: async (el) => { await stageNotify(el.dataset.id, false); }, 711 712 awReviewers: () => { S.drawer = 'reviewers'; paintDrawer(); scrollDrawer(); }, 713 awAddReviewer: async () => { 714 const name = val('awRevName'), email = val('awRevEmail'); 715 if (!name || !email) { ui.toast(COPY_AW.reviewers.nameFirst); return; } 716 const root = H.rootEl(); 717 const picked = [...root.querySelectorAll('[data-role="awRevCat"]')].filter(x => x.checked).map(x => x.value); 718 if (!picked.length) { ui.toast(COPY_AW.reviewers.catFirst); return; } 719 try { 720 const r = await api.post(opsUrl('/reviewers') + edQ(), { name, email, categories: picked }); 721 ui.toast(r && r.invited ? COPY_AW.reviewers.sent : 'READER ADDED'); 722 await reloadOverview(); paintAll(); 723 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 724 }, 725 awResend: async (el) => { 726 try { const r = await api.post(opsUrl('/reviewers/' + encodeURIComponent(el.dataset.id) + '/resend'), {}); ui.toast(r && r.sent ? COPY_AW.reviewers.sent : 'COULD NOT SEND â COPY THE
726LINK INSTEAD'); } 727 catch (e) { ui.toast(e.message, { kind: 'error' }); } 728 }, 729 awRevoke: async (el) => { 730 const ok = await ui.confirm({ title: COPY_AW.reviewers.revokeAsk(el.dataset.name || 'this reader'), ok: COPY_AW.reviewers.revokeOk, cancel: COPY_AW.reviewers.revokeKeep }); 731 if (!ok) return; 732 try { await api.del(opsUrl('/reviewers/' + encodeURIComponent(el.dataset.id))); await reloadOverview(); paintAll(); } 733 catch (e) { ui.toast(e.message, { kind: 'error' }); } 734 }, 735 736 awCopy: async (el) => { ui.toast(await copyText(el.dataset.id) ? COPY_AW.reviewers.copied : el.dataset.id); }, 737 awDrawerClose: () => { S.drawer = null; S.entry = null; S.rank = null; paintDrawer(); }, 738 739 awCsv: async (el) => { 740 try { dl(await fetchBlob(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/entries.csv')), 'medx-awards-entries.csv'); } 741 catch (e) { ui.toast(e.message, { kind: 'error' }); } 742 }, 743 awRoster: async () => { 744 try { dl(await fetchBlob(opsUrl('/roster.csv') + edQ()), 'medx-awards-gala-roster.csv'); } 745 catch (e) { ui.toast(e.message, { kind: 'error' }); } 746 }, 747 // Same 401 as awPdf: window.open sends no Authorization header, so the MC's sheet opened as a 748 // blank tab. Fetch it WITH the header and save the page â it prints from disk with the same 749 // PRINT button, and the file is what the MC actually wants to carry. 750 awOnePager: async () => { 751 try { dl(await fetchBlob(opsUrl('/one-pager') + edQ()), 'medx-awards-running-order.html'); } 752 catch (e) { ui.toast(e.message, { kind: 'error' }); } 753 } 754}; 755 756async function stageNotify(catId, preview) { 757 const body = { 758 preview: !!preview, 759 winners: checked('awNWin'), shortlisted: checked('awNShort'), 760 declined: checked('awNDecl'), nominators: checked('awNNoms') 761 }; 762 try { 763 const r = await api.post(opsUrl('/categories/' + encodeURIComponent(catId) + '/notify'), body); 764 S.notify = { catId, drafts: r.drafts || [] }; 765 if (!preview) { 766 ui.toast(r.staged ? COPY_AW.notify.staged(r.staged) : COPY_AW.notify.none); 767 await reloadOverview(); 768 if (S.cat) await reloadCategory(S.cat); 769 } 770 paintDrawer(); 771 } catch (e) { ui.toast(e.message, { kind: 'error' }); } 772} 773 774// The search box repaints only its own block, so typing never loses focus mid-word. 775export function awardsOnInput(e) { 776 const t = e.target; 777 if (!t || !t.matches || !t.matches('[data-role="awSearch"]')) return false; 778 S.term = t.value; 779 const rows = H.rootEl().querySelector('[data-block="awPanel"]'); 780 if (rows) { 781 const pos = t.selectionStart; 782 H.paintPart('[data-block="awPanel"]', blockAwPanel()); 783 const again = H.rootEl().querySelector('[data-role="awSearch"]'); 784 if (again) { again.focus(); try { again.setSelectionRange(pos, pos); } catch (err) {} } 785 } 786 return true; 787} 788 789export default { initAwards, setAwardsData, loadAwards, blockAwards, awardsHandlers, awardsOnInput, AW_SECTION, AW_RO_SAFE, COPY_AW, canAwards };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.