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: '', }; 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 section() { return location.pathname.replaceAll('/', ''); } function renderNav() { const nav = document.querySelector('#nav'); nav.replaceChildren(); 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 === section()) { 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 photoOrInitials(url, name, className) { if (url) { const img = el('img', className); img.src = 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 personContext(p) { if (p.isStudent) { return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '); } 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('div', 'person-card'); 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('div', 'student-card'); if (p.photoUrl) { const img = el('img', 'student-photo'); img.src = 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', [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '))); 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 { 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, }; }); 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, }); } } 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('div', 'family-card'); 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('div', 'person-card'); 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 render() { renderNav(); if (section() === '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();