diff --git a/internal/blob/blob.go b/internal/blob/blob.go
index 423014c..cbc9034 100644
--- a/internal/blob/blob.go
+++ b/internal/blob/blob.go
@@ -233,11 +233,11 @@ func (s *Store) serve(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "image/jpeg")
- w.Write(e.thumb)
+ http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(e.thumb))
return
}
w.Header().Set("Content-Type", e.mimeType)
- w.Write(e.data)
+ http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(e.data))
}
func thumbnail(src []byte) ([]byte, error) {
diff --git a/web/static/directory/app.js b/web/static/directory/app.js
index 7a8e572..7a1d346 100644
--- a/web/static/directory/app.js
+++ b/web/static/directory/app.js
@@ -22,7 +22,9 @@ const icons = {
'chevron-right': '',
heart: '',
mail: '',
+ copy: '',
message: '',
+ phone: '',
};
const navSections = [
@@ -362,16 +364,22 @@ function breadcrumbs(parts) {
return top;
}
-function iconButton(name, href) {
- const node = el(href ? 'a' : 'button', 'icon-button');
- if (href) {
- node.href = href;
+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));
+ 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);
@@ -420,6 +428,12 @@ function familyBand(p, family) {
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));
}
@@ -433,14 +447,16 @@ function familyBand(p, family) {
if (p.isStudent && kid.email === p.email) {
continue;
}
- right.append(memberRow(kid, gradeChain(kid).toUpperCase(), kid.email));
+ 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) {
- right.append(memberRow(adult, adult.pronouns ? `(${adult.pronouns.toUpperCase()})` : '', adult.email));
+ 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');
@@ -489,9 +505,16 @@ function renderPersonDetail(email) {
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), [
- iconButton('message'),
- iconButton('mail', 'mailto:' + p.email),
+ copyButton(p.email),
+ iconButton('mail', 'Email', 'mailto:' + p.email),
]));
const family = state.model.families[p.familyKey];
if (family && family.address) {
@@ -499,10 +522,18 @@ function renderPersonDetail(email) {
block.append(el('div', 'field-label', 'Address'));
block.append(el('div', 'contact-value', family.address));
right.append(contactRow(block, [
- iconButton('message'),
- iconButton('map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)),
+ 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);
@@ -561,10 +592,18 @@ function renderFamilyDetail(key) {
}
if (family.address) {
right.append(contactRow(el('div', 'contact-value', family.address), [
- iconButton('message'),
- iconButton('map', 'https://maps.google.com/?q=' + encodeURIComponent(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);
diff --git a/web/static/directory/style.css b/web/static/directory/style.css
index e5c26b1..8f0e053 100644
--- a/web/static/directory/style.css
+++ b/web/static/directory/style.css
@@ -477,7 +477,7 @@ a.family-card {
.detail-grid {
display: grid;
- grid-template-columns: 302px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 31fr) minmax(0, 69fr);
gap: 28px;
}
@@ -523,15 +523,19 @@ a.family-card {
}
.icon-button {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- border: 0;
- background: #f1f3f4;
- color: #3c4650;
display: flex;
align-items: center;
- justify-content: center;
+ gap: 9px;
+ height: 44px;
+ padding: 0 20px;
+ border-radius: 22px;
+ border: 0;
+ background: #f1f3f4;
+ color: #1f2933;
+ font: inherit;
+ font-size: 14px;
+ font-weight: 600;
+ text-decoration: none;
cursor: pointer;
}
@@ -543,6 +547,32 @@ a.family-card {
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
+ flex-shrink: 0;
+}
+
+@media (max-width: 1399px) {
+ .icon-button {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ justify-content: center;
+ border-radius: 50%;
+ }
+ .icon-button span {
+ display: none;
+ }
+}
+
+.pronounce-label {
+ text-align: center;
+ color: var(--muted);
+ font-size: 14px;
+ margin: 34px 0 12px;
+}
+
+.pronounce-player {
+ width: 100%;
+ display: block;
}
.about-header {
@@ -564,8 +594,8 @@ a.family-card {
.band-grid {
display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 44px;
+ grid-template-columns: minmax(0, 31fr) minmax(0, 69fr);
+ gap: 28px;
padding-top: 36px;
padding-bottom: 40px;
}
@@ -577,13 +607,18 @@ a.family-card {
text-align: center;
}
+.band-photo {
+ width: 100%;
+ border-radius: 8px;
+ display: block;
+}
+
.band-caption {
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
line-height: 1.5;
text-align: center;
- max-width: 320px;
- margin: 0 auto;
+ margin-top: 20px;
}
.band-title {
@@ -610,9 +645,9 @@ a.family-card {
}
.member-thumb {
- width: 40px;
- height: 40px;
- border-radius: 6px;
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}