const state = {model: null, tab: 'everyone', q: ''}; let byEmail = {}; const icons = { people: '', classrooms: '', 'my-family': '', staff: '', map: '', 'email-list': '', 'data-view': '', 'bug-report': '', about: '', everyone: '', students: '', families: '', 'staff-tab': '', search: '', filter: '', chevron: '', 'chevron-left': '', 'chevron-right': '', heart: '', mail: '', copy: '', message: '', phone: '', }; const navSections = [ {path: 'people', label: 'People'}, {path: 'classrooms', label: 'Classrooms'}, {path: 'my-family', label: 'My Family'}, {path: 'staff', label: 'Staff'}, {path: 'map', label: 'Map'}, {path: 'email-list', label: 'Email List'}, {divider: true}, {path: 'data-view', label: 'Data View'}, {path: 'bug-report', label: 'Bug Report'}, {path: 'about', label: 'Share & About'}, ]; const peopleTabs = [ {key: 'everyone', label: 'Everyone'}, {key: 'students', label: 'Students'}, {key: 'families', label: 'Families'}, {key: 'staff', label: 'Staff'}, ]; function el(tag, className, text) { const node = document.createElement(tag); if (className) { node.className = className; } if (text) { node.textContent = text; } return node; } function svg(name) { const holder = document.createElement('template'); holder.innerHTML = icons[name]; return holder.content.firstChild; } function segments() { return location.pathname.split('/').filter(Boolean).map(decodeURIComponent); } function personLink(p) { return '/people/' + encodeURIComponent(p.email); } function renderNav() { const nav = document.querySelector('#nav'); nav.replaceChildren(); const seg = segments()[0] === 'families' ? 'people' : segments()[0]; for (const item of navSections) { if (item.divider) { nav.append(el('div', 'nav-divider')); continue; } const a = el('a'); a.href = '/' + item.path; if (item.path === seg) { a.className = 'active'; } a.append(svg(item.path), el('span', '', item.label)); nav.append(a); } } function hue(text) { let h = 0; for (const c of text) { h = (h * 31 + c.codePointAt(0)) % 360; } return h; } function firstName(fullName) { return fullName.trim().split(/\s+/)[0]; } function thumbUrl(url) { return url ? url + '?thumb=1' : url; } function photoOrInitials(url, name, className) { if (url) { const img = el('img', className); img.src = thumbUrl(url); img.loading = 'lazy'; img.alt = ''; return img; } const div = el('div', className, name.trim().split(/\s+/).map(w => w[0]).slice(0, 2).join('')); div.style.background = `hsl(${hue(name)} 45% 55%)`; return div; } function roleLabel(p) { let role = 'Parent'; if (p.isStudent) { role = 'Student'; } else if (p.isStaff) { role = 'Staff'; } return (p.pronouns ? `${role} (${p.pronouns})` : role).toUpperCase(); } function gradeChain(p) { return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '); } function personContext(p) { if (p.isStudent) { return gradeChain(p); } if (p.isStaff && p.jobTitle) { return p.jobTitle; } const family = state.model.families[p.familyKey]; if (family) { return (family.kidEmails || []).map(e => byEmail[e]?.fullName).filter(Boolean).join(', '); } return ''; } function personCard(p) { const card = el('a', 'person-card'); card.href = personLink(p); card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); card.append(el('div', 'role-label', roleLabel(p))); card.append(el('div', 'person-name', p.fullName)); const context = personContext(p); if (context) { card.append(el('div', 'person-sub', context)); } return card; } function renderEveryone(grid) { grid.className = 'people-grid'; const q = state.q; const matches = state.model.people.filter(p => { const family = state.model.families[p.familyKey]; return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q); }); for (const p of matches) { grid.append(personCard(p)); } return matches.length; } function renderStudents(grid) { grid.className = 'student-grid'; const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q)); for (const p of matches) { const card = el('a', 'student-card'); card.href = personLink(p); if (p.photoUrl) { const img = el('img', 'student-photo'); img.src = thumbUrl(p.photoUrl); img.loading = 'lazy'; img.alt = ''; card.append(img); } card.append(el('div', 'student-first', firstName(p.fullName))); card.append(el('div', 'student-last', p.fullName.replace(firstName(p.fullName), '').trim())); card.append(el('div', 'student-line', gradeChain(p))); if (p.pronouns) { card.append(el('div', 'student-pronouns', p.pronouns)); } grid.append(card); } return matches.length; } function familyEntries() { const entries = Object.values(state.model.families).map(f => { const members = [...(f.kidEmails || []), ...(f.adultEmails || [])]; const kidGrades = [...new Set((f.kidEmails || []).map(e => byEmail[e]?.grade).filter(Boolean))]; return { key: f.key, name: (f.name || '').replace(/ Family$/, ''), label: kidGrades.length ? kidGrades.join(', ') : 'Staff', members: members.map(e => byEmail[e] ? firstName(byEmail[e].fullName) : '').filter(Boolean), photoUrl: f.photoUrl, href: '/families/' + encodeURIComponent(f.key), }; }); for (const p of state.model.people) { if (p.isStaff && !p.isParent && !p.isStudent && !state.model.families[p.familyKey]) { entries.push({ name: p.fullName.trim().split(/\s+/).slice(-1)[0], label: 'Staff', members: [firstName(p.fullName)], photoUrl: p.photoUrl, href: personLink(p), }); } } entries.sort((a, b) => a.name.localeCompare(b.name)); return entries; } function renderFamilies(grid) { grid.className = 'family-grid'; const matches = familyEntries().filter(f => `${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q)); for (const f of matches) { const card = el('a', 'family-card'); card.href = f.href; card.append(photoOrInitials(f.photoUrl, f.name, 'family-photo')); card.append(el('div', 'family-label', f.label)); card.append(el('div', 'family-name', f.name)); card.append(el('div', 'family-kids', f.members.join(', '))); grid.append(card); } return matches.length; } function renderStaff(grid) { grid.className = ''; const staff = state.model.people.filter(p => p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q)); const departments = state.model.departments || []; const groups = new Map(); for (const p of staff) { const dept = p.department || 'Staff'; if (!groups.has(dept)) { groups.set(dept, []); } groups.get(dept).push(p); } const ordered = [...groups.keys()].sort((a, b) => { const ia = departments.indexOf(a); const ib = departments.indexOf(b); return (ia < 0 ? departments.length : ia) - (ib < 0 ? departments.length : ib); }); let count = 0; for (const dept of ordered) { grid.append(el('h2', 'staff-section', dept)); const deptGrid = el('div', 'people-grid'); for (const p of groups.get(dept)) { const card = el('a', 'person-card'); card.href = personLink(p); card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); card.append(el('div', 'role-label', p.jobTitle || 'Staff')); card.append(el('div', 'person-name', p.fullName)); deptGrid.append(card); count++; } grid.append(deptGrid); } return count; } const tabRenderers = { everyone: renderEveryone, students: renderStudents, families: renderFamilies, staff: renderStaff, }; function renderPeople() { const main = document.querySelector('#main'); main.replaceChildren(); const tabs = el('div', 'tabs'); const tabsRow = el('div', 'container tabs-row'); for (const tab of peopleTabs) { const node = el('div', 'tab' + (tab.key === state.tab ? ' active' : '')); node.append(svg(tab.key === 'staff' ? 'staff-tab' : tab.key), el('span', '', tab.label)); node.addEventListener('click', () => { state.tab = tab.key; state.q = ''; renderPeople(); }); tabsRow.append(node); } tabs.append(tabsRow); main.append(tabs); const content = el('div', 'content container'); const header = el('div', 'content-header'); header.append(el('h1', '', peopleTabs.find(t => t.key === state.tab).label)); const controls = el('div', 'controls'); const search = el('div', 'search'); search.append(svg('search')); const input = el('input'); input.placeholder = 'Search'; input.value = state.q; input.addEventListener('input', () => { state.q = input.value.trim().toLowerCase(); renderGrid(); }); search.append(input); const filter = el('button', 'filter-button'); filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); controls.append(search, filter); header.append(controls); content.append(header); const grid = el('div'); content.append(grid); main.append(content); function renderGrid() { grid.replaceChildren(); if (tabRenderers[state.tab](grid) === 0) { grid.append(el('div', 'empty', 'No matches.')); } } renderGrid(); input.focus(); } function breadcrumbs(parts) { const top = el('div', 'detail-top container'); const crumbs = el('div', 'crumbs'); const back = el('a', 'crumb-back'); back.href = '/people'; back.append(svg('chevron-left')); crumbs.append(back); parts.forEach(([label, href], i) => { if (i > 0) { crumbs.append(el('span', 'crumb-sep', '/')); } if (href) { const a = el('a', 'crumb', label); a.href = href; crumbs.append(a); } else { crumbs.append(el('span', 'crumb current', label)); } }); top.append(crumbs); const heart = el('button', 'heart-button'); heart.append(svg('heart')); top.append(heart); return top; } function iconButton(name, label, action) { const node = el(typeof action === 'string' ? 'a' : 'button', 'icon-button'); if (typeof action === 'string') { node.href = action; node.target = '_blank'; } else if (action) { node.addEventListener('click', action); } node.append(svg(name), el('span', '', label)); return node; } function copyButton(text) { return iconButton('copy', 'Copy', () => navigator.clipboard.writeText(text)); } function contactRow(value, buttons) { const row = el('div', 'contact-row'); row.append(value); const actions = el('div', 'contact-actions'); for (const b of buttons) { actions.append(b); } row.append(actions); return row; } function displayNameLine(p) { if (!p.legalName || p.legalName === p.fullName) { return null; } return p.legalName; } function memberRow(p, label, sub) { const row = el('a', 'member-row'); row.href = personLink(p); if (p.photoUrl) { const img = el('img', 'member-thumb'); img.src = thumbUrl(p.photoUrl); img.loading = 'lazy'; img.alt = ''; row.append(img); } const info = el('div', 'member-info'); if (label) { info.append(el('div', 'member-label', label)); } info.append(el('div', 'member-name', p.fullName)); if (sub) { info.append(el('div', 'member-email', sub)); } row.append(info); const chev = el('div', 'member-chevron'); chev.append(svg('chevron-right')); row.append(chev); return row; } function familyBand(p, family) { const band = el('div', 'band'); const grid = el('div', 'band-grid container'); const left = el('div', 'band-left'); left.append(el('h2', '', `${p.fullName}'s Family`)); if (family.photoUrl && !p.isStudent) { const img = el('img', 'band-photo'); img.src = thumbUrl(family.photoUrl); img.alt = ''; left.append(img); } if (family.photoCaption) { left.append(el('div', 'band-caption', family.photoCaption)); } grid.append(left); const right = el('div', 'band-right'); const kidsList = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean); if (kidsList.length && !(p.isStudent && kidsList.length === 1 && kidsList[0].email === p.email)) { right.append(el('div', 'member-header', 'Kids')); for (const kid of kidsList) { if (p.isStudent && kid.email === p.email) { continue; } const label = kid.pronouns ? `${gradeChain(kid)} (${kid.pronouns})` : gradeChain(kid); right.append(memberRow(kid, label.toUpperCase(), kid.email)); } } const adults = (family.adultEmails || []).map(e => byEmail[e]).filter(Boolean).filter(a => a.email !== p.email); if (adults.length) { right.append(el('div', 'member-header', 'Adults')); for (const adult of adults) { const label = [adult.phone, adult.pronouns ? `(${adult.pronouns.toUpperCase()})` : ''].filter(Boolean).join(' '); right.append(memberRow(adult, label, adult.email)); } } const see = el('a', 'see-family'); see.href = '/families/' + encodeURIComponent(family.key); see.append(el('span', '', `See ${family.name}`)); const chev = el('div', 'member-chevron'); chev.append(svg('chevron-right')); see.append(chev); right.append(see); grid.append(right); band.append(grid); return band; } function renderPersonDetail(email) { const main = document.querySelector('#main'); main.replaceChildren(); const p = byEmail[email]; if (!p) { main.append(el('div', 'empty', 'Not found.')); return; } main.append(breadcrumbs([['People', '/people'], [p.fullName, null]])); const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); const left = el('div'); if (p.photoUrl) { const img = el('img', 'detail-photo'); img.src = p.photoUrl; img.alt = ''; left.append(img); } grid.append(left); const right = el('div'); right.append(el('div', 'role-label', roleLabel(p))); right.append(el('h1', 'detail-name', p.fullName)); const nickname = displayNameLine(p); if (nickname) { right.append(el('div', 'detail-sub', nickname)); } if (p.isStudent) { const chain = gradeChain(p); if (chain) { right.append(el('div', 'detail-sub', chain)); } } if (p.phone) { right.append(contactRow(el('div', 'contact-value', p.phone), [ copyButton(p.phone), iconButton('message', 'Text', 'sms:' + p.phone), iconButton('phone', 'Call', 'tel:' + p.phone), ])); } right.append(contactRow(el('div', 'contact-value', p.email), [ copyButton(p.email), iconButton('mail', 'Email', 'mailto:' + p.email), ])); const family = state.model.families[p.familyKey]; if (family && family.address) { const block = el('div'); block.append(el('div', 'field-label', 'Address')); block.append(el('div', 'contact-value', family.address)); right.append(contactRow(block, [ copyButton(family.address), iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)), ])); } if (p.pronunciationUrl) { right.append(el('div', 'pronounce-label', 'How do I pronounce this?')); const audio = el('audio', 'pronounce-player'); audio.controls = true; audio.preload = 'metadata'; audio.src = p.pronunciationUrl; right.append(audio); } grid.append(right); content.append(grid); if (p.facts) { content.append(el('h2', 'about-header', 'About Me')); content.append(el('div', 'about-text', p.facts)); } main.append(content); if (family) { main.append(familyBand(p, family)); } } function renderFamilyDetail(key) { const main = document.querySelector('#main'); main.replaceChildren(); const family = state.model.families[key]; if (!family) { main.append(el('div', 'empty', 'Not found.')); return; } const shortName = (family.name || '').replace(/ Family$/, ''); main.append(breadcrumbs([['People', '/people'], [shortName, null], ['Family', null]])); const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); const left = el('div'); if (family.photoUrl) { const link = el('a'); link.href = family.photoUrl; link.target = '_blank'; const img = el('img', 'detail-photo'); img.src = family.photoUrl; img.alt = ''; link.append(img); left.append(link); } if (family.photoCaption) { left.append(el('div', 'family-caption', family.photoCaption)); } if (family.photoUrl) { left.append(el('div', 'photo-hint', 'click photo to open full size')); } grid.append(left); const right = el('div'); const kids = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean); const adults = (family.adultEmails || []).map(e => byEmail[e]).filter(Boolean); const grades = [...new Set(kids.map(k => k.grade).filter(Boolean))]; right.append(el('div', 'role-label', grades.length ? grades.join(', ') : 'Staff')); right.append(el('h1', 'detail-name', family.name)); const firsts = [...kids, ...adults].map(m => firstName(m.fullName)); if (firsts.length) { right.append(el('div', 'detail-sub', firsts.join(', '))); } if (family.address) { right.append(contactRow(el('div', 'contact-value', family.address), [ copyButton(family.address), iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)), ])); } if (family.pronunciationUrl) { right.append(el('div', 'pronounce-label', 'How do I pronounce this?')); const audio = el('audio', 'pronounce-player'); audio.controls = true; audio.preload = 'metadata'; audio.src = family.pronunciationUrl; right.append(audio); } grid.append(right); content.append(grid); main.append(content); const band = el('div', 'band'); const inner = el('div', 'container'); inner.append(el('h2', 'band-title', 'Family Members')); const cols = el('div', 'band-grid'); const adultsCol = el('div'); if (adults.length) { adultsCol.append(el('div', 'member-header', 'Adults')); for (const a of adults) { adultsCol.append(memberRow(a, a.pronouns ? a.pronouns.toUpperCase() : '')); } } const kidsCol = el('div'); if (kids.length) { kidsCol.append(el('div', 'member-header', 'Kids')); for (const k of kids) { kidsCol.append(memberRow(k, k.pronouns ? k.pronouns.toUpperCase() : '', k.grade)); } } cols.append(adultsCol, kidsCol); inner.append(cols); band.append(inner); main.append(band); } function render() { renderNav(); const seg = segments(); if (seg[0] === 'people' && seg[1]) { renderPersonDetail(seg[1]); } else if (seg[0] === 'families' && seg[1]) { renderFamilyDetail(seg[1]); } else if (seg[0] === 'people') { renderPeople(); } } async function load() { const res = await fetch('/api/directory/model'); if (!res.ok) { throw new Error(`loading model failed: ${res.status}`); } state.model = await res.json(); byEmail = {}; for (const p of state.model.people) { byEmail[p.email] = p; } render(); } load();