Compare commits

...
2 Commits
Author SHA1 Message Date
Ian Gulliver b418dd19ee Add production deployment doc 2026-08-15 23:58:26 -07:00
Ian Gulliver 0f2291473f Add mobile chrome matching the original app 2026-08-15 23:58:26 -07:00
5 changed files with 647 additions and 78 deletions
+66
View File
@@ -0,0 +1,66 @@
# Production deployment
The server runs as Cloud Run service `heliosian` in project `gen-lang-client-0758114984`, region `us-west1`, at https://heliosian-326077318680.us-west1.run.app. Everything below is driven with the gcloud CLI (`brew install --cask gcloud-cli`) authenticated as the deploy identity:
gcloud auth activate-service-account --key-file=creds/service-account.json
gcloud config set project gen-lang-client-0758114984
## Image
The Dockerfile builds in two stages: a `golang` stage compiles the static binary (`CGO_ENABLED=0`), and `gcr.io/distroless/static-debian12` — CA certificates and tzdata, nothing else — carries the binary plus `web/`, whose templates and static assets are read from disk at runtime. `sampledata/` is deliberately excluded: production always sets `DIRECTORY_SHEET`, and a misconfigured server fails at startup rather than silently serving sample data. `creds/` never enters the image or the source upload (`.dockerignore`, `.gcloudignore`).
Cloud Build produces the image into Artifact Registry. Builds ship git HEAD, never the working tree — building the live tree can capture files mid-edit:
BUILDDIR=$(mktemp -d)
git archive HEAD | tar -x -C "$BUILDDIR"
gcloud builds submit --tag us-west1-docker.pkg.dev/gen-lang-client-0758114984/heliosian/heliosian "$BUILDDIR"
## Service
gcloud run deploy heliosian \
--image us-west1-docker.pkg.dev/gen-lang-client-0758114984/heliosian/heliosian:latest \
--region us-west1 --allow-unauthenticated \
--min-instances 1 --memory 2Gi --no-cpu-throttling \
--set-env-vars DIRECTORY_SHEET=<spreadsheet id>,GOOGLE_CLIENT_ID=<oauth client id> \
--set-secrets "/app/creds/service-account.json=heliosian-sa-key:latest,SESSION_KEY=heliosian-session-key:latest,GOOGLE_MAPS_SERVER_KEY=heliosian-geocoding-key:latest,GOOGLE_MAPS_BROWSER_KEY=heliosian-maps-browser-key:latest" \
--quiet
Each flag is load-bearing:
- `--min-instances 1` — startup preloads every media file from Drive before listening (about 90 seconds); far too slow for scale-to-zero.
- `--memory 2Gi` — the blob store holds all media and thumbnails in RAM. The startup log line `blob store: … MB in memory` reports the footprint; resize when it approaches the limit.
- `--no-cpu-throttling` — the directory model and blob store refresh on five-minute tickers between requests; default throttling would starve them.
- `--allow-unauthenticated` — the app enforces its own Google sign-in; Cloud Run must let everyone reach the login page.
Cloud Run injects `PORT`; the server honors it.
## Configuration
Plain environment variables:
- `DIRECTORY_SHEET` — the production spreadsheet id. The sheet is the single spreadsheet shared with the service account.
- `GOOGLE_CLIENT_ID` — the OAuth web client id; not a secret (it is embedded in the login page). The client secret from `creds/oauth-client.json` is never used by the server and lives nowhere in production.
Secret Manager secrets, delivered per `--set-secrets` above:
- `heliosian-sa-key``creds/service-account.json`, mounted as a file at `/app/creds/service-account.json` (the exact path the server opens, relative to `/app`)
- `heliosian-session-key` — session-cookie HMAC key (any long random string); losing or rotating it signs everyone out
- `heliosian-geocoding-key``creds/geocoding.key`
- `heliosian-maps-browser-key``creds/maps.key`
## IAM
`heliosian-test@gen-lang-client-0758114984.iam.gserviceaccount.com` serves two unrelated purposes:
- Data access: the spreadsheet and the media shared drive are shared with it in Drive/Sheets directly — never through project IAM.
- Deploy identity: project roles Editor, Service Account User, Cloud Run Admin, and Secret Manager Admin. The extra roles exist because Editor cannot set IAM policy on services or secrets.
The runtime identity is the default compute service account (`326077318680-compute@developer.gserviceaccount.com`) holding Secret Manager Secret Accessor on each secret individually. The basic Editor role deliberately cannot read secret payloads, so these explicit grants are the only thing standing between the service and a startup failure — the console's inherited-role rows on a secret's Permissions tab do not imply payload access.
## OAuth
The service URL belongs in the OAuth client's authorized JavaScript origins alongside `http://localhost:8080`; sign-in fails on any origin not listed, and edits take a few minutes to propagate. The browser maps key is rendered into every page, so it carries an HTTP-referer restriction for the service URL and localhost (see `docs/dev.md`).
## Verifying a deploy
The startup log (Cloud Run → Logs, or `gcloud logging read`) shows the full boot sequence: geocoding count, directory model load, the blob store footprint line, then `listening`. After any deploy, an existing session should still work — if everyone got signed out, `SESSION_KEY` stopped reaching the server.
+2 -4
View File
@@ -1,14 +1,12 @@
# Plan # Plan
What remains to build. Current behavior is documented in `docs/dev.md`, `docs/data.md`, `docs/directory.md`, `docs/design.md`, and `docs/pwa.md`. What remains to build. Current behavior is documented in `docs/dev.md`, `docs/data.md`, `docs/directory.md`, `docs/design.md`, `docs/pwa.md`, and `docs/deploy.md`.
## Directory app ## Directory app
- 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.
## Hosting and deployment ## Hosting and deployment
- Cloud Run service in the school's project, minimum one instance (media is held in memory; startup is too slow for scale-to-zero). - Move from `gen-lang-client-0758114984` to the school's project: recreate the OAuth client there (Internal consent screen, only available inside the school's Workspace org, removes unverified-app friction), plus the service account, secrets, and service; re-share the spreadsheet and media drive with the new service account.
- Docker build producing the static binary in a minimal base image containing only tzinfo and CA certificates.
+26
View File
@@ -10,6 +10,11 @@
<link rel="stylesheet" href="/static/directory/style.css"> <link rel="stylesheet" href="/static/directory/style.css">
</head> </head>
<body data-user-email="{{.UserEmail}}" data-maps-key="{{.MapsKey}}"> <body data-user-email="{{.UserEmail}}" data-maps-key="{{.MapsKey}}">
<header class="mobile-top">
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="Menu"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<a class="mobile-back" id="mobile-back" hidden><svg viewBox="0 0 24 24"><path d="M19 12H5m7-7-7 7 7 7"/></svg></a>
<div class="mobile-title" id="mobile-title"></div>
</header>
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div> <div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
<nav id="nav"></nav> <nav id="nav"></nav>
@@ -20,6 +25,27 @@
<div class="user" id="user"><span class="user-avatar">{{.UserInitial}}</span><span>{{.UserName}}</span></div> <div class="user" id="user"><span class="user-avatar">{{.UserInitial}}</span><span>{{.UserName}}</span></div>
</aside> </aside>
<main id="main"></main> <main id="main"></main>
<nav class="mobile-tabs" id="mobile-tabs"></nav>
<div class="drawer-overlay" id="drawer-overlay" hidden></div>
<aside class="drawer" id="drawer" hidden>
<div class="drawer-head">
<img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span>
<button class="drawer-close" id="drawer-close" aria-label="Close"><svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="drawer-user">
<div class="drawer-user-menu-anchor">
<div class="user-menu" id="drawer-user-menu" hidden>
<a href="/profile">View Profile</a>
<form method="post" action="/auth/logout"><button>Sign Out</button></form>
</div>
</div>
<div class="drawer-user-info">
<div class="drawer-user-name">{{.UserName}}</div>
<div class="drawer-user-email">{{.UserEmail}}</div>
</div>
<button class="drawer-user-more" id="drawer-user-more" aria-label="Account"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg></button>
</div>
</aside>
<script src="/static/directory/app.js"></script> <script src="/static/directory/app.js"></script>
</body> </body>
</html> </html>
+180 -60
View File
@@ -33,8 +33,14 @@ const icons = {
message: '<svg viewBox="0 0 24 24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>', message: '<svg viewBox="0 0 24 24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
phone: '<svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>', phone: '<svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
zap: '<svg viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>', zap: '<svg viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>',
more: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>',
dots: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
}; };
function isMobile() {
return matchMedia('(max-width: 900px)').matches;
}
const navSections = [ const navSections = [
{path: 'people', label: 'People'}, {path: 'people', label: 'People'},
{path: 'classrooms', label: 'Classrooms'}, {path: 'classrooms', label: 'Classrooms'},
@@ -90,13 +96,32 @@ function myFamilyKey() {
return (me && me.familyKey) || ''; 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() { function renderNav() {
const seg = activeSection();
const nav = document.querySelector('#nav'); const nav = document.querySelector('#nav');
nav.replaceChildren(); nav.replaceChildren();
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'));
@@ -110,6 +135,14 @@ function renderNav() {
a.append(svg(item.path), el('span', '', item.label)); a.append(svg(item.path), el('span', '', item.label));
nav.append(a); 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) { function hue(text) {
@@ -169,8 +202,35 @@ function personContext(p) {
return ''; 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) { function personCard(p) {
const card = el('a', 'person-card'); const card = el('a', 'person-card');
card.append(cardMore(p.email));
card.href = personLink(p); card.href = personLink(p);
card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo'));
card.append(el('div', 'role-label', roleLabel(p))); card.append(el('div', 'role-label', roleLabel(p)));
@@ -201,6 +261,7 @@ function renderStudents(grid) {
for (const p of matches) { for (const p of matches) {
const card = el('a', 'student-card'); const card = el('a', 'student-card');
card.href = personLink(p); card.href = personLink(p);
card.append(cardMore(p.email));
const head = el('div', 'student-head'); const head = el('div', 'student-head');
const family = state.model.families[p.familyKey]; const family = state.model.families[p.familyKey];
if (family && family.photoUrl) { if (family && family.photoUrl) {
@@ -294,6 +355,7 @@ function renderStaff(grid, autoFit) {
for (const p of groups.get(dept)) { for (const p of groups.get(dept)) {
const card = el('a', 'person-card'); const card = el('a', 'person-card');
card.href = personLink(p); card.href = personLink(p);
card.append(cardMore(p.email));
card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo')); card.append(photoOrInitials(p.photoUrl, p.fullName, 'person-photo'));
card.append(el('div', 'role-label', p.jobTitle || 'Staff')); card.append(el('div', 'role-label', p.jobTitle || 'Staff'));
card.append(el('div', 'person-name', p.fullName)); card.append(el('div', 'person-name', p.fullName));
@@ -316,21 +378,13 @@ function renderPeople() {
const main = document.querySelector('#main'); const main = document.querySelector('#main');
main.replaceChildren(); main.replaceChildren();
const tabs = el('div', 'tabs'); const items = peopleTabs.map(t => ({...t, icon: t.key === 'staff' ? 'staff-tab' : t.key}));
const tabsRow = el('div', 'container tabs-row'); main.append(tabStrip(items, state.tab, 2, key => {
for (const tab of peopleTabs) { state.tab = key;
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 = ''; state.q = '';
history.replaceState(null, '', tabHref(tab.key)); history.replaceState(null, '', tabHref(key));
renderPeople(); renderPeople();
}); }));
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
const content = el('div', 'content container'); const content = el('div', 'content container');
const header = el('div', 'content-header'); const header = el('div', 'content-header');
@@ -559,10 +613,11 @@ function fromCrumbs() {
} }
function breadcrumbs(parts, favoriteEmail) { 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 top = el('div', 'detail-top container');
const crumbs = el('div', 'crumbs'); const crumbs = el('div', 'crumbs');
const back = el('a', 'crumb-back'); const back = el('a', 'crumb-back');
const parent = [...parts].reverse().find(([, href]) => href);
back.href = parent ? parent[1] : '/people'; back.href = parent ? parent[1] : '/people';
back.append(svg('chevron-left')); back.append(svg('chevron-left'));
crumbs.append(back); crumbs.append(back);
@@ -1039,21 +1094,12 @@ function renderClassroomsPage() {
const main = document.querySelector('#main'); const main = document.querySelector('#main');
main.replaceChildren(); main.replaceChildren();
const tabs = el('div', 'tabs'); main.append(tabStrip(classroomsTabs, state.classTab, 1, key => {
const tabsRow = el('div', 'container tabs-row'); state.classTab = key;
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 = ''; state.q = '';
history.replaceState(null, '', tabHref(tab.key)); history.replaceState(null, '', tabHref(key));
renderClassroomsPage(); renderClassroomsPage();
}); }));
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
const content = el('div', 'content container'); const content = el('div', 'content container');
const header = el('div', 'content-header'); 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}, {key: 'parents', label: 'Parents', icon: 'families', count: parents.length},
]; ];
const tabs = el('div', 'tabs roster-tabs'); const strip = tabStrip(memberTabs, state.rosterTab, 2, key => {
const tabsRow = el('div', 'tabs-row'); state.rosterTab = key;
for (const tab of memberTabs) { history.replaceState(null, '', tabHref(key));
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); renderRoster(title, image, groups, backLabel);
}); });
tabsRow.append(node); strip.classList.add('roster-tabs');
} strip.querySelector('.tabs-row').classList.remove('container');
tabs.append(tabsRow); content.append(strip);
content.append(tabs);
const list = el('div'); const list = el('div');
if (state.rosterTab === 'students') { 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.')); main.append(el('div', 'container email-hint', 'Use the filters to select for specific grades or classrooms.'));
const tabs = el('div', 'tabs'); const items = emailTabs.map(t => (t.key === 'bookmarks' ? {...t, icon: 'heart'} : t));
const tabsRow = el('div', 'container tabs-row'); main.append(tabStrip(items, state.emailTab, 2, key => {
for (const tab of emailTabs) { state.emailTab = key;
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 = ''; state.q = '';
history.replaceState(null, '', tabHref(tab.key)); history.replaceState(null, '', tabHref(key));
renderEmailListPage(); renderEmailListPage();
}); }));
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
const content = el('div', 'content container'); const content = el('div', 'content container');
const header = el('div', 'content-header'); const header = el('div', 'content-header');
@@ -1360,7 +1389,7 @@ function renderEmailListPage() {
header.append(controls); header.append(controls);
content.append(header); content.append(header);
const holder = el('div'); const holder = el('div', 'email-holder');
content.append(holder); content.append(holder);
main.append(content); main.append(content);
@@ -1574,14 +1603,75 @@ function tabParam(fallback) {
return new URLSearchParams(location.search).get('tab') || 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) { function tabHref(key) {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set('tab', key); params.set('tab', key);
return location.pathname + '?' + params; 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() { function render() {
renderNav(); renderNav();
setChrome(sectionTitles[segments()[0]] || 'Helios Who?', null);
const seg = segments(); const seg = segments();
if (seg[0] === 'people' && seg[1]) { if (seg[0] === 'people' && seg[1]) {
renderPersonDetail(seg[1]); renderPersonDetail(seg[1]);
@@ -1619,6 +1709,26 @@ document.querySelector('#user').addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
userMenu.hidden = !userMenu.hidden; 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() { function closeFilterPanels() {
for (const panel of document.querySelectorAll('.filter-panel')) { for (const panel of document.querySelectorAll('.filter-panel')) {
panel.hidden = true; panel.hidden = true;
@@ -1628,6 +1738,12 @@ function closeFilterPanels() {
document.addEventListener('click', e => { document.addEventListener('click', e => {
userMenu.hidden = true; 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')) { for (const panel of document.querySelectorAll('.filter-panel')) {
if (!panel.hidden && !panel.parentElement.contains(e.target)) { if (!panel.hidden && !panel.parentElement.contains(e.target)) {
panel.hidden = true; panel.hidden = true;
@@ -1638,7 +1754,11 @@ document.addEventListener('click', e => {
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
userMenu.hidden = true; userMenu.hidden = true;
setDrawer(false);
closeFilterPanels(); closeFilterPanels();
for (const menu of document.querySelectorAll('.more-menu, .card-menu')) {
menu.hidden = true;
}
} }
}); });
+365 -6
View File
@@ -49,14 +49,14 @@ body {
border-radius: 8px; border-radius: 8px;
} }
nav { #nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
flex: 1; flex: 1;
} }
nav a { #nav a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -69,7 +69,7 @@ nav a {
border-radius: 8px; border-radius: 8px;
} }
nav a svg { #nav a svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
stroke: currentColor; stroke: currentColor;
@@ -80,11 +80,11 @@ nav a svg {
flex-shrink: 0; flex-shrink: 0;
} }
nav a:hover { #nav a:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
nav a.active { #nav a.active {
background: var(--sidebar-active); background: var(--sidebar-active);
color: #fff; color: #fff;
} }
@@ -460,6 +460,10 @@ main {
padding-bottom: 2px; padding-bottom: 2px;
} }
.email-holder {
overflow-x: auto;
}
.email-table { .email-table {
width: 100%; width: 100%;
border-collapse: collapse; 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) { @media (max-width: 900px) {
body {
display: block;
}
.sidebar {
display: none;
}
main {
height: 100vh;
padding-top: 48px;
padding-bottom: 62px;
}
.container { .container {
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
@@ -1263,7 +1381,248 @@ a.list-row:hover {
.student-grid { .student-grid {
grid-template-columns: 1fr; 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; 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;
} }
} }