From 2eaba6810c8a43976ac1d7b3944d922a80602576 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 16 Aug 2026 13:35:55 -0700 Subject: [PATCH] Add CSV download and hover copy glyphs for rows, columns, and fields to the email list --- web/static/directory/app.js | 65 +++++++++++++++++++++++++++++----- web/static/directory/style.css | 57 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 8d440dd..19f8058 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -30,6 +30,8 @@ const icons = { heart: '', mail: '', copy: '', + check: '', + download: '', message: '', phone: '', zap: '', @@ -666,6 +668,22 @@ function copyButton(text) { return iconButton('copy', 'Copy', () => navigator.clipboard.writeText(text)); } +function copyGlyph(text) { + const btn = el('button', 'copy-glyph'); + btn.title = 'Copy'; + btn.append(svg('copy')); + btn.addEventListener('click', () => { + navigator.clipboard.writeText(text); + btn.classList.add('copied'); + btn.replaceChildren(svg('check')); + setTimeout(() => { + btn.classList.remove('copied'); + btn.replaceChildren(svg('copy')); + }, 1200); + }); + return btn; +} + async function submitField(key, field, value, status) { status.classList.remove('error'); status.textContent = 'Saving…'; @@ -1533,6 +1551,18 @@ const emailTabs = [ {key: 'bookmarks', label: 'My Bookmarks'}, ]; +const emailColumns = [ + {label: 'Full Name', get: r => r.p.fullName}, + {label: 'Email', get: r => r.p.email}, + {label: 'Role', get: r => r.role}, + {label: 'Grade', get: r => r.grade}, + {label: 'Classroom', get: r => r.classroom}, +]; + +function csvField(value) { + return /[",\n]/.test(value) ? '"' + value.replaceAll('"', '""') + '"' : value; +} + function kidsField(parent, field) { const family = state.model.families[parent.familyKey]; const values = ((family && family.kidEmails) || []) @@ -1603,7 +1633,10 @@ function renderEmailListPage() { renderTable(); }); search.append(input); - controls.append(search, filterControl(renderTable)); + const download = el('a', 'filter-button email-download'); + download.title = 'Download what the table currently shows'; + download.append(svg('download'), el('span', '', 'CSV')); + controls.append(search, filterControl(renderTable), download); header.append(controls); content.append(header); @@ -1615,6 +1648,11 @@ function renderEmailListPage() { 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)); + const csv = [emailColumns.map(c => c.label).join(',')] + .concat(rows.map(r => emailColumns.map(c => csvField(c.get(r))).join(','))) + .join('\n'); + download.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv); + download.download = state.emailTab + '.csv'; if (!rows.length) { holder.append(el('div', 'empty', state.emailTab === 'bookmarks' ? 'No bookmarks yet.' : 'No matches.')); return; @@ -1622,24 +1660,33 @@ function renderEmailListPage() { 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)); + headRow.append(el('th')); + for (const c of emailColumns) { + const th = el('th', '', c.label); + th.append(copyGlyph(rows.map(c.get).filter(Boolean).join('\n'))); + headRow.append(th); } + headRow.append(el('th')); 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 num = el('td', 'email-num'); + num.append(el('span', '', String(i + 1)), copyGlyph(emailColumns.map(c => c.get(r)).join('\t'))); + tr.append(num); const nameCell = el('td', 'email-name'); const nameLink = el('a', '', r.p.fullName); nameLink.href = personLink(r.p); - nameCell.append(nameLink); + nameCell.append(nameLink, copyGlyph(r.p.fullName)); 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)); + for (const c of emailColumns.slice(1)) { + const td = el('td', '', c.get(r)); + if (c.get(r)) { + td.append(copyGlyph(c.get(r))); + } + tr.append(td); + } const heartCell = el('td', 'email-heart'); const heart = el('button', 'row-heart' + (favorites.has(r.p.email) ? ' active' : '')); heart.append(svg('heart')); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 31061b6..3298913 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -494,6 +494,7 @@ main { .email-table td { border: 1px solid var(--line); padding: 9px 12px; + position: relative; } .email-table tbody tr:nth-child(even) { @@ -524,6 +525,62 @@ main { text-align: center; } +.copy-glyph { + border: 0; + background: #fff; + padding: 2px; + cursor: pointer; + color: #98a0a6; + display: inline-flex; + border-radius: 4px; + opacity: 0; +} + +.copy-glyph svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2; +} + +.copy-glyph:hover, +.copy-glyph.copied { + color: var(--brand); +} + +.email-table td > .copy-glyph { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); +} + +.email-table th .copy-glyph { + background: none; + margin-left: 6px; + vertical-align: middle; +} + +.email-table td:hover > .copy-glyph, +.email-table th:hover > .copy-glyph, +.copy-glyph.copied { + opacity: 1; +} + +.email-table tbody tr:hover .email-num > span { + visibility: hidden; +} + +.email-table tbody tr:hover .email-num > .copy-glyph { + opacity: 1; +} + +.email-download { + text-decoration: none; + cursor: pointer; +} + .row-heart { border: 0; background: none;