Route my-family straight to the family page and build crumbs from URL state
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.gcloudignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
creds/
|
||||||
|
docs/
|
||||||
|
sampledata/
|
||||||
|
screenshots/
|
||||||
|
LICENSE.md
|
||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user