From 54a5b4615f452afb8175a967c48489660000bafd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 16 Aug 2026 08:01:34 -0700 Subject: [PATCH] Add self-service photo and pronunciation upload with archiving and change log --- docs/deploy.md | 2 +- docs/dev.md | 2 +- docs/directory.md | 1 + docs/plan.md | 2 +- internal/blob/blob.go | 68 +++++++++++- internal/data/sheet.go | 100 ++++++++++++++++- internal/directory/cache.go | 4 + internal/directory/upload.go | 170 +++++++++++++++++++++++++++++ main.go | 4 +- tools/cookie/main.go | 27 +++++ web/static/directory/app.js | 191 ++++++++++++++++++++++++++++----- web/static/directory/style.css | 108 +++++++++++++++++++ 12 files changed, 645 insertions(+), 34 deletions(-) create mode 100644 internal/directory/upload.go create mode 100644 tools/cookie/main.go diff --git a/docs/deploy.md b/docs/deploy.md index 5ecbf5f..e19e4e6 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -52,7 +52,7 @@ Secret Manager secrets, delivered per `--set-secrets` above: `heliosian-test@gen-lang-client-0758114984.iam.gserviceaccount.com` serves two unrelated purposes: -- Data access: the spreadsheet and the media shared drive are shared with it in Drive/Sheets directly — never through project IAM. +- Data access: the spreadsheet (as editor — uploads write media cells and the Change Log tab) and the media shared drive (as content manager — uploads create and archive files) are shared with it in Drive/Sheets directly — never through project IAM. - Deploy identity: project roles Editor, Service Account User, Cloud Run Admin, and Secret Manager Admin. The extra roles exist because Editor cannot set IAM policy on services or secrets. The runtime identity is the default compute service account (`326077318680-compute@developer.gserviceaccount.com`) holding Secret Manager Secret Accessor on each secret individually. The basic Editor role deliberately cannot read secret payloads, so these explicit grants are the only thing standing between the service and a startup failure — the console's inherited-role rows on a secret's Permissions tab do not imply payload access. diff --git a/docs/dev.md b/docs/dev.md index f625e1c..f90f5e7 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -29,7 +29,7 @@ The server reads local data from `sampledata/`, mirroring the production Sheets DIRECTORY_SHEET= go run . -switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires the service account key at `creds/service-account.json` (the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk. +switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires the service account key at `creds/service-account.json` (the directory is gitignored) with the Sheets API enabled, the spreadsheet shared with the service account as an editor (self-service uploads write media cells and append to the Change Log tab), and the media shared drive shared as content manager (uploads create files and archive old versions). Real data never leaves the process: nothing is written to disk. ## Layout diff --git a/docs/directory.md b/docs/directory.md index 1600053..3a2379f 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -70,3 +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). diff --git a/docs/plan.md b/docs/plan.md index 8dc0e51..65bee5a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da ## Directory app -- Self-service flows: photo and pronunciation upload, address update, opt-out. +- Self-service flows: address update, opt-out. ## Hosting and deployment diff --git a/internal/blob/blob.go b/internal/blob/blob.go index cbc9034..635d894 100644 --- a/internal/blob/blob.go +++ b/internal/blob/blob.go @@ -21,9 +21,9 @@ import ( "golang.org/x/image/draw" _ "golang.org/x/image/webp" - "heliosian/internal/data" "google.golang.org/api/drive/v3" "google.golang.org/api/option" + "heliosian/internal/data" ) const ( @@ -54,7 +54,7 @@ type Store struct { func New() (*Store, error) { service, err := drive.NewService(context.Background(), option.WithCredentialsFile(data.KeyFile), - option.WithScopes(drive.DriveReadonlyScope)) + option.WithScopes(drive.DriveScope)) if err != nil { return nil, err } @@ -107,6 +107,9 @@ func (s *Store) refresh() error { return fmt.Errorf("list %s: %w", folderName, err) } for _, f := range list.Files { + if f.MimeType == folderMime { + continue + } base := strings.TrimSuffix(f.Name, path.Ext(f.Name)) if strings.HasSuffix(base, "-thumb") { continue @@ -196,19 +199,78 @@ func (s *Store) refresh() error { } func (s *Store) subfolder(name string) (string, error) { + return s.subfolderIn(s.root, name, false) +} + +func (s *Store) subfolderIn(parent, name string, create bool) (string, error) { list, err := s.service.Files.List(). - Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, s.root, folderMime)). + Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)). SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives"). Fields("files(id)").Do() if err != nil { return "", fmt.Errorf("find folder %s: %w", name, err) } + if len(list.Files) == 0 && create { + folder, err := s.service.Files.Create(&drive.File{ + Name: name, + MimeType: folderMime, + Parents: []string{parent}, + }).SupportsAllDrives(true).Fields("id").Do() + if err != nil { + return "", fmt.Errorf("create folder %s: %w", name, err) + } + return folder.Id, nil + } if len(list.Files) != 1 { return "", fmt.Errorf("expected one %s folder, found %d", name, len(list.Files)) } return list.Files[0].Id, nil } +func (s *Store) Refresh() error { + return s.refresh() +} + +func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) { + folderID, err := s.subfolder(folder) + if err != nil { + return "", err + } + archived := "" + s.mu.RLock() + existing, exists := s.entries[folder+"/"+base] + s.mu.RUnlock() + if exists { + current, err := s.service.Files.Get(existing.id).SupportsAllDrives(true).Fields("name").Do() + if err != nil { + return "", fmt.Errorf("look up current %s/%s: %w", folder, base, err) + } + archiveID, err := s.subfolderIn(folderID, "archive", true) + if err != nil { + return "", err + } + archived = strings.TrimSuffix(current.Name, path.Ext(current.Name)) + + "-" + time.Now().UTC().Format("20060102-150405") + path.Ext(current.Name) + _, err = s.service.Files.Update(existing.id, &drive.File{Name: archived}). + AddParents(archiveID).RemoveParents(folderID).SupportsAllDrives(true).Do() + if err != nil { + return "", fmt.Errorf("archive %s/%s: %w", folder, base, err) + } + } + _, err = s.service.Files.Create(&drive.File{ + Name: base + "." + ext, + MimeType: mimeType, + Parents: []string{folderID}, + }).SupportsAllDrives(true).Media(bytes.NewReader(content)).Do() + if err != nil { + return "", fmt.Errorf("upload %s/%s: %w", folder, base, err) + } + if err := s.refresh(); err != nil { + return "", fmt.Errorf("refresh after upload: %w", err) + } + return archived, nil +} + func (s *Store) download(id string) ([]byte, error) { resp, err := s.service.Files.Get(id).SupportsAllDrives(true).Download() if err != nil { diff --git a/internal/data/sheet.go b/internal/data/sheet.go index 95cf997..cd6aa68 100644 --- a/internal/data/sheet.go +++ b/internal/data/sheet.go @@ -19,7 +19,7 @@ type Sheet struct { func NewSheet(spreadsheets map[string]string) (*Sheet, error) { service, err := sheets.NewService(context.Background(), option.WithCredentialsFile(KeyFile), - option.WithScopes(sheets.SpreadsheetsReadonlyScope)) + option.WithScopes(sheets.SpreadsheetsScope)) if err != nil { return nil, err } @@ -38,6 +38,104 @@ func (s *Sheet) Table(app, name string) ([]map[string]string, error) { return toRecords(resp.Values), nil } +func (s *Sheet) SetColumn(app, table, keyColumn, keyValue, column, value string) error { + id, ok := s.spreadsheets[app] + if !ok { + return fmt.Errorf("no spreadsheet configured for app %q", app) + } + quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'" + resp, err := s.service.Spreadsheets.Values.Get(id, quoted).Do() + if err != nil { + return err + } + if len(resp.Values) == 0 { + return fmt.Errorf("table %s is empty", table) + } + keyIdx, colIdx := -1, -1 + for i, cell := range resp.Values[0] { + switch strings.TrimSpace(fmt.Sprint(cell)) { + case keyColumn: + keyIdx = i + case column: + colIdx = i + } + } + if keyIdx < 0 || colIdx < 0 { + return fmt.Errorf("table %s is missing column %q or %q", table, keyColumn, column) + } + ranges := []*sheets.ValueRange{} + for i, row := range resp.Values[1:] { + if keyIdx >= len(row) || !strings.EqualFold(strings.TrimSpace(fmt.Sprint(row[keyIdx])), keyValue) { + continue + } + ranges = append(ranges, &sheets.ValueRange{ + Range: fmt.Sprintf("%s!%s%d", quoted, columnName(colIdx), i+2), + Values: [][]interface{}{{value}}, + }) + } + if len(ranges) == 0 { + return fmt.Errorf("no row in %s has %s = %q", table, keyColumn, keyValue) + } + _, err = s.service.Spreadsheets.Values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{ + ValueInputOption: "RAW", + Data: ranges, + }).Do() + return err +} + +func (s *Sheet) Append(app, table string, header, row []string) error { + id, ok := s.spreadsheets[app] + if !ok { + return fmt.Errorf("no spreadsheet configured for app %q", app) + } + meta, err := s.service.Spreadsheets.Get(id).Fields("sheets(properties(title))").Do() + if err != nil { + return err + } + exists := false + for _, sh := range meta.Sheets { + if sh.Properties.Title == table { + exists = true + break + } + } + quoted := "'" + strings.ReplaceAll(table, "'", "''") + "'" + if !exists { + _, err := s.service.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{ + Properties: &sheets.SheetProperties{Title: table}, + }}}, + }).Do() + if err != nil { + return fmt.Errorf("create table %s: %w", table, err) + } + if err := s.appendRow(id, quoted, header); err != nil { + return err + } + } + return s.appendRow(id, quoted, row) +} + +func (s *Sheet) appendRow(id, quotedTable string, row []string) error { + values := make([]interface{}, len(row)) + for i, cell := range row { + values[i] = cell + } + _, err := s.service.Spreadsheets.Values.Append(id, quotedTable, &sheets.ValueRange{ + Values: [][]interface{}{values}, + }).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do() + return err +} + +func columnName(idx int) string { + name := "" + for idx >= 0 { + name = string(rune('A'+idx%26)) + name + idx = idx/26 - 1 + } + return name +} + func toRecords(values [][]interface{}) []map[string]string { if len(values) == 0 { return nil diff --git a/internal/directory/cache.go b/internal/directory/cache.go index 451a466..8697726 100644 --- a/internal/directory/cache.go +++ b/internal/directory/cache.go @@ -27,6 +27,10 @@ func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) { return c, nil } +func (c *Cache) Refresh() error { + return c.refresh() +} + func (c *Cache) Model() *Model { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/directory/upload.go b/internal/directory/upload.go new file mode 100644 index 0000000..321ac04 --- /dev/null +++ b/internal/directory/upload.go @@ -0,0 +1,170 @@ +package directory + +import ( + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "heliosian/internal/auth" + "heliosian/internal/blob" + "heliosian/internal/data" +) + +const changeLogTable = "Change Log" + +var changeLogHeader = []string{"Timestamp", "Actor", "Target", "Kind", "File", "Archived"} + +var photoExtensions = map[string]string{ + "image/jpeg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", +} + +var audioExtensions = map[string]string{ + "audio/webm": "webm", + "video/webm": "webm", + "audio/mp4": "m4a", + "video/mp4": "m4a", + "audio/x-m4a": "m4a", + "audio/mpeg": "mp3", + "audio/ogg": "ogg", + "audio/wav": "wav", +} + +type uploader struct { + 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} + mux.HandleFunc("POST /api/directory/upload", u.upload) +} + +func (u uploader) upload(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 30<<20) + if err := r.ParseMultipartForm(30 << 20); err != nil { + http.Error(w, "upload too large or malformed", http.StatusBadRequest) + return + } + target := r.FormValue("target") + key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) + kind := r.FormValue("kind") + if (target != "person" && target != "family") || (kind != "photo" && kind != "pronunciation") || key == "" { + http.Error(w, "bad upload request", http.StatusBadRequest) + return + } + + me := auth.Email(r) + model := u.cache.Model() + if !mayEdit(model, me, target, key) { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil || len(content) == 0 { + http.Error(w, "unreadable file", http.StatusBadRequest) + return + } + + mimeType, ext, err := mediaType(kind, content, header.Header.Get("Content-Type")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + folder := "people" + keyColumn := "Email Lower" + column := map[string]string{"photo": "Primary Photo", "pronunciation": "Pronunciation"}[kind] + if target == "family" { + folder = "families" + keyColumn = "Family Key" + column = map[string]string{"photo": "Family Photo", "pronunciation": "Family Pronunciation"}[kind] + } + local, _, _ := strings.Cut(key, "@") + base := local + "-" + kind + name := base + "." + ext + + archived, err := u.store.Upload(folder, base, ext, mimeType, content) + if err != nil { + serverError(w, err) + return + } + if err := u.sheet.SetColumn(appName, "Basic Directory", keyColumn, key, column, name); err != nil { + serverError(w, fmt.Errorf("update sheet after upload of %s/%s: %w", folder, name, err)) + return + } + logRow := []string{time.Now().UTC().Format(time.RFC3339), me, key, target + " " + kind, name, archived} + if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { + serverError(w, fmt.Errorf("append change log after upload of %s/%s: %w", folder, name, err)) + return + } + if err := u.cache.Refresh(); err != nil { + serverError(w, fmt.Errorf("refresh model after upload: %w", err)) + return + } + log.Printf("upload: %s set %s %s %s (archived %q)", me, target, key, name, archived) + w.WriteHeader(http.StatusNoContent) +} + +func mayEdit(model *Model, me, target, key string) bool { + var mine *Person + for i := range model.People { + if model.People[i].Email == me { + mine = &model.People[i] + break + } + } + if mine == nil { + return false + } + if target == "family" { + return mine.FamilyKey != "" && mine.FamilyKey == key + } + if key == me { + return true + } + family, ok := model.Families[mine.FamilyKey] + if !ok { + return false + } + for _, kid := range family.KidEmails { + if kid == key { + return true + } + } + return false +} + +func mediaType(kind string, content []byte, declared string) (string, string, error) { + if kind == "photo" { + sniffed := http.DetectContentType(content) + ext, ok := photoExtensions[sniffed] + if !ok { + return "", "", fmt.Errorf("unsupported photo type %s", sniffed) + } + return sniffed, ext, nil + } + base, _, _ := strings.Cut(declared, ";") + base = strings.TrimSpace(strings.ToLower(base)) + ext, ok := audioExtensions[base] + if !ok { + return "", "", fmt.Errorf("unsupported audio type %s", declared) + } + if strings.HasPrefix(base, "video/") { + base = "audio/" + strings.TrimPrefix(base, "video/") + } + return base, ext, nil +} diff --git a/main.go b/main.go index 8887a9f..6f7a6f3 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,8 @@ func main() { authn := auth.New(clientID(), sessionKey()) serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key") browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key") - cache, err := directory.NewCache(directorySource(), geocode.New(serverKey)) + source := directorySource() + cache, err := directory.NewCache(source, geocode.New(serverKey)) if err != nil { log.Fatalf("[ERROR] load directory data: %v", err) } @@ -102,6 +103,7 @@ func main() { log.Fatalf("[ERROR] blob store: %v", err) } blob.Register(mux, store) + directory.RegisterUpload(mux, cache, source.(*data.Sheet), store) } mux.Handle("GET /{$}", http.RedirectHandler("/people", http.StatusFound)) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) diff --git a/tools/cookie/main.go b/tools/cookie/main.go new file mode 100644 index 0000000..3b53d68 --- /dev/null +++ b/tools/cookie/main.go @@ -0,0 +1,27 @@ +// Command cookie prints a signed session cookie for local api testing. +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "flag" + "fmt" + "log" + "os" + "time" +) + +func main() { + email := flag.String("email", "", "session email address") + flag.Parse() + key := os.Getenv("SESSION_KEY") + if key == "" || *email == "" { + log.Fatal("[ERROR] SESSION_KEY and -email are required") + } + payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix()) + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(payload)) + fmt.Println(base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))) +} diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 14a2f65..8b5ba90 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -34,6 +34,9 @@ const icons = { phone: '', zap: '', more: '', + camera: '', + mic: '', + upload: '', dots: '', }; @@ -767,11 +770,23 @@ function renderPersonDetail(email) { const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); const left = el('div'); - if (p.photoUrl) { - const img = el('img', 'detail-photo'); - img.src = p.photoUrl; - img.alt = ''; - left.append(img); + const editable = canEditPerson(p.email); + if (p.photoUrl || editable) { + const wrap = el('div', 'photo-wrap'); + if (p.photoUrl) { + const img = el('img', 'detail-photo'); + img.src = p.photoUrl; + img.alt = ''; + wrap.append(img); + } else { + wrap.append(el('div', 'detail-photo detail-photo-empty')); + } + left.append(wrap); + if (editable) { + const status = el('div', 'media-status'); + wrap.append(uploadIcon('camera', 'Upload photo', 'image/*', 'person', p.email, 'photo', status)); + left.append(status); + } } grid.append(left); @@ -809,13 +824,18 @@ function renderPersonDetail(email) { iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)), ])); } - if (p.pronunciationUrl) { + if (p.pronunciationUrl || editable) { right.append(el('div', 'pronounce-label', 'How do I pronounce this?')); - const audio = el('audio', 'pronounce-player'); - audio.controls = true; - audio.preload = 'metadata'; - audio.src = p.pronunciationUrl; - right.append(audio); + if (p.pronunciationUrl) { + const audio = el('audio', 'pronounce-player'); + audio.controls = true; + audio.preload = 'metadata'; + audio.src = p.pronunciationUrl; + right.append(audio); + } + if (editable) { + right.append(pronounceEditor('person', p.email)); + } } grid.append(right); content.append(grid); @@ -865,15 +885,27 @@ function renderFamilyDetail(key) { const content = el('div', 'container detail-content'); const grid = el('div', 'detail-grid'); const left = el('div'); - if (family.photoUrl) { - const link = el('a'); - link.href = family.photoUrl; - link.target = '_blank'; - const img = el('img', 'detail-photo'); - img.src = family.photoUrl; - img.alt = ''; - link.append(img); - left.append(link); + const editable = key === myFamilyKey(); + if (family.photoUrl || editable) { + const wrap = el('div', 'photo-wrap'); + if (family.photoUrl) { + const link = el('a'); + link.href = family.photoUrl; + link.target = '_blank'; + const img = el('img', 'detail-photo'); + img.src = family.photoUrl; + img.alt = ''; + link.append(img); + wrap.append(link); + } else { + wrap.append(el('div', 'detail-photo detail-photo-empty')); + } + left.append(wrap); + if (editable) { + const status = el('div', 'media-status'); + wrap.append(uploadIcon('camera', 'Upload family photo', 'image/*', 'family', key, 'photo', status)); + left.append(status); + } } if (family.photoCaption) { left.append(el('div', 'family-caption', family.photoCaption)); @@ -899,13 +931,18 @@ function renderFamilyDetail(key) { iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)), ])); } - if (family.pronunciationUrl) { + if (family.pronunciationUrl || editable) { right.append(el('div', 'pronounce-label', 'How do I pronounce this?')); - const audio = el('audio', 'pronounce-player'); - audio.controls = true; - audio.preload = 'metadata'; - audio.src = family.pronunciationUrl; - right.append(audio); + if (family.pronunciationUrl) { + const audio = el('audio', 'pronounce-player'); + audio.controls = true; + audio.preload = 'metadata'; + audio.src = family.pronunciationUrl; + right.append(audio); + } + if (editable) { + right.append(pronounceEditor('family', key)); + } } grid.append(right); content.append(grid); @@ -1575,6 +1612,108 @@ function renderMapPage() { }); } +async function submitMedia(target, key, kind, file, name, status) { + status.classList.remove('error'); + status.textContent = 'Uploading…'; + const form = new FormData(); + form.append('target', target); + form.append('key', key); + form.append('kind', kind); + form.append('file', file, name); + const res = await fetch('/api/directory/upload', {method: 'POST', body: form}); + if (!res.ok) { + status.classList.add('error'); + status.textContent = await res.text(); + return; + } + await load(); +} + +function canEditPerson(email) { + const meEmail = document.body.dataset.userEmail; + if (email === meEmail) { + return true; + } + const me = byEmail[meEmail]; + const family = me && state.model.families[me.familyKey]; + return Boolean(family && (family.kidEmails || []).includes(email)); +} + +function uploadIcon(iconName, title, accept, target, key, kind, status) { + const wrap = el('label', 'edit-icon'); + wrap.title = title; + wrap.append(svg(iconName)); + const input = el('input'); + input.type = 'file'; + input.accept = accept; + input.hidden = true; + input.addEventListener('change', () => { + if (input.files.length) { + submitMedia(target, key, kind, input.files[0], input.files[0].name, status); + } + }); + wrap.append(input); + return wrap; +} + +function recordIcon(target, key, status, preview) { + const button = el('button', 'edit-icon'); + button.title = 'Record pronunciation'; + button.append(svg('mic')); + let recorder = null; + button.addEventListener('click', async () => { + if (recorder) { + recorder.stop(); + return; + } + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({audio: true}); + } catch (err) { + status.classList.add('error'); + status.textContent = 'microphone unavailable: ' + err.message; + return; + } + status.classList.remove('error'); + status.textContent = 'Recording… tap the microphone again to stop'; + const chunks = []; + recorder = new MediaRecorder(stream); + recorder.addEventListener('dataavailable', e => chunks.push(e.data)); + recorder.addEventListener('stop', () => { + for (const track of stream.getTracks()) { + track.stop(); + } + const blob = new Blob(chunks, {type: recorder.mimeType || 'audio/webm'}); + recorder = null; + button.classList.remove('recording'); + status.textContent = ''; + preview.replaceChildren(); + const audio = el('audio'); + audio.controls = true; + audio.src = URL.createObjectURL(blob); + const save = el('button', 'media-button primary', 'Save'); + save.addEventListener('click', () => submitMedia(target, key, 'pronunciation', blob, 'recording', status)); + const discard = el('button', 'media-button', 'Discard'); + discard.addEventListener('click', () => preview.replaceChildren()); + preview.append(audio, save, discard); + }); + recorder.start(); + button.classList.add('recording'); + }); + return button; +} + +function pronounceEditor(target, key) { + const box = el('div', 'pronounce-edit'); + const actions = el('div', 'pronounce-actions'); + const status = el('div', 'media-status'); + const preview = el('div', 'record-preview'); + actions.append(recordIcon(target, key, status, preview)); + actions.append(uploadIcon('upload', 'Upload an audio file', 'audio/*', target, key, 'pronunciation', status)); + box.append(actions, status, preview); + return box; +} + function renderProfile() { const main = document.querySelector('#main'); main.replaceChildren(); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 8229f56..0f69fa7 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -1093,6 +1093,114 @@ a.list-row:hover { margin: 18px 0 4px; } +.photo-wrap { + position: relative; +} + +.detail-photo-empty { + aspect-ratio: 1; + background: #eef1f3; +} + +.photo-wrap > .edit-icon { + position: absolute; + right: 10px; + bottom: 10px; +} + +.edit-icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 0; + color: var(--ink); + padding: 0; +} + +.edit-icon svg { + width: 19px; + height: 19px; + stroke: currentColor; + fill: none; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.edit-icon:hover { + color: var(--brand); +} + +.edit-icon.recording { + background: #c62828; + color: #fff; +} + +.edit-icon.recording:hover { + color: #fff; +} + +.pronounce-edit { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-top: 14px; +} + +.pronounce-actions { + display: flex; + gap: 12px; +} + +.record-preview { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + width: 100%; +} + +.record-preview audio { + width: 100%; +} + +.media-button { + padding: 7px 16px; + border-radius: 8px; + border: 1px solid var(--line); + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + color: var(--ink); + cursor: pointer; +} + +.media-button.primary { + background: var(--brand); + border-color: var(--brand); + color: #fff; +} + +.media-status { + color: var(--muted); + font-size: 12.5px; + margin-top: 8px; + text-align: center; +} + +.media-status.error { + color: #b3261e; +} + .pronounce-label { text-align: center; color: var(--muted);