Add edit mode with granular self-service edits and child opt-out
This commit is contained in:
+2
-2
@@ -53,7 +53,7 @@ The explosion turns each import row into one student record plus up to four adul
|
|||||||
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
|
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
|
||||||
|
|
||||||
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
|
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
|
||||||
- **Self-service text** (app): facts, pronouns, address preferences, and the Opted Out flag. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet.
|
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet.
|
||||||
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
|
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
|
||||||
|
|
||||||
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
|
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
|
||||||
@@ -67,7 +67,7 @@ Flagged rows must supply every field the model requires; unflagged rows can be a
|
|||||||
|
|
||||||
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
|
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
|
||||||
|
|
||||||
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it themselves from their own profile page (with a confirmation spelling out both consequences), or an admin sets the cell by hand.
|
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it from their own page, parents set it for their kids (each with a confirmation spelling out the consequences), or an admin sets the cell by hand.
|
||||||
|
|
||||||
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
|
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
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`.
|
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
|
|
||||||
|
|
||||||
- Self-service flows: address update.
|
|
||||||
|
|
||||||
## Hosting and deployment
|
## Hosting and deployment
|
||||||
|
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -63,24 +63,132 @@ func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *
|
|||||||
mux.HandleFunc("POST /api/directory/upload", u.upload)
|
mux.HandleFunc("POST /api/directory/upload", u.upload)
|
||||||
mux.HandleFunc("POST /api/directory/facts", u.facts)
|
mux.HandleFunc("POST /api/directory/facts", u.facts)
|
||||||
mux.HandleFunc("POST /api/directory/optout", u.optOut)
|
mux.HandleFunc("POST /api/directory/optout", u.optOut)
|
||||||
|
mux.HandleFunc("POST /api/directory/edit", u.edit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearable(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
|
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
|
||||||
|
field := r.FormValue("field")
|
||||||
|
value := strings.TrimSpace(r.FormValue("value"))
|
||||||
|
me := auth.Email(r)
|
||||||
|
model := u.cache.Model()
|
||||||
|
var person *Person
|
||||||
|
for i := range model.People {
|
||||||
|
if model.People[i].Email == key {
|
||||||
|
person = &model.People[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if person == nil {
|
||||||
|
http.Error(w, "no such person", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cells := map[string]string{}
|
||||||
|
previous := map[string]string{}
|
||||||
|
switch field {
|
||||||
|
case "preferred-name":
|
||||||
|
if !mayEdit(model, me, "person", key) {
|
||||||
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if value == "" || len(value) > 80 {
|
||||||
|
http.Error(w, "bad preferred name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := person.LegalName
|
||||||
|
if base == "" {
|
||||||
|
base = person.FullName
|
||||||
|
}
|
||||||
|
cells["Preferred Name"] = value
|
||||||
|
cells["Full Name"] = value + " " + surname(base)
|
||||||
|
previous["Preferred Name"] = person.PreferredName
|
||||||
|
previous["Full Name"] = person.FullName
|
||||||
|
case "phone":
|
||||||
|
if !mayEdit(model, me, "person", key) {
|
||||||
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(value) > 40 {
|
||||||
|
http.Error(w, "bad phone number", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cells["Phone"] = clearable(value)
|
||||||
|
previous["Phone"] = person.Phone
|
||||||
|
case "address":
|
||||||
|
if key != strings.ToLower(me) || !person.IsParent {
|
||||||
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
family, ok := model.Families[person.FamilyKey]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no family record", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(value) > 200 {
|
||||||
|
http.Error(w, "bad address", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cells["Address"] = clearable(value)
|
||||||
|
previous["Address"] = family.Address
|
||||||
|
default:
|
||||||
|
http.Error(w, "bad field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for column, cell := range cells {
|
||||||
|
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, column, cell); err != nil {
|
||||||
|
serverError(w, fmt.Errorf("set %s for %s: %w", column, key, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logRow := changeLogRow(me, key, previous)
|
||||||
|
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
|
||||||
|
serverError(w, fmt.Errorf("append change log after %s edit for %s: %w", field, key, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := u.cache.Refresh(); err != nil {
|
||||||
|
serverError(w, fmt.Errorf("refresh model after %s edit: %w", field, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("edit: %s set %s on %s", me, field, key)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u uploader) optOut(w http.ResponseWriter, r *http.Request) {
|
func (u uploader) optOut(w http.ResponseWriter, r *http.Request) {
|
||||||
me := strings.ToLower(auth.Email(r))
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
if err := u.sheet.Upsert(appName, "Overrides", "Email", me, "Opted Out", "TRUE"); err != nil {
|
me := auth.Email(r)
|
||||||
serverError(w, fmt.Errorf("opt out %s: %w", me, err))
|
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
|
||||||
|
if key == "" {
|
||||||
|
http.Error(w, "bad opt out request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logRow := changeLogRow(me, me, map[string]string{"Opted Out": ""})
|
if !mayEdit(u.cache.Model(), me, "person", key) {
|
||||||
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil {
|
||||||
|
serverError(w, fmt.Errorf("opt out %s: %w", key, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logRow := changeLogRow(me, key, map[string]string{"Opted Out": ""})
|
||||||
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
|
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
|
||||||
serverError(w, fmt.Errorf("append change log after opt out of %s: %w", me, err))
|
serverError(w, fmt.Errorf("append change log after opt out of %s: %w", key, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := u.cache.Refresh(); err != nil {
|
if err := u.cache.Refresh(); err != nil {
|
||||||
serverError(w, fmt.Errorf("refresh model after opt out: %w", err))
|
serverError(w, fmt.Errorf("refresh model after opt out: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("optout: %s removed themselves from the directory", me)
|
log.Printf("optout: %s removed %s from the directory", me, key)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ func main() {
|
|||||||
out := flag.String("out", "screenshots/capture.png", "output png path")
|
out := flag.String("out", "screenshots/capture.png", "output png path")
|
||||||
wait := flag.String("wait", "body", "css selector that must be visible before capturing")
|
wait := flag.String("wait", "body", "css selector that must be visible before capturing")
|
||||||
remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome")
|
remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome")
|
||||||
|
cookie := flag.String("cookie", "", "name=value cookie to set for localhost before navigating")
|
||||||
|
click := flag.String("click", "", "css selector to click after the wait selector appears")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if *remote {
|
if *remote {
|
||||||
@@ -28,14 +32,29 @@ func main() {
|
|||||||
defer cancelBrowser()
|
defer cancelBrowser()
|
||||||
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
|
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancelTimeout()
|
defer cancelTimeout()
|
||||||
|
actions := []chromedp.Action{chromedp.EmulateViewport(1280, 800)}
|
||||||
|
if *cookie != "" {
|
||||||
|
name, value, ok := strings.Cut(*cookie, "=")
|
||||||
|
if !ok {
|
||||||
|
log.Fatal("[ERROR] -cookie must be name=value")
|
||||||
|
}
|
||||||
|
actions = append(actions, chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
return network.SetCookie(name, value).WithDomain("localhost").WithPath("/").Do(ctx)
|
||||||
|
}))
|
||||||
|
}
|
||||||
var png []byte
|
var png []byte
|
||||||
err := chromedp.Run(ctx,
|
actions = append(actions,
|
||||||
chromedp.EmulateViewport(1280, 800),
|
|
||||||
chromedp.Navigate(*url),
|
chromedp.Navigate(*url),
|
||||||
chromedp.WaitVisible(*wait, chromedp.ByQuery),
|
chromedp.WaitVisible(*wait, chromedp.ByQuery),
|
||||||
chromedp.FullScreenshot(&png, 90),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if *click != "" {
|
||||||
|
actions = append(actions,
|
||||||
|
chromedp.Click(*click, chromedp.ByQuery),
|
||||||
|
chromedp.Sleep(500*time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actions = append(actions, chromedp.FullScreenshot(&png, 90))
|
||||||
|
if err := chromedp.Run(ctx, actions...); err != nil {
|
||||||
log.Fatalf("[ERROR] capture %s: %v", *url, err)
|
log.Fatalf("[ERROR] capture %s: %v", *url, err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
||||||
|
|||||||
+130
-20
@@ -666,6 +666,59 @@ function copyButton(text) {
|
|||||||
return iconButton('copy', 'Copy', () => navigator.clipboard.writeText(text));
|
return iconButton('copy', 'Copy', () => navigator.clipboard.writeText(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitField(key, field, value, status) {
|
||||||
|
status.classList.remove('error');
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('key', key);
|
||||||
|
form.append('field', field);
|
||||||
|
form.append('value', value);
|
||||||
|
const res = await fetch('/api/directory/edit', {method: 'POST', body: form});
|
||||||
|
if (!res.ok) {
|
||||||
|
status.classList.add('error');
|
||||||
|
status.textContent = await res.text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPencil(title) {
|
||||||
|
const pencil = el('button', 'edit-icon inline');
|
||||||
|
pencil.title = title;
|
||||||
|
pencil.append(svg('pencil'));
|
||||||
|
return pencil;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldEditor(anchor, pencil, opts) {
|
||||||
|
const box = el('div', 'field-editor');
|
||||||
|
const input = el('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = opts.current || '';
|
||||||
|
const note = el('div', 'field-note', "This doesn't affect the values shown in Veracross.");
|
||||||
|
const buttons = el('div', 'about-buttons');
|
||||||
|
const status = el('div', 'media-status about-status');
|
||||||
|
const save = el('button', 'media-button primary', 'Save');
|
||||||
|
const cancel = el('button', 'media-button', 'Cancel');
|
||||||
|
buttons.append(save, cancel);
|
||||||
|
if (opts.allowHide && opts.current) {
|
||||||
|
const hide = el('button', 'media-button', 'Hide');
|
||||||
|
buttons.append(hide);
|
||||||
|
hide.addEventListener('click', () => opts.submit('', status));
|
||||||
|
}
|
||||||
|
box.append(input, note, buttons, status);
|
||||||
|
cancel.addEventListener('click', () => {
|
||||||
|
box.remove();
|
||||||
|
anchor.hidden = false;
|
||||||
|
pencil.hidden = false;
|
||||||
|
});
|
||||||
|
save.addEventListener('click', () => opts.submit(input.value.trim(), status));
|
||||||
|
anchor.hidden = true;
|
||||||
|
pencil.hidden = true;
|
||||||
|
anchor.after(box);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function contactRow(value, buttons) {
|
function contactRow(value, buttons) {
|
||||||
const row = el('div', 'contact-row');
|
const row = el('div', 'contact-row');
|
||||||
row.append(value);
|
row.append(value);
|
||||||
@@ -757,6 +810,8 @@ function familyBand(p, family) {
|
|||||||
return band;
|
return band;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let personEdit = null;
|
||||||
|
|
||||||
function renderPersonDetail(email) {
|
function renderPersonDetail(email) {
|
||||||
const main = document.querySelector('#main');
|
const main = document.querySelector('#main');
|
||||||
main.replaceChildren();
|
main.replaceChildren();
|
||||||
@@ -772,7 +827,8 @@ function renderPersonDetail(email) {
|
|||||||
const grid = el('div', 'detail-grid');
|
const grid = el('div', 'detail-grid');
|
||||||
const left = el('div');
|
const left = el('div');
|
||||||
const editable = canEditPerson(p.email);
|
const editable = canEditPerson(p.email);
|
||||||
if (p.photoUrl || editable) {
|
const editing = editable && personEdit === p.email;
|
||||||
|
if (p.photoUrl || editing) {
|
||||||
const wrap = el('div', 'photo-wrap');
|
const wrap = el('div', 'photo-wrap');
|
||||||
if (p.photoUrl) {
|
if (p.photoUrl) {
|
||||||
const img = el('img', 'detail-photo');
|
const img = el('img', 'detail-photo');
|
||||||
@@ -783,7 +839,7 @@ function renderPersonDetail(email) {
|
|||||||
wrap.append(el('div', 'detail-photo detail-photo-empty'));
|
wrap.append(el('div', 'detail-photo detail-photo-empty'));
|
||||||
}
|
}
|
||||||
left.append(wrap);
|
left.append(wrap);
|
||||||
if (editable) {
|
if (editing) {
|
||||||
const status = el('div', 'media-status');
|
const status = el('div', 'media-status');
|
||||||
wrap.append(uploadIcon('camera', 'Upload photo', 'image/*', 'person', p.email, 'photo', status));
|
wrap.append(uploadIcon('camera', 'Upload photo', 'image/*', 'person', p.email, 'photo', status));
|
||||||
left.append(status);
|
left.append(status);
|
||||||
@@ -792,8 +848,28 @@ function renderPersonDetail(email) {
|
|||||||
grid.append(left);
|
grid.append(left);
|
||||||
|
|
||||||
const right = el('div');
|
const right = el('div');
|
||||||
right.append(el('div', 'role-label', roleLabel(p)));
|
const topRow = el('div', 'detail-top');
|
||||||
right.append(el('h1', 'detail-name', p.fullName));
|
topRow.append(el('div', 'role-label', roleLabel(p)));
|
||||||
|
if (editable) {
|
||||||
|
const toggle = el('button', 'media-button edit-toggle', editing ? 'Done' : 'Edit info');
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
personEdit = editing ? null : p.email;
|
||||||
|
renderPersonDetail(email);
|
||||||
|
});
|
||||||
|
topRow.append(toggle);
|
||||||
|
}
|
||||||
|
right.append(topRow);
|
||||||
|
const nameHeader = el('h1', 'detail-name');
|
||||||
|
nameHeader.append(el('span', '', p.fullName));
|
||||||
|
right.append(nameHeader);
|
||||||
|
if (editing) {
|
||||||
|
const pencil = editPencil('Edit preferred name');
|
||||||
|
nameHeader.append(pencil);
|
||||||
|
pencil.addEventListener('click', () => fieldEditor(nameHeader, pencil, {
|
||||||
|
current: p.preferredName || '',
|
||||||
|
submit: (value, status) => submitField(p.email, 'preferred-name', value, status),
|
||||||
|
}));
|
||||||
|
}
|
||||||
const nickname = displayNameLine(p);
|
const nickname = displayNameLine(p);
|
||||||
if (nickname) {
|
if (nickname) {
|
||||||
right.append(el('div', 'detail-sub', nickname));
|
right.append(el('div', 'detail-sub', nickname));
|
||||||
@@ -804,28 +880,56 @@ function renderPersonDetail(email) {
|
|||||||
right.append(el('div', 'detail-sub', chain));
|
right.append(el('div', 'detail-sub', chain));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (p.phone) {
|
if (p.phone || editing) {
|
||||||
right.append(contactRow(el('div', 'contact-value', p.phone), [
|
const actions = p.phone ? [
|
||||||
copyButton(p.phone),
|
copyButton(p.phone),
|
||||||
iconButton('message', 'Text', 'sms:' + p.phone),
|
iconButton('message', 'Text', 'sms:' + p.phone),
|
||||||
iconButton('phone', 'Call', 'tel:' + p.phone),
|
iconButton('phone', 'Call', 'tel:' + p.phone),
|
||||||
]));
|
] : [];
|
||||||
|
const phoneValue = el('div', 'contact-value editable-value');
|
||||||
|
phoneValue.append(el('span', '', p.phone || 'No phone number'));
|
||||||
|
const phoneRow = contactRow(phoneValue, actions);
|
||||||
|
right.append(phoneRow);
|
||||||
|
if (editing) {
|
||||||
|
const pencil = editPencil('Edit phone number');
|
||||||
|
phoneValue.append(pencil);
|
||||||
|
pencil.addEventListener('click', () => fieldEditor(phoneRow, pencil, {
|
||||||
|
current: p.phone || '',
|
||||||
|
allowHide: true,
|
||||||
|
submit: (value, status) => submitField(p.email, 'phone', value, status),
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
right.append(contactRow(el('div', 'contact-value', p.email), [
|
right.append(contactRow(el('div', 'contact-value', p.email), [
|
||||||
copyButton(p.email),
|
copyButton(p.email),
|
||||||
iconButton('mail', 'Email', 'mailto:' + p.email),
|
iconButton('mail', 'Email', 'mailto:' + p.email),
|
||||||
]));
|
]));
|
||||||
const family = state.model.families[p.familyKey];
|
const family = state.model.families[p.familyKey];
|
||||||
if (family && family.address) {
|
const addressEditable = editing && family && p.email === document.body.dataset.userEmail &&
|
||||||
|
(family.adultEmails || []).includes(p.email);
|
||||||
|
if (family && (family.address || addressEditable)) {
|
||||||
const block = el('div');
|
const block = el('div');
|
||||||
block.append(el('div', 'field-label', 'Address'));
|
block.append(el('div', 'field-label', 'Address'));
|
||||||
block.append(el('div', 'contact-value', family.address));
|
const addressValue = el('div', 'contact-value editable-value');
|
||||||
right.append(contactRow(block, [
|
addressValue.append(el('span', '', family.address || 'No address'));
|
||||||
|
block.append(addressValue);
|
||||||
|
const actions = family.address ? [
|
||||||
copyButton(family.address),
|
copyButton(family.address),
|
||||||
iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)),
|
iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)),
|
||||||
]));
|
] : [];
|
||||||
|
const addressRow = contactRow(block, actions);
|
||||||
|
right.append(addressRow);
|
||||||
|
if (addressEditable) {
|
||||||
|
const pencil = editPencil('Edit address');
|
||||||
|
addressValue.append(pencil);
|
||||||
|
pencil.addEventListener('click', () => fieldEditor(addressRow, pencil, {
|
||||||
|
current: family.address || '',
|
||||||
|
allowHide: true,
|
||||||
|
submit: (value, status) => submitField(p.email, 'address', value, status),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (p.pronunciationUrl || editable) {
|
}
|
||||||
|
if (p.pronunciationUrl || editing) {
|
||||||
right.append(el('div', 'pronounce-label', 'How do I pronounce this?'));
|
right.append(el('div', 'pronounce-label', 'How do I pronounce this?'));
|
||||||
if (p.pronunciationUrl) {
|
if (p.pronunciationUrl) {
|
||||||
const audio = el('audio', 'pronounce-player');
|
const audio = el('audio', 'pronounce-player');
|
||||||
@@ -834,19 +938,19 @@ function renderPersonDetail(email) {
|
|||||||
audio.src = p.pronunciationUrl;
|
audio.src = p.pronunciationUrl;
|
||||||
right.append(audio);
|
right.append(audio);
|
||||||
}
|
}
|
||||||
if (editable) {
|
if (editing) {
|
||||||
right.append(pronounceEditor('person', p.email));
|
right.append(pronounceEditor('person', p.email));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
grid.append(right);
|
grid.append(right);
|
||||||
content.append(grid);
|
content.append(grid);
|
||||||
|
|
||||||
if (p.facts || editable) {
|
if (p.facts || editing) {
|
||||||
const header = el('h2', 'about-header', 'About Me');
|
const header = el('h2', 'about-header', 'About Me');
|
||||||
content.append(header);
|
content.append(header);
|
||||||
const text = el('div', 'about-text', p.facts || '');
|
const text = el('div', 'about-text', p.facts || '');
|
||||||
const status = el('div', 'media-status about-status');
|
const status = el('div', 'media-status about-status');
|
||||||
if (editable) {
|
if (editing) {
|
||||||
const pencil = el('button', 'edit-icon inline');
|
const pencil = el('button', 'edit-icon inline');
|
||||||
pencil.title = 'Edit';
|
pencil.title = 'Edit';
|
||||||
pencil.append(svg('pencil'));
|
pencil.append(svg('pencil'));
|
||||||
@@ -886,19 +990,25 @@ function renderPersonDetail(email) {
|
|||||||
content.append(text, status);
|
content.append(text, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.email === document.body.dataset.userEmail) {
|
if (editing) {
|
||||||
|
const self = p.email === document.body.dataset.userEmail;
|
||||||
const header = el('h2', 'about-header', 'Privacy');
|
const header = el('h2', 'about-header', 'Privacy');
|
||||||
const button = el('button', 'media-button', 'Remove me from the directory');
|
const button = el('button', 'media-button',
|
||||||
|
'Remove ' + (self ? 'me' : firstName(p.fullName)) + ' from this directory');
|
||||||
const status = el('div', 'media-status');
|
const status = el('div', 'media-status');
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
const message = 'This removes all of your data from the directory. ' +
|
const message = 'This removes all data about ' + (self ? 'you' : firstName(p.fullName)) +
|
||||||
'For security, users not in the directory cannot access it. Continue?';
|
' from this directory. ' +
|
||||||
|
'For security, users not in the directory cannot access it. ' +
|
||||||
|
"This doesn't affect the values shown in Veracross. Continue?";
|
||||||
if (!confirm(message)) {
|
if (!confirm(message)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status.classList.remove('error');
|
status.classList.remove('error');
|
||||||
status.textContent = 'Removing…';
|
status.textContent = 'Removing…';
|
||||||
const res = await fetch('/api/directory/optout', {method: 'POST'});
|
const form = new FormData();
|
||||||
|
form.append('key', p.email);
|
||||||
|
const res = await fetch('/api/directory/optout', {method: 'POST', body: form});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
status.classList.add('error');
|
status.classList.add('error');
|
||||||
status.textContent = await res.text();
|
status.textContent = await res.text();
|
||||||
|
|||||||
@@ -1251,6 +1251,45 @@ a.list-row:hover {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-editor {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-editor input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.about-status {
|
.about-status {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user