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
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
- 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`.
- Self-service flows: photo and pronunciation upload, address update, opt-out.
## 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).
- Docker build producing the static binary in a minimal base image containing only tzinfo and CA certificates.
- 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.
+26
View File
@@ -10,6 +10,11 @@
<link rel="stylesheet" href="/static/directory/style.css">
</head>
<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">
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
<nav id="nav"></nav>
@@ -20,6 +25,27 @@
<div class="user" id="user"><span class="user-avatar">{{.UserInitial}}</span><span>{{.UserName}}</span></div>
</aside>
<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>
</body>
</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>',
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>',
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 = [
{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;
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(tab.key));
history.replaceState(null, '', tabHref(key));
renderPeople();
});
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
}));
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;
main.append(tabStrip(classroomsTabs, state.classTab, 1, key => {
state.classTab = key;
state.q = '';
history.replaceState(null, '', tabHref(tab.key));
history.replaceState(null, '', tabHref(key));
renderClassroomsPage();
});
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
}));
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));
const strip = tabStrip(memberTabs, state.rosterTab, 2, key => {
state.rosterTab = key;
history.replaceState(null, '', tabHref(key));
renderRoster(title, image, groups, backLabel);
});
tabsRow.append(node);
}
tabs.append(tabsRow);
content.append(tabs);
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;
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(tab.key));
history.replaceState(null, '', tabHref(key));
renderEmailListPage();
});
tabsRow.append(node);
}
tabs.append(tabsRow);
main.append(tabs);
}));
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;
}
}
});
+365 -6
View File
@@ -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;
}
}