diff --git a/features/admin/handlers.go b/features/admin/handlers.go
index 709a00f..d3dc44e 100644
--- a/features/admin/handlers.go
+++ b/features/admin/handlers.go
@@ -8,6 +8,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "fmt"
"log/slog"
"net/http"
"strconv"
@@ -340,14 +341,13 @@ func parseEventForm(r *http.Request) (event.CreateInput, pages.AdminEventFormVie
in.ExpectedAttendees = n
}
// HTML datetime-local inputs come through as "2006-01-02T15:04" in the
- // browser's local TZ. We parse as local time and convert to UTC in
- // event.Put. Callers running the server in a TZ other than the event's
- // local TZ will see times slightly off — for v1, that's fine; the
- // conference admins running this will be co-located.
+ // browser's local TZ. Some browsers also send seconds
+ // ("2006-01-02T15:04:05") or a space instead of "T". We try multiple
+ // layouts so the form works across browsers.
startStr := r.FormValue("start_time")
endStr := r.FormValue("end_time")
- startT, errS := time.ParseInLocation("2006-01-02T15:04", startStr, time.Local)
- endT, errE := time.ParseInLocation("2006-01-02T15:04", endStr, time.Local)
+ startT, errS := parseDatetimeLocal(startStr)
+ endT, errE := parseDatetimeLocal(endStr)
view := pages.AdminEventFormView{
Name: in.Name,
@@ -451,3 +451,26 @@ func writeJSON(w http.ResponseWriter, v any) {
}
var _ = writeJSON // keep available for upcoming admin JSON endpoints
+
+// parseDatetimeLocal parses the value of an HTML .
+// Browsers can send several formats:
+// - "2006-01-02T15:04" (Chrome, Safari — most common)
+// - "2006-01-02T15:04:05" (with seconds — some mobile browsers)
+// - "2006-01-02 15:04" (space instead of T — older browsers)
+// - "2006-01-02 15:04:05" (space + seconds)
+//
+// All are parsed as server-local time.
+func parseDatetimeLocal(s string) (time.Time, error) {
+ s = strings.TrimSpace(s)
+ for _, layout := range []string{
+ "2006-01-02T15:04",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04",
+ "2006-01-02 15:04:05",
+ } {
+ if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
+ return t, nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("unrecognized datetime format: %q", s)
+}
diff --git a/features/connections/handlers.go b/features/connections/handlers.go
new file mode 100644
index 0000000..b329eaa
--- /dev/null
+++ b/features/connections/handlers.go
@@ -0,0 +1,318 @@
+// Package connections implements the /connections page: a searchable,
+// filterable list of all quest.atmo.connection records in the user's PDS.
+package connections
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/go-chi/chi/v5"
+
+ "atmoquest/features/auth"
+ "atmoquest/features/connections/pages"
+ "atmoquest/internal/connection"
+ "atmoquest/internal/event"
+ "atmoquest/internal/notes"
+ "atmoquest/internal/profile"
+)
+
+// Handlers holds the dependencies for the connections feature.
+type Handlers struct {
+ DB *sql.DB
+ Auth *auth.Handlers
+}
+
+// NewHandlers wires the connections feature.
+func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers {
+ return &Handlers{DB: conn, Auth: authH}
+}
+
+// List renders the full connections list page.
+func (h *Handlers) List(w http.ResponseWriter, r *http.Request) {
+ did, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ pds := h.lookupPDSForDID(r, did)
+
+ entries, err := connection.List(r.Context(), pds, did)
+ if err != nil {
+ slog.Warn("connections: list from PDS", "did", did.String(), "err", err)
+ // Render the page with an empty list + error hint rather than 500.
+ entries = nil
+ }
+
+ // Load events for the filter dropdown.
+ events, err := event.ListAll(r.Context(), h.DB)
+ if err != nil {
+ slog.Warn("connections: list events", "err", err)
+ }
+
+ // Build event name lookup for enrichment.
+ eventNames := make(map[string]string, len(events))
+ for _, ev := range events {
+ eventNames[ev.URI] = ev.Name
+ }
+
+ // Query params for search / filter / sort.
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ filterEvent := strings.TrimSpace(r.URL.Query().Get("event"))
+ sortBy := strings.TrimSpace(r.URL.Query().Get("sort"))
+ if sortBy == "" {
+ sortBy = "recent"
+ }
+
+ // Enrich each connection with display name + avatar from the target's
+ // public Bluesky profile. Each fetch is independent and soft-fails.
+ var items []pages.ConnectionItem
+ for _, entry := range entries {
+ // Apply event filter before enrichment to avoid unnecessary PDS calls.
+ if filterEvent != "" && entry.EventURI != filterEvent {
+ continue
+ }
+
+ item := pages.ConnectionItem{
+ DID: entry.With.String(),
+ ConnectedAt: entry.ConnectedAt.Format("Jan 2, 2006"),
+ EventURI: entry.EventURI,
+ EventName: eventNames[entry.EventURI],
+ SortTime: entry.ConnectedAt,
+ }
+
+ targetPDS := h.lookupPDSForDID(r, entry.With)
+ bsky, err := profile.FetchBluesky(r.Context(), targetPDS, entry.With)
+ if err == nil && bsky != nil {
+ item.DisplayName = bsky.DisplayName
+ item.Handle = "" // handle resolution is expensive; DID is sufficient for v1
+ if bsky.Avatar != nil {
+ item.AvatarURL = profile.AvatarURL(targetPDS, entry.With, bsky.Avatar.CID())
+ }
+ item.Bio = bsky.Description
+ }
+
+ // Apply search filter (after enrichment so we can match on display name).
+ if query != "" {
+ q := strings.ToLower(query)
+ match := strings.Contains(strings.ToLower(item.DisplayName), q) ||
+ strings.Contains(strings.ToLower(item.Handle), q) ||
+ strings.Contains(strings.ToLower(item.DID), q) ||
+ strings.Contains(strings.ToLower(item.Bio), q)
+ if !match {
+ continue
+ }
+ }
+
+ items = append(items, item)
+ }
+
+ // Sort.
+ switch sortBy {
+ case "alpha":
+ sort.Slice(items, func(i, j int) bool {
+ ni := strings.ToLower(displayOrDID(items[i]))
+ nj := strings.ToLower(displayOrDID(items[j]))
+ return ni < nj
+ })
+ default: // "recent"
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].SortTime.After(items[j].SortTime)
+ })
+ }
+
+ // Build event options for the filter dropdown.
+ var eventOpts []pages.EventOption
+ for _, ev := range events {
+ eventOpts = append(eventOpts, pages.EventOption{
+ URI: ev.URI,
+ Name: ev.Name,
+ })
+ }
+
+ view := pages.ConnectionsView{
+ Items: items,
+ Events: eventOpts,
+ Query: query,
+ FilterEvent: filterEvent,
+ SortBy: sortBy,
+ Total: len(entries),
+ Showing: len(items),
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := pages.Connections(view).Render(r.Context(), w); err != nil {
+ slog.Error("render connections", "err", err)
+ }
+}
+
+func displayOrDID(item pages.ConnectionItem) string {
+ if item.DisplayName != "" {
+ return item.DisplayName
+ }
+ return item.DID
+}
+
+// lookupPDSForDID returns the PDS host for the given DID. Same pattern used
+// by the connect and index features.
+func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string {
+ if h.DB == nil {
+ return "https://bsky.social"
+ }
+ var host string
+ err := h.DB.QueryRowContext(r.Context(), `
+ SELECT data ->> 'host_url' FROM oauth_sessions
+ WHERE did = ?
+ ORDER BY updated_at DESC LIMIT 1
+ `, did.String()).Scan(&host)
+ if err == nil && host != "" {
+ return host
+ }
+ return "https://bsky.social"
+}
+
+// View renders the detailed profile page for a connected person, including
+// private notes and follow-up flag (backed by localStorage on the client).
+func (h *Handlers) View(w http.ResponseWriter, r *http.Request) {
+ viewerDID, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ targetStr := chi.URLParam(r, "did")
+ target, err := syntax.ParseDID(targetStr)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Self-view → redirect to own profile.
+ if target == viewerDID {
+ http.Redirect(w, r, "/profile", http.StatusSeeOther)
+ return
+ }
+
+ targetPDS := h.lookupPDSForDID(r, target)
+
+ view := pages.ProfileView{
+ ViewerDID: viewerDID.String(),
+ TargetDID: target.String(),
+ }
+
+ // Bluesky profile (display name, avatar, bio) — public, soft-fail.
+ bsky, err := profile.FetchBluesky(r.Context(), targetPDS, target)
+ if err != nil && !errors.Is(err, profile.ErrNotFound) {
+ slog.Debug("connections/view: bluesky fetch", "target", target.String(), "err", err)
+ }
+ if bsky != nil {
+ view.DisplayName = bsky.DisplayName
+ view.Bio = bsky.Description
+ if bsky.Avatar != nil {
+ view.AvatarURL = profile.AvatarURL(targetPDS, target, bsky.Avatar.CID())
+ }
+ }
+
+ // Quest profile (links, interests, location, etc.) — public, soft-fail.
+ quest, err := profile.FetchQuest(r.Context(), targetPDS, target)
+ if err != nil && !errors.Is(err, profile.ErrNotFound) {
+ slog.Debug("connections/view: quest fetch", "target", target.String(), "err", err)
+ }
+ if quest != nil {
+ view.Location = quest.Location
+ view.WorksAt = quest.WorksAt
+ view.ContactMethod = quest.ContactMethod
+ if quest.Hiring != nil && *quest.Hiring {
+ view.Hiring = true
+ }
+ if quest.Looking != nil && *quest.Looking {
+ view.Looking = true
+ }
+ for _, l := range quest.Links {
+ view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL})
+ }
+ view.Interests = quest.Interests
+ // Quest bio overrides Bluesky bio if set.
+ if b := strings.TrimSpace(quest.Bio); b != "" {
+ view.Bio = b
+ }
+ }
+
+ // Find the connection date from the viewer's PDS records. We scan
+ // through the viewer's connection list for this target — the list is
+ // already loaded for the page anyway and is typically small.
+ viewerPDS := h.lookupPDSForDID(r, viewerDID)
+ entries, err := connection.List(r.Context(), viewerPDS, viewerDID)
+ if err != nil {
+ slog.Debug("connections/view: list viewer connections", "err", err)
+ }
+ for _, e := range entries {
+ if e.With == target {
+ view.ConnectedAt = e.ConnectedAt.Format("Jan 2, 2006")
+ if e.EventURI != "" {
+ if ev, err := event.Get(r.Context(), h.DB, e.EventURI); err == nil {
+ view.EventName = ev.Name
+ }
+ }
+ break // use the first (most recent) match
+ }
+ }
+
+ // Load private notes from SQLite.
+ note, err := notes.Get(r.Context(), h.DB, viewerDID.String(), target.String())
+ if err != nil {
+ slog.Debug("connections/view: load notes", "err", err)
+ }
+ view.Notes = note.Notes
+ view.FollowUp = note.FollowUp
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := pages.ProfileViewPage(view).Render(r.Context(), w); err != nil {
+ slog.Error("render connection profile", "err", err)
+ }
+}
+
+// SaveNotes handles POST /connections/{did}/notes — persists the viewer's
+// private notes and follow-up flag for the given target to SQLite.
+func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) {
+ viewerDID, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ targetStr := chi.URLParam(r, "did")
+ if _, err := syntax.ParseDID(targetStr); err != nil {
+ http.Error(w, "invalid DID", http.StatusBadRequest)
+ return
+ }
+
+ var body struct {
+ Notes string `json:"notes"`
+ FollowUp bool `json:"followUp"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ // Cap notes length server-side.
+ if len([]rune(body.Notes)) > 2000 {
+ body.Notes = string([]rune(body.Notes)[:2000])
+ }
+
+ if err := notes.Put(r.Context(), h.DB, viewerDID.String(), targetStr, notes.Note{
+ Notes: body.Notes,
+ FollowUp: body.FollowUp,
+ }); err != nil {
+ slog.Warn("save notes", "viewer", viewerDID.String(), "target", targetStr, "err", err)
+ http.Error(w, "failed to save", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
diff --git a/features/connections/pages/connections.templ b/features/connections/pages/connections.templ
new file mode 100644
index 0000000..235fd10
--- /dev/null
+++ b/features/connections/pages/connections.templ
@@ -0,0 +1,185 @@
+package pages
+
+import (
+ "fmt"
+ "time"
+
+ "atmoquest/features/common/layouts"
+)
+
+// ConnectionsView is the data passed to the connections list template.
+type ConnectionsView struct {
+ Items []ConnectionItem
+ Events []EventOption
+ Query string
+ FilterEvent string
+ SortBy string
+ Total int // total connections before filtering
+ Showing int // after search + filter
+}
+
+// ConnectionItem is one row in the connections list.
+type ConnectionItem struct {
+ DID string
+ DisplayName string
+ Handle string
+ AvatarURL string
+ Bio string
+ ConnectedAt string // pre-formatted
+ EventURI string
+ EventName string
+ SortTime time.Time // used for sorting, not rendered
+}
+
+// EventOption is an entry in the event filter dropdown.
+type EventOption struct {
+ URI string
+ Name string
+}
+
+templ Connections(v ConnectionsView) {
+ @layouts.Base("connections — atmo.quest", "Your connections across events.") {
+
+
+
+
+
+
~/atmo.quest — connections
+
+
+
+ you { "@" } atmo.quest :~$
+ ls connections/
+
+
+
+
+
+ if v.Total == 0 {
+ no connections yet — go scan some QR codes!
+ } else if v.Showing == v.Total {
+ { itoa(v.Total) } { pluralize(v.Total, "connection", "connections") }
+ } else {
+ showing { itoa(v.Showing) } of { itoa(v.Total) }
+ }
+
+
+
+ if len(v.Items) > 0 {
+
+ for _, item := range v.Items {
+ @connectionRow(item)
+ }
+
+ } else if v.Total > 0 {
+
+ } else {
+
+
you haven't connected with anyone yet.
+
scan someone's QR code at an event to get started.
+
← back home
+
+ }
+
+
+
+ ● reads from quest.atmo.connection
+
+
v0.1
+
+
+
+
+ built on atproto · open source · made by PDX ATProto
+
+ }
+}
+
+templ connectionRow(item ConnectionItem) {
+
+
+ if item.AvatarURL != "" {
+
+ } else {
+
?
+ }
+
+
+
+ if item.DisplayName != "" {
+ { item.DisplayName }
+ } else {
+ { shortDID(item.DID) }
+ }
+ { item.ConnectedAt }
+
+ if item.Bio != "" {
+
{ truncate(item.Bio, 120) }
+ }
+ if item.EventName != "" {
+
{ item.EventName }
+ }
+
+
+}
+
+// shortDID renders a compact form of a DID for display.
+func shortDID(did string) string {
+ if len(did) <= 18 {
+ return did
+ }
+ return did[:18] + "…"
+}
+
+// avatarAlt produces accessible alt text.
+func avatarAlt(displayName string) string {
+ if displayName == "" {
+ return "profile picture"
+ }
+ return displayName + "'s profile picture"
+}
+
+// truncate clips s to at most n runes, appending "…" if clipped.
+func truncate(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "…"
+}
+
+// itoa converts an int to its string form for templ interpolation.
+func itoa(n int) string {
+ return fmt.Sprintf("%d", n)
+}
+
+// pluralize returns singular or plural based on count.
+func pluralize(n int, singular, plural string) string {
+ if n == 1 {
+ return singular
+ }
+ return plural
+}
diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go
new file mode 100644
index 0000000..de31038
--- /dev/null
+++ b/features/connections/pages/connections_templ.go
@@ -0,0 +1,494 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "time"
+
+ "atmoquest/features/common/layouts"
+)
+
+// ConnectionsView is the data passed to the connections list template.
+type ConnectionsView struct {
+ Items []ConnectionItem
+ Events []EventOption
+ Query string
+ FilterEvent string
+ SortBy string
+ Total int // total connections before filtering
+ Showing int // after search + filter
+}
+
+// ConnectionItem is one row in the connections list.
+type ConnectionItem struct {
+ DID string
+ DisplayName string
+ Handle string
+ AvatarURL string
+ Bio string
+ ConnectedAt string // pre-formatted
+ EventURI string
+ EventName string
+ SortTime time.Time // used for sorting, not rendered
+}
+
+// EventOption is an entry in the event filter dropdown.
+type EventOption struct {
+ URI string
+ Name string
+}
+
+func Connections(v ConnectionsView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — connections
you ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 51, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " atmo.quest :~$ ls connections/
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.Total == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "no connections yet — go scan some QR codes!")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if v.Showing == v.Total {
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Total))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 81, Col: 22}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(pluralize(v.Total, "connection", "connections"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 81, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "showing ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Showing))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 32}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " of ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Total))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 53}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(v.Items) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, item := range v.Items {
+ templ_7745c5c3_Err = connectionRow(item).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if v.Total > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
you haven't connected with anyone yet.
scan someone's QR code at an event to get started.
← back home ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
● reads from quest.atmo.connection
v0.1
built on atproto · open source · made by PDX ATProto ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = layouts.Base("connections — atmo.quest", "Your connections across events.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func connectionRow(item ConnectionItem) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.AvatarURL != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
?
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.DisplayName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DisplayName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 133, Col: 53}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(item.DID))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 135, Col: 77}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.ConnectedAt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 137, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if item.Bio != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(truncate(item.Bio, 120))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 140, Col: 55}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if item.EventName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EventName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 143, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// shortDID renders a compact form of a DID for display.
+func shortDID(did string) string {
+ if len(did) <= 18 {
+ return did
+ }
+ return did[:18] + "…"
+}
+
+// avatarAlt produces accessible alt text.
+func avatarAlt(displayName string) string {
+ if displayName == "" {
+ return "profile picture"
+ }
+ return displayName + "'s profile picture"
+}
+
+// truncate clips s to at most n runes, appending "…" if clipped.
+func truncate(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "…"
+}
+
+// itoa converts an int to its string form for templ interpolation.
+func itoa(n int) string {
+ return fmt.Sprintf("%d", n)
+}
+
+// pluralize returns singular or plural based on count.
+func pluralize(n int, singular, plural string) string {
+ if n == 1 {
+ return singular
+ }
+ return plural
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/features/connections/pages/profile.templ b/features/connections/pages/profile.templ
new file mode 100644
index 0000000..92fbbbb
--- /dev/null
+++ b/features/connections/pages/profile.templ
@@ -0,0 +1,186 @@
+package pages
+
+import "atmoquest/features/common/layouts"
+
+// ProfileView is the data for rendering another person's profile page.
+type ProfileView struct {
+ ViewerDID string // the logged-in user's DID
+ TargetDID string
+ DisplayName string
+ AvatarURL string
+ Bio string
+ Location string
+ WorksAt string
+ ContactMethod string
+ Hiring bool
+ Looking bool
+ Links []ProfileLink
+ Interests []string
+ ConnectedAt string // pre-formatted date, or empty if not connected
+ EventName string // event where connection happened, if any
+ Notes string // private notes from SQLite
+ FollowUp bool // follow-up flag from SQLite
+}
+
+// ProfileLink is a labeled URL from the target's quest.atmo.profile.
+type ProfileLink struct {
+ Label string
+ URL string
+}
+
+templ ProfileViewPage(v ProfileView) {
+ @layouts.Base(profileTitle(v), "Connection profile on atmo.quest.") {
+
+
+
+
+
+
~/atmo.quest — profile
+
+
+
+ you { "@" } atmo.quest :~$
+ cat connections/{ shortDID(v.TargetDID) }
+
+
+
+
+
+ // ── Badges row ──
+
+ if v.ConnectedAt != "" {
+
+ ✓ connected · { v.ConnectedAt }
+
+ }
+ if v.EventName != "" {
+ { v.EventName }
+ }
+ if v.Hiring {
+ hiring
+ }
+ if v.Looking {
+ open to work
+ }
+
+
+ // ── Profile details ──
+ if v.Bio != "" || v.WorksAt != "" || v.Location != "" || v.ContactMethod != "" {
+
+ if v.Bio != "" {
+
{ v.Bio }
+ }
+
+
+ }
+
+ // ── Links pills ──
+ if len(v.Links) > 0 {
+
+ }
+
+ // ── Interests pills ──
+ if len(v.Interests) > 0 {
+
+
interests
+
+ for _, interest := range v.Interests {
+ { interest }
+ }
+
+
+ }
+
+ // ── Notes (private, saved to server) ──
+
+
+
+
+ if v.FollowUp {
+
+ } else {
+
+ }
+ ⚑ follow up after the conference
+
+
+
+
+
+
+
+
+ ● reads from quest.atmo.profile · notes saved to server
+
+
v0.1
+
+
+
+
+ built on atproto · open source · made by PDX ATProto
+
+
+ }
+}
+
+func profileTitle(v ProfileView) string {
+ if v.DisplayName != "" {
+ return v.DisplayName + " — atmo.quest"
+ }
+ return "profile — atmo.quest"
+}
diff --git a/features/connections/pages/profile_templ.go b/features/connections/pages/profile_templ.go
new file mode 100644
index 0000000..07bc7c2
--- /dev/null
+++ b/features/connections/pages/profile_templ.go
@@ -0,0 +1,474 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "atmoquest/features/common/layouts"
+
+// ProfileView is the data for rendering another person's profile page.
+type ProfileView struct {
+ ViewerDID string // the logged-in user's DID
+ TargetDID string
+ DisplayName string
+ AvatarURL string
+ Bio string
+ Location string
+ WorksAt string
+ ContactMethod string
+ Hiring bool
+ Looking bool
+ Links []ProfileLink
+ Interests []string
+ ConnectedAt string // pre-formatted date, or empty if not connected
+ EventName string // event where connection happened, if any
+ Notes string // private notes from SQLite
+ FollowUp bool // follow-up flag from SQLite
+}
+
+// ProfileLink is a labeled URL from the target's quest.atmo.profile.
+type ProfileLink struct {
+ Label string
+ URL string
+}
+
+func ProfileViewPage(v ProfileView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "you ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 42, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " atmo.quest :~$ cat connections/")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(v.TargetDID))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 43, Col: 62}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.ConnectedAt != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "✓ connected · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.ConnectedAt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 69, Col: 75}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if v.EventName != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 73, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if v.Hiring {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "hiring ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if v.Looking {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "open to work ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.Bio != "" || v.WorksAt != "" || v.Location != "" || v.ContactMethod != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.Bio != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Bio)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 87, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.WorksAt != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
works at ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.WorksAt)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 93, Col: 49}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if v.Location != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
location ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 99, Col: 50}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if v.ContactMethod != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
best contact ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(v.ContactMethod)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 105, Col: 55}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if len(v.Links) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
links ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, link := range v.Links {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(link.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 118, Col: 128}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if len(v.Interests) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
interests ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, interest := range v.Interests {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(interest)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 130, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.FollowUp {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "⚑ follow up after the conference
● reads from quest.atmo.profile · notes saved to server
v0.1
built on atproto · open source · made by PDX ATProto ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = layouts.Base(profileTitle(v), "Connection profile on atmo.quest.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func profileTitle(v ProfileView) string {
+ if v.DisplayName != "" {
+ return v.DisplayName + " — atmo.quest"
+ }
+ return "profile — atmo.quest"
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/features/connections/routes.go b/features/connections/routes.go
new file mode 100644
index 0000000..b554279
--- /dev/null
+++ b/features/connections/routes.go
@@ -0,0 +1,22 @@
+package connections
+
+import (
+ "database/sql"
+
+ "github.com/go-chi/chi/v5"
+
+ "atmoquest/features/auth"
+)
+
+// SetupRoutes wires the connections feature's HTTP routes.
+//
+// - GET /connections — list of all connections
+// - GET /connections/{did} — other person's profile view
+// - POST /connections/{did}/notes — save private notes + follow-up flag
+func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) {
+ h := NewHandlers(conn, authH)
+
+ router.Get("/connections", h.List)
+ router.Get("/connections/{did}", h.View)
+ router.Post("/connections/{did}/notes", h.SaveNotes)
+}
diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ
index 5c9c080..0c8dddd 100644
--- a/features/index/pages/index.templ
+++ b/features/index/pages/index.templ
@@ -101,7 +101,8 @@ templ indexAuthed(v IndexView) {
}
diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go
index 4b5b0a4..0364020 100644
--- a/features/index/pages/index_templ.go
+++ b/features/index/pages/index_templ.go
@@ -249,7 +249,7 @@ func indexAuthed(v IndexView) templ.Component {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -269,7 +269,7 @@ func indexAuthed(v IndexView) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("@" + v.Handle)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 116, Col: 63}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 117, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -287,7 +287,7 @@ func indexAuthed(v IndexView) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("@get('/demo/clock')")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 119, Col: 70}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 120, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func indexEventCard(ev *IndexEventCard) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 137, Col: 40}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 138, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -361,7 +361,7 @@ func indexEventCard(ev *IndexEventCard) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 139, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 140, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -379,7 +379,7 @@ func indexEventCard(ev *IndexEventCard) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 141, Col: 44}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 142, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -392,7 +392,7 @@ func indexEventCard(ev *IndexEventCard) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 141, Col: 75}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 142, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -457,7 +457,7 @@ func indexGuest() templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs("@")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 169, Col: 56}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 170, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -470,7 +470,7 @@ func indexGuest() templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs("for")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 180, Col: 56}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 181, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -483,7 +483,7 @@ func indexGuest() templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("@get('/demo/clock')")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 190, Col: 70}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 191, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -496,7 +496,7 @@ func indexGuest() templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs("// available")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 203, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 204, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
diff --git a/features/settings/handlers.go b/features/settings/handlers.go
new file mode 100644
index 0000000..3b720a3
--- /dev/null
+++ b/features/settings/handlers.go
@@ -0,0 +1,159 @@
+// Package settings implements the /settings page: export notes to CSV
+// and delete all local data with handle-based confirmation.
+package settings
+
+import (
+ "database/sql"
+ "encoding/csv"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/bluesky-social/indigo/atproto/identity"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+
+ "atmoquest/features/auth"
+ "atmoquest/features/settings/pages"
+ "atmoquest/internal/notes"
+ "atmoquest/internal/users"
+)
+
+// Handlers holds the dependencies for the settings feature.
+type Handlers struct {
+ DB *sql.DB
+ Auth *auth.Handlers
+ Directory identity.Directory
+}
+
+// NewHandlers wires the settings feature.
+func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers {
+ return &Handlers{
+ DB: conn,
+ Auth: authH,
+ Directory: identity.DefaultDirectory(),
+ }
+}
+
+// Page renders the settings page.
+func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) {
+ did, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ handle := h.resolveHandle(r, did.String())
+ deleted := r.URL.Query().Get("deleted") == "1"
+
+ isAdmin, _ := users.IsAdmin(r.Context(), h.DB, did)
+
+ view := pages.SettingsView{
+ Handle: handle,
+ Deleted: deleted,
+ IsAdmin: isAdmin,
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := pages.Settings(view).Render(r.Context(), w); err != nil {
+ slog.Error("render settings", "err", err)
+ }
+}
+
+// ExportCSV streams the viewer's notes as a CSV download.
+func (h *Handlers) ExportCSV(w http.ResponseWriter, r *http.Request) {
+ did, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ rows, err := notes.ListAll(r.Context(), h.DB, did.String())
+ if err != nil {
+ slog.Warn("settings: export notes", "did", did.String(), "err", err)
+ http.Error(w, "failed to export", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", "attachment; filename=atmoquest-notes.csv")
+
+ cw := csv.NewWriter(w)
+ _ = cw.Write([]string{"target_did", "handle", "notes", "follow_up", "updated_at"})
+ for _, row := range rows {
+ followUp := "no"
+ if row.FollowUp {
+ followUp = "yes"
+ }
+ handle := h.resolveHandle(r, row.TargetDID)
+ _ = cw.Write([]string{row.TargetDID, handle, row.Notes, followUp, row.UpdatedAt})
+ }
+ cw.Flush()
+}
+
+// DeleteData handles POST /settings/delete — deletes all the viewer's notes
+// after verifying the handle confirmation field matches.
+func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) {
+ did, _, ok := h.Auth.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ handle := h.resolveHandle(r, did.String())
+ confirm := strings.TrimSpace(r.FormValue("confirm_handle"))
+
+ if confirm != handle {
+ // Re-render with error.
+ view := pages.SettingsView{
+ Handle: handle,
+ DeleteError: fmt.Sprintf("You typed %q but your handle is %q. Please type your exact handle to confirm.", confirm, handle),
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ if err := pages.Settings(view).Render(r.Context(), w); err != nil {
+ slog.Error("render settings", "err", err)
+ }
+ return
+ }
+
+ count, err := notes.DeleteAll(r.Context(), h.DB, did.String())
+ if err != nil {
+ slog.Warn("settings: delete notes", "did", did.String(), "err", err)
+ http.Error(w, "failed to delete", http.StatusInternalServerError)
+ return
+ }
+
+ slog.Info("settings: deleted notes", "did", did.String(), "count", count)
+ http.Redirect(w, r, "/settings?deleted=1", http.StatusSeeOther)
+}
+
+// resolveHandle returns the user's atproto handle, falling back to a short
+// DID if resolution fails.
+func (h *Handlers) resolveHandle(r *http.Request, didStr string) string {
+ did, err := syntax.ParseDID(didStr)
+ if err != nil {
+ return shortDID(didStr)
+ }
+ if h.Directory == nil {
+ return shortDID(didStr)
+ }
+ ident, err := h.Directory.LookupDID(r.Context(), did)
+ if err != nil || ident == nil {
+ return shortDID(didStr)
+ }
+ hStr := ident.Handle.String()
+ if hStr == "" || hStr == "handle.invalid" {
+ return shortDID(didStr)
+ }
+ return hStr
+}
+
+func shortDID(did string) string {
+ if len(did) <= 18 {
+ return did
+ }
+ return did[:18] + "…"
+}
diff --git a/features/settings/pages/settings.templ b/features/settings/pages/settings.templ
new file mode 100644
index 0000000..ea15adc
--- /dev/null
+++ b/features/settings/pages/settings.templ
@@ -0,0 +1,97 @@
+package pages
+
+import "atmoquest/features/common/layouts"
+
+// SettingsView is the data for the settings page.
+type SettingsView struct {
+ Handle string // user's atproto handle (used for delete confirmation)
+ Deleted bool // true after a successful delete
+ DeleteError string // non-empty when confirmation failed
+ IsAdmin bool // show admin link when true
+}
+
+templ Settings(v SettingsView) {
+ @layouts.Base("settings — atmo.quest", "Export your notes or delete your data.") {
+
+
+
+
+
+
~/atmo.quest — settings
+
+
+
+ you { "@" } atmo.quest :~$
+ settings --data
+
+
+ if v.Deleted {
+
+ ✓
+ all your notes and follow-up flags have been deleted.
+
+ }
+
+ // ── Export section ──
+
+ export your notes
+
+ download a CSV of all your private notes and follow-up flags.
+ this includes every note you've written about your connections.
+
+ ▸ download CSV
+
+
+ // ── Delete section ──
+
+ delete your data
+
+ permanently delete all your private notes and follow-up flags from
+ atmo.quest. this does not remove your ATProto
+ records (connections, check-ins, profile) — those live in your PDS
+ and are yours to keep.
+
+
+ to confirm, type your handle: { v.Handle }
+
+
+ if v.DeleteError != "" {
+
+ { v.DeleteError }
+
+ }
+
+
+
+
+
+
+
+
+ ● your data, your control
+
+
v0.1
+
+
+
+
+ built on atproto · open source · made by PDX ATProto
+
+ }
+}
diff --git a/features/settings/pages/settings_templ.go b/features/settings/pages/settings_templ.go
new file mode 100644
index 0000000..7b6cd4a
--- /dev/null
+++ b/features/settings/pages/settings_templ.go
@@ -0,0 +1,150 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package pages
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "atmoquest/features/common/layouts"
+
+// SettingsView is the data for the settings page.
+type SettingsView struct {
+ Handle string // user's atproto handle (used for delete confirmation)
+ Deleted bool // true after a successful delete
+ DeleteError string // non-empty when confirmation failed
+ IsAdmin bool // show admin link when true
+}
+
+func Settings(v SettingsView) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "you ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 24, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " atmo.quest :~$ settings --data
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.Deleted {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
✓ all your notes and follow-up flags have been deleted.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
export your notes download a CSV of all your private notes and follow-up flags. this includes every note you've written about your connections.
▸ download CSV delete your data permanently delete all your private notes and follow-up flags from atmo.quest. this does not remove your ATProto records (connections, check-ins, profile) — those live in your PDS and are yours to keep.
to confirm, type your handle: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Handle)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 55, Col: 76}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.DeleteError != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.DeleteError)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 60, Col: 22}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "← back home ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.IsAdmin {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
admin console → ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
● your data, your control
v0.1
built on atproto · open source · made by PDX ATProto ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = layouts.Base("settings — atmo.quest", "Export your notes or delete your data.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/features/settings/routes.go b/features/settings/routes.go
new file mode 100644
index 0000000..d4f287c
--- /dev/null
+++ b/features/settings/routes.go
@@ -0,0 +1,22 @@
+package settings
+
+import (
+ "database/sql"
+
+ "github.com/go-chi/chi/v5"
+
+ "atmoquest/features/auth"
+)
+
+// SetupRoutes wires the settings feature's HTTP routes.
+//
+// - GET /settings — settings page
+// - GET /settings/export — CSV download of notes
+// - POST /settings/delete — delete all notes (requires handle confirmation)
+func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) {
+ h := NewHandlers(conn, authH)
+
+ router.Get("/settings", h.Page)
+ router.Get("/settings/export", h.ExportCSV)
+ router.Post("/settings/delete", h.DeleteData)
+}
diff --git a/internal/connection/list.go b/internal/connection/list.go
new file mode 100644
index 0000000..d2d7a5a
--- /dev/null
+++ b/internal/connection/list.go
@@ -0,0 +1,85 @@
+// Package connection — see connection.go for package doc.
+package connection
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+const nsidListRecords = "com.atproto.repo.listRecords"
+
+// ListEntry is one decoded quest.atmo.connection record returned by List.
+type ListEntry struct {
+ URI string // at-uri of this record
+ With syntax.DID // the connected person
+ ConnectedAt time.Time
+ EventURI string // optional at-uri of the associated event
+}
+
+// listRecordsResponse is the wire shape of com.atproto.repo.listRecords output.
+type listRecordsResponse struct {
+ Records []listRecordsEntry `json:"records"`
+ Cursor string `json:"cursor,omitempty"`
+}
+
+type listRecordsEntry struct {
+ URI string `json:"uri"`
+ CID string `json:"cid"`
+ Value connectionValue `json:"value"`
+}
+
+type connectionValue struct {
+ Type string `json:"$type"`
+ With string `json:"with"`
+ ConnectedAt string `json:"connectedAt"`
+ Event string `json:"event,omitempty"`
+}
+
+// List reads all quest.atmo.connection records from the user's PDS via
+// com.atproto.repo.listRecords (public, unauthenticated). Paginated
+// internally — returns the full set.
+func List(ctx context.Context, pdsHost string, did syntax.DID) ([]ListEntry, error) {
+ c := atclient.NewAPIClient(pdsHost)
+
+ var all []ListEntry
+ cursor := ""
+ for {
+ params := map[string]any{
+ "repo": did.String(),
+ "collection": NSID,
+ "limit": 100,
+ }
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+
+ var resp listRecordsResponse
+ if err := c.Get(ctx, syntax.NSID(nsidListRecords), params, &resp); err != nil {
+ return nil, fmt.Errorf("listRecords %s: %w", NSID, err)
+ }
+
+ for _, r := range resp.Records {
+ targetDID, err := syntax.ParseDID(r.Value.With)
+ if err != nil {
+ continue // skip malformed
+ }
+ t, _ := time.Parse(time.RFC3339, r.Value.ConnectedAt)
+ all = append(all, ListEntry{
+ URI: r.URI,
+ With: targetDID,
+ ConnectedAt: t,
+ EventURI: r.Value.Event,
+ })
+ }
+
+ if resp.Cursor == "" || len(resp.Records) == 0 {
+ break
+ }
+ cursor = resp.Cursor
+ }
+ return all, nil
+}
diff --git a/internal/db/migrations/006_connection_notes.sql b/internal/db/migrations/006_connection_notes.sql
new file mode 100644
index 0000000..ee0fa84
--- /dev/null
+++ b/internal/db/migrations/006_connection_notes.sql
@@ -0,0 +1,18 @@
+-- Connection notes: private per-viewer notes and follow-up flags.
+--
+-- These are private to the viewer (the logged-in user) and are NOT
+-- written to anyone's PDS. They live only in the app's SQLite database.
+-- The spec originally called for localStorage, but SQLite survives
+-- browser clears and works across devices.
+--
+-- Keyed by (viewer_did, target_did) — one row per pair.
+
+CREATE TABLE IF NOT EXISTS connection_notes (
+ viewer_did TEXT NOT NULL,
+ target_did TEXT NOT NULL,
+ notes TEXT NOT NULL DEFAULT '',
+ follow_up INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (viewer_did, target_did)
+);
diff --git a/internal/event/list.go b/internal/event/list.go
new file mode 100644
index 0000000..287e217
--- /dev/null
+++ b/internal/event/list.go
@@ -0,0 +1,32 @@
+package event
+package event
+
+import (
+ "context"
+ "database/sql"
+)
+
+// ListAll returns all cached events, ordered by start_time DESC.
+// Used for the connections-list filter dropdown.
+func ListAll(ctx context.Context, db *sql.DB) ([]Record, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT uri, name, start_time, end_time, location,
+ geofence_lat, geofence_lng, geofence_radius, organizer_did
+ FROM events
+ ORDER BY start_time DESC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []Record
+ for rows.Next() {
+ rec, err := scanRow(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, rec)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/notes/notes.go b/internal/notes/notes.go
new file mode 100644
index 0000000..6f021d8
--- /dev/null
+++ b/internal/notes/notes.go
@@ -0,0 +1,106 @@
+// Package notes reads and writes private per-viewer connection notes.
+//
+// Notes live in the app's SQLite database (connection_notes table), not in
+// any PDS. They are private to the viewer — other users cannot see them.
+package notes
+
+import (
+ "context"
+ "database/sql"
+)
+
+// Note is a viewer's private annotation about a connection target.
+type Note struct {
+ Notes string
+ FollowUp bool
+}
+
+// Get returns the note for (viewer, target), or a zero Note if none exists.
+func Get(ctx context.Context, db *sql.DB, viewerDID, targetDID string) (Note, error) {
+ var n Note
+ var followUp int
+ err := db.QueryRowContext(ctx, `
+ SELECT notes, follow_up FROM connection_notes
+ WHERE viewer_did = ? AND target_did = ?
+ `, viewerDID, targetDID).Scan(&n.Notes, &followUp)
+ if err == sql.ErrNoRows {
+ return Note{}, nil
+ }
+ if err != nil {
+ return Note{}, err
+ }
+ n.FollowUp = followUp != 0
+ return n, nil
+}
+
+// Put upserts the note for (viewer, target). Empty notes with no follow-up
+// flag deletes the row to keep the table tidy.
+func Put(ctx context.Context, db *sql.DB, viewerDID, targetDID string, n Note) error {
+ if n.Notes == "" && !n.FollowUp {
+ _, err := db.ExecContext(ctx, `
+ DELETE FROM connection_notes
+ WHERE viewer_did = ? AND target_did = ?
+ `, viewerDID, targetDID)
+ return err
+ }
+
+ followUp := 0
+ if n.FollowUp {
+ followUp = 1
+ }
+ _, err := db.ExecContext(ctx, `
+ INSERT INTO connection_notes (viewer_did, target_did, notes, follow_up, updated_at)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(viewer_did, target_did) DO UPDATE SET
+ notes = excluded.notes,
+ follow_up = excluded.follow_up,
+ updated_at = CURRENT_TIMESTAMP
+ `, viewerDID, targetDID, n.Notes, followUp)
+ return err
+}
+
+// ExportRow is one row in the CSV export.
+type ExportRow struct {
+ TargetDID string
+ Notes string
+ FollowUp bool
+ ConnectedAt string // left empty here; caller enriches from PDS data
+ UpdatedAt string
+}
+
+// ListAll returns every note the viewer has. Used for CSV export.
+func ListAll(ctx context.Context, db *sql.DB, viewerDID string) ([]ExportRow, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT target_did, notes, follow_up, updated_at
+ FROM connection_notes
+ WHERE viewer_did = ?
+ ORDER BY updated_at DESC
+ `, viewerDID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []ExportRow
+ for rows.Next() {
+ var r ExportRow
+ var followUp int
+ if err := rows.Scan(&r.TargetDID, &r.Notes, &followUp, &r.UpdatedAt); err != nil {
+ return nil, err
+ }
+ r.FollowUp = followUp != 0
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// DeleteAll removes every note the viewer has. Returns the number of rows deleted.
+func DeleteAll(ctx context.Context, db *sql.DB, viewerDID string) (int64, error) {
+ res, err := db.ExecContext(ctx, `
+ DELETE FROM connection_notes WHERE viewer_did = ?
+ `, viewerDID)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
diff --git a/router/router.go b/router/router.go
index 72e9962..91548ac 100644
--- a/router/router.go
+++ b/router/router.go
@@ -16,10 +16,12 @@ import (
"atmoquest/features/admin"
"atmoquest/features/auth"
"atmoquest/features/connect"
+ "atmoquest/features/connections"
"atmoquest/features/demo"
"atmoquest/features/event"
"atmoquest/features/index"
"atmoquest/features/profile"
+ "atmoquest/features/settings"
"atmoquest/internal/admincrypto"
"atmoquest/internal/connection"
"atmoquest/internal/session"
@@ -55,8 +57,10 @@ func SetupRoutes(
auth.SetupRoutes(router, authH)
profile.SetupRoutes(router, conn, authH)
connect.SetupRoutes(router, conn, authH, connQueue)
+ connections.SetupRoutes(router, conn, authH)
event.SetupRoutes(router, conn, authH)
admin.SetupRoutes(router, conn, authH, signer)
+ settings.SetupRoutes(router, conn, authH)
about.SetupRoutes(router)
demo.SetupRoutes(router)
index.SetupRoutes(router, conn, authH)
diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css
index 35df9be..078f927 100644
--- a/web/resources/static/css/terminal.css
+++ b/web/resources/static/css/terminal.css
@@ -1381,3 +1381,457 @@ footer a:hover { color: var(--lavender); }
.status-handle { color: var(--green); text-decoration: none; }
.status-handle:hover { text-decoration: underline; }
.break { word-break: break-all; }
+
+/* ─── Connections list (/connections) ─── */
+
+.connections-controls {
+ margin: 16px 0 12px;
+}
+.connections-search-form {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+.connections-search-input {
+ flex: 1 1 180px;
+ min-width: 0;
+ padding: 7px 12px;
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ background: var(--mantle);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+}
+.connections-search-input::placeholder { color: var(--muted); }
+.connections-search-input:focus {
+ outline: none;
+ border-color: var(--peach);
+ box-shadow: 0 0 0 2px rgba(250, 179, 135, 0.18);
+}
+.connections-filter-select {
+ padding: 7px 10px;
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ background: var(--mantle);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 12px;
+ cursor: pointer;
+}
+.connections-filter-select:focus {
+ outline: none;
+ border-color: var(--peach);
+}
+.connections-count {
+ font-size: 12px;
+ margin-top: 8px;
+}
+
+.connections-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin: 8px 0 16px;
+}
+
+.connection-row {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ padding: 12px 14px;
+ border-radius: 8px;
+ text-decoration: none;
+ color: var(--text);
+ border: 1px solid transparent;
+ transition: background 0.12s ease, border-color 0.12s ease;
+}
+.connection-row:hover {
+ background: rgba(180, 190, 254, 0.06);
+ border-color: var(--overlay);
+}
+
+.connection-avatar-wrap {
+ flex-shrink: 0;
+}
+.connection-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--surface);
+}
+.connection-avatar-empty {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted);
+ font-size: 16px;
+}
+
+.connection-info {
+ flex: 1;
+ min-width: 0;
+}
+.connection-name-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+.connection-name {
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.connection-name-muted {
+ color: var(--subtext);
+ font-weight: 400;
+}
+.connection-date {
+ font-size: 11px;
+ flex-shrink: 0;
+}
+.connection-bio {
+ font-size: 12px;
+ color: var(--subtext);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.connection-event-pill {
+ display: inline-block;
+ margin-top: 4px;
+ padding: 2px 8px;
+ font-size: 10px;
+ border-radius: 999px;
+ background: rgba(250, 179, 135, 0.12);
+ color: var(--peach);
+ border: 1px solid rgba(250, 179, 135, 0.25);
+}
+
+.connections-empty {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--subtext);
+ font-size: 14px;
+}
+.connections-empty p + p { margin-top: 8px; }
+.connections-empty .btn { margin-top: 16px; }
+
+/* ─── Profile view (/connections/{did}) ─── */
+
+.pv-card {
+ margin: 16px 0 12px;
+ padding: 20px 18px;
+ border: 1px solid var(--overlay);
+ border-radius: 10px;
+ background: linear-gradient(180deg, rgba(180, 190, 254, 0.04), transparent 60%);
+}
+.pv-header {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ margin-bottom: 14px;
+}
+.pv-avatar-wrap { flex-shrink: 0; }
+.pv-avatar {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--surface);
+}
+.pv-avatar-empty {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: var(--surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted);
+ font-size: 22px;
+}
+.pv-identity { min-width: 0; }
+.pv-name {
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0;
+}
+.pv-name-muted { color: var(--subtext); font-weight: 400; }
+.pv-did {
+ font-size: 11px;
+ color: var(--muted);
+ margin-top: 2px;
+}
+.pv-did code { background: transparent; padding: 0; color: inherit; }
+
+/* badges row */
+.pv-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+.pv-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ font-size: 11px;
+ border-radius: 999px;
+ border: 1px solid;
+}
+.pv-badge-connected {
+ color: var(--green);
+ border-color: rgba(166, 227, 161, 0.35);
+ background: rgba(166, 227, 161, 0.10);
+}
+.pv-badge-event {
+ color: var(--peach);
+ border-color: rgba(250, 179, 135, 0.25);
+ background: rgba(250, 179, 135, 0.10);
+}
+.pv-badge-hiring {
+ color: var(--blue);
+ border-color: rgba(137, 180, 250, 0.30);
+ background: rgba(137, 180, 250, 0.10);
+}
+.pv-badge-looking {
+ color: var(--lavender);
+ border-color: rgba(180, 190, 254, 0.30);
+ background: rgba(180, 190, 254, 0.10);
+}
+.pv-badge-icon { font-weight: 700; }
+
+/* details block */
+.pv-details { margin-bottom: 14px; }
+.pv-bio {
+ font-size: 14px;
+ color: var(--text);
+ margin-bottom: 10px;
+ line-height: 1.5;
+}
+.pv-meta-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+}
+.pv-meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.pv-meta-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+.pv-meta-value {
+ font-size: 13px;
+ color: var(--text);
+}
+
+/* pills */
+.pv-pills-section { margin-bottom: 12px; }
+.pv-pills-label {
+ display: block;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+.pv-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.pv-pill {
+ display: inline-block;
+ padding: 3px 10px;
+ font-size: 12px;
+ border-radius: 999px;
+ border: 1px solid var(--overlay);
+ color: var(--text);
+ text-decoration: none;
+ transition: border-color 0.12s ease;
+}
+.pv-pill-link:hover {
+ border-color: var(--peach);
+ color: var(--peach);
+}
+.pv-pill-interest {
+ background: rgba(180, 190, 254, 0.08);
+ border-color: rgba(180, 190, 254, 0.20);
+}
+
+/* notes section */
+.pv-notes-section {
+ margin-top: 18px;
+ padding-top: 16px;
+ border-top: 1px solid var(--surface);
+}
+.pv-notes-header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.pv-notes-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text);
+}
+.pv-notes-hint { font-size: 11px; }
+.pv-notes-saved {
+ font-size: 11px;
+ margin-left: auto;
+ color: var(--green);
+}
+.pv-notes-textarea {
+ width: 100%;
+ min-height: 80px;
+ padding: 10px 12px;
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ background: var(--mantle);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ resize: vertical;
+ line-height: 1.5;
+}
+.pv-notes-textarea::placeholder { color: var(--muted); }
+.pv-notes-textarea:focus {
+ outline: none;
+ border-color: var(--peach);
+ box-shadow: 0 0 0 2px rgba(250, 179, 135, 0.18);
+}
+.pv-followup-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 10px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text);
+}
+.pv-followup-toggle input {
+ accent-color: var(--peach);
+ width: 16px;
+ height: 16px;
+}
+.pv-followup-text { user-select: none; }
+
+/* bottom nav links */
+.pv-nav {
+ display: flex;
+ gap: 8px;
+ margin: 12px 0 16px;
+}
+
+/* ─── Settings (/settings) ─── */
+
+.settings-section {
+ margin: 20px 0;
+ padding: 18px;
+ border: 1px solid var(--overlay);
+ border-radius: 10px;
+ background: linear-gradient(180deg, rgba(180, 190, 254, 0.03), transparent 60%);
+}
+.settings-heading {
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 8px;
+}
+.settings-heading-danger { color: var(--red); }
+.settings-desc {
+ font-size: 13px;
+ color: var(--subtext);
+ margin: 6px 0;
+ line-height: 1.5;
+}
+.settings-desc strong { color: var(--text); }
+.settings-handle {
+ color: var(--peach);
+ background: rgba(250, 179, 135, 0.10);
+ padding: 1px 6px;
+ border-radius: 4px;
+}
+.settings-danger {
+ border-color: rgba(243, 139, 168, 0.25);
+ background: linear-gradient(180deg, rgba(243, 139, 168, 0.04), transparent 60%);
+}
+
+.settings-success {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ margin: 16px 0 4px;
+ border-radius: 6px;
+ background: rgba(166, 227, 161, 0.12);
+ border: 1px solid rgba(166, 227, 161, 0.30);
+ color: var(--green);
+ font-size: 13px;
+}
+.settings-success-icon { font-weight: 700; font-size: 15px; }
+
+.settings-error {
+ padding: 8px 14px;
+ margin: 8px 0;
+ border-radius: 6px;
+ background: rgba(243, 139, 168, 0.10);
+ border: 1px solid rgba(243, 139, 168, 0.30);
+ color: var(--red);
+ font-size: 12px;
+}
+
+.settings-delete-form {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+.settings-confirm-input {
+ flex: 1 1 180px;
+ min-width: 0;
+ padding: 7px 12px;
+ border: 1px solid var(--overlay);
+ border-radius: 6px;
+ background: var(--mantle);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+}
+.settings-confirm-input::placeholder { color: var(--muted); }
+.settings-confirm-input:focus {
+ outline: none;
+ border-color: var(--red);
+ box-shadow: 0 0 0 2px rgba(243, 139, 168, 0.18);
+}
+
+.btn-danger {
+ background: rgba(243, 139, 168, 0.15);
+ color: var(--red);
+ border: 1px solid rgba(243, 139, 168, 0.35);
+}
+.btn-danger:hover {
+ background: rgba(243, 139, 168, 0.25);
+}
+
+.settings-nav {
+ margin: 12px 0 16px;
+}
diff --git a/web/resources/static/js/notes.js b/web/resources/static/js/notes.js
new file mode 100644
index 0000000..554550b
--- /dev/null
+++ b/web/resources/static/js/notes.js
@@ -0,0 +1,62 @@
+// atmo.quest — private notes + follow-up flag for connection profiles.
+//
+// Notes and the follow-up checkbox are saved to the server via
+// POST /connections/{did}/notes. The page template pre-fills the textarea
+// and checkbox from server data; this script auto-saves on change.
+(function () {
+ "use strict";
+
+ var section = document.querySelector(".pv-notes-section");
+ if (!section) return;
+
+ var targetDID = section.getAttribute("data-target-did");
+ if (!targetDID) return;
+
+ var textarea = document.getElementById("pv-notes");
+ var checkbox = document.getElementById("pv-followup");
+ var status = document.getElementById("pv-notes-status");
+
+ function showStatus(msg) {
+ if (!status) return;
+ status.textContent = msg;
+ clearTimeout(status._timer);
+ status._timer = setTimeout(function () { status.textContent = ""; }, 2000);
+ }
+
+ function save() {
+ var body = {
+ notes: textarea ? textarea.value : "",
+ followUp: checkbox ? checkbox.checked : false
+ };
+ fetch("/connections/" + encodeURIComponent(targetDID) + "/notes", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ })
+ .then(function (resp) {
+ if (resp.ok) {
+ showStatus("✓ saved");
+ } else {
+ showStatus("save failed");
+ }
+ })
+ .catch(function () {
+ showStatus("save failed");
+ });
+ }
+
+ // Auto-save notes with debounce.
+ var saveTimer = null;
+ if (textarea) {
+ textarea.addEventListener("input", function () {
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(save, 800);
+ });
+ }
+
+ // Save follow-up immediately on change.
+ if (checkbox) {
+ checkbox.addEventListener("change", save);
+ }
+})();