Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd52c948db | ||
|
|
c56310d219 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
creds/
|
||||||
|
docs/
|
||||||
|
sampledata/
|
||||||
|
screenshots/
|
||||||
|
Dockerfile
|
||||||
|
LICENSE.md
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /heliosian .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /heliosian /app/heliosian
|
||||||
|
COPY web /app/web
|
||||||
|
ENTRYPOINT ["/app/heliosian"]
|
||||||
@@ -14,6 +14,13 @@ Everything — pages, static assets, and the API — sits behind Google sign-in
|
|||||||
|
|
||||||
To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page.
|
To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page.
|
||||||
|
|
||||||
|
## Maps
|
||||||
|
|
||||||
|
The map section geocodes family addresses server-side via the Google Geocoding API (results cached in memory per address) and renders in the browser with the Maps JavaScript API. Two API keys from a project with those APIs enabled, both required at startup:
|
||||||
|
|
||||||
|
- Server key — Geocoding API; restrict by server IP (or leave unrestricted for dev). Never rendered into pages. Read from `creds/geocoding.key`, or `GOOGLE_MAPS_SERVER_KEY` when set.
|
||||||
|
- Browser key — Maps JavaScript API; rendered into the page, so restrict by HTTP referer (localhost and the serving domain). Read from `creds/maps.key`, or `GOOGLE_MAPS_BROWSER_KEY` when set.
|
||||||
|
|
||||||
## Local data
|
## Local data
|
||||||
|
|
||||||
The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to.
|
The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to.
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ The staff list as a top-level section — same content as the People staff tab.
|
|||||||
|
|
||||||
### Map
|
### Map
|
||||||
|
|
||||||
A map of family locations, plus an "update my address" self-service action.
|
A Google map of family locations: one brand-teal pin per geocoded family address, a popup card (family photo, name, address, family-page link) on pin click, and search and filters narrowing the pins. Below the map, an "update my address" self-service action.
|
||||||
|
|
||||||
### Email List
|
### Email List
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
|
|||||||
|
|
||||||
## Directory app
|
## Directory app
|
||||||
|
|
||||||
- Remaining sections: My Family, Map.
|
- Remaining sections: My Family.
|
||||||
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
|
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
|
||||||
- Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`.
|
- Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`.
|
||||||
- Self-service flows: photo and pronunciation upload, address update, opt-out.
|
- Self-service flows: photo and pronunciation upload, address update, opt-out.
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"heliosian/internal/data"
|
"heliosian/internal/data"
|
||||||
|
"heliosian/internal/geocode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshInterval = 5 * time.Minute
|
const refreshInterval = 5 * time.Minute
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
source data.Source
|
source data.Source
|
||||||
mu sync.RWMutex
|
geocoder *geocode.Client
|
||||||
model *Model
|
mu sync.RWMutex
|
||||||
|
model *Model
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCache(source data.Source) (*Cache, error) {
|
func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) {
|
||||||
c := &Cache{source: source}
|
c := &Cache{source: source, geocoder: geocoder}
|
||||||
if err := c.refresh(); err != nil {
|
if err := c.refresh(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ func (c *Cache) refresh() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.geocodeFamilies(model)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.model = model
|
c.model = model
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -53,3 +56,45 @@ func (c *Cache) refresh() error {
|
|||||||
time.Since(start).Round(time.Millisecond))
|
time.Since(start).Round(time.Millisecond))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Cache) geocodeFamilies(model *Model) {
|
||||||
|
start := time.Now()
|
||||||
|
type job struct {
|
||||||
|
key string
|
||||||
|
address string
|
||||||
|
}
|
||||||
|
jobs := make(chan job)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
located := 0
|
||||||
|
for range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range jobs {
|
||||||
|
point, err := c.geocoder.Lookup(j.address)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
family := model.Families[j.key]
|
||||||
|
family.Lat = point.Lat
|
||||||
|
family.Lng = point.Lng
|
||||||
|
model.Families[j.key] = family
|
||||||
|
located++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for key, family := range model.Families {
|
||||||
|
if family.Address != "" {
|
||||||
|
total++
|
||||||
|
jobs <- job{key: key, address: family.Address}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
log.Printf("geocoded %d of %d family addresses in %s", located, total, time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ var legacy = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
|
mapsKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Register(mux *http.ServeMux, cache *Cache) {
|
func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
|
||||||
a := app{cache: cache}
|
a := app{cache: cache, mapsKey: mapsKey}
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
mux.HandleFunc("GET /"+section, a.page)
|
mux.HandleFunc("GET /"+section, a.page)
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,7 @@ func (a app) page(w http.ResponseWriter, r *http.Request) {
|
|||||||
"UserName": name,
|
"UserName": name,
|
||||||
"UserInitial": strings.ToUpper(name[:1]),
|
"UserInitial": strings.ToUpper(name[:1]),
|
||||||
"UserEmail": auth.Email(r),
|
"UserEmail": auth.Email(r),
|
||||||
|
"MapsKey": a.mapsKey,
|
||||||
}
|
}
|
||||||
if err := t.Execute(w, data); err != nil {
|
if err := t.Execute(w, data); err != nil {
|
||||||
log.Printf("[ERROR] render directory page: %v", err)
|
log.Printf("[ERROR] render directory page: %v", err)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Family struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Address string `json:"address,omitempty"`
|
Address string `json:"address,omitempty"`
|
||||||
|
Lat float64 `json:"lat,omitempty"`
|
||||||
|
Lng float64 `json:"lng,omitempty"`
|
||||||
PhotoURL string `json:"photoUrl,omitempty"`
|
PhotoURL string `json:"photoUrl,omitempty"`
|
||||||
PhotoCaption string `json:"photoCaption,omitempty"`
|
PhotoCaption string `json:"photoCaption,omitempty"`
|
||||||
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Package geocode resolves street addresses to coordinates via the google geocoding api.
|
||||||
|
package geocode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
Lat float64
|
||||||
|
Lng float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
key string
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]Point
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(key string) *Client {
|
||||||
|
return &Client{key: key, cache: map[string]Point{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Lookup(address string) (Point, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
point, ok := c.cache[address]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return point, nil
|
||||||
|
}
|
||||||
|
resp, err := http.Get("https://maps.googleapis.com/maps/api/geocode/json?address=" +
|
||||||
|
url.QueryEscape(address) + "&key=" + url.QueryEscape(c.key))
|
||||||
|
if err != nil {
|
||||||
|
return Point{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var parsed struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Results []struct {
|
||||||
|
Geometry struct {
|
||||||
|
Location struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lng float64 `json:"lng"`
|
||||||
|
} `json:"location"`
|
||||||
|
} `json:"geometry"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return Point{}, err
|
||||||
|
}
|
||||||
|
if parsed.Status != "OK" || len(parsed.Results) == 0 {
|
||||||
|
return Point{}, fmt.Errorf("geocode %q: %s", address, parsed.Status)
|
||||||
|
}
|
||||||
|
point = Point{Lat: parsed.Results[0].Geometry.Location.Lat, Lng: parsed.Results[0].Geometry.Location.Lng}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.cache[address] = point
|
||||||
|
c.mu.Unlock()
|
||||||
|
return point, nil
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"heliosian/internal/auth"
|
"heliosian/internal/auth"
|
||||||
"heliosian/internal/blob"
|
"heliosian/internal/blob"
|
||||||
"heliosian/internal/data"
|
"heliosian/internal/data"
|
||||||
"heliosian/internal/directory"
|
"heliosian/internal/directory"
|
||||||
|
"heliosian/internal/geocode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func directorySource() data.Source {
|
func directorySource() data.Source {
|
||||||
@@ -64,15 +66,32 @@ func noCache(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapsKey(envName, file string) string {
|
||||||
|
if key := os.Getenv(envName); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[ERROR] read %s (or set %s): %v", file, envName, err)
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(string(raw))
|
||||||
|
if key == "" {
|
||||||
|
log.Fatalf("[ERROR] %s is empty", file)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
authn := auth.New(clientID(), sessionKey())
|
authn := auth.New(clientID(), sessionKey())
|
||||||
cache, err := directory.NewCache(directorySource())
|
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))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[ERROR] load directory data: %v", err)
|
log.Fatalf("[ERROR] load directory data: %v", err)
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
authn.Register(mux)
|
authn.Register(mux)
|
||||||
directory.Register(mux, cache)
|
directory.Register(mux, cache, browserKey)
|
||||||
if os.Getenv("DIRECTORY_SHEET") != "" {
|
if os.Getenv("DIRECTORY_SHEET") != "" {
|
||||||
store, err := blob.New()
|
store, err := blob.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/static/fonts/fonts.css">
|
<link rel="stylesheet" href="/static/fonts/fonts.css">
|
||||||
<link rel="stylesheet" href="/static/directory/style.css">
|
<link rel="stylesheet" href="/static/directory/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-user-email="{{.UserEmail}}">
|
<body data-user-email="{{.UserEmail}}" data-maps-key="{{.MapsKey}}">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
|
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
|
||||||
<nav id="nav"></nav>
|
<nav id="nav"></nav>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const icons = {
|
|||||||
copy: '<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
|
copy: '<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
|
||||||
message: '<svg viewBox="0 0 24 24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
|
message: '<svg viewBox="0 0 24 24"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
|
||||||
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>',
|
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>',
|
||||||
};
|
};
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
@@ -1371,6 +1372,137 @@ function renderEmailListPage() {
|
|||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mapsPromise = null;
|
||||||
|
|
||||||
|
function loadMaps() {
|
||||||
|
if (!mapsPromise) {
|
||||||
|
mapsPromise = new Promise(resolve => {
|
||||||
|
window._mapsReady = resolve;
|
||||||
|
const script = el('script');
|
||||||
|
script.src = 'https://maps.googleapis.com/maps/api/js?key=' +
|
||||||
|
encodeURIComponent(document.body.dataset.mapsKey) + '&callback=_mapsReady';
|
||||||
|
script.async = true;
|
||||||
|
document.head.append(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mapsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinIcon = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="34" height="34">' +
|
||||||
|
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" fill="#173c41" stroke="#fff" stroke-width="1"/>' +
|
||||||
|
'<circle cx="12" cy="10" r="3" fill="#fff"/></svg>');
|
||||||
|
|
||||||
|
function renderMapPage() {
|
||||||
|
const main = document.querySelector('#main');
|
||||||
|
main.replaceChildren();
|
||||||
|
|
||||||
|
const content = el('div', 'content container');
|
||||||
|
const header = el('div', 'content-header');
|
||||||
|
header.append(el('h1', '', 'Map'));
|
||||||
|
const controls = el('div', 'controls');
|
||||||
|
const search = el('div', 'search');
|
||||||
|
search.append(svg('search'));
|
||||||
|
const input = el('input');
|
||||||
|
input.placeholder = 'Search';
|
||||||
|
input.value = state.q;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
state.q = input.value.trim().toLowerCase();
|
||||||
|
renderPins();
|
||||||
|
});
|
||||||
|
search.append(input);
|
||||||
|
controls.append(search, filterControl(() => renderPins()));
|
||||||
|
header.append(controls);
|
||||||
|
content.append(header);
|
||||||
|
|
||||||
|
const canvas = el('div', 'map-canvas');
|
||||||
|
content.append(canvas);
|
||||||
|
|
||||||
|
const update = el('div', 'map-update');
|
||||||
|
const action = el('a', 'map-update-link');
|
||||||
|
action.append(svg('zap'), el('span', '', 'Update My Address'));
|
||||||
|
update.append(action);
|
||||||
|
content.append(update);
|
||||||
|
main.append(content);
|
||||||
|
|
||||||
|
let map = null;
|
||||||
|
let info = null;
|
||||||
|
let markers = [];
|
||||||
|
|
||||||
|
function familySearchText(family) {
|
||||||
|
const members = [...(family.kidEmails || []), ...(family.adultEmails || [])]
|
||||||
|
.map(e => byEmail[e]).filter(Boolean).map(p => p.fullName);
|
||||||
|
return `${family.name || ''} ${members.join(' ')}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupContent(family) {
|
||||||
|
const box = el('div', 'map-popup');
|
||||||
|
if (family.photoUrl) {
|
||||||
|
const img = el('img', 'map-popup-photo');
|
||||||
|
img.src = thumbUrl(family.photoUrl);
|
||||||
|
img.alt = '';
|
||||||
|
box.append(img);
|
||||||
|
}
|
||||||
|
box.append(el('div', 'map-popup-name', family.name));
|
||||||
|
if (family.address) {
|
||||||
|
box.append(el('div', 'map-popup-sub', family.address));
|
||||||
|
}
|
||||||
|
const link = el('a', 'map-popup-link', 'See family');
|
||||||
|
link.href = '/families/' + encodeURIComponent(family.key);
|
||||||
|
box.append(link);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPins() {
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const m of markers) {
|
||||||
|
m.setMap(null);
|
||||||
|
}
|
||||||
|
markers = [];
|
||||||
|
for (const family of Object.values(state.model.families)) {
|
||||||
|
if (!family.lat && !family.lng) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!familyMatchesFilters(family.key) || !familySearchText(family).includes(state.q)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker = new google.maps.Marker({
|
||||||
|
map,
|
||||||
|
position: {lat: family.lat, lng: family.lng},
|
||||||
|
icon: {url: pinIcon, anchor: new google.maps.Point(17, 33)},
|
||||||
|
title: family.name,
|
||||||
|
});
|
||||||
|
marker.addListener('click', () => {
|
||||||
|
info.setContent(popupContent(family));
|
||||||
|
info.open(map, marker);
|
||||||
|
});
|
||||||
|
markers.push(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMaps().then(() => {
|
||||||
|
if (!canvas.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map = new google.maps.Map(canvas, {
|
||||||
|
mapTypeControl: false,
|
||||||
|
streetViewControl: false,
|
||||||
|
fullscreenControl: false,
|
||||||
|
});
|
||||||
|
info = new google.maps.InfoWindow();
|
||||||
|
const bounds = new google.maps.LatLngBounds();
|
||||||
|
for (const family of Object.values(state.model.families)) {
|
||||||
|
if (family.lat || family.lng) {
|
||||||
|
bounds.extend({lat: family.lat, lng: family.lng});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.fitBounds(bounds);
|
||||||
|
renderPins();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderProfile() {
|
function renderProfile() {
|
||||||
const main = document.querySelector('#main');
|
const main = document.querySelector('#main');
|
||||||
main.replaceChildren();
|
main.replaceChildren();
|
||||||
@@ -1425,6 +1557,9 @@ function render() {
|
|||||||
state.emailTab = tabParam('parents');
|
state.emailTab = tabParam('parents');
|
||||||
state.q = '';
|
state.q = '';
|
||||||
renderEmailListPage();
|
renderEmailListPage();
|
||||||
|
} else if (seg[0] === 'map') {
|
||||||
|
state.q = '';
|
||||||
|
renderMapPage();
|
||||||
} else if (seg[0] === 'profile') {
|
} else if (seg[0] === 'profile') {
|
||||||
renderProfile();
|
renderProfile();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,71 @@ main {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-canvas {
|
||||||
|
height: 600px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef1f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-update {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-update-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-update-link svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup {
|
||||||
|
max-width: 220px;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup-photo {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.email-hint {
|
.email-hint {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user