diff --git a/internal/directory/directory.go b/internal/directory/directory.go index 7e92647..5d77a94 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -36,6 +36,8 @@ func Register(mux *http.ServeMux, cache *Cache) { } mux.HandleFunc("GET /people/{email}", a.page) mux.HandleFunc("GET /families/{key}", a.page) + mux.HandleFunc("GET /classrooms/{name}", a.page) + mux.HandleFunc("GET /grades/{name}", a.page) mux.HandleFunc("GET /dl/", a.legacyRedirect) mux.HandleFunc("GET /api/directory/model", a.model) } diff --git a/internal/directory/load.go b/internal/directory/load.go index e1f3d25..3c34b26 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -125,9 +125,13 @@ func LoadModel(source data.Source) (*Model, error) { if row["Class"] == "" { continue } + imageURL := "" + if row["Classroom Image"] != "" { + imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(row["Class"]) + ".jpg" + } model.Classrooms = append(model.Classrooms, Classroom{ Name: row["Class"], - ImageURL: row["Classroom Image"], + ImageURL: imageURL, HasSections: row["Has Sections"] == "TRUE", }) } diff --git a/web/static/brand/classrooms/classroom-condors.jpg b/web/static/brand/classrooms/classroom-condors.jpg new file mode 100644 index 0000000..bb14c29 Binary files /dev/null and b/web/static/brand/classrooms/classroom-condors.jpg differ diff --git a/web/static/brand/classrooms/classroom-egrets.jpg b/web/static/brand/classrooms/classroom-egrets.jpg new file mode 100644 index 0000000..fa6e8e0 Binary files /dev/null and b/web/static/brand/classrooms/classroom-egrets.jpg differ diff --git a/web/static/brand/classrooms/classroom-falcons.jpg b/web/static/brand/classrooms/classroom-falcons.jpg new file mode 100644 index 0000000..17783d1 Binary files /dev/null and b/web/static/brand/classrooms/classroom-falcons.jpg differ diff --git a/web/static/brand/classrooms/classroom-hawks.jpg b/web/static/brand/classrooms/classroom-hawks.jpg new file mode 100644 index 0000000..df6f3d6 Binary files /dev/null and b/web/static/brand/classrooms/classroom-hawks.jpg differ diff --git a/web/static/brand/classrooms/classroom-herons.jpg b/web/static/brand/classrooms/classroom-herons.jpg new file mode 100644 index 0000000..eb6466c Binary files /dev/null and b/web/static/brand/classrooms/classroom-herons.jpg differ diff --git a/web/static/brand/classrooms/classroom-hummingbirds.jpg b/web/static/brand/classrooms/classroom-hummingbirds.jpg new file mode 100644 index 0000000..d504ecd Binary files /dev/null and b/web/static/brand/classrooms/classroom-hummingbirds.jpg differ diff --git a/web/static/brand/classrooms/classroom-jays.jpg b/web/static/brand/classrooms/classroom-jays.jpg new file mode 100644 index 0000000..370220b Binary files /dev/null and b/web/static/brand/classrooms/classroom-jays.jpg differ diff --git a/web/static/brand/classrooms/classroom-ospreys.jpg b/web/static/brand/classrooms/classroom-ospreys.jpg new file mode 100644 index 0000000..e583f85 Binary files /dev/null and b/web/static/brand/classrooms/classroom-ospreys.jpg differ diff --git a/web/static/brand/classrooms/classroom-ravens.jpg b/web/static/brand/classrooms/classroom-ravens.jpg new file mode 100644 index 0000000..194b1d0 Binary files /dev/null and b/web/static/brand/classrooms/classroom-ravens.jpg differ diff --git a/web/static/brand/classrooms/grade-1.jpg b/web/static/brand/classrooms/grade-1.jpg new file mode 100644 index 0000000..35038a7 Binary files /dev/null and b/web/static/brand/classrooms/grade-1.jpg differ diff --git a/web/static/brand/classrooms/grade-2.jpg b/web/static/brand/classrooms/grade-2.jpg new file mode 100644 index 0000000..66e2552 Binary files /dev/null and b/web/static/brand/classrooms/grade-2.jpg differ diff --git a/web/static/brand/classrooms/grade-3.jpg b/web/static/brand/classrooms/grade-3.jpg new file mode 100644 index 0000000..11a2770 Binary files /dev/null and b/web/static/brand/classrooms/grade-3.jpg differ diff --git a/web/static/brand/classrooms/grade-4.jpg b/web/static/brand/classrooms/grade-4.jpg new file mode 100644 index 0000000..bb24c7a Binary files /dev/null and b/web/static/brand/classrooms/grade-4.jpg differ diff --git a/web/static/brand/classrooms/grade-5.jpg b/web/static/brand/classrooms/grade-5.jpg new file mode 100644 index 0000000..9ae4da7 Binary files /dev/null and b/web/static/brand/classrooms/grade-5.jpg differ diff --git a/web/static/brand/classrooms/grade-6.jpg b/web/static/brand/classrooms/grade-6.jpg new file mode 100644 index 0000000..92ca599 Binary files /dev/null and b/web/static/brand/classrooms/grade-6.jpg differ diff --git a/web/static/brand/classrooms/grade-7.jpg b/web/static/brand/classrooms/grade-7.jpg new file mode 100644 index 0000000..9db6bad Binary files /dev/null and b/web/static/brand/classrooms/grade-7.jpg differ diff --git a/web/static/brand/classrooms/grade-8.jpg b/web/static/brand/classrooms/grade-8.jpg new file mode 100644 index 0000000..dd4f67c Binary files /dev/null and b/web/static/brand/classrooms/grade-8.jpg differ diff --git a/web/static/brand/classrooms/grade-k.jpg b/web/static/brand/classrooms/grade-k.jpg new file mode 100644 index 0000000..d504ecd Binary files /dev/null and b/web/static/brand/classrooms/grade-k.jpg differ diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 7a1d346..5de293e 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -1,4 +1,4 @@ -const state = {model: null, tab: 'everyone', q: ''}; +const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: ''}; let byEmail = {}; const icons = { @@ -297,6 +297,7 @@ function renderPeople() { node.addEventListener('click', () => { state.tab = tab.key; state.q = ''; + history.replaceState(null, '', '/people?tab=' + tab.key); renderPeople(); }); tabsRow.append(node); @@ -338,11 +339,39 @@ function renderPeople() { input.focus(); } +function referrerCrumbs() { + if (!document.referrer) { + return null; + } + const ref = new URL(document.referrer); + if (ref.origin !== location.origin) { + return null; + } + const seg = ref.pathname.split('/').filter(Boolean).map(decodeURIComponent); + if (seg[0] === 'grades' && seg[1]) { + const grade = state.model.grades.find(g => slugify(g.name) === seg[1]); + if (grade) { + return [['Classrooms', '/classrooms'], [grade.name, ref.pathname + ref.search]]; + } + } + if (seg[0] === 'classrooms' && seg[1]) { + const classroom = state.model.classrooms.find(c => slugify(c.name) === seg[1]); + if (classroom) { + return [['Classrooms', '/classrooms'], [classroom.name, ref.pathname + ref.search]]; + } + } + if (seg[0] === 'classrooms') { + return [['Classrooms', '/classrooms']]; + } + return null; +} + function breadcrumbs(parts) { const top = el('div', 'detail-top container'); const crumbs = el('div', 'crumbs'); const back = el('a', 'crumb-back'); - back.href = '/people'; + const parent = [...parts].reverse().find(([, href]) => href); + back.href = parent ? parent[1] : '/people'; back.append(svg('chevron-left')); crumbs.append(back); parts.forEach(([label, href], i) => { @@ -479,7 +508,8 @@ function renderPersonDetail(email) { main.append(el('div', 'empty', 'Not found.')); return; } - main.append(breadcrumbs([['People', '/people'], [p.fullName, null]])); + const origin = referrerCrumbs() || [['People', '/people']]; + main.append(breadcrumbs([...origin, [p.fullName, null]])); const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); @@ -632,6 +662,361 @@ function renderFamilyDetail(key) { 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, '', '/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, '', '/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(); + + const tabs = el('div', 'tabs'); + const tabsRow = el('div', 'container tabs-row'); + for (const tab of classroomsTabs) { + const node = el('div', 'tab' + (tab.key === state.classTab ? ' active' : '')); + node.append(el('span', '', tab.label)); + node.addEventListener('click', () => { + state.classTab = tab.key; + state.q = ''; + history.replaceState(null, '', '/classrooms?tab=' + tab.key); + renderClassroomsPage(); + }); + tabsRow.append(node); + } + tabs.append(tabsRow); + main.append(tabs); + + 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 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(); + main.append(breadcrumbs([['Classrooms', '/classrooms'], [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 tabs = el('div', 'tabs roster-tabs'); + const tabsRow = el('div', 'tabs-row'); + for (const tab of memberTabs) { + const node = el('div', 'tab' + (tab.key === state.rosterTab ? ' active' : '')); + node.append(svg(tab.icon), el('span', '', tab.label), el('span', 'tab-count', String(tab.count))); + node.addEventListener('click', () => { + state.rosterTab = tab.key; + history.replaceState(null, '', location.pathname + '?tab=' + tab.key); + renderRoster(title, image, groups, backLabel); + }); + tabsRow.append(node); + } + tabs.append(tabsRow); + content.append(tabs); + + 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); +} + +function tabParam(fallback) { + return new URLSearchParams(location.search).get('tab') || fallback; +} + function render() { renderNav(); const seg = segments(); @@ -640,7 +1025,17 @@ function render() { } 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(); } } diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 8f0e053..1180680 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -563,6 +563,109 @@ a.family-card { } } +.group-header { + font-size: 16px; + font-weight: 600; + margin: 26px 0 6px; +} + +.list-row { + display: flex; + align-items: center; + gap: 18px; + padding: 13px 0; + border-bottom: 1px solid var(--line); + color: inherit; + text-decoration: none; +} + +.list-row.plain { + display: block; + font-weight: 600; +} + +.list-tile { + width: 80px; + height: 80px; + border-radius: 14px; + object-fit: cover; + background: #eef1f3; + flex-shrink: 0; +} + +.list-info { + min-width: 0; +} + +.list-title { + font-weight: 600; + font-size: 16px; +} + +.list-sub { + color: var(--muted); + font-size: 14px; + line-height: 1.45; + margin-top: 3px; + white-space: pre-line; +} + +.list-chevron { + margin-left: auto; + color: #b6bcc2; + flex-shrink: 0; +} + +.list-chevron svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.class-head { + display: flex; + align-items: center; + gap: 26px; + margin: 4px 0 10px; +} + +.class-tile { + width: 120px; + height: 120px; + border-radius: 18px; + object-fit: cover; +} + +.class-title { + font-size: 28px; + font-weight: 700; + margin: 0; +} + +.roster-tabs { + position: static; + padding-top: 0; + margin-bottom: 6px; +} + +.tab-count { + background: #eef0f2; + border-radius: 10px; + padding: 1px 8px; + font-size: 12px; + color: #5b6572; +} + +.roster-heading { + font-size: 22px; + font-weight: 700; + margin: 18px 0 4px; +} + .pronounce-label { text-align: center; color: var(--muted);