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 {