diff --git a/docs/screenshots.md b/docs/screenshots.md index f8cd95d..e1e1d41 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -6,11 +6,11 @@ With the server running: - go run ./tools/screenshot -url http://localhost:8080/directory/ -out screenshots/directory.png -wait header + go run ./tools/screenshot -url http://localhost:8080/people -out screenshots/directory.png -wait .sidebar Flags: -- `-url` — page to capture (default `http://localhost:8080/directory/`) +- `-url` — page to capture (default `http://localhost:8080/people`) - `-out` — output PNG path (default `screenshots/capture.png`); `screenshots/` is gitignored - `-wait` — CSS selector that must be visible before capture (default `body`); pass a selector the page's JavaScript renders (for example `.card`) to capture after data loads @@ -67,7 +67,7 @@ The login session persists in the profile, so the next `tools/capturebrowser` la One self-contained command that starts the server, captures, and shuts down: go run . & - go run ./tools/screenshot -out screenshots/directory.png -wait header + go run ./tools/screenshot -out screenshots/directory.png -wait .sidebar kill $(lsof -ti :8080) Then read `screenshots/directory.png` to inspect the result. diff --git a/internal/directory/directory.go b/internal/directory/directory.go index bc0d831..c3874f1 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -6,27 +6,60 @@ import ( "html/template" "log" "net/http" + "strings" + + "heliosian/internal/auth" ) +var sections = []string{"people", "classrooms", "my-family", "staff", "map", "email-list", "data-view", "bug-report", "about"} + +var legacy = map[string]string{ + "people": "/people", + "explore": "/classrooms", + "myfamily": "/my-family", + "staff": "/staff", + "map": "/map", + "emails": "/email-list", + "33234e": "/data-view", + "ee614d": "/bug-report", + "255ce0": "/about", +} + type app struct { cache *Cache } func Register(mux *http.ServeMux, cache *Cache) { a := app{cache: cache} - mux.HandleFunc("GET /directory/{$}", a.index) - mux.HandleFunc("GET /directory/api/model", a.model) - mux.Handle("GET /directory/static/", http.StripPrefix("/directory/static/", http.FileServer(http.Dir("web/directory/static")))) + for _, section := range sections { + mux.HandleFunc("GET /"+section, a.page) + } + mux.HandleFunc("GET /dl/", a.legacyRedirect) + mux.HandleFunc("GET /api/directory/model", a.model) } -func (a app) index(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/"), "/") + target, ok := legacy[first] + if !ok { + target = "/people" + } + http.Redirect(w, r, target, http.StatusMovedPermanently) +} + +func (a app) page(w http.ResponseWriter, r *http.Request) { t, err := template.ParseFiles("web/directory/index.html") if err != nil { serverError(w, err) return } - if err := t.Execute(w, nil); err != nil { - log.Printf("[ERROR] render directory index: %v", err) + name := a.cache.Model().DisplayName(auth.Email(r)) + data := map[string]string{ + "UserName": name, + "UserInitial": strings.ToUpper(name[:1]), + } + if err := t.Execute(w, data); err != nil { + log.Printf("[ERROR] render directory page: %v", err) } } diff --git a/internal/directory/model.go b/internal/directory/model.go index cd0e128..0821b31 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -54,6 +54,15 @@ type Grade struct { NextBand string `json:"nextBand,omitempty"` } +func (m *Model) DisplayName(email string) string { + for _, p := range m.People { + if p.Email == email { + return p.FullName + } + } + return email +} + type Model struct { People []Person `json:"people"` Families map[string]Family `json:"families"` diff --git a/main.go b/main.go index a54054d..82bf4a7 100644 --- a/main.go +++ b/main.go @@ -65,7 +65,7 @@ func main() { mux := http.NewServeMux() authn.Register(mux) directory.Register(mux, cache) - mux.Handle("GET /{$}", http.RedirectHandler("/directory/", http.StatusFound)) + mux.Handle("GET /{$}", http.RedirectHandler("/people", http.StatusFound)) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) port := os.Getenv("PORT") if port == "" { diff --git a/tools/browse/main.go b/tools/browse/main.go index 5dddce3..c7ebbcc 100644 --- a/tools/browse/main.go +++ b/tools/browse/main.go @@ -15,6 +15,7 @@ import ( "time" "github.com/chromedp/cdproto/input" + "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/target" "github.com/chromedp/chromedp" ) @@ -108,7 +109,9 @@ func main() { typeText := flag.String("type", "", "insert text into the focused element") key := flag.String("key", "", "press a key: enter, tab, escape, backspace, or a literal character") wait := flag.String("wait", "", "css selector that must be visible before capturing") + cookie := flag.String("cookie", "", "set a name=value cookie on localhost before acting") mobile := flag.Bool("mobile", false, "emulate a phone viewport (390x844, touch) instead of desktop 1280x800") + size := flag.String("size", "", "viewport size as WxH, overriding the desktop default") dump := flag.Bool("dump", false, "print page html instead of writing a screenshot") eval := flag.String("eval", "", "evaluate javascript in the page and print the json result instead of writing a screenshot") out := flag.String("out", "screenshots/browse.png", "output png path") @@ -128,7 +131,23 @@ func main() { if *mobile { viewport = chromedp.EmulateViewport(390, 844, chromedp.EmulateMobile) } + if *size != "" { + w, h, ok := strings.Cut(*size, "x") + width, werr := strconv.ParseInt(w, 10, 64) + height, herr := strconv.ParseInt(h, 10, 64) + if !ok || werr != nil || herr != nil { + log.Fatalf("[ERROR] size must be WxH, got %q", *size) + } + viewport = chromedp.EmulateViewport(width, height) + } actions := []chromedp.Action{viewport} + if *cookie != "" { + name, value, ok := strings.Cut(*cookie, "=") + if !ok { + log.Fatalf("[ERROR] cookie must be name=value, got %q", *cookie) + } + actions = append(actions, network.SetCookie(name, value).WithDomain("localhost").WithPath("/")) + } if *nav != "" { actions = append(actions, chromedp.Navigate(*nav)) } diff --git a/tools/screenshot/main.go b/tools/screenshot/main.go index 3351ae9..63a8267 100644 --- a/tools/screenshot/main.go +++ b/tools/screenshot/main.go @@ -13,7 +13,7 @@ import ( ) func main() { - url := flag.String("url", "http://localhost:8080/directory/", "page to capture") + url := flag.String("url", "http://localhost:8080/people", "page to capture") out := flag.String("out", "screenshots/capture.png", "output png path") wait := flag.String("wait", "body", "css selector that must be visible before capturing") remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome") diff --git a/web/directory/index.html b/web/directory/index.html index b15016d..4132bad 100644 --- a/web/directory/index.html +++ b/web/directory/index.html @@ -2,16 +2,21 @@ - -Helios Directory - + + +Helios Who? + + + + -
-

Helios Directory

- -
-
- + +
+ diff --git a/web/directory/static/app.js b/web/directory/static/app.js deleted file mode 100644 index c0c2ca5..0000000 --- a/web/directory/static/app.js +++ /dev/null @@ -1,104 +0,0 @@ -const state = {model: null}; - -function hue(text) { - let h = 0; - for (const c of text) { - h = (h * 31 + c.codePointAt(0)) % 360; - } - return h; -} - -function el(tag, className, text) { - const node = document.createElement(tag); - if (className) { - node.className = className; - } - if (text) { - node.textContent = text; - } - return node; -} - -function initials(name) { - const words = name.trim().split(/\s+/); - if (words.length === 0 || !words[0]) { - return '?'; - } - const first = words[0][0]; - return words.length > 1 ? first + words[words.length - 1][0] : first; -} - -function roles(p) { - return [p.isStudent && 'Student', p.isParent && 'Parent', p.isStaff && 'Staff'].filter(Boolean); -} - -function card(p, family) { - const root = el('div', 'card'); - if (p.photoUrl) { - const img = el('img', 'avatar'); - img.src = p.photoUrl; - img.loading = 'lazy'; - root.append(img); - } else { - const avatar = el('div', 'avatar', initials(p.fullName)); - avatar.style.background = `hsl(${hue(p.fullName)} 60% 45%)`; - root.append(avatar); - } - const info = el('div'); - info.append(el('div', 'name', p.fullName)); - if (p.pronouns) { - info.append(el('div', 'pronouns', p.pronouns)); - } - const who = [roles(p).join(' / ')]; - if (p.grade) { - who.push([p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ ')); - } - if (p.jobTitle) { - who.push(p.jobTitle); - } - info.append(el('div', 'detail', who.join(' · '))); - if (family && family.name) { - info.append(el('div', 'detail', family.name)); - } - if (family && family.address) { - info.append(el('div', 'detail', family.address)); - } - const contact = [p.phone, p.email].filter(Boolean).join(' · '); - if (contact) { - info.append(el('div', 'detail', contact)); - } - root.append(info); - return root; -} - -function render() { - const q = document.querySelector('#search').value.trim().toLowerCase(); - const list = document.querySelector('#people'); - list.replaceChildren(); - if (!state.model) { - return; - } - const matches = state.model.people.filter(p => { - const family = state.model.families[p.familyKey]; - return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q); - }); - if (matches.length === 0) { - list.append(el('div', 'empty', 'No matches.')); - return; - } - for (const p of matches) { - list.append(card(p, state.model.families[p.familyKey])); - } -} - -async function load() { - const res = await fetch('/directory/api/model'); - if (!res.ok) { - throw new Error(`loading model failed: ${res.status}`); - } - state.model = await res.json(); - render(); -} - -document.querySelector('#search').addEventListener('input', render); -load(); diff --git a/web/directory/static/style.css b/web/directory/static/style.css deleted file mode 100644 index 125617b..0000000 --- a/web/directory/static/style.css +++ /dev/null @@ -1,97 +0,0 @@ -:root { - --bg: #f6f7f9; - --card: #ffffff; - --line: #e2e6eb; - --text: #1c2430; - --muted: #5b6572; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: system-ui, sans-serif; - background: var(--bg); - color: var(--text); -} - -header { - position: sticky; - top: 0; - background: var(--card); - border-bottom: 1px solid var(--line); - padding: 1rem 1.5rem; - display: flex; - align-items: center; - gap: 1.5rem; -} - -header h1 { - font-size: 1.25rem; - margin: 0; -} - -#search { - flex: 1; - max-width: 24rem; - padding: 0.5rem 0.75rem; - border: 1px solid #cdd3db; - border-radius: 0.5rem; - font-size: 1rem; -} - -#people { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); - gap: 1rem; - padding: 1.5rem; - max-width: 72rem; - margin: 0 auto; -} - -.card { - background: var(--card); - border: 1px solid var(--line); - border-radius: 0.75rem; - padding: 1rem; - display: flex; - gap: 0.75rem; -} - -.avatar { - width: 3rem; - height: 3rem; - border-radius: 50%; - color: white; - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - flex-shrink: 0; - object-fit: cover; -} - -.name { - font-weight: 600; -} - -.pronunciation { - color: var(--muted); - font-style: italic; - font-size: 0.875rem; -} - -.detail { - color: var(--muted); - font-size: 0.875rem; - margin-top: 0.25rem; -} - -.empty { - color: var(--muted); - padding: 3rem; - text-align: center; - grid-column: 1 / -1; -} diff --git a/web/static/directory/app.js b/web/static/directory/app.js new file mode 100644 index 0000000..8b13e6a --- /dev/null +++ b/web/static/directory/app.js @@ -0,0 +1,334 @@ +const state = {model: null, tab: 'everyone', q: ''}; +let byEmail = {}; + +const icons = { + people: '', + classrooms: '', + 'my-family': '', + staff: '', + map: '', + 'email-list': '', + 'data-view': '', + 'bug-report': '', + about: '', + everyone: '', + students: '', + families: '', + 'staff-tab': '', + search: '', + filter: '', + chevron: '', +}; + +const navSections = [ + {path: 'people', label: 'People'}, + {path: 'classrooms', label: 'Classrooms'}, + {path: 'my-family', label: 'My Family'}, + {path: 'staff', label: 'Staff'}, + {path: 'map', label: 'Map'}, + {path: 'email-list', label: 'Email List'}, + {divider: true}, + {path: 'data-view', label: 'Data View'}, + {path: 'bug-report', label: 'Bug Report'}, + {path: 'about', label: 'Share & About'}, +]; + +const peopleTabs = [ + {key: 'everyone', label: 'Everyone'}, + {key: 'students', label: 'Students'}, + {key: 'families', label: 'Families'}, + {key: 'staff', label: 'Staff'}, +]; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text) { + node.textContent = text; + } + return node; +} + +function svg(name) { + const holder = document.createElement('template'); + holder.innerHTML = icons[name]; + return holder.content.firstChild; +} + +function section() { + return location.pathname.replaceAll('/', ''); +} + +function renderNav() { + const nav = document.querySelector('#nav'); + nav.replaceChildren(); + for (const item of navSections) { + if (item.divider) { + nav.append(el('div', 'nav-divider')); + continue; + } + const a = el('a'); + a.href = '/' + item.path; + if (item.path === section()) { + a.className = 'active'; + } + a.append(svg(item.path), el('span', '', item.label)); + nav.append(a); + } +} + +function hue(text) { + let h = 0; + for (const c of text) { + h = (h * 31 + c.codePointAt(0)) % 360; + } + return h; +} + +function firstName(fullName) { + return fullName.trim().split(/\s+/)[0]; +} + +function photoOrInitials(url, name, className) { + if (url) { + const img = el('img', className); + img.src = url; + img.loading = 'lazy'; + img.alt = ''; + return img; + } + const div = el('div', className, name.trim().split(/\s+/).map(w => w[0]).slice(0, 2).join('')); + div.style.background = `hsl(${hue(name)} 45% 55%)`; + return div; +} + +function roleLabel(p) { + let role = 'Parent'; + if (p.isStudent) { + role = 'Student'; + } else if (p.isStaff) { + role = 'Staff'; + } + return (p.pronouns ? `${role} (${p.pronouns})` : role).toUpperCase(); +} + +function personContext(p) { + if (p.isStudent) { + return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '); + } + if (p.isStaff && p.jobTitle) { + return p.jobTitle; + } + const family = state.model.families[p.familyKey]; + if (family) { + return (family.kidEmails || []).map(e => byEmail[e]?.fullName).filter(Boolean).join(', '); + } + return ''; +} + +function personCard(p) { + const card = el('div', 'person-card'); + card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); + card.append(el('div', 'role-label', roleLabel(p))); + card.append(el('div', 'person-name', p.fullName)); + const context = personContext(p); + if (context) { + card.append(el('div', 'person-sub', context)); + } + return card; +} + +function renderEveryone(grid) { + grid.className = 'people-grid'; + const q = state.q; + const matches = state.model.people.filter(p => { + const family = state.model.families[p.familyKey]; + return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q); + }); + for (const p of matches) { + grid.append(personCard(p)); + } + return matches.length; +} + +function renderStudents(grid) { + grid.className = 'student-grid'; + const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q)); + for (const p of matches) { + const card = el('div', 'student-card'); + if (p.photoUrl) { + const img = el('img', 'student-photo'); + img.src = p.photoUrl; + img.loading = 'lazy'; + img.alt = ''; + card.append(img); + } + card.append(el('div', 'student-first', firstName(p.fullName))); + card.append(el('div', 'student-last', p.fullName.replace(firstName(p.fullName), '').trim())); + card.append(el('div', 'student-line', [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ '))); + if (p.pronouns) { + card.append(el('div', 'student-pronouns', p.pronouns)); + } + grid.append(card); + } + return matches.length; +} + +function familyEntries() { + const entries = Object.values(state.model.families).map(f => { + const members = [...(f.kidEmails || []), ...(f.adultEmails || [])]; + const kidGrades = [...new Set((f.kidEmails || []).map(e => byEmail[e]?.grade).filter(Boolean))]; + return { + name: (f.name || '').replace(/ Family$/, ''), + label: kidGrades.length ? kidGrades.join(', ') : 'Staff', + members: members.map(e => byEmail[e] ? firstName(byEmail[e].fullName) : '').filter(Boolean), + photoUrl: f.photoUrl, + }; + }); + for (const p of state.model.people) { + if (p.isStaff && !p.isParent && !p.isStudent && !state.model.families[p.familyKey]) { + entries.push({ + name: p.fullName.trim().split(/\s+/).slice(-1)[0], + label: 'Staff', + members: [firstName(p.fullName)], + photoUrl: p.photoUrl, + }); + } + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + return entries; +} + +function renderFamilies(grid) { + grid.className = 'family-grid'; + const matches = familyEntries().filter(f => + `${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q)); + for (const f of matches) { + const card = el('div', 'family-card'); + card.append(photoOrInitials(f.photoUrl, f.name, 'family-photo')); + card.append(el('div', 'family-label', f.label)); + card.append(el('div', 'family-name', f.name)); + card.append(el('div', 'family-kids', f.members.join(', '))); + grid.append(card); + } + return matches.length; +} + +function renderStaff(grid) { + grid.className = ''; + const staff = state.model.people.filter(p => + p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q)); + const departments = state.model.departments || []; + const groups = new Map(); + for (const p of staff) { + const dept = p.department || 'Staff'; + if (!groups.has(dept)) { + groups.set(dept, []); + } + groups.get(dept).push(p); + } + const ordered = [...groups.keys()].sort((a, b) => { + const ia = departments.indexOf(a); + const ib = departments.indexOf(b); + return (ia < 0 ? departments.length : ia) - (ib < 0 ? departments.length : ib); + }); + let count = 0; + for (const dept of ordered) { + grid.append(el('h2', 'staff-section', dept)); + const deptGrid = el('div', 'people-grid'); + for (const p of groups.get(dept)) { + const card = el('div', 'person-card'); + card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); + card.append(el('div', 'role-label', p.jobTitle || 'Staff')); + card.append(el('div', 'person-name', p.fullName)); + deptGrid.append(card); + count++; + } + grid.append(deptGrid); + } + return count; +} + +const tabRenderers = { + everyone: renderEveryone, + students: renderStudents, + families: renderFamilies, + staff: renderStaff, +}; + +function renderPeople() { + const main = document.querySelector('#main'); + main.replaceChildren(); + + const tabs = el('div', 'tabs'); + const tabsRow = el('div', 'container tabs-row'); + for (const tab of peopleTabs) { + const node = el('div', 'tab' + (tab.key === state.tab ? ' active' : '')); + node.append(svg(tab.key === 'staff' ? 'staff-tab' : tab.key), el('span', '', tab.label)); + node.addEventListener('click', () => { + state.tab = tab.key; + state.q = ''; + renderPeople(); + }); + tabsRow.append(node); + } + tabs.append(tabsRow); + main.append(tabs); + + const content = el('div', 'content container'); + const header = el('div', 'content-header'); + header.append(el('h1', '', peopleTabs.find(t => t.key === state.tab).label)); + const controls = el('div', 'controls'); + const search = el('div', 'search'); + search.append(svg('search')); + const input = el('input'); + input.placeholder = 'Search'; + input.value = state.q; + input.addEventListener('input', () => { + state.q = input.value.trim().toLowerCase(); + renderGrid(); + }); + search.append(input); + const filter = el('button', 'filter-button'); + filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); + controls.append(search, filter); + header.append(controls); + content.append(header); + + const grid = el('div'); + content.append(grid); + main.append(content); + + function renderGrid() { + grid.replaceChildren(); + if (tabRenderers[state.tab](grid) === 0) { + grid.append(el('div', 'empty', 'No matches.')); + } + } + renderGrid(); + input.focus(); +} + +function render() { + renderNav(); + if (section() === 'people') { + renderPeople(); + } +} + +async function load() { + const res = await fetch('/api/directory/model'); + if (!res.ok) { + throw new Error(`loading model failed: ${res.status}`); + } + state.model = await res.json(); + byEmail = {}; + for (const p of state.model.people) { + byEmail[p.email] = p; + } + render(); +} + +load(); diff --git a/web/static/directory/style.css b/web/static/directory/style.css new file mode 100644 index 0000000..4ea1196 --- /dev/null +++ b/web/static/directory/style.css @@ -0,0 +1,428 @@ +:root { + --brand: #014e54; + --sidebar: #173c41; + --sidebar-active: #2f5054; + --line: #e2e6eb; + --ink: #0d0d0d; + --muted: #707070; + --label: #1f4d53; + --input: #efefef; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Inter, system-ui, sans-serif; + display: flex; + height: 100vh; + overflow: hidden; + color: var(--ink); + background: #fff; +} + +.sidebar { + width: 256px; + flex-shrink: 0; + background: var(--sidebar); + color: #fff; + display: flex; + flex-direction: column; + padding: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 12px; + font-weight: 700; + font-size: 16px; +} + +.brand img { + width: 32px; + height: 32px; + border-radius: 8px; +} + +nav { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; +} + +nav a { + display: flex; + align-items: center; + gap: 12px; + height: 36px; + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 0 12px; + border-radius: 8px; +} + +nav a svg { + width: 20px; + height: 20px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +nav a:hover { + background: rgba(255, 255, 255, 0.06); +} + +nav a.active { + background: var(--sidebar-active); + color: #fff; +} + +.nav-divider { + border-top: 1px solid rgba(255, 255, 255, 0.15); + margin: 8px 12px; +} + +.user { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); +} + +.user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: #f5f1d0; + color: #333; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} + +main { + flex: 1; + overflow-y: auto; + background: #fff; +} + +.container { + max-width: 1260px; + width: 100%; + margin: 0 auto; + padding-left: 30px; + padding-right: 30px; +} + +.tabs { + border-bottom: 1px solid var(--line); + padding-top: 13px; + position: sticky; + top: 0; + background: #fff; + z-index: 2; +} + +.tabs-row { + display: flex; + gap: 4px; +} + +.tab { + position: relative; + display: flex; + align-items: center; + gap: 8px; + height: 43px; + padding: 0 12px; + font-size: 14px; + font-weight: 500; + color: rgba(51, 51, 51, 0.7); + cursor: pointer; +} + +.tab svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.tab.active { + color: rgba(0, 0, 0, 0.95); +} + +.tab.active::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 3px; + height: 2px; + border-radius: 1px; + background: var(--label); +} + +.content { + padding-top: 15px; + padding-bottom: 48px; +} + +.content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 4px 0 26px; +} + +.content-header h1 { + font-size: 24px; + font-weight: 700; + margin: 0; +} + +.controls { + display: flex; + gap: 10px; +} + +.search { + display: flex; + align-items: center; + gap: 8px; + background: var(--input); + border-radius: 8px; + padding: 0 12px; + width: 231px; + color: var(--muted); +} + +.search svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + flex-shrink: 0; +} + +.search input { + border: 0; + background: transparent; + outline: 0; + font: inherit; + width: 100%; + padding: 9px 0; + color: var(--ink); +} + +.filter-button { + display: flex; + align-items: center; + gap: 8px; + background: var(--input); + border-radius: 8px; + padding: 9px 14px; + font: inherit; + font-size: 14px; + color: #444; + border: 0; +} + +.filter-button svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; +} + +.people-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px 16px; +} + +.person-card { + text-align: center; +} + +.person-photo { + width: 62%; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; + display: block; + margin: 0 auto 12px; + background: #eef1f3; + color: #fff; +} + +div.person-photo { + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + font-weight: 600; +} + +.role-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--label); + text-transform: uppercase; + margin-bottom: 3px; +} + +.person-name { + font-weight: 700; + font-size: 15px; +} + +.person-sub { + color: var(--muted); + font-size: 13px; + margin-top: 3px; + line-height: 1.4; +} + +.student-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.student-card { + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + text-align: center; + padding-bottom: 20px; + background: #fff; +} + +.student-photo { + width: 100%; + aspect-ratio: 1.35; + object-fit: cover; + display: block; + background: #fff; +} + +.student-first { + font-size: 24px; + font-weight: 700; + margin-top: 16px; +} + +.student-last { + color: var(--muted); + font-size: 15px; + margin-top: 2px; +} + +.student-line { + font-size: 12px; + color: var(--muted); + margin-top: 14px; +} + +.student-pronouns { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--label); + text-transform: uppercase; + margin-top: 10px; +} + +.family-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px 24px; +} + +.family-photo { + width: 100%; + aspect-ratio: 1.32; + object-fit: cover; + display: block; + border-radius: 6px; + background: #f4f5f6; +} + +.family-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--label); + text-transform: uppercase; + margin: 12px 0 2px; +} + +.family-name { + font-weight: 700; + font-size: 15px; +} + +.family-kids { + color: var(--muted); + font-size: 13px; + margin-top: 3px; +} + +.staff-section { + margin: 10px 0 20px; + font-weight: 600; + font-size: 16px; +} + +.staff-section:not(:first-child) { + margin-top: 36px; +} + +.empty { + color: var(--muted); + padding: 3rem; + text-align: center; + grid-column: 1 / -1; +} + +@media (max-width: 900px) { + .container { + padding-left: 16px; + padding-right: 16px; + } + .people-grid, + .family-grid { + grid-template-columns: repeat(2, 1fr); + } + .student-grid { + grid-template-columns: 1fr; + } + .search { + width: 160px; + } +}