From 1465a4ef055336ab3f001977c959d680347dbe72 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 16 Aug 2026 08:19:06 -0700 Subject: [PATCH] Add inline About Me editing on own records --- docs/directory.md | 2 +- internal/directory/upload.go | 39 ++++++++++++++++++++++++++++ web/static/directory/app.js | 47 +++++++++++++++++++++++++++++++--- web/static/directory/style.css | 36 ++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/docs/directory.md b/docs/directory.md index 3a2379f..c332d31 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -70,4 +70,4 @@ Share the app by SMS or link, an explanation of why photos and facts are collect - 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). - All data is community-only, behind sign-in; opt-out removes a person on request. -- Self-service media: viewing your own record, your kids', or your family page shows inline edit icons — a camera on the photo for uploads, and microphone/file icons under the pronunciation player to record in the browser or upload audio. Replaced files move to an `archive` folder in the media drive with a timestamp, the sheet's media cell is updated, and every change appends to the sheet's `Change Log` tab (timestamp, actor, target, kind, file, archived file). +- Self-service media: viewing your own record, your kids', or your family page shows inline edit icons — a camera on the photo for uploads, microphone/file icons under the pronunciation player to record in the browser or upload audio, and a pencil on the About Me text for inline editing. Replaced files move to an `archive` folder in the media drive with a timestamp, the sheet cells are updated, and every change appends to the sheet's `Change Log` tab (timestamp, actor, target, kind, new value, replaced value). diff --git a/internal/directory/upload.go b/internal/directory/upload.go index 321ac04..ad2d2a6 100644 --- a/internal/directory/upload.go +++ b/internal/directory/upload.go @@ -44,6 +44,45 @@ type uploader struct { func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) { u := uploader{cache: cache, sheet: sheet, store: store} mux.HandleFunc("POST /api/directory/upload", u.upload) + mux.HandleFunc("POST /api/directory/facts", u.facts) +} + +func (u uploader) facts(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) + facts := strings.TrimSpace(r.FormValue("facts")) + if key == "" || len(facts) > 4000 { + http.Error(w, "bad facts request", http.StatusBadRequest) + return + } + me := auth.Email(r) + model := u.cache.Model() + if !mayEdit(model, me, "person", key) { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + old := "" + for _, p := range model.People { + if p.Email == key { + old = p.Facts + break + } + } + if err := u.sheet.SetColumn(appName, "Basic Directory", "Email Lower", key, "Facts", facts); err != nil { + serverError(w, fmt.Errorf("update facts for %s: %w", key, err)) + return + } + logRow := []string{time.Now().UTC().Format(time.RFC3339), me, key, "person facts", facts, old} + if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { + serverError(w, fmt.Errorf("append change log after facts update for %s: %w", key, err)) + return + } + if err := u.cache.Refresh(); err != nil { + serverError(w, fmt.Errorf("refresh model after facts update: %w", err)) + return + } + log.Printf("facts: %s set %s (%d chars)", me, key, len(facts)) + w.WriteHeader(http.StatusNoContent) } func (u uploader) upload(w http.ResponseWriter, r *http.Request) { diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 8b5ba90..a513088 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -37,6 +37,7 @@ const icons = { camera: '', mic: '', upload: '', + pencil: '', dots: '', }; @@ -840,9 +841,49 @@ function renderPersonDetail(email) { grid.append(right); content.append(grid); - if (p.facts) { - content.append(el('h2', 'about-header', 'About Me')); - content.append(el('div', 'about-text', p.facts)); + if (p.facts || editable) { + const header = el('h2', 'about-header', 'About Me'); + content.append(header); + const text = el('div', 'about-text', p.facts || ''); + const status = el('div', 'media-status about-status'); + if (editable) { + const pencil = el('button', 'edit-icon inline'); + pencil.title = 'Edit'; + pencil.append(svg('pencil')); + header.append(pencil); + pencil.addEventListener('click', () => { + const editor = el('textarea', 'about-editor'); + editor.value = p.facts || ''; + const buttons = el('div', 'about-buttons'); + const save = el('button', 'media-button primary', 'Save'); + const cancel = el('button', 'media-button', 'Cancel'); + buttons.append(save, cancel); + text.replaceWith(editor); + editor.after(buttons); + pencil.hidden = true; + editor.focus(); + cancel.addEventListener('click', () => { + buttons.remove(); + editor.replaceWith(text); + pencil.hidden = false; + }); + save.addEventListener('click', async () => { + status.classList.remove('error'); + status.textContent = 'Saving…'; + const form = new FormData(); + form.append('key', p.email); + form.append('facts', editor.value); + const res = await fetch('/api/directory/facts', {method: 'POST', body: form}); + if (!res.ok) { + status.classList.add('error'); + status.textContent = await res.text(); + return; + } + await load(); + }); + }); + } + content.append(text, status); } main.append(content); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 0f69fa7..b0290dc 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -1217,6 +1217,42 @@ a.list-row:hover { font-size: 16px; font-weight: 700; margin: 40px 0 10px; + display: flex; + align-items: center; + gap: 12px; +} + +.edit-icon.inline { + width: 30px; + height: 30px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} + +.edit-icon.inline svg { + width: 15px; + height: 15px; +} + +.about-editor { + width: 100%; + min-height: 130px; + font: inherit; + font-size: 14px; + line-height: 1.55; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + resize: vertical; +} + +.about-buttons { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.about-status { + text-align: left; } .about-text {