Add inline About Me editing on own records
This commit is contained in:
+1
-1
@@ -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.
|
- 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.
|
||||||
- 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).
|
||||||
|
|||||||
@@ -44,6 +44,45 @@ type uploader struct {
|
|||||||
func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) {
|
func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) {
|
||||||
u := uploader{cache: cache, sheet: sheet, store: store}
|
u := uploader{cache: cache, sheet: sheet, store: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const icons = {
|
|||||||
camera: '<svg viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>',
|
camera: '<svg viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>',
|
||||||
mic: '<svg viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>',
|
mic: '<svg viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>',
|
||||||
upload: '<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>',
|
upload: '<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>',
|
||||||
|
pencil: '<svg viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></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>',
|
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>',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -840,9 +841,49 @@ function renderPersonDetail(email) {
|
|||||||
grid.append(right);
|
grid.append(right);
|
||||||
content.append(grid);
|
content.append(grid);
|
||||||
|
|
||||||
if (p.facts) {
|
if (p.facts || editable) {
|
||||||
content.append(el('h2', 'about-header', 'About Me'));
|
const header = el('h2', 'about-header', 'About Me');
|
||||||
content.append(el('div', 'about-text', p.facts));
|
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);
|
main.append(content);
|
||||||
|
|
||||||
|
|||||||
@@ -1217,6 +1217,42 @@ a.list-row:hover {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 40px 0 10px;
|
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 {
|
.about-text {
|
||||||
|
|||||||
Reference in New Issue
Block a user