diff --git a/web/directory/index.html b/web/directory/index.html index e0d8fbb..844613a 100644 --- a/web/directory/index.html +++ b/web/directory/index.html @@ -10,6 +10,11 @@ +
+ + +
+
+ + + diff --git a/web/static/directory/app.js b/web/static/directory/app.js index e19a02f..14a2f65 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -33,8 +33,14 @@ const icons = { message: '', phone: '', zap: '', + more: '', + dots: '', }; +function isMobile() { + return matchMedia('(max-width: 900px)').matches; +} + const navSections = [ {path: 'people', label: 'People'}, {path: 'classrooms', label: 'Classrooms'}, @@ -90,13 +96,32 @@ function myFamilyKey() { return (me && me.familyKey) || ''; } +function activeSection() { + const seg = segments(); + if (seg[0] === 'families') { + return seg[1] === myFamilyKey() ? 'my-family' : 'people'; + } + if (seg[0] === 'grades') { + return 'classrooms'; + } + return seg[0]; +} + +function setChrome(title, backHref) { + document.querySelector('#mobile-title').textContent = title; + const back = document.querySelector('#mobile-back'); + const menuBtn = document.querySelector('#mobile-menu-btn'); + back.hidden = !backHref; + menuBtn.hidden = Boolean(backHref); + if (backHref) { + back.href = backHref; + } +} + function renderNav() { + const seg = activeSection(); const nav = document.querySelector('#nav'); nav.replaceChildren(); - let seg = segments()[0]; - if (seg === 'families') { - seg = segments()[1] === myFamilyKey() ? 'my-family' : 'people'; - } for (const item of navSections) { if (item.divider) { nav.append(el('div', 'nav-divider')); @@ -110,6 +135,14 @@ function renderNav() { a.append(svg(item.path), el('span', '', item.label)); nav.append(a); } + const tabs = document.querySelector('#mobile-tabs'); + tabs.replaceChildren(); + for (const item of navSections) { + const a = el('a', item.path === seg ? 'active' : ''); + a.href = '/' + item.path; + a.append(svg(item.path), el('span', '', item.label)); + tabs.append(a); + } } function hue(text) { @@ -169,8 +202,35 @@ function personContext(p) { return ''; } +function cardMore(email) { + const wrap = el('div', 'card-more-wrap'); + const button = el('button', 'card-more'); + button.append(svg('dots')); + const menu = el('div', 'card-menu'); + menu.hidden = true; + const item = el('button', 'card-menu-item'); + const label = el('span', '', favorites.has(email) ? 'Remove Bookmark' : 'Add Bookmark'); + item.append(svg('heart'), label); + item.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + toggleFavorite(email); + label.textContent = favorites.has(email) ? 'Remove Bookmark' : 'Add Bookmark'; + menu.hidden = true; + }); + menu.append(item); + button.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + menu.hidden = !menu.hidden; + }); + wrap.append(button, menu); + return wrap; +} + function personCard(p) { const card = el('a', 'person-card'); + card.append(cardMore(p.email)); card.href = personLink(p); card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); card.append(el('div', 'role-label', roleLabel(p))); @@ -201,6 +261,7 @@ function renderStudents(grid) { for (const p of matches) { const card = el('a', 'student-card'); card.href = personLink(p); + card.append(cardMore(p.email)); const head = el('div', 'student-head'); const family = state.model.families[p.familyKey]; if (family && family.photoUrl) { @@ -294,6 +355,7 @@ function renderStaff(grid, autoFit) { for (const p of groups.get(dept)) { const card = el('a', 'person-card'); card.href = personLink(p); + card.append(cardMore(p.email)); 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)); @@ -316,21 +378,13 @@ 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 = ''; - history.replaceState(null, '', tabHref(tab.key)); - renderPeople(); - }); - tabsRow.append(node); - } - tabs.append(tabsRow); - main.append(tabs); + const items = peopleTabs.map(t => ({...t, icon: t.key === 'staff' ? 'staff-tab' : t.key})); + main.append(tabStrip(items, state.tab, 2, key => { + state.tab = key; + state.q = ''; + history.replaceState(null, '', tabHref(key)); + renderPeople(); + })); const content = el('div', 'content container'); const header = el('div', 'content-header'); @@ -559,10 +613,11 @@ function fromCrumbs() { } function breadcrumbs(parts, favoriteEmail) { + const parent = [...parts].reverse().find(([, href]) => href); + setChrome(parts[parts.length - 1][0], parent ? parent[1] : '/people'); const top = el('div', 'detail-top container'); const crumbs = el('div', 'crumbs'); const back = el('a', 'crumb-back'); - const parent = [...parts].reverse().find(([, href]) => href); back.href = parent ? parent[1] : '/people'; back.append(svg('chevron-left')); crumbs.append(back); @@ -1039,21 +1094,12 @@ function renderClassroomsPage() { const main = document.querySelector('#main'); main.replaceChildren(); - const tabs = el('div', 'tabs'); - const tabsRow = el('div', 'container tabs-row'); - for (const tab of classroomsTabs) { - const node = el('div', 'tab' + (tab.key === state.classTab ? ' active' : '')); - node.append(el('span', '', tab.label)); - node.addEventListener('click', () => { - state.classTab = tab.key; - state.q = ''; - history.replaceState(null, '', tabHref(tab.key)); - renderClassroomsPage(); - }); - tabsRow.append(node); - } - tabs.append(tabsRow); - main.append(tabs); + main.append(tabStrip(classroomsTabs, state.classTab, 1, key => { + state.classTab = key; + state.q = ''; + history.replaceState(null, '', tabHref(key)); + renderClassroomsPage(); + })); const content = el('div', 'content container'); const header = el('div', 'content-header'); @@ -1194,20 +1240,14 @@ function renderRoster(title, image, groups, backLabel) { {key: 'parents', label: 'Parents', icon: 'families', count: parents.length}, ]; - const tabs = el('div', 'tabs roster-tabs'); - const tabsRow = el('div', 'tabs-row'); - for (const tab of memberTabs) { - const node = el('div', 'tab' + (tab.key === state.rosterTab ? ' active' : '')); - node.append(svg(tab.icon), el('span', '', tab.label), el('span', 'tab-count', String(tab.count))); - node.addEventListener('click', () => { - state.rosterTab = tab.key; - history.replaceState(null, '', tabHref(tab.key)); - renderRoster(title, image, groups, backLabel); - }); - tabsRow.append(node); - } - tabs.append(tabsRow); - content.append(tabs); + const strip = tabStrip(memberTabs, state.rosterTab, 2, key => { + state.rosterTab = key; + history.replaceState(null, '', tabHref(key)); + renderRoster(title, image, groups, backLabel); + }); + strip.classList.add('roster-tabs'); + strip.querySelector('.tabs-row').classList.remove('container'); + content.append(strip); const list = el('div'); if (state.rosterTab === 'students') { @@ -1323,24 +1363,13 @@ function renderEmailListPage() { main.append(el('div', 'container email-hint', 'Use the filters to select for specific grades or classrooms.')); - const tabs = el('div', 'tabs'); - const tabsRow = el('div', 'container tabs-row'); - for (const tab of emailTabs) { - const node = el('div', 'tab' + (tab.key === state.emailTab ? ' active' : '')); - if (tab.key === 'bookmarks') { - node.append(svg('heart')); - } - node.append(el('span', '', tab.label)); - node.addEventListener('click', () => { - state.emailTab = tab.key; - state.q = ''; - history.replaceState(null, '', tabHref(tab.key)); - renderEmailListPage(); - }); - tabsRow.append(node); - } - tabs.append(tabsRow); - main.append(tabs); + const items = emailTabs.map(t => (t.key === 'bookmarks' ? {...t, icon: 'heart'} : t)); + main.append(tabStrip(items, state.emailTab, 2, key => { + state.emailTab = key; + state.q = ''; + history.replaceState(null, '', tabHref(key)); + renderEmailListPage(); + })); const content = el('div', 'content container'); const header = el('div', 'content-header'); @@ -1360,7 +1389,7 @@ function renderEmailListPage() { header.append(controls); content.append(header); - const holder = el('div'); + const holder = el('div', 'email-holder'); content.append(holder); main.append(content); @@ -1574,14 +1603,75 @@ function tabParam(fallback) { return new URLSearchParams(location.search).get('tab') || fallback; } +function tabNode(item, active, onSelect) { + const node = el('div', 'tab' + (active ? ' active' : '')); + if (item.icon) { + node.append(svg(item.icon)); + } + node.append(el('span', '', item.label)); + if (item.count !== undefined) { + node.append(el('span', 'tab-count', String(item.count))); + } + node.addEventListener('click', () => onSelect(item.key)); + return node; +} + +function tabStrip(items, activeKey, mobileVisible, onSelect) { + const tabs = el('div', 'tabs'); + const row = el('div', 'container tabs-row'); + const visible = isMobile() ? items.slice(0, mobileVisible) : items; + const hidden = isMobile() ? items.slice(mobileVisible) : []; + for (const item of visible) { + row.append(tabNode(item, item.key === activeKey, onSelect)); + } + if (hidden.length) { + const wrap = el('div', 'more-wrap'); + const more = el('div', 'tab' + (hidden.some(t => t.key === activeKey) ? ' active' : '')); + more.append(svg('more'), el('span', '', 'More'), svg('chevron')); + const menu = el('div', 'more-menu'); + menu.hidden = true; + for (const item of hidden) { + const entry = el('div', 'more-item' + (item.key === activeKey ? ' active' : '')); + if (item.icon) { + entry.append(svg(item.icon)); + } + entry.append(el('span', '', item.label)); + if (item.count !== undefined) { + entry.append(el('span', 'tab-count', String(item.count))); + } + entry.addEventListener('click', () => onSelect(item.key)); + menu.append(entry); + } + more.addEventListener('click', e => { + e.stopPropagation(); + menu.hidden = !menu.hidden; + }); + wrap.append(more, menu); + row.append(wrap); + } + tabs.append(row); + return tabs; +} + function tabHref(key) { const params = new URLSearchParams(location.search); params.set('tab', key); return location.pathname + '?' + params; } +const sectionTitles = { + people: 'People', + classrooms: 'Classrooms', + 'my-family': 'My Family', + staff: 'Staff', + map: 'Map', + 'email-list': 'Email List', + profile: 'Profile', +}; + function render() { renderNav(); + setChrome(sectionTitles[segments()[0]] || 'Helios Who?', null); const seg = segments(); if (seg[0] === 'people' && seg[1]) { renderPersonDetail(seg[1]); @@ -1619,6 +1709,26 @@ document.querySelector('#user').addEventListener('click', e => { e.stopPropagation(); userMenu.hidden = !userMenu.hidden; }); + +const drawer = document.querySelector('#drawer'); +const drawerOverlay = document.querySelector('#drawer-overlay'); +const drawerUserMenu = document.querySelector('#drawer-user-menu'); + +function setDrawer(open) { + drawer.hidden = !open; + drawerOverlay.hidden = !open; + if (!open) { + drawerUserMenu.hidden = true; + } +} + +document.querySelector('#mobile-menu-btn').addEventListener('click', () => setDrawer(true)); +document.querySelector('#drawer-close').addEventListener('click', () => setDrawer(false)); +drawerOverlay.addEventListener('click', () => setDrawer(false)); +document.querySelector('#drawer-user-more').addEventListener('click', e => { + e.stopPropagation(); + drawerUserMenu.hidden = !drawerUserMenu.hidden; +}); function closeFilterPanels() { for (const panel of document.querySelectorAll('.filter-panel')) { panel.hidden = true; @@ -1628,6 +1738,12 @@ function closeFilterPanels() { document.addEventListener('click', e => { userMenu.hidden = true; + drawerUserMenu.hidden = true; + for (const menu of document.querySelectorAll('.more-menu, .card-menu')) { + if (!menu.hidden && !menu.parentElement.contains(e.target)) { + menu.hidden = true; + } + } for (const panel of document.querySelectorAll('.filter-panel')) { if (!panel.hidden && !panel.parentElement.contains(e.target)) { panel.hidden = true; @@ -1638,7 +1754,11 @@ document.addEventListener('click', e => { document.addEventListener('keydown', e => { if (e.key === 'Escape') { userMenu.hidden = true; + setDrawer(false); closeFilterPanels(); + for (const menu of document.querySelectorAll('.more-menu, .card-menu')) { + menu.hidden = true; + } } }); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index ccb27e2..8229f56 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -49,14 +49,14 @@ body { border-radius: 8px; } -nav { +#nav { display: flex; flex-direction: column; gap: 1px; flex: 1; } -nav a { +#nav a { display: flex; align-items: center; gap: 12px; @@ -69,7 +69,7 @@ nav a { border-radius: 8px; } -nav a svg { +#nav a svg { width: 20px; height: 20px; stroke: currentColor; @@ -80,11 +80,11 @@ nav a svg { flex-shrink: 0; } -nav a:hover { +#nav a:hover { background: rgba(255, 255, 255, 0.06); } -nav a.active { +#nav a.active { background: var(--sidebar-active); color: #fff; } @@ -460,6 +460,10 @@ main { padding-bottom: 2px; } +.email-holder { + overflow-x: auto; +} + .email-table { width: 100%; border-collapse: collapse; @@ -1251,7 +1255,121 @@ a.list-row:hover { } } +.mobile-top, +.mobile-tabs, +.drawer, +.drawer-overlay, +.card-more-wrap { + display: none; +} + +.more-wrap { + position: relative; +} + +.more-menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + min-width: 180px; + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + padding: 6px 0; + z-index: 25; +} + +.more-item { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + font-size: 14px; + font-weight: 500; + color: var(--ink); + cursor: pointer; +} + +.more-item:hover { + background: #f6f6f7; +} + +.more-item.active { + color: var(--brand); +} + +.more-item svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.card-more { + border: 0; + background: none; + padding: 4px; + color: #9aa4ab; + cursor: pointer; + display: flex; +} + +.card-more svg { + width: 18px; + height: 18px; + fill: currentColor; +} + +.card-menu { + position: absolute; + top: 26px; + right: 0; + background: #fff; + border-radius: 10px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2); + z-index: 20; +} + +.card-menu-item { + display: flex; + align-items: center; + gap: 10px; + border: 0; + background: none; + padding: 12px 16px; + font: inherit; + font-size: 14px; + color: var(--ink); + cursor: pointer; + white-space: nowrap; +} + +.card-menu-item svg { + width: 17px; + height: 17px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + @media (max-width: 900px) { + body { + display: block; + } + .sidebar { + display: none; + } + main { + height: 100vh; + padding-top: 48px; + padding-bottom: 62px; + } .container { padding-left: 16px; padding-right: 16px; @@ -1263,7 +1381,248 @@ a.list-row:hover { .student-grid { grid-template-columns: 1fr; } - .search { + .mobile-top { + display: flex; + align-items: center; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 48px; + background: var(--sidebar); + z-index: 30; + padding: 0 6px; + } + .mobile-title { + position: absolute; + left: 48px; + right: 48px; + text-align: center; + color: #fff; + font-weight: 600; + font-size: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .mobile-menu-btn[hidden], + .mobile-back[hidden] { + display: none; + } + .mobile-menu-btn, + .mobile-back { + background: none; + border: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + z-index: 31; + } + .mobile-menu-btn svg, + .mobile-back svg { + width: 22px; + height: 22px; + stroke: rgba(255, 255, 255, 0.85); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .mobile-tabs { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 62px; + background: #fff; + border-top: 1px solid var(--line); + z-index: 30; + } + .mobile-tabs a { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: #8a939b; + text-decoration: none; + font-size: 10.5px; + } + .mobile-tabs a svg { + width: 22px; + height: 22px; + stroke: currentColor; + fill: none; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + } + .mobile-tabs a.active { + color: var(--sidebar); + } + .drawer { + display: block; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 285px; + background: #fff; + z-index: 50; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.25); + } + .drawer[hidden], + .drawer-overlay[hidden] { + display: none; + } + .drawer-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 40; + } + .drawer-head { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + font-weight: 700; + font-size: 16px; + } + .drawer-head img { + width: 32px; + height: 32px; + border-radius: 8px; + } + .drawer-close { + margin-left: auto; + background: none; + border: 0; + cursor: pointer; + color: #5b6572; + display: flex; + } + .drawer-close svg { + width: 20px; + height: 20px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + } + .drawer-user { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + padding: 18px 16px; + } + .drawer-user-name { + font-weight: 700; + font-size: 15px; + } + .drawer-user-email { + color: var(--muted); + font-size: 12.5px; + margin-top: 2px; + } + .drawer-user-more { + margin-left: auto; + background: none; + border: 0; + cursor: pointer; + color: #5b6572; + display: flex; + } + .drawer-user-more svg { + width: 20px; + height: 20px; + fill: currentColor; + } + .drawer-user-menu-anchor { + position: absolute; + right: 14px; + bottom: 64px; + width: 190px; + height: 0; + } + .drawer .user-menu { + left: auto; + right: 0; + bottom: 0; + } + .crumbs { + display: none; + } + .detail-top { + justify-content: flex-end; + min-height: 44px; + } + .detail-photo { width: 160px; + margin: 0 auto; + display: block; + } + .detail-name { + font-size: 26px; + overflow-wrap: anywhere; + } + .contact-row { + gap: 10px; + } + .contact-row > :first-child { + min-width: 0; + } + .contact-value { + overflow-wrap: anywhere; + font-size: 14px; + } + .contact-actions { + flex-shrink: 0; + } + .band-photo { + max-width: 200px; + margin: 0 auto; + } + .content-header { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + .controls { + width: 100%; + } + .search { + flex: 1; + width: auto; + } + .search input { + width: 100%; + } + .filter-button span, + .filter-button > svg:last-child { + display: none; + } + .card-more-wrap { + display: block; + position: absolute; + top: 6px; + right: 6px; + } + a.person-card, + a.student-card { + position: relative; + } + .map-canvas { + height: 62vh; } }