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 { +
+

no connections match your search.

+ clear filters +
+ } 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
+
+
+ + + } +} + +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, "

no connections match your search.

clear filters
") + 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
") + 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) } +
+ +
+
+
+ if v.AvatarURL != "" { + { + } else { + + } +
+
+ if v.DisplayName != "" { +

{ v.DisplayName }

+ } else { +

(no display name)

+ } +

{ 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 }

+ } +
+ if v.WorksAt != "" { +
+ works at + { v.WorksAt } +
+ } + if v.Location != "" { +
+ location + { v.Location } +
+ } + if v.ContactMethod != "" { +
+ best contact + { v.ContactMethod } +
+ } +
+
+ } + + // ── Links pills ── + if len(v.Links) > 0 { +
+ links +
+ for _, link := range v.Links { + { link.Label } + } +
+
+ } + + // ── Interests pills ── + if len(v.Interests) > 0 { +
+ interests +
+ for _, interest := range v.Interests { + { interest } + } +
+
+ } + + // ── Notes (private, saved to server) ── +
+
+ private notes + only you can see these + +
+ + +
+
+ + +
+
+
+ ● reads from quest.atmo.profile · notes saved to server +
+
v0.1
+
+
+ + + + } +} + +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, "
~/atmo.quest — profile
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.AvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.DisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 57, Col: 43} + } + _, 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, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

(no display name)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/profile.templ`, Line: 61, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + 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, "") + 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, "
private notes only you can see these
● reads from quest.atmo.profile · notes saved to server
v0.1
") + 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) { }
view profile → - edit profile → + connections → + settings →
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, "
view profile → edit profile →
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
view profile → connections → settings →
") 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 != "" { + + } + +
+ + +
+
+ +
+ ← back home + if v.IsAdmin { + admin console → + } +
+
+
+
+ ● your data, your control +
+
v0.1
+
+
+ + + } +} 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, "
~/atmo.quest — settings
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
") + 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); + } +})();