From 57e8fc92ba967b57048dc618f2405e1b34712b8a Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 15 Aug 2026 22:45:00 -0700 Subject: [PATCH] Add email list section with bookmark hearts and linked names --- docs/directory.md | 2 +- docs/plan.md | 4 +- web/static/directory/app.js | 166 +++++++++++++++++++++++++++++++++ web/static/directory/style.css | 86 +++++++++++++++++ 4 files changed, 255 insertions(+), 3 deletions(-) diff --git a/docs/directory.md b/docs/directory.md index 576c1b5..99a4b47 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -53,7 +53,7 @@ A map of family locations, plus an "update my address" self-service action. ### Email List -A copyable/exportable contact table for party planning and outreach: full name, email, role, grade, classroom. Tabs narrow to parents, students, both, or the user's bookmarked people. Filters select grades or classrooms. Includes a note that young students (K-2) have assigned email addresses but no access to them yet. +A copyable contact table for party planning and outreach: full name, email, role, grade, classroom. Tabs narrow to parents, students, both, or the user's bookmarked people. Filters select grades or classrooms. ### Data View diff --git a/docs/plan.md b/docs/plan.md index 9f9536b..ec5f7b6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -4,10 +4,10 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da ## Directory app -- Remaining sections: My Family, Map, Email List. +- Remaining sections: My Family, Map. - Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful. - Filter button behavior — currently visual only; the original filters by grade and classroom. -- Favorites: the heart toggle and bookmarks, feeding the email list's bookmarks tab. +- Favorites: heart toggles on detail pages; email-list rows already heart into the bookmarks tab, saved per-device in the browser. - Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`. - Self-service flows: photo and pronunciation upload, address update, opt-out. diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 251c54d..182eb52 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -1,5 +1,15 @@ const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: ''}; 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: '', @@ -362,6 +372,9 @@ function referrerCrumbs() { if (seg[0] === 'profile') { return [['Profile', '/profile']]; } + if (seg[0] === 'email-list') { + return [['Email List', ref.pathname + ref.search]]; + } return null; } @@ -1059,6 +1072,155 @@ function renderClassroomDetail(slug) { 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 tabs = el('div', 'tabs'); + const tabsRow = el('div', 'container tabs-row'); + for (const tab of emailTabs) { + const node = el('div', 'tab' + (tab.key === state.emailTab ? ' active' : '')); + if (tab.key === 'bookmarks') { + node.append(svg('heart')); + } + node.append(el('span', '', tab.label)); + node.addEventListener('click', () => { + state.emailTab = tab.key; + state.q = ''; + history.replaceState(null, '', '/email-list?tab=' + tab.key); + renderEmailListPage(); + }); + 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', '', 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); + 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 holder = el('div'); + 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)); + 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(); +} + function renderProfile() { const main = document.querySelector('#main'); main.replaceChildren(); @@ -1109,6 +1271,10 @@ function render() { } 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] === 'profile') { renderProfile(); } diff --git a/web/static/directory/style.css b/web/static/directory/style.css index ca351ef..8fce8cf 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -246,6 +246,92 @@ main { margin: 0; } +.email-hint { + color: var(--muted); + font-size: 14px; + padding-top: 16px; + padding-bottom: 2px; +} + +.email-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.email-table th { + text-align: left; + color: var(--muted); + font-weight: 500; + font-size: 13px; + background: #f8f9fa; +} + +.email-table th, +.email-table td { + border: 1px solid var(--line); + padding: 9px 12px; +} + +.email-table tbody tr:nth-child(even) { + background: #fafbfb; +} + +.email-num { + color: #b6bcc2; + font-size: 12px; + width: 34px; +} + +.email-name { + font-weight: 600; +} + +.email-name a { + color: inherit; + text-decoration: none; +} + +.email-name a:hover { + text-decoration: underline; +} + +.email-heart { + width: 40px; + text-align: center; +} + +.row-heart { + border: 0; + background: none; + padding: 2px; + cursor: pointer; + color: #b6bcc2; + display: inline-flex; +} + +.row-heart svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.row-heart:hover { + color: var(--brand); +} + +.row-heart.active { + color: var(--brand); +} + +.row-heart.active svg { + fill: currentColor; +} + .profile-heading { font-size: 24px; font-weight: 700;