From c1a648e2bb0a2e9a19a611261594f22df92ad9ba Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 15 Aug 2026 22:36:19 -0700 Subject: [PATCH] Add user menu with profile page, card and row hovers, and family breadcrumb fixes --- docs/screenshots.md | 6 +-- internal/directory/directory.go | 2 + web/directory/index.html | 8 +++- web/static/directory/app.js | 55 +++++++++++++++++++++- web/static/directory/style.css | 81 +++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/docs/screenshots.md b/docs/screenshots.md index e1e1d41..05c226b 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -56,11 +56,7 @@ The capture is the visible viewport, not the full page, so click coordinates rea Invocations must not linger: every run exits by itself within its 15-second internal timeout, leaving the browser and tab untouched. -At the end of a capture session, quit the browser: - - pkill -f capture-profile - -The login session persists in the profile, so the next `tools/capturebrowser` launch is still signed in. +Leave the browser running between capture sessions — never kill it. In practice logins do not reliably survive a browser restart, and killing it forces a human to sign in again. ## Agent recipe diff --git a/internal/directory/directory.go b/internal/directory/directory.go index 65bd4c3..8c24805 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -31,6 +31,7 @@ func Register(mux *http.ServeMux, cache *Cache) { for _, section := range sections { mux.HandleFunc("GET /"+section, a.page) } + mux.HandleFunc("GET /profile", a.page) mux.HandleFunc("GET /people/{email}", a.page) mux.HandleFunc("GET /families/{key}", a.page) mux.HandleFunc("GET /classrooms/{name}", a.page) @@ -58,6 +59,7 @@ func (a app) page(w http.ResponseWriter, r *http.Request) { data := map[string]string{ "UserName": name, "UserInitial": strings.ToUpper(name[:1]), + "UserEmail": auth.Email(r), } if err := t.Execute(w, data); err != nil { log.Printf("[ERROR] render directory page: %v", err) diff --git a/web/directory/index.html b/web/directory/index.html index d17f809..12718b6 100644 --- a/web/directory/index.html +++ b/web/directory/index.html @@ -9,11 +9,15 @@ - +
diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 4e2da95..251c54d 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -359,6 +359,9 @@ function referrerCrumbs() { if (seg[0] === 'staff') { return [['Staff', '/staff']]; } + if (seg[0] === 'profile') { + return [['Profile', '/profile']]; + } return null; } @@ -583,7 +586,17 @@ function renderFamilyDetail(key) { return; } const shortName = (family.name || '').replace(/ Family$/, ''); - main.append(breadcrumbs([['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 ref = new URL(document.referrer); + const rseg = ref.pathname.split('/').filter(Boolean).map(decodeURIComponent); + if (rseg[0] === 'people' && rseg[1] && byEmail[rseg[1]]) { + crumbs = [['People', '/people'], [byEmail[rseg[1]].fullName, ref.pathname], ['Family', null]]; + } else if (rseg[0] === 'profile') { + crumbs = [['Profile', '/profile'], ['Family', null]]; + } + } + main.append(breadcrumbs(crumbs)); const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); @@ -1046,6 +1059,30 @@ function renderClassroomDetail(slug) { renderRoster(classroom.name, classroom.imageUrl, groups); } +function renderProfile() { + const main = document.querySelector('#main'); + main.replaceChildren(); + const me = byEmail[document.body.dataset.userEmail]; + if (!me) { + main.append(el('div', 'empty', 'Not found.')); + return; + } + const content = el('div', 'container content'); + const family = state.model.families[me.familyKey]; + if (family) { + content.append(el('h1', 'profile-heading', 'Family Photo')); + content.append(listRow(thumbUrl(family.photoUrl), '', 'Family Photo', family.photoCaption || '', '/families/' + encodeURIComponent(me.familyKey))); + const kids = (family.kidEmails || []).map(e => byEmail[e]).filter(Boolean); + if (kids.length) { + content.append(el('h1', 'profile-heading', 'Students')); + for (const k of kids) { + content.append(listRow(thumbUrl(k.photoUrl), (k.pronouns || '').toUpperCase(), firstName(k.fullName), '', personLink(k))); + } + } + } + main.append(content); +} + function tabParam(fallback) { return new URLSearchParams(location.search).get('tab') || fallback; } @@ -1072,9 +1109,25 @@ function render() { } else if (seg[0] === 'staff') { state.q = ''; renderStaffPage(); + } else if (seg[0] === 'profile') { + renderProfile(); } } +const userMenu = document.querySelector('#user-menu'); +document.querySelector('#user').addEventListener('click', e => { + e.stopPropagation(); + userMenu.hidden = !userMenu.hidden; +}); +document.addEventListener('click', () => { + userMenu.hidden = true; +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + userMenu.hidden = true; + } +}); + async function load() { const res = await fetch('/api/directory/model'); if (!res.ok) { diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 3f01361..ca351ef 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -31,6 +31,7 @@ body { display: flex; flex-direction: column; padding: 12px; + position: relative; } .brand { @@ -101,6 +102,49 @@ nav a.active { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.8); + border-radius: 8px; + cursor: pointer; +} + +.user:hover { + background: rgba(255, 255, 255, 0.06); +} + +.user-menu { + position: absolute; + left: 12px; + bottom: 62px; + width: 190px; + background: #fff; + border-radius: 10px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25); + padding: 6px 0; + z-index: 10; +} + +.user-menu form { + margin: 0; +} + +.user-menu a, +.user-menu button { + display: block; + width: 100%; + padding: 8px 16px; + border: 0; + background: none; + text-align: left; + text-decoration: none; + font: inherit; + font-size: 14px; + font-weight: 500; + color: var(--ink); + cursor: pointer; +} + +.user-menu a:hover, +.user-menu button:hover { + background: #f2f4f6; } .user-avatar { @@ -202,6 +246,16 @@ main { margin: 0; } +.profile-heading { + font-size: 24px; + font-weight: 700; + margin: 26px 0 10px; +} + +.profile-heading:first-child { + margin-top: 4px; +} + .controls { display: flex; gap: 10px; @@ -427,6 +481,22 @@ a.family-card { text-decoration: none; } +a.person-card, +a.family-card { + padding: 14px; + margin: -14px; + border-radius: 12px; +} + +a.person-card:hover, +a.family-card:hover { + background: #f6f6f7; +} + +a.student-card:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); +} + .detail-top { display: flex; align-items: center; @@ -593,6 +663,17 @@ a.family-card { font-weight: 600; } +a.list-row { + padding-left: 14px; + padding-right: 14px; + margin: 0 -14px; + border-radius: 12px; +} + +a.list-row:hover { + background: #f6f6f7; +} + .list-tile { width: 80px; height: 80px;