Route my-family straight to the family page and build crumbs from URL state

This commit is contained in:
Ian Gulliver
2026-08-15 23:43:35 -07:00
parent 8ddf729a92
commit 2a800135b7
5 changed files with 105 additions and 33 deletions
+8
View File
@@ -0,0 +1,8 @@
.gcloudignore
.git
.gitignore
creds/
docs/
sampledata/
screenshots/
LICENSE.md
+1 -1
View File
@@ -41,7 +41,7 @@ Three tabs: browse classrooms by grade band (mascot art, student count, link to
### My Family ### My Family
A shortcut card to the signed-in user's own family page. Goes straight to the signed-in user's own family page.
### Staff ### Staff
-1
View File
@@ -4,7 +4,6 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
## Directory app ## Directory app
- Remaining sections: My Family.
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful. - Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
- Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`. - Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`.
- Self-service flows: photo and pronunciation upload, address update, opt-out. - Self-service flows: photo and pronunciation upload, address update, opt-out.
+17 -1
View File
@@ -6,12 +6,13 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"heliosian/internal/auth" "heliosian/internal/auth"
) )
var sections = []string{"people", "classrooms", "my-family", "staff", "map", "email-list"} var sections = []string{"people", "classrooms", "staff", "map", "email-list"}
var legacy = map[string]string{ var legacy = map[string]string{
"people": "/people", "people": "/people",
@@ -32,6 +33,7 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
for _, section := range sections { for _, section := range sections {
mux.HandleFunc("GET /"+section, a.page) mux.HandleFunc("GET /"+section, a.page)
} }
mux.HandleFunc("GET /my-family", a.myFamily)
mux.HandleFunc("GET /profile", a.page) mux.HandleFunc("GET /profile", a.page)
mux.HandleFunc("GET /people/{email}", a.page) mux.HandleFunc("GET /people/{email}", a.page)
mux.HandleFunc("GET /families/{key}", a.page) mux.HandleFunc("GET /families/{key}", a.page)
@@ -41,6 +43,20 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
mux.HandleFunc("GET /api/directory/model", a.model) mux.HandleFunc("GET /api/directory/model", a.model)
} }
func (a app) myFamily(w http.ResponseWriter, r *http.Request) {
model := a.cache.Model()
email := auth.Email(r)
for _, p := range model.People {
if p.Email == email && p.FamilyKey != "" {
if _, ok := model.Families[p.FamilyKey]; ok {
http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound)
return
}
}
}
http.Error(w, "no family record for "+email, http.StatusNotFound)
}
func (a app) legacyRedirect(w http.ResponseWriter, r *http.Request) { func (a app) legacyRedirect(w http.ResponseWriter, r *http.Request) {
first, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/dl/"), "/") first, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/dl/"), "/")
target, ok := legacy[first] target, ok := legacy[first]
+79 -30
View File
@@ -72,14 +72,31 @@ function segments() {
return location.pathname.split('/').filter(Boolean).map(decodeURIComponent); return location.pathname.split('/').filter(Boolean).map(decodeURIComponent);
} }
function withFrom(href) {
const from = encodeURIComponent(location.pathname + location.search);
return href + (href.includes('?') ? '&' : '?') + 'from=' + from;
}
function personLink(p) { function personLink(p) {
return '/people/' + encodeURIComponent(p.email); return withFrom('/people/' + encodeURIComponent(p.email));
}
function familyLink(key) {
return withFrom('/families/' + encodeURIComponent(key));
}
function myFamilyKey() {
const me = byEmail[document.body.dataset.userEmail];
return (me && me.familyKey) || '';
} }
function renderNav() { function renderNav() {
const nav = document.querySelector('#nav'); const nav = document.querySelector('#nav');
nav.replaceChildren(); nav.replaceChildren();
const seg = segments()[0] === 'families' ? 'people' : segments()[0]; let seg = segments()[0];
if (seg === 'families') {
seg = segments()[1] === myFamilyKey() ? 'my-family' : 'people';
}
for (const item of navSections) { for (const item of navSections) {
if (item.divider) { if (item.divider) {
nav.append(el('div', 'nav-divider')); nav.append(el('div', 'nav-divider'));
@@ -216,7 +233,7 @@ function familyEntries() {
label: kidGrades.length ? kidGrades.join(', ') : 'Staff', label: kidGrades.length ? kidGrades.join(', ') : 'Staff',
members: members.map(e => byEmail[e] ? firstName(byEmail[e].fullName) : '').filter(Boolean), members: members.map(e => byEmail[e] ? firstName(byEmail[e].fullName) : '').filter(Boolean),
photoUrl: f.photoUrl, photoUrl: f.photoUrl,
href: '/families/' + encodeURIComponent(f.key), href: familyLink(f.key),
}; };
}); });
for (const p of state.model.people) { for (const p of state.model.people) {
@@ -307,7 +324,7 @@ function renderPeople() {
node.addEventListener('click', () => { node.addEventListener('click', () => {
state.tab = tab.key; state.tab = tab.key;
state.q = ''; state.q = '';
history.replaceState(null, '', '/people?tab=' + tab.key); history.replaceState(null, '', tabHref(tab.key));
renderPeople(); renderPeople();
}); });
tabsRow.append(node); tabsRow.append(node);
@@ -491,38 +508,52 @@ function filterControl(rerender) {
return wrap; return wrap;
} }
function referrerCrumbs() { function fromURL() {
if (!document.referrer) { const raw = new URLSearchParams(location.search).get('from');
if (!raw) {
return null; return null;
} }
const ref = new URL(document.referrer); return new URL(raw, location.origin);
if (ref.origin !== location.origin) { }
function classroomsBackOf(from) {
const parent = new URLSearchParams(from.search).get('from');
return parent && parent.startsWith('/classrooms') ? parent : '/classrooms';
}
function fromCrumbs() {
const from = fromURL();
if (!from) {
return null; return null;
} }
const seg = ref.pathname.split('/').filter(Boolean).map(decodeURIComponent); const seg = from.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const back = from.pathname + from.search;
if (seg[0] === 'grades' && seg[1]) { if (seg[0] === 'grades' && seg[1]) {
const grade = state.model.grades.find(g => slugify(g.name) === seg[1]); const grade = state.model.grades.find(g => slugify(g.name) === seg[1]);
if (grade) { if (grade) {
return [['Classrooms', '/classrooms'], [grade.name, ref.pathname + ref.search]]; return [['Classrooms', classroomsBackOf(from)], [grade.name, back]];
} }
} }
if (seg[0] === 'classrooms' && seg[1]) { if (seg[0] === 'classrooms' && seg[1]) {
const classroom = state.model.classrooms.find(c => slugify(c.name) === seg[1]); const classroom = state.model.classrooms.find(c => slugify(c.name) === seg[1]);
if (classroom) { if (classroom) {
return [['Classrooms', '/classrooms'], [classroom.name, ref.pathname + ref.search]]; return [['Classrooms', classroomsBackOf(from)], [classroom.name, back]];
} }
} }
if (seg[0] === 'people' && !seg[1]) {
return [['People', back]];
}
if (seg[0] === 'classrooms') { if (seg[0] === 'classrooms') {
return [['Classrooms', '/classrooms']]; return [['Classrooms', back]];
} }
if (seg[0] === 'staff') { if (seg[0] === 'staff') {
return [['Staff', '/staff']]; return [['Staff', back]];
} }
if (seg[0] === 'profile') { if (seg[0] === 'profile') {
return [['Profile', '/profile']]; return [['Profile', back]];
} }
if (seg[0] === 'email-list') { if (seg[0] === 'email-list') {
return [['Email List', ref.pathname + ref.search]]; return [['Email List', back]];
} }
return null; return null;
} }
@@ -656,7 +687,7 @@ function familyBand(p, family) {
} }
} }
const see = el('a', 'see-family'); const see = el('a', 'see-family');
see.href = '/families/' + encodeURIComponent(family.key); see.href = familyLink(family.key);
see.append(el('span', '', `See ${family.name}`)); see.append(el('span', '', `See ${family.name}`));
const chev = el('div', 'member-chevron'); const chev = el('div', 'member-chevron');
chev.append(svg('chevron-right')); chev.append(svg('chevron-right'));
@@ -675,7 +706,7 @@ function renderPersonDetail(email) {
main.append(el('div', 'empty', 'Not found.')); main.append(el('div', 'empty', 'Not found.'));
return; return;
} }
const origin = referrerCrumbs() || [['People', '/people']]; const origin = fromCrumbs() || [['People', '/people']];
main.append(breadcrumbs([...origin, [p.fullName, null]], p.email)); main.append(breadcrumbs([...origin, [p.fullName, null]], p.email));
const content = el('div', 'container detail-content'); const content = el('div', 'container detail-content');
@@ -755,13 +786,23 @@ function renderFamilyDetail(key) {
} }
const shortName = (family.name || '').replace(/ Family$/, ''); const shortName = (family.name || '').replace(/ Family$/, '');
let crumbs = [['People', '/people'], [shortName, null], ['Family', null]]; let crumbs = [['People', '/people'], [shortName, null], ['Family', null]];
if (document.referrer && new URL(document.referrer).origin === location.origin) { const from = fromURL();
const ref = new URL(document.referrer); if (key === myFamilyKey()) {
const rseg = ref.pathname.split('/').filter(Boolean).map(decodeURIComponent); crumbs = [['My Family', '/my-family'], ['Family', null]];
} else if (from) {
const rseg = from.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const back = from.pathname + from.search;
if (rseg[0] === 'people' && rseg[1] && byEmail[rseg[1]]) { if (rseg[0] === 'people' && rseg[1] && byEmail[rseg[1]]) {
crumbs = [['People', '/people'], [byEmail[rseg[1]].fullName, ref.pathname], ['Family', null]]; const person = byEmail[rseg[1]];
const peopleBack = new URLSearchParams(from.search).get('from');
const peopleHref = peopleBack && peopleBack.startsWith('/people') && !peopleBack.startsWith('/people/') ? peopleBack : '/people';
crumbs = [['People', peopleHref], [person.fullName, back], ['Family', null]];
} else if (rseg[0] === 'people' && !rseg[1]) {
crumbs = [['People', back], [shortName, null], ['Family', null]];
} else if (rseg[0] === 'map') {
crumbs = [['Map', back], [shortName, null], ['Family', null]];
} else if (rseg[0] === 'profile') { } else if (rseg[0] === 'profile') {
crumbs = [['Profile', '/profile'], ['Family', null]]; crumbs = [['Profile', back], ['Family', null]];
} }
} }
main.append(breadcrumbs(crumbs)); main.append(breadcrumbs(crumbs));
@@ -931,7 +972,7 @@ function renderClassroomsList(list) {
list.append(el('h2', 'group-header', group.label)); list.append(el('h2', 'group-header', group.label));
for (const c of rows) { for (const c of rows) {
const students = studentsOf(p => p.classroom === c.name).length; const students = studentsOf(p => p.classroom === c.name).length;
list.append(listRow(c.imageUrl, `${students} students`, c.name, '', '/classrooms/' + slugify(c.name))); list.append(listRow(c.imageUrl, `${students} students`, c.name, '', withFrom('/classrooms/' + slugify(c.name))));
count++; count++;
} }
} }
@@ -949,7 +990,7 @@ function renderGradesList(list) {
list.append(el('h2', 'group-header', group.label)); list.append(el('h2', 'group-header', group.label));
for (const name of rows) { for (const name of rows) {
const students = studentsOf(p => p.grade === name).length; const students = studentsOf(p => p.grade === name).length;
list.append(listRow(gradeImage(name), `${students} students`, name, '', '/grades/' + slugify(name))); list.append(listRow(gradeImage(name), `${students} students`, name, '', withFrom('/grades/' + slugify(name))));
count++; count++;
} }
} }
@@ -1006,7 +1047,7 @@ function renderClassroomsPage() {
node.addEventListener('click', () => { node.addEventListener('click', () => {
state.classTab = tab.key; state.classTab = tab.key;
state.q = ''; state.q = '';
history.replaceState(null, '', '/classrooms?tab=' + tab.key); history.replaceState(null, '', tabHref(tab.key));
renderClassroomsPage(); renderClassroomsPage();
}); });
tabsRow.append(node); tabsRow.append(node);
@@ -1129,7 +1170,9 @@ function otherFamilyMembers(student) {
function renderRoster(title, image, groups, backLabel) { function renderRoster(title, image, groups, backLabel) {
const main = document.querySelector('#main'); const main = document.querySelector('#main');
main.replaceChildren(); main.replaceChildren();
main.append(breadcrumbs([['Classrooms', '/classrooms'], [title, null]])); const from = fromURL();
const back = from && from.pathname === '/classrooms' ? from.pathname + from.search : '/classrooms';
main.append(breadcrumbs([['Classrooms', back], [title, null]]));
const content = el('div', 'container detail-content'); const content = el('div', 'container detail-content');
const head = el('div', 'class-head'); const head = el('div', 'class-head');
@@ -1158,7 +1201,7 @@ function renderRoster(title, image, groups, backLabel) {
node.append(svg(tab.icon), el('span', '', tab.label), el('span', 'tab-count', String(tab.count))); node.append(svg(tab.icon), el('span', '', tab.label), el('span', 'tab-count', String(tab.count)));
node.addEventListener('click', () => { node.addEventListener('click', () => {
state.rosterTab = tab.key; state.rosterTab = tab.key;
history.replaceState(null, '', location.pathname + '?tab=' + tab.key); history.replaceState(null, '', tabHref(tab.key));
renderRoster(title, image, groups, backLabel); renderRoster(title, image, groups, backLabel);
}); });
tabsRow.append(node); tabsRow.append(node);
@@ -1291,7 +1334,7 @@ function renderEmailListPage() {
node.addEventListener('click', () => { node.addEventListener('click', () => {
state.emailTab = tab.key; state.emailTab = tab.key;
state.q = ''; state.q = '';
history.replaceState(null, '', '/email-list?tab=' + tab.key); history.replaceState(null, '', tabHref(tab.key));
renderEmailListPage(); renderEmailListPage();
}); });
tabsRow.append(node); tabsRow.append(node);
@@ -1448,7 +1491,7 @@ function renderMapPage() {
box.append(el('div', 'map-popup-sub', family.address)); box.append(el('div', 'map-popup-sub', family.address));
} }
const link = el('a', 'map-popup-link', 'See family'); const link = el('a', 'map-popup-link', 'See family');
link.href = '/families/' + encodeURIComponent(family.key); link.href = familyLink(family.key);
box.append(link); box.append(link);
return box; return box;
} }
@@ -1515,7 +1558,7 @@ function renderProfile() {
const family = state.model.families[me.familyKey]; const family = state.model.families[me.familyKey];
if (family) { if (family) {
content.append(el('h1', 'profile-heading', 'Family Photo')); content.append(el('h1', 'profile-heading', 'Family Photo'));
content.append(listRow(thumbUrl(family.photoUrl), '', 'Family Photo', family.photoCaption || '', '/families/' + encodeURIComponent(me.familyKey))); content.append(listRow(thumbUrl(family.photoUrl), '', 'Family Photo', family.photoCaption || '', familyLink(me.familyKey)));
const kids = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean); const kids = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean);
if (kids.length) { if (kids.length) {
content.append(el('h1', 'profile-heading', 'Students')); content.append(el('h1', 'profile-heading', 'Students'));
@@ -1531,6 +1574,12 @@ function tabParam(fallback) {
return new URLSearchParams(location.search).get('tab') || fallback; return new URLSearchParams(location.search).get('tab') || fallback;
} }
function tabHref(key) {
const params = new URLSearchParams(location.search);
params.set('tab', key);
return location.pathname + '?' + params;
}
function render() { function render() {
renderNav(); renderNav();
const seg = segments(); const seg = segments();