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;