Add filter facets, new-to-Helios flag, and original-style student cards
This commit is contained in:
@@ -9,6 +9,7 @@ One record per person, all roles in one shape:
|
|||||||
- **Key**: school email, lowercased. Everyone has one, including the youngest students (their addresses exist but they don't have access yet).
|
- **Key**: school email, lowercased. Everyone has one, including the youngest students (their addresses exist but they don't have access yet).
|
||||||
- **Names**: full name, legal name, preferred name.
|
- **Names**: full name, legal name, preferred name.
|
||||||
- **Roles**: student, parent, and staff booleans — combinations are valid (staff members are often also parents). Display strings derive from the flags.
|
- **Roles**: student, parent, and staff booleans — combinations are valid (staff members are often also parents). Display strings derive from the flags.
|
||||||
|
- **New to Helios**: flag marking people who just joined the community; drives the matching filter toggle.
|
||||||
- **Pronouns**: optional; a curated list plus a freeform escape hatch.
|
- **Pronouns**: optional; a curated list plus a freeform escape hatch.
|
||||||
- **Pronunciation**: optional audio recording of the person's name.
|
- **Pronunciation**: optional audio recording of the person's name.
|
||||||
- **Photo**: official portrait or personal upload; people may opt for an illustrated avatar instead.
|
- **Photo**: official portrait or personal upload; people may opt for an illustrated avatar instead.
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ switches the directory app to the Google Sheets source. At startup the directory
|
|||||||
- `internal/directory` — directory app handlers
|
- `internal/directory` — directory app handlers
|
||||||
- `web/directory` — directory app page templates and static assets
|
- `web/directory` — directory app page templates and static assets
|
||||||
- `tools/screenshot` — dev-site page capture, see [screenshots.md](screenshots.md)
|
- `tools/screenshot` — dev-site page capture, see [screenshots.md](screenshots.md)
|
||||||
|
- `tools/columns` — print the column names of each directory table in the configured source
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ Share the app by SMS or link, an explanation of why photos and facts are collect
|
|||||||
## Behaviors
|
## Behaviors
|
||||||
|
|
||||||
- Everything is cross-linked: parents ↔ kids ↔ families ↔ classrooms; any person reference navigates to that person.
|
- Everything is cross-linked: parents ↔ kids ↔ families ↔ classrooms; any person reference navigates to that person.
|
||||||
- Search is per-list and immediate; filters cover grade and classroom.
|
- Search is per-list and immediate; filters cover role, class, grade, city, pronouns, and new-to-Helios.
|
||||||
- Favorites/bookmarks mark people and feed the email list's bookmark tab.
|
- Favorites/bookmarks mark people and feed the email list's bookmark tab.
|
||||||
- Photos lazy-load; full-size view on click where the photo is the subject (family pages).
|
- Photos lazy-load; full-size view on click where the photo is the subject (family pages).
|
||||||
- All data is community-only, behind sign-in; opt-out removes a person on request.
|
- All data is community-only, behind sign-in; opt-out removes a person on request.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
|
|||||||
|
|
||||||
- Remaining sections: My Family, Map.
|
- Remaining sections: My Family, Map.
|
||||||
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
|
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
|
||||||
- Filter button behavior — currently visual only; the original filters by grade and classroom.
|
|
||||||
- Favorites: per-account storage; hearts on person pages and email-list rows feed the bookmarks tab, saved per-device in the browser.
|
|
||||||
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func LoadModel(source data.Source) (*Model, error) {
|
|||||||
IsStaff: row["Is Staff?"] == "TRUE",
|
IsStaff: row["Is Staff?"] == "TRUE",
|
||||||
IsParent: row["Is Parent?"] == "TRUE",
|
IsParent: row["Is Parent?"] == "TRUE",
|
||||||
IsStudent: row["Is Student?"] == "TRUE",
|
IsStudent: row["Is Student?"] == "TRUE",
|
||||||
|
IsNew: row["Is Totally New"] == "TRUE",
|
||||||
Pronouns: row["Pronouns"],
|
Pronouns: row["Pronouns"],
|
||||||
Facts: row["Facts"],
|
Facts: row["Facts"],
|
||||||
PronunciationURL: blobURL("people", email, "pronunciation", row["Pronunciation"]),
|
PronunciationURL: blobURL("people", email, "pronunciation", row["Pronunciation"]),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Person struct {
|
|||||||
IsStaff bool `json:"isStaff"`
|
IsStaff bool `json:"isStaff"`
|
||||||
IsParent bool `json:"isParent"`
|
IsParent bool `json:"isParent"`
|
||||||
IsStudent bool `json:"isStudent"`
|
IsStudent bool `json:"isStudent"`
|
||||||
|
IsNew bool `json:"isNew,omitempty"`
|
||||||
Pronouns string `json:"pronouns,omitempty"`
|
Pronouns string `json:"pronouns,omitempty"`
|
||||||
Facts string `json:"facts,omitempty"`
|
Facts string `json:"facts,omitempty"`
|
||||||
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Command columns prints the column names of each directory table in the configured source.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"heliosian/internal/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sheetID := os.Getenv("DIRECTORY_SHEET")
|
||||||
|
var source data.Source
|
||||||
|
if sheetID == "" {
|
||||||
|
source = data.Dir{Root: "sampledata"}
|
||||||
|
} else {
|
||||||
|
s, err := data.NewSheet(map[string]string{"directory": sheetID})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[ERROR] load directory sheet: %v", err)
|
||||||
|
}
|
||||||
|
source = s
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} {
|
||||||
|
rows, err := source.Table("directory", name)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[ERROR] table %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
fmt.Printf("%s: no rows\n", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
columns := []string{}
|
||||||
|
for _, row := range rows {
|
||||||
|
for column := range row {
|
||||||
|
if !seen[column] {
|
||||||
|
seen[column] = true
|
||||||
|
columns = append(columns, column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(columns)
|
||||||
|
fmt.Printf("%s:\n", name)
|
||||||
|
for _, column := range columns {
|
||||||
|
fmt.Printf(" %s\n", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
-22
@@ -1,4 +1,4 @@
|
|||||||
const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: ''};
|
const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: '', filterGrades: new Set(), filterClassrooms: new Set(), filterRoles: new Set(), filterCities: new Set(), filterPronouns: new Set(), filterNew: false};
|
||||||
let byEmail = {};
|
let byEmail = {};
|
||||||
const favorites = new Set(JSON.parse(localStorage.getItem('favorites') || '[]'));
|
const favorites = new Set(JSON.parse(localStorage.getItem('favorites') || '[]'));
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ function renderEveryone(grid) {
|
|||||||
const q = state.q;
|
const q = state.q;
|
||||||
const matches = state.model.people.filter(p => {
|
const matches = state.model.people.filter(p => {
|
||||||
const family = state.model.families[p.familyKey];
|
const family = state.model.families[p.familyKey];
|
||||||
return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q);
|
return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q) && matchesFilters(p);
|
||||||
});
|
});
|
||||||
for (const p of matches) {
|
for (const p of matches) {
|
||||||
grid.append(personCard(p));
|
grid.append(personCard(p));
|
||||||
@@ -179,17 +179,21 @@ function renderEveryone(grid) {
|
|||||||
|
|
||||||
function renderStudents(grid) {
|
function renderStudents(grid) {
|
||||||
grid.className = 'student-grid';
|
grid.className = 'student-grid';
|
||||||
const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q));
|
const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q) && matchesFilters(p));
|
||||||
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);
|
||||||
if (p.photoUrl) {
|
const head = el('div', 'student-head');
|
||||||
const img = el('img', 'student-photo');
|
const family = state.model.families[p.familyKey];
|
||||||
img.src = thumbUrl(p.photoUrl);
|
if (family && family.photoUrl) {
|
||||||
img.loading = 'lazy';
|
const bg = el('img', 'student-family-photo');
|
||||||
img.alt = '';
|
bg.src = thumbUrl(family.photoUrl);
|
||||||
card.append(img);
|
bg.loading = 'lazy';
|
||||||
|
bg.alt = '';
|
||||||
|
head.append(bg);
|
||||||
}
|
}
|
||||||
|
head.append(photoOrInitials(p.photoUrl, p.fullName, 'student-photo'));
|
||||||
|
card.append(head);
|
||||||
card.append(el('div', 'student-first', firstName(p.fullName)));
|
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-last', p.fullName.replace(firstName(p.fullName), '').trim()));
|
||||||
card.append(el('div', 'student-line', gradeChain(p)));
|
card.append(el('div', 'student-line', gradeChain(p)));
|
||||||
@@ -222,6 +226,7 @@ function familyEntries() {
|
|||||||
members: [firstName(p.fullName)],
|
members: [firstName(p.fullName)],
|
||||||
photoUrl: p.photoUrl,
|
photoUrl: p.photoUrl,
|
||||||
href: personLink(p),
|
href: personLink(p),
|
||||||
|
email: p.email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +237,8 @@ function familyEntries() {
|
|||||||
function renderFamilies(grid) {
|
function renderFamilies(grid) {
|
||||||
grid.className = 'family-grid';
|
grid.className = 'family-grid';
|
||||||
const matches = familyEntries().filter(f =>
|
const matches = familyEntries().filter(f =>
|
||||||
`${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q));
|
`${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q) &&
|
||||||
|
(f.key ? familyMatchesFilters(f.key) : matchesFilters(byEmail[f.email])));
|
||||||
for (const f of matches) {
|
for (const f of matches) {
|
||||||
const card = el('a', 'family-card');
|
const card = el('a', 'family-card');
|
||||||
card.href = f.href;
|
card.href = f.href;
|
||||||
@@ -248,7 +254,7 @@ function renderFamilies(grid) {
|
|||||||
function renderStaff(grid, autoFit) {
|
function renderStaff(grid, autoFit) {
|
||||||
grid.className = '';
|
grid.className = '';
|
||||||
const staff = state.model.people.filter(p =>
|
const staff = state.model.people.filter(p =>
|
||||||
p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q));
|
p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q) && matchesFilters(p));
|
||||||
const departments = state.model.departments || [];
|
const departments = state.model.departments || [];
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const p of staff) {
|
for (const p of staff) {
|
||||||
@@ -322,9 +328,7 @@ function renderPeople() {
|
|||||||
renderGrid();
|
renderGrid();
|
||||||
});
|
});
|
||||||
search.append(input);
|
search.append(input);
|
||||||
const filter = el('button', 'filter-button');
|
controls.append(search, filterControl(renderGrid));
|
||||||
filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron'));
|
|
||||||
controls.append(search, filter);
|
|
||||||
header.append(controls);
|
header.append(controls);
|
||||||
content.append(header);
|
content.append(header);
|
||||||
|
|
||||||
@@ -342,6 +346,150 @@ function renderPeople() {
|
|||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function personFacets(p, field) {
|
||||||
|
if (p.isStudent) {
|
||||||
|
return p[field] ? [p[field]] : [];
|
||||||
|
}
|
||||||
|
if (p.isParent) {
|
||||||
|
const family = state.model.families[p.familyKey];
|
||||||
|
return ((family && family.kidEmails) || []).map(e => byEmail[e]).filter(Boolean).map(k => k[field]).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityOf(p) {
|
||||||
|
const family = state.model.families[p.familyKey];
|
||||||
|
if (!family || !family.address) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = family.address.split(',').map(s => s.trim());
|
||||||
|
return parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyFiltersActive() {
|
||||||
|
return Boolean(state.filterGrades.size || state.filterClassrooms.size || state.filterRoles.size ||
|
||||||
|
state.filterCities.size || state.filterPronouns.size || state.filterNew);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilters(p) {
|
||||||
|
const gradeOK = !state.filterGrades.size || personFacets(p, 'grade').some(g => state.filterGrades.has(g));
|
||||||
|
const classOK = !state.filterClassrooms.size || personFacets(p, 'classroom').some(c => state.filterClassrooms.has(c));
|
||||||
|
const roleOK = !state.filterRoles.size ||
|
||||||
|
(state.filterRoles.has('Student') && p.isStudent) ||
|
||||||
|
(state.filterRoles.has('Parent') && p.isParent) ||
|
||||||
|
(state.filterRoles.has('Staff') && p.isStaff);
|
||||||
|
const cityOK = !state.filterCities.size || state.filterCities.has(cityOf(p));
|
||||||
|
const pronounsOK = !state.filterPronouns.size || state.filterPronouns.has(p.pronouns);
|
||||||
|
const newOK = !state.filterNew || p.isNew;
|
||||||
|
return gradeOK && classOK && roleOK && cityOK && pronounsOK && newOK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function familyMatchesFilters(key) {
|
||||||
|
const family = state.model.families[key];
|
||||||
|
if (!family) {
|
||||||
|
return !anyFiltersActive();
|
||||||
|
}
|
||||||
|
const members = [...(family.kidEmails || []), ...(family.adultEmails || [])].map(e => byEmail[e]).filter(Boolean);
|
||||||
|
return !anyFiltersActive() || members.some(matchesFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeOptions() {
|
||||||
|
const present = new Set(state.model.people.filter(p => p.isStudent).map(p => p.grade).filter(Boolean));
|
||||||
|
return state.model.grades.map(g => g.name).filter(n => present.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityOptions() {
|
||||||
|
return [...new Set(state.model.people.map(cityOf).filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pronounOptions() {
|
||||||
|
return [...new Set(state.model.people.map(p => p.pronouns).filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterControl(rerender) {
|
||||||
|
const wrap = el('div', 'filter-wrap');
|
||||||
|
const button = el('button', 'filter-button');
|
||||||
|
button.append(svg('filter'), el('span', '', 'Filter'), svg('chevron'));
|
||||||
|
const panel = el('div', 'filter-panel');
|
||||||
|
panel.hidden = true;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
panel.hidden = !panel.hidden;
|
||||||
|
button.classList.toggle('open', !panel.hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{label: 'Role', values: ['Student', 'Parent', 'Staff'], set: state.filterRoles},
|
||||||
|
{label: 'Class', values: state.model.classrooms.map(c => c.name), set: state.filterClassrooms},
|
||||||
|
{label: 'Grade', values: gradeOptions(), set: state.filterGrades},
|
||||||
|
{label: 'City', values: cityOptions(), set: state.filterCities},
|
||||||
|
{label: 'Pronouns', values: pronounOptions(), set: state.filterPronouns},
|
||||||
|
];
|
||||||
|
for (const s of sections) {
|
||||||
|
const head = el('div', 'filter-section');
|
||||||
|
head.append(el('span', '', s.label), svg('chevron'));
|
||||||
|
const body = el('div', 'filter-options');
|
||||||
|
body.hidden = true;
|
||||||
|
head.addEventListener('click', () => {
|
||||||
|
body.hidden = !body.hidden;
|
||||||
|
head.classList.toggle('open', !body.hidden);
|
||||||
|
});
|
||||||
|
for (const v of s.values) {
|
||||||
|
const row = el('label', 'filter-option');
|
||||||
|
const box = el('input');
|
||||||
|
box.type = 'checkbox';
|
||||||
|
box.checked = s.set.has(v);
|
||||||
|
box.addEventListener('change', () => {
|
||||||
|
if (box.checked) {
|
||||||
|
s.set.add(v);
|
||||||
|
} else {
|
||||||
|
s.set.delete(v);
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
row.append(el('span', '', v), box);
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
panel.append(head, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRow = el('label', 'filter-toggle-row');
|
||||||
|
toggleRow.append(el('span', '', 'New to Helios'));
|
||||||
|
const toggle = el('input', 'filter-switch');
|
||||||
|
toggle.type = 'checkbox';
|
||||||
|
toggle.checked = state.filterNew;
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
state.filterNew = toggle.checked;
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
toggleRow.append(toggle);
|
||||||
|
panel.append(toggleRow);
|
||||||
|
|
||||||
|
const footer = el('div', 'filter-footer');
|
||||||
|
const clear = el('button', 'filter-clear', 'Clear all');
|
||||||
|
clear.addEventListener('click', () => {
|
||||||
|
state.filterGrades.clear();
|
||||||
|
state.filterClassrooms.clear();
|
||||||
|
state.filterRoles.clear();
|
||||||
|
state.filterCities.clear();
|
||||||
|
state.filterPronouns.clear();
|
||||||
|
state.filterNew = false;
|
||||||
|
for (const box of panel.querySelectorAll('input')) {
|
||||||
|
box.checked = false;
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
const done = el('button', 'filter-done', 'Done');
|
||||||
|
done.addEventListener('click', () => {
|
||||||
|
panel.hidden = true;
|
||||||
|
button.classList.remove('open');
|
||||||
|
});
|
||||||
|
footer.append(clear, done);
|
||||||
|
panel.append(footer);
|
||||||
|
|
||||||
|
wrap.append(button, panel);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function referrerCrumbs() {
|
function referrerCrumbs() {
|
||||||
if (!document.referrer) {
|
if (!document.referrer) {
|
||||||
return null;
|
return null;
|
||||||
@@ -915,9 +1063,7 @@ function renderStaffPage() {
|
|||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
search.append(input);
|
search.append(input);
|
||||||
const filter = el('button', 'filter-button');
|
controls.append(search, filterControl(renderList));
|
||||||
filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron'));
|
|
||||||
controls.append(search, filter);
|
|
||||||
header.append(controls);
|
header.append(controls);
|
||||||
content.append(header);
|
content.append(header);
|
||||||
|
|
||||||
@@ -1166,9 +1312,7 @@ function renderEmailListPage() {
|
|||||||
renderTable();
|
renderTable();
|
||||||
});
|
});
|
||||||
search.append(input);
|
search.append(input);
|
||||||
const filter = el('button', 'filter-button');
|
controls.append(search, filterControl(renderTable));
|
||||||
filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron'));
|
|
||||||
controls.append(search, filter);
|
|
||||||
header.append(controls);
|
header.append(controls);
|
||||||
content.append(header);
|
content.append(header);
|
||||||
|
|
||||||
@@ -1179,7 +1323,7 @@ function renderEmailListPage() {
|
|||||||
function renderTable() {
|
function renderTable() {
|
||||||
holder.replaceChildren();
|
holder.replaceChildren();
|
||||||
const rows = emailEntries(state.emailTab)
|
const rows = emailEntries(state.emailTab)
|
||||||
.filter(r => r.p.fullName.toLowerCase().includes(state.q) || r.p.email.toLowerCase().includes(state.q));
|
.filter(r => (r.p.fullName.toLowerCase().includes(state.q) || r.p.email.toLowerCase().includes(state.q)) && matchesFilters(r.p));
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
holder.append(el('div', 'empty', state.emailTab === 'bookmarks' ? 'No bookmarks yet.' : 'No matches.'));
|
holder.append(el('div', 'empty', state.emailTab === 'bookmarks' ? 'No bookmarks yet.' : 'No matches.'));
|
||||||
return;
|
return;
|
||||||
@@ -1291,12 +1435,26 @@ document.querySelector('#user').addEventListener('click', e => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
userMenu.hidden = !userMenu.hidden;
|
userMenu.hidden = !userMenu.hidden;
|
||||||
});
|
});
|
||||||
document.addEventListener('click', () => {
|
function closeFilterPanels() {
|
||||||
|
for (const panel of document.querySelectorAll('.filter-panel')) {
|
||||||
|
panel.hidden = true;
|
||||||
|
panel.parentElement.querySelector('.filter-button').classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
userMenu.hidden = true;
|
userMenu.hidden = true;
|
||||||
|
for (const panel of document.querySelectorAll('.filter-panel')) {
|
||||||
|
if (!panel.hidden && !panel.parentElement.contains(e.target)) {
|
||||||
|
panel.hidden = true;
|
||||||
|
panel.parentElement.querySelector('.filter-button').classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
userMenu.hidden = true;
|
userMenu.hidden = true;
|
||||||
|
closeFilterPanels();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,148 @@ main {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-button.open {
|
||||||
|
box-shadow: 0 0 0 2px var(--sidebar-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
width: 300px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: #9aa4ab;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section.open svg {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option:hover {
|
||||||
|
background: #f6f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option input {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-switch {
|
||||||
|
appearance: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #d5dadd;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-switch::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
transition: left 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-switch:checked {
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-switch:checked::before {
|
||||||
|
left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-footer button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-clear {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-done {
|
||||||
|
border: 0;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.email-hint {
|
.email-hint {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -459,8 +601,9 @@ div.person-photo {
|
|||||||
|
|
||||||
.student-grid {
|
.student-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 24px;
|
gap: 28px 24px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-card {
|
.student-card {
|
||||||
@@ -468,16 +611,43 @@ div.person-photo {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 28px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-photo {
|
.student-head {
|
||||||
|
position: relative;
|
||||||
|
height: 150px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-family-photo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1.35;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
background: #fff;
|
}
|
||||||
|
|
||||||
|
.student-photo {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -50px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
background: #eef1f3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.student-photo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-first {
|
.student-first {
|
||||||
|
|||||||
Reference in New Issue
Block a user