Add self-service photo and pronunciation upload with archiving and change log
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ The server reads local data from `sampledata/`, mirroring the production Sheets
|
||||
|
||||
DIRECTORY_SHEET=<spreadsheet id> 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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+65
-3
@@ -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 {
|
||||
|
||||
+99
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
+165
-26
@@ -34,6 +34,9 @@ const icons = {
|
||||
phone: '<svg viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
|
||||
zap: '<svg viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>',
|
||||
more: '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></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>',
|
||||
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>',
|
||||
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>',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user