const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: '', filterGrades: new Set(), filterClassrooms: new Set(), filterRoles: new Set(), filterCities: new Set(), filterPronouns: new Set(), filterNew: false}; let byEmail = {}; const favorites = new Set(JSON.parse(localStorage.getItem('favorites') || '[]')); function toggleFavorite(email) { if (favorites.has(email)) { favorites.delete(email); } else { favorites.add(email); } localStorage.setItem('favorites', JSON.stringify([...favorites])); } const icons = { people: '', classrooms: '', 'my-family': '', staff: '', map: '', 'email-list': '', everyone: '', students: '', families: '', 'staff-tab': '', search: '', filter: '', chevron: '', 'chevron-left': '', 'chevron-right': '', heart: '', mail: '', copy: '', message: '', phone: '', zap: '', more: '', dots: '', }; function isMobile() { return matchMedia('(max-width: 900px)').matches; } 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'}, ]; 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 withFrom(href) { const from = encodeURIComponent(location.pathname + location.search); return href + (href.includes('?') ? '&' : '?') + 'from=' + from; } function personLink(p) { return withFrom('/people/' + encodeURIComponent(p.email)); } function familyLink(key) { return withFrom('/families/' + encodeURIComponent(key)); } function myFamilyKey() { const me = byEmail[document.body.dataset.userEmail]; return (me && me.familyKey) || ''; } function activeSection() { const seg = segments(); if (seg[0] === 'families') { return seg[1] === myFamilyKey() ? 'my-family' : 'people'; } if (seg[0] === 'grades') { return 'classrooms'; } return seg[0]; } function setChrome(title, backHref) { document.querySelector('#mobile-title').textContent = title; const back = document.querySelector('#mobile-back'); const menuBtn = document.querySelector('#mobile-menu-btn'); back.hidden = !backHref; menuBtn.hidden = Boolean(backHref); if (backHref) { back.href = backHref; } } function renderNav() { const seg = activeSection(); 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 === seg) { a.className = 'active'; } a.append(svg(item.path), el('span', '', item.label)); nav.append(a); } const tabs = document.querySelector('#mobile-tabs'); tabs.replaceChildren(); for (const item of navSections) { const a = el('a', item.path === seg ? 'active' : ''); a.href = '/' + item.path; a.append(svg(item.path), el('span', '', item.label)); tabs.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 cardMore(email) { const wrap = el('div', 'card-more-wrap'); const button = el('button', 'card-more'); button.append(svg('dots')); const menu = el('div', 'card-menu'); menu.hidden = true; const item = el('button', 'card-menu-item'); const label = el('span', '', favorites.has(email) ? 'Remove Bookmark' : 'Add Bookmark'); item.append(svg('heart'), label); item.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); toggleFavorite(email); label.textContent = favorites.has(email) ? 'Remove Bookmark' : 'Add Bookmark'; menu.hidden = true; }); menu.append(item); button.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); menu.hidden = !menu.hidden; }); wrap.append(button, menu); return wrap; } function personCard(p) { const card = el('a', 'person-card'); card.append(cardMore(p.email)); 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) && matchesFilters(p); }); 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) && matchesFilters(p)); for (const p of matches) { const card = el('a', 'student-card'); card.href = personLink(p); card.append(cardMore(p.email)); const head = el('div', 'student-head'); const family = state.model.families[p.familyKey]; if (family && family.photoUrl) { const bg = el('img', 'student-family-photo'); bg.src = thumbUrl(family.photoUrl); bg.loading = 'lazy'; bg.alt = ''; head.append(bg); } head.append(photoOrInitials(p.photoUrl, p.fullName, 'student-photo')); card.append(head); 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: familyLink(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), email: p.email, }); } } 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) && (f.key ? familyMatchesFilters(f.key) : matchesFilters(byEmail[f.email]))); 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, autoFit) { grid.className = ''; const staff = state.model.people.filter(p => p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q) && matchesFilters(p)); 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' + (autoFit ? ' autofit' : '')); for (const p of groups.get(dept)) { const card = el('a', 'person-card'); card.href = personLink(p); card.append(cardMore(p.email)); 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 items = peopleTabs.map(t => ({...t, icon: t.key === 'staff' ? 'staff-tab' : t.key})); main.append(tabStrip(items, state.tab, 2, key => { state.tab = key; state.q = ''; history.replaceState(null, '', tabHref(key)); renderPeople(); })); 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); controls.append(search, filterControl(renderGrid)); 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 personFacets(p, field) { if (p.isStudent) { return p[field] ? [p[field]] : []; } if (p.isParent) { const family = state.model.families[p.familyKey]; return ((family && family.kidEmails) || []).map(e => byEmail[e]).filter(Boolean).map(k => k[field]).filter(Boolean); } return []; } function cityOf(p) { const family = state.model.families[p.familyKey]; if (!family || !family.address) { return ''; } const parts = family.address.split(',').map(s => s.trim()); return parts.length >= 2 ? parts[parts.length - 2] : parts[0]; } function anyFiltersActive() { return Boolean(state.filterGrades.size || state.filterClassrooms.size || state.filterRoles.size || state.filterCities.size || state.filterPronouns.size || state.filterNew); } function matchesFilters(p) { const gradeOK = !state.filterGrades.size || personFacets(p, 'grade').some(g => state.filterGrades.has(g)); const classOK = !state.filterClassrooms.size || personFacets(p, 'classroom').some(c => state.filterClassrooms.has(c)); const roleOK = !state.filterRoles.size || (state.filterRoles.has('Student') && p.isStudent) || (state.filterRoles.has('Parent') && p.isParent) || (state.filterRoles.has('Staff') && p.isStaff); const cityOK = !state.filterCities.size || state.filterCities.has(cityOf(p)); const pronounsOK = !state.filterPronouns.size || state.filterPronouns.has(p.pronouns); const newOK = !state.filterNew || p.isNew; return gradeOK && classOK && roleOK && cityOK && pronounsOK && newOK; } function familyMatchesFilters(key) { const family = state.model.families[key]; if (!family) { return !anyFiltersActive(); } const members = [...(family.kidEmails || []), ...(family.adultEmails || [])].map(e => byEmail[e]).filter(Boolean); return !anyFiltersActive() || members.some(matchesFilters); } function gradeOptions() { const present = new Set(state.model.people.filter(p => p.isStudent).map(p => p.grade).filter(Boolean)); return state.model.grades.map(g => g.name).filter(n => present.has(n)); } function cityOptions() { return [...new Set(state.model.people.map(cityOf).filter(Boolean))].sort(); } function pronounOptions() { return [...new Set(state.model.people.map(p => p.pronouns).filter(Boolean))].sort(); } function filterControl(rerender) { const wrap = el('div', 'filter-wrap'); const button = el('button', 'filter-button'); button.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); const panel = el('div', 'filter-panel'); panel.hidden = true; button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.classList.toggle('open', !panel.hidden); }); const sections = [ {label: 'Role', values: ['Student', 'Parent', 'Staff'], set: state.filterRoles}, {label: 'Class', values: state.model.classrooms.map(c => c.name), set: state.filterClassrooms}, {label: 'Grade', values: gradeOptions(), set: state.filterGrades}, {label: 'City', values: cityOptions(), set: state.filterCities}, {label: 'Pronouns', values: pronounOptions(), set: state.filterPronouns}, ]; for (const s of sections) { const head = el('div', 'filter-section'); head.append(el('span', '', s.label), svg('chevron')); const body = el('div', 'filter-options'); body.hidden = true; head.addEventListener('click', () => { body.hidden = !body.hidden; head.classList.toggle('open', !body.hidden); }); for (const v of s.values) { const row = el('label', 'filter-option'); const box = el('input'); box.type = 'checkbox'; box.checked = s.set.has(v); box.addEventListener('change', () => { if (box.checked) { s.set.add(v); } else { s.set.delete(v); } rerender(); }); row.append(el('span', '', v), box); body.append(row); } panel.append(head, body); } const toggleRow = el('label', 'filter-toggle-row'); toggleRow.append(el('span', '', 'New to Helios')); const toggle = el('input', 'filter-switch'); toggle.type = 'checkbox'; toggle.checked = state.filterNew; toggle.addEventListener('change', () => { state.filterNew = toggle.checked; rerender(); }); toggleRow.append(toggle); panel.append(toggleRow); const footer = el('div', 'filter-footer'); const clear = el('button', 'filter-clear', 'Clear all'); clear.addEventListener('click', () => { state.filterGrades.clear(); state.filterClassrooms.clear(); state.filterRoles.clear(); state.filterCities.clear(); state.filterPronouns.clear(); state.filterNew = false; for (const box of panel.querySelectorAll('input')) { box.checked = false; } rerender(); }); const done = el('button', 'filter-done', 'Done'); done.addEventListener('click', () => { panel.hidden = true; button.classList.remove('open'); }); footer.append(clear, done); panel.append(footer); wrap.append(button, panel); return wrap; } function fromURL() { const raw = new URLSearchParams(location.search).get('from'); if (!raw) { return null; } return new URL(raw, location.origin); } function classroomsBackOf(from) { const parent = new URLSearchParams(from.search).get('from'); return parent && parent.startsWith('/classrooms') ? parent : '/classrooms'; } function fromCrumbs() { const from = fromURL(); if (!from) { return null; } const seg = from.pathname.split('/').filter(Boolean).map(decodeURIComponent); const back = from.pathname + from.search; if (seg[0] === 'grades' && seg[1]) { const grade = state.model.grades.find(g => slugify(g.name) === seg[1]); if (grade) { return [['Classrooms', classroomsBackOf(from)], [grade.name, back]]; } } if (seg[0] === 'classrooms' && seg[1]) { const classroom = state.model.classrooms.find(c => slugify(c.name) === seg[1]); if (classroom) { return [['Classrooms', classroomsBackOf(from)], [classroom.name, back]]; } } if (seg[0] === 'people' && !seg[1]) { return [['People', back]]; } if (seg[0] === 'classrooms') { return [['Classrooms', back]]; } if (seg[0] === 'staff') { return [['Staff', back]]; } if (seg[0] === 'profile') { return [['Profile', back]]; } if (seg[0] === 'email-list') { return [['Email List', back]]; } return null; } function breadcrumbs(parts, favoriteEmail) { const parent = [...parts].reverse().find(([, href]) => href); setChrome(parts[parts.length - 1][0], parent ? parent[1] : '/people'); const top = el('div', 'detail-top container'); const crumbs = el('div', 'crumbs'); const back = el('a', 'crumb-back'); back.href = parent ? parent[1] : '/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); if (favoriteEmail) { const heart = el('button', 'heart-button' + (favorites.has(favoriteEmail) ? ' active' : '')); heart.append(svg('heart')); heart.addEventListener('click', () => { toggleFavorite(favoriteEmail); heart.classList.toggle('active'); }); 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 = familyLink(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; } const origin = fromCrumbs() || [['People', '/people']]; main.append(breadcrumbs([...origin, [p.fullName, null]], p.email)); 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$/, ''); let crumbs = [['People', '/people'], [shortName, null], ['Family', null]]; const from = fromURL(); if (key === myFamilyKey()) { crumbs = [['My Family', '/my-family'], ['Family', null]]; } else if (from) { const rseg = from.pathname.split('/').filter(Boolean).map(decodeURIComponent); const back = from.pathname + from.search; if (rseg[0] === 'people' && rseg[1] && byEmail[rseg[1]]) { const person = byEmail[rseg[1]]; const peopleBack = new URLSearchParams(from.search).get('from'); const peopleHref = peopleBack && peopleBack.startsWith('/people') && !peopleBack.startsWith('/people/') ? peopleBack : '/people'; crumbs = [['People', peopleHref], [person.fullName, back], ['Family', null]]; } else if (rseg[0] === 'people' && !rseg[1]) { crumbs = [['People', back], [shortName, null], ['Family', null]]; } else if (rseg[0] === 'map') { crumbs = [['Map', back], [shortName, null], ['Family', null]]; } else if (rseg[0] === 'profile') { crumbs = [['Profile', back], ['Family', null]]; } } main.append(breadcrumbs(crumbs)); 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 slugify(name) { return name.toLowerCase().replaceAll(' ', '-'); } function ordinal(gradeName) { const n = Number(gradeName.split(' ')[1]); return n + ({1: 'st', 2: 'nd', 3: 'rd'}[n] || 'th'); } function bandGroups() { const groups = []; for (const g of state.model.grades) { if (!g.band || g.band === 'Eggs' || g.band === 'Alum' || g.name === 'PreK' || g.name === 'Grade 9') { continue; } let group = groups.find(x => x.band === g.band); if (!group) { group = {band: g.band, grades: []}; groups.push(group); } group.grades.push(g.name); } for (const group of groups) { group.label = group.band === 'Hummingbirds' ? 'K' : group.grades.map(ordinal).join(' / '); } return groups; } function gradeImage(gradeName) { const suffix = gradeName === 'Kindergarten' ? 'k' : gradeName.split(' ')[1]; return '/static/brand/classrooms/grade-' + suffix + '.jpg'; } function studentsOf(filter) { return state.model.people.filter(p => p.isStudent && filter(p)); } function classroomBand(name) { const student = state.model.people.find(p => p.isStudent && p.classroom === name); if (!student) { return ''; } const grade = state.model.grades.find(g => g.name === student.grade); return grade ? grade.band : ''; } function listRow(image, label, title, sub, href) { const row = el('a', 'list-row'); row.href = href; if (image) { const img = el('img', 'list-tile'); img.src = image; img.loading = 'lazy'; img.alt = ''; row.append(img); } else { row.append(el('div', 'list-tile')); } const info = el('div', 'list-info'); if (label) { info.append(el('div', 'role-label', label)); } info.append(el('div', 'list-title', title)); if (sub) { info.append(el('div', 'list-sub', sub)); } row.append(info); const chev = el('div', 'list-chevron'); chev.append(svg('chevron-right')); row.append(chev); return row; } const classroomsTabs = [ {key: 'by-classroom', label: 'Explore by Classroom', heading: 'Explore by Classroom'}, {key: 'by-grade', label: 'Explore by Grade', heading: 'Explore By Grade'}, {key: 'room-parents', label: 'Room Parents', heading: ''}, ]; function renderClassroomsList(list) { const q = state.q; let count = 0; for (const group of bandGroups()) { const rows = state.model.classrooms .filter(c => classroomBand(c.name) === group.band) .filter(c => c.name.toLowerCase().includes(q)); if (!rows.length) { continue; } list.append(el('h2', 'group-header', group.label)); for (const c of rows) { const students = studentsOf(p => p.classroom === c.name).length; list.append(listRow(c.imageUrl, `${students} students`, c.name, '', withFrom('/classrooms/' + slugify(c.name)))); count++; } } return count; } function renderGradesList(list) { const q = state.q; let count = 0; for (const group of bandGroups()) { const rows = group.grades.filter(name => name.toLowerCase().includes(q)); if (!rows.length) { continue; } list.append(el('h2', 'group-header', group.label)); for (const name of rows) { const students = studentsOf(p => p.grade === name).length; list.append(listRow(gradeImage(name), `${students} students`, name, '', withFrom('/grades/' + slugify(name)))); count++; } } return count; } function kidsSummary(parent) { const family = state.model.families[parent.familyKey]; if (!family) { return ''; } return (family.kidEmails || []) .map(e => byEmail[e]) .filter(Boolean) .map(k => `${firstName(k.fullName)} (${[k.classroom, k.grade].filter(Boolean).join(' - ')})`) .join(' • '); } function renderRoomParents(list) { const q = state.q; let count = 0; for (const group of bandGroups()) { const parents = (state.model.roomParents[group.label] || []) .map(e => byEmail[e]) .filter(Boolean) .filter(p => p.fullName.toLowerCase().includes(q)); if (!parents.length) { continue; } list.append(el('h2', 'group-header', group.label)); for (const p of parents) { list.append(listRow(thumbUrl(p.photoUrl), '', p.fullName, kidsSummary(p), personLink(p))); count++; } } return count; } const classroomsTabRenderers = { 'by-classroom': renderClassroomsList, 'by-grade': renderGradesList, 'room-parents': renderRoomParents, }; function renderClassroomsPage() { const main = document.querySelector('#main'); main.replaceChildren(); main.append(tabStrip(classroomsTabs, state.classTab, 1, key => { state.classTab = key; state.q = ''; history.replaceState(null, '', tabHref(key)); renderClassroomsPage(); })); const content = el('div', 'content container'); const header = el('div', 'content-header'); const heading = classroomsTabs.find(t => t.key === state.classTab).heading; header.append(el('h1', '', heading)); 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(); renderList(); }); search.append(input); controls.append(search); header.append(controls); content.append(header); const list = el('div'); content.append(list); main.append(content); function renderList() { list.replaceChildren(); if (classroomsTabRenderers[state.classTab](list) === 0) { list.append(el('div', 'empty', 'No matches.')); } } renderList(); } function renderStaffPage() { const main = document.querySelector('#main'); main.replaceChildren(); const content = el('div', 'content container'); const header = el('div', 'content-header'); header.append(el('h1', '', 'Staff')); 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(); renderList(); }); search.append(input); controls.append(search, filterControl(renderList)); header.append(controls); content.append(header); const list = el('div'); content.append(list); main.append(content); function renderList() { list.replaceChildren(); if (renderStaff(list, true) === 0) { list.append(el('div', 'empty', 'No matches.')); } } renderList(); } function parentsOf(students) { const seen = new Set(); const parents = []; for (const s of students) { const family = state.model.families[s.familyKey]; for (const email of (family && family.adultEmails) || []) { if (!seen.has(email) && byEmail[email]) { seen.add(email); parents.push(byEmail[email]); } } } return parents; } function teachersOf(classroomNames) { const seen = new Set(); const teachers = []; for (const section of state.model.sections) { if (!classroomNames.includes(section.classroom)) { continue; } for (const name of section.teachers || []) { if (!seen.has(name)) { seen.add(name); teachers.push(name); } } } return teachers; } function otherFamilyMembers(student) { const family = state.model.families[student.familyKey]; if (!family) { return ''; } return [...(family.kidEmails || []), ...(family.adultEmails || [])] .filter(e => e !== student.email) .map(e => byEmail[e]) .filter(Boolean) .map(m => m.fullName) .join(', '); } function renderRoster(title, image, groups, backLabel) { const main = document.querySelector('#main'); main.replaceChildren(); const from = fromURL(); const back = from && from.pathname === '/classrooms' ? from.pathname + from.search : '/classrooms'; main.append(breadcrumbs([['Classrooms', back], [title, null]])); const content = el('div', 'container detail-content'); const head = el('div', 'class-head'); if (image) { const img = el('img', 'class-tile'); img.src = image; img.alt = ''; head.append(img); } head.append(el('h1', 'class-title', title)); content.append(head); const allStudents = groups.flatMap(g => g.students); const teachers = teachersOf([...new Set(allStudents.map(s => s.classroom).filter(Boolean))]); const parents = parentsOf(allStudents); const memberTabs = [ {key: 'students', label: 'Students', icon: 'students', count: allStudents.length}, {key: 'staff', label: 'Staff', icon: 'staff-tab', count: teachers.length}, {key: 'parents', label: 'Parents', icon: 'families', count: parents.length}, ]; const strip = tabStrip(memberTabs, state.rosterTab, 2, key => { state.rosterTab = key; history.replaceState(null, '', tabHref(key)); renderRoster(title, image, groups, backLabel); }); strip.classList.add('roster-tabs'); strip.querySelector('.tabs-row').classList.remove('container'); content.append(strip); const list = el('div'); if (state.rosterTab === 'students') { const heading = el('h2', 'roster-heading', `${allStudents.length} Students`); list.append(heading); for (const group of groups) { if (group.header) { list.append(el('h2', 'group-header', group.header)); } for (const s of group.students) { const row = listRow(thumbUrl(s.photoUrl), otherFamilyMembers(s).toUpperCase(), s.fullName, s.facts || '', personLink(s)); list.append(row); } } } else if (state.rosterTab === 'staff') { for (const email of teachers) { const person = byEmail[email.toLowerCase()]; if (person) { list.append(listRow(thumbUrl(person.photoUrl), (person.jobTitle || '').toUpperCase(), person.fullName, person.facts || '', personLink(person))); } else { list.append(el('div', 'list-row plain', email)); } } } else { for (const p of parents) { list.append(listRow(thumbUrl(p.photoUrl), '', p.fullName, kidsSummary(p), personLink(p))); } } content.append(list); main.append(content); } function renderGradeDetail(slug) { const grade = state.model.grades.find(g => slugify(g.name) === slug); if (!grade) { document.querySelector('#main').replaceChildren(el('div', 'empty', 'Not found.')); return; } const students = studentsOf(p => p.grade === grade.name); const classrooms = [...new Set(students.map(s => s.classroom).filter(Boolean))].sort(); const groups = classrooms.length ? classrooms.map(name => ({header: name, students: students.filter(s => s.classroom === name)})) : [{header: '', students}]; renderRoster(grade.name, gradeImage(grade.name), groups); } function renderClassroomDetail(slug) { const classroom = state.model.classrooms.find(c => slugify(c.name) === slug); if (!classroom) { document.querySelector('#main').replaceChildren(el('div', 'empty', 'Not found.')); return; } const students = studentsOf(p => p.classroom === classroom.name); const sections = [...new Set(students.map(s => s.section).filter(Boolean))].sort(); const groups = sections.length ? sections.map(name => ({header: name, students: students.filter(s => s.section === name)})) : [{header: classroom.name, students}]; renderRoster(classroom.name, classroom.imageUrl, groups); } const emailTabs = [ {key: 'parents', label: 'Parents'}, {key: 'students', label: 'Students'}, {key: 'both', label: 'Students & Parents'}, {key: 'bookmarks', label: 'My Bookmarks'}, ]; function kidsField(parent, field) { const family = state.model.families[parent.familyKey]; const values = ((family && family.kidEmails) || []) .map(e => byEmail[e]) .filter(Boolean) .map(k => k[field]) .filter(Boolean); return [...new Set(values)].join(', '); } function emailEntries(tab) { if (tab === 'bookmarks') { return emailEntries('both').filter(r => favorites.has(r.p.email)); } const students = state.model.people .filter(p => p.isStudent) .sort((a, b) => a.fullName.localeCompare(b.fullName)); const rows = []; const seen = new Set(); const add = (p, role, grade, classroom) => { if (!seen.has(p.email)) { seen.add(p.email); rows.push({p, role, grade, classroom}); } }; for (const s of students) { if (tab !== 'parents') { add(s, 'Student', s.grade || '', s.classroom || ''); } if (tab !== 'students') { const family = state.model.families[s.familyKey]; for (const email of (family && family.adultEmails) || []) { const parent = byEmail[email]; if (parent) { add(parent, 'Parent', kidsField(parent, 'grade'), kidsField(parent, 'classroom')); } } } } return rows; } function renderEmailListPage() { const main = document.querySelector('#main'); main.replaceChildren(); main.append(el('div', 'container email-hint', 'Use the filters to select for specific grades or classrooms.')); const items = emailTabs.map(t => (t.key === 'bookmarks' ? {...t, icon: 'heart'} : t)); main.append(tabStrip(items, state.emailTab, 2, key => { state.emailTab = key; state.q = ''; history.replaceState(null, '', tabHref(key)); renderEmailListPage(); })); const content = el('div', 'content container'); const header = el('div', 'content-header'); header.append(el('h1', '', emailTabs.find(t => t.key === state.emailTab).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(); renderTable(); }); search.append(input); controls.append(search, filterControl(renderTable)); header.append(controls); content.append(header); const holder = el('div', 'email-holder'); content.append(holder); main.append(content); function renderTable() { holder.replaceChildren(); const rows = emailEntries(state.emailTab) .filter(r => (r.p.fullName.toLowerCase().includes(state.q) || r.p.email.toLowerCase().includes(state.q)) && matchesFilters(r.p)); if (!rows.length) { holder.append(el('div', 'empty', state.emailTab === 'bookmarks' ? 'No bookmarks yet.' : 'No matches.')); return; } const table = el('table', 'email-table'); const thead = el('thead'); const headRow = el('tr'); for (const label of ['', 'Full Name', 'Email', 'Role', 'Grade', 'Classroom', '']) { headRow.append(el('th', '', label)); } thead.append(headRow); table.append(thead); const tbody = el('tbody'); rows.forEach((r, i) => { const tr = el('tr'); tr.append(el('td', 'email-num', String(i + 1))); const nameCell = el('td', 'email-name'); const nameLink = el('a', '', r.p.fullName); nameLink.href = personLink(r.p); nameCell.append(nameLink); tr.append(nameCell); tr.append(el('td', '', r.p.email)); tr.append(el('td', '', r.role)); tr.append(el('td', '', r.grade)); tr.append(el('td', '', r.classroom)); const heartCell = el('td', 'email-heart'); const heart = el('button', 'row-heart' + (favorites.has(r.p.email) ? ' active' : '')); heart.append(svg('heart')); heart.addEventListener('click', () => { toggleFavorite(r.p.email); if (state.emailTab === 'bookmarks') { renderTable(); } else { heart.classList.toggle('active'); } }); heartCell.append(heart); tr.append(heartCell); tbody.append(tr); }); table.append(tbody); holder.append(table); } renderTable(); input.focus(); } let mapsPromise = null; function loadMaps() { if (!mapsPromise) { mapsPromise = new Promise(resolve => { window._mapsReady = resolve; const script = el('script'); script.src = 'https://maps.googleapis.com/maps/api/js?key=' + encodeURIComponent(document.body.dataset.mapsKey) + '&callback=_mapsReady'; script.async = true; document.head.append(script); }); } return mapsPromise; } const pinIcon = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent( '' + '' + ''); function renderMapPage() { const main = document.querySelector('#main'); main.replaceChildren(); const content = el('div', 'content container'); const header = el('div', 'content-header'); header.append(el('h1', '', 'Map')); 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(); renderPins(); }); search.append(input); controls.append(search, filterControl(() => renderPins())); header.append(controls); content.append(header); const canvas = el('div', 'map-canvas'); content.append(canvas); const update = el('div', 'map-update'); const action = el('a', 'map-update-link'); action.append(svg('zap'), el('span', '', 'Update My Address')); update.append(action); content.append(update); main.append(content); let map = null; let info = null; let markers = []; function familySearchText(family) { const members = [...(family.kidEmails || []), ...(family.adultEmails || [])] .map(e => byEmail[e]).filter(Boolean).map(p => p.fullName); return `${family.name || ''} ${members.join(' ')}`.toLowerCase(); } function popupContent(family) { const box = el('div', 'map-popup'); if (family.photoUrl) { const img = el('img', 'map-popup-photo'); img.src = thumbUrl(family.photoUrl); img.alt = ''; box.append(img); } box.append(el('div', 'map-popup-name', family.name)); if (family.address) { box.append(el('div', 'map-popup-sub', family.address)); } const link = el('a', 'map-popup-link', 'See family'); link.href = familyLink(family.key); box.append(link); return box; } function renderPins() { if (!map) { return; } for (const m of markers) { m.setMap(null); } markers = []; for (const family of Object.values(state.model.families)) { if (!family.lat && !family.lng) { continue; } if (!familyMatchesFilters(family.key) || !familySearchText(family).includes(state.q)) { continue; } const marker = new google.maps.Marker({ map, position: {lat: family.lat, lng: family.lng}, icon: {url: pinIcon, anchor: new google.maps.Point(17, 33)}, title: family.name, }); marker.addListener('click', () => { info.setContent(popupContent(family)); info.open(map, marker); }); markers.push(marker); } } loadMaps().then(() => { if (!canvas.isConnected) { return; } map = new google.maps.Map(canvas, { mapTypeControl: false, streetViewControl: false, fullscreenControl: false, }); info = new google.maps.InfoWindow(); const bounds = new google.maps.LatLngBounds(); for (const family of Object.values(state.model.families)) { if (family.lat || family.lng) { bounds.extend({lat: family.lat, lng: family.lng}); } } map.fitBounds(bounds); renderPins(); }); } function renderProfile() { const main = document.querySelector('#main'); main.replaceChildren(); const me = byEmail[document.body.dataset.userEmail]; if (!me) { main.append(el('div', 'empty', 'Not found.')); return; } const content = el('div', 'container content'); const family = state.model.families[me.familyKey]; if (family) { content.append(el('h1', 'profile-heading', 'Family Photo')); content.append(listRow(thumbUrl(family.photoUrl), '', 'Family Photo', family.photoCaption || '', familyLink(me.familyKey))); const kids = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean); if (kids.length) { content.append(el('h1', 'profile-heading', 'Students')); for (const k of kids) { content.append(listRow(thumbUrl(k.photoUrl), (k.pronouns || '').toUpperCase(), firstName(k.fullName), '', personLink(k))); } } } main.append(content); } function tabParam(fallback) { return new URLSearchParams(location.search).get('tab') || fallback; } function tabNode(item, active, onSelect) { const node = el('div', 'tab' + (active ? ' active' : '')); if (item.icon) { node.append(svg(item.icon)); } node.append(el('span', '', item.label)); if (item.count !== undefined) { node.append(el('span', 'tab-count', String(item.count))); } node.addEventListener('click', () => onSelect(item.key)); return node; } function tabStrip(items, activeKey, mobileVisible, onSelect) { const tabs = el('div', 'tabs'); const row = el('div', 'container tabs-row'); const visible = isMobile() ? items.slice(0, mobileVisible) : items; const hidden = isMobile() ? items.slice(mobileVisible) : []; for (const item of visible) { row.append(tabNode(item, item.key === activeKey, onSelect)); } if (hidden.length) { const wrap = el('div', 'more-wrap'); const more = el('div', 'tab' + (hidden.some(t => t.key === activeKey) ? ' active' : '')); more.append(svg('more'), el('span', '', 'More'), svg('chevron')); const menu = el('div', 'more-menu'); menu.hidden = true; for (const item of hidden) { const entry = el('div', 'more-item' + (item.key === activeKey ? ' active' : '')); if (item.icon) { entry.append(svg(item.icon)); } entry.append(el('span', '', item.label)); if (item.count !== undefined) { entry.append(el('span', 'tab-count', String(item.count))); } entry.addEventListener('click', () => onSelect(item.key)); menu.append(entry); } more.addEventListener('click', e => { e.stopPropagation(); menu.hidden = !menu.hidden; }); wrap.append(more, menu); row.append(wrap); } tabs.append(row); return tabs; } function tabHref(key) { const params = new URLSearchParams(location.search); params.set('tab', key); return location.pathname + '?' + params; } const sectionTitles = { people: 'People', classrooms: 'Classrooms', 'my-family': 'My Family', staff: 'Staff', map: 'Map', 'email-list': 'Email List', profile: 'Profile', }; function render() { renderNav(); setChrome(sectionTitles[segments()[0]] || 'Helios Who?', null); 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') { state.tab = tabParam('everyone'); renderPeople(); } else if (seg[0] === 'classrooms' && seg[1]) { state.rosterTab = tabParam('students'); renderClassroomDetail(seg[1]); } else if (seg[0] === 'grades' && seg[1]) { state.rosterTab = tabParam('students'); renderGradeDetail(seg[1]); } else if (seg[0] === 'classrooms') { state.classTab = tabParam('by-classroom'); renderClassroomsPage(); } else if (seg[0] === 'staff') { state.q = ''; renderStaffPage(); } else if (seg[0] === 'email-list') { state.emailTab = tabParam('parents'); state.q = ''; renderEmailListPage(); } else if (seg[0] === 'map') { state.q = ''; renderMapPage(); } else if (seg[0] === 'profile') { renderProfile(); } } const userMenu = document.querySelector('#user-menu'); document.querySelector('#user').addEventListener('click', e => { e.stopPropagation(); userMenu.hidden = !userMenu.hidden; }); const drawer = document.querySelector('#drawer'); const drawerOverlay = document.querySelector('#drawer-overlay'); const drawerUserMenu = document.querySelector('#drawer-user-menu'); function setDrawer(open) { drawer.hidden = !open; drawerOverlay.hidden = !open; if (!open) { drawerUserMenu.hidden = true; } } document.querySelector('#mobile-menu-btn').addEventListener('click', () => setDrawer(true)); document.querySelector('#drawer-close').addEventListener('click', () => setDrawer(false)); drawerOverlay.addEventListener('click', () => setDrawer(false)); document.querySelector('#drawer-user-more').addEventListener('click', e => { e.stopPropagation(); drawerUserMenu.hidden = !drawerUserMenu.hidden; }); function closeFilterPanels() { for (const panel of document.querySelectorAll('.filter-panel')) { panel.hidden = true; panel.parentElement.querySelector('.filter-button').classList.remove('open'); } } document.addEventListener('click', e => { userMenu.hidden = true; drawerUserMenu.hidden = true; for (const menu of document.querySelectorAll('.more-menu, .card-menu')) { if (!menu.hidden && !menu.parentElement.contains(e.target)) { menu.hidden = true; } } for (const panel of document.querySelectorAll('.filter-panel')) { if (!panel.hidden && !panel.parentElement.contains(e.target)) { panel.hidden = true; panel.parentElement.querySelector('.filter-button').classList.remove('open'); } } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { userMenu.hidden = true; setDrawer(false); closeFilterPanels(); for (const menu of document.querySelectorAll('.more-menu, .card-menu')) { menu.hidden = true; } } }); 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();