~/atmo.quest — settings
- @layouts.TopNav("home")
+ @layouts.TopNav("settings")
-
- you { "@" } atmo.quest :~$
- settings --data
-
+
+
Settings
+
v0.1 · atmo.quest
+
+ if v.Deleted {
+
+ ✓
+ all your notes and follow-up flags have been deleted.
+
+ }
- if v.Deleted {
-
-
✓
- all your notes and follow-up flags have been deleted.
+ // ── Account card ──
+
+
{ v.AvatarInitial }
+
+
+ if v.DisplayName != "" {
+ { v.DisplayName }
+ } else {
+ { v.Handle }
+ }
+
+
{ "@" } { v.Handle }
+ if v.PDS != "" {
+
PDS · { v.PDS }
+ }
+
- }
- // ── 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
-
+ // ── Privacy ──
+
+
Privacy
+
+
+
◆
+
+
Show me on event attendee lists
+
when off, you can still scan and connect
+
+
+
+
+
◇
+
+
Appear on the leaderboard
+
opt out anytime
+
+
+
+
+
- // ── 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 }
-
+ // ── Your data ──
+
- if v.DeleteError != "" {
-
- { v.DeleteError }
+ // ── About ──
+
-
-
+ // ── Account (danger) ──
+
-
-
+
+
atmo.quest
@@ -97,6 +187,33 @@ templ Settings(v SettingsView) {
- @layouts.BottomNav("home")
+ @layouts.BottomNav("settings")
}
}
+
+templ deleteDataRow(v SettingsView) {
+
+
▲
+
+
Delete atmo.quest data
+
removes local notes and flags
+ if v.DeleteError != "" {
+
+ { v.DeleteError }
+
+ }
+
+
+
+}
diff --git a/features/settings/pages/settings_templ.go b/features/settings/pages/settings_templ.go
index df02d08..ae0f8f2 100644
--- a/features/settings/pages/settings_templ.go
+++ b/features/settings/pages/settings_templ.go
@@ -8,14 +8,26 @@ package pages
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
-import "atmoquest/features/common/layouts"
+import (
+ "fmt"
+
+ "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
+ Handle string // user's atproto handle
+ DisplayName string // user's display name
+ AvatarInitial string // first letter for avatar placeholder
+ PDS string // PDS hostname (e.g. "bsky.social")
+ NoteCount int // number of local notes
+ Deleted bool // true after a successful delete
+ DeleteError string // non-empty when confirmation failed
+ IsAdmin bool // show admin link when true
+}
+
+func settingsItoa(n int) string {
+ return fmt.Sprintf("%d", n)
}
func Settings(v SettingsView) templ.Component {
@@ -55,103 +67,214 @@ func Settings(v SettingsView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = layouts.TopNav("home").Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = layouts.TopNav("settings").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Settings
v0.1 · atmo.quest
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
you ")
+ 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, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@")
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(v.AvatarInitial)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 25, Col: 56}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 49, Col: 60}
}
_, 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, 3, "atmo.quest :~$ settings --data
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if v.Deleted {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
✓ all your notes and follow-up flags have been deleted.
")
+ if v.DisplayName != "" {
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 53, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, 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: 19}
+ }
+ _, 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, 5, "
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: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var4 string
- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Handle)
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("@")
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 56, Col: 76}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 58, Col: 66}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- if v.DeleteError != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, 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: 58, Col: 85}
+ }
+ _, 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, 8, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.PDS != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
PDS · ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var5 string
- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.DeleteError)
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.PDS)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 61, Col: 22}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 60, Col: 79}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ _, 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, 8, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Privacy
◆
Show me on event attendee lists
when off, you can still scan and connect
◇
Appear on the leaderboard
opt out anytime
Your data
◆
Export connections & notes
download a CSV summary
›
◇
Local notes
stored on this device only
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Handle)
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(settingsItoa(v.NoteCount))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 69, Col: 29}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 106, Col: 67}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
+ _, 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, 10, "\" class=\"settings-confirm-input\" autocomplete=\"off\" required aria-label=\"Type your handle to confirm deletion\">
✕ delete my notes ← back home ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " notes
› Account
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = deleteDataRow(v).Render(ctx, templ_7745c5c3_Buffer)
+ 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.IsAdmin {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
admin console → ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = layouts.BottomNav("home").Render(ctx, templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = layouts.BottomNav("settings").Render(ctx, templ_7745c5c3_Buffer)
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)
+ templ_7745c5c3_Err = layouts.Base("settings — atmo.quest", "Manage your account, privacy, and data.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func deleteDataRow(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_Var10 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var10 == nil {
+ templ_7745c5c3_Var10 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
▲
Delete atmo.quest data
removes local notes and flags
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if v.DeleteError != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, 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: 202, Col: 20}
+ }
+ _, 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, 18, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/internal/checkin/checkin.go b/internal/checkin/checkin.go
index ac665ea..e337cfd 100644
--- a/internal/checkin/checkin.go
+++ b/internal/checkin/checkin.go
@@ -47,6 +47,20 @@ func Put(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, eventURI st
at = time.Now().UTC()
}
+ // Dedup: if this user already has a check-in for this event in the local
+ // cache, skip the PDS write and return the existing record URI.
+ if db != nil {
+ var existing string
+ err := db.QueryRowContext(ctx, `
+ SELECT record_uri FROM checkins
+ WHERE did = ? AND event_uri = ?
+ LIMIT 1
+ `, sess.Data.AccountDID.String(), eventURI).Scan(&existing)
+ if err == nil && existing != "" {
+ return existing, nil
+ }
+ }
+
value := map[string]any{
"$type": NSID,
"event": eventURI,
diff --git a/internal/checkin/list.go b/internal/checkin/list.go
index 4864c70..6ddf470 100644
--- a/internal/checkin/list.go
+++ b/internal/checkin/list.go
@@ -39,10 +39,15 @@ func ListForUser(ctx context.Context, db *sql.DB, did syntax.DID, now time.Time)
var out []UserEvent
for rows.Next() {
var ue UserEvent
+ var checkedInAtStr string
if err := rows.Scan(&ue.EventURI, &ue.EventName, &ue.Location,
- &ue.StartTime, &ue.EndTime, &ue.CheckedInAt); err != nil {
+ &ue.StartTime, &ue.EndTime, &checkedInAtStr); err != nil {
return nil, err
}
+ ue.CheckedInAt, _ = time.Parse(time.RFC3339, checkedInAtStr)
+ if ue.CheckedInAt.IsZero() {
+ ue.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAtStr)
+ }
ue.IsOngoing = !now.Before(ue.StartTime) && !now.After(ue.EndTime)
out = append(out, ue)
}
diff --git a/internal/connection/drain.go b/internal/connection/drain.go
index 667f845..b744a39 100644
--- a/internal/connection/drain.go
+++ b/internal/connection/drain.go
@@ -38,7 +38,7 @@ func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slo
return res, err
}
for _, item := range items {
- _, _, err := Put(ctx, sess, Record{With: item.InitiatorDID})
+ _, _, err := Put(ctx, sess, Record{With: item.InitiatorDID, EventURI: item.EventURI})
if err != nil {
if logger != nil {
logger.Warn("connection drain: write failed",
diff --git a/internal/connection/list.go b/internal/connection/list.go
index d2d7a5a..8eb11ee 100644
--- a/internal/connection/list.go
+++ b/internal/connection/list.go
@@ -83,3 +83,20 @@ func List(ctx context.Context, pdsHost string, did syntax.DID) ([]ListEntry, err
}
return all, nil
}
+
+// HasConnection checks if the owner already has a connection record with
+// the given target at the given event (or with no event if eventURI is empty).
+// Returns true if a matching record exists. Uses the public listRecords
+// endpoint (unauthenticated).
+func HasConnection(ctx context.Context, pdsHost string, ownerDID, targetDID syntax.DID, eventURI string) bool {
+ entries, err := List(ctx, pdsHost, ownerDID)
+ if err != nil {
+ return false // can't tell — allow the write
+ }
+ for _, e := range entries {
+ if e.With == targetDID && e.EventURI == eventURI {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/connection/queue.go b/internal/connection/queue.go
index 3ccc938..3b5a97f 100644
--- a/internal/connection/queue.go
+++ b/internal/connection/queue.go
@@ -15,6 +15,7 @@ type PendingItem struct {
ID int64
TargetDID syntax.DID
InitiatorDID syntax.DID
+ EventURI string
}
// Queue is a SQLite-backed FIFO of reciprocal connection writes. Methods are
@@ -30,9 +31,9 @@ func NewQueue(db *sql.DB) *Queue {
}
// Enqueue records that `target` owes a connection record listing `initiator`.
-// Idempotent: re-enqueueing the same (target, initiator) is a no-op thanks to
-// the unique index on (target_did, initiator_did).
-func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID) error {
+// Idempotent: re-enqueueing the same (target, initiator, eventURI) is a no-op
+// thanks to the unique index.
+func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID, eventURI string) error {
if target == "" || initiator == "" {
return errors.New("connection queue: empty DID")
}
@@ -40,10 +41,10 @@ func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID) error
return errors.New("connection queue: self-connection")
}
_, err := q.db.ExecContext(ctx, `
- INSERT INTO pending_connections (target_did, initiator_did)
- VALUES (?, ?)
- ON CONFLICT(target_did, initiator_did) DO NOTHING
- `, target.String(), initiator.String())
+ INSERT INTO pending_connections (target_did, initiator_did, event_uri)
+ VALUES (?, ?, ?)
+ ON CONFLICT(target_did, initiator_did, event_uri) DO NOTHING
+ `, target.String(), initiator.String(), eventURI)
if err != nil {
return fmt.Errorf("enqueue: %w", err)
}
@@ -59,14 +60,14 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi
)
if limit <= 0 {
rows, err = q.db.QueryContext(ctx, `
- SELECT id, target_did, initiator_did
+ SELECT id, target_did, initiator_did, event_uri
FROM pending_connections
WHERE target_did = ?
ORDER BY id
`, target.String())
} else {
rows, err = q.db.QueryContext(ctx, `
- SELECT id, target_did, initiator_did
+ SELECT id, target_did, initiator_did, event_uri
FROM pending_connections
WHERE target_did = ?
ORDER BY id
@@ -84,8 +85,9 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi
id int64
targetStr string
initiatorStr string
+ eventURI string
)
- if err := rows.Scan(&id, &targetStr, &initiatorStr); err != nil {
+ if err := rows.Scan(&id, &targetStr, &initiatorStr, &eventURI); err != nil {
return nil, fmt.Errorf("scan pending: %w", err)
}
tDID, err := syntax.ParseDID(targetStr)
@@ -96,7 +98,7 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi
if err != nil {
return nil, fmt.Errorf("pending initiator DID parse: %w", err)
}
- out = append(out, PendingItem{ID: id, TargetDID: tDID, InitiatorDID: iDID})
+ out = append(out, PendingItem{ID: id, TargetDID: tDID, InitiatorDID: iDID, EventURI: eventURI})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending: %w", err)
diff --git a/internal/connection/queue_test.go b/internal/connection/queue_test.go
index 7b4809b..17dfe8e 100644
--- a/internal/connection/queue_test.go
+++ b/internal/connection/queue_test.go
@@ -46,7 +46,7 @@ func TestQueue_EnqueueAndList(t *testing.T) {
target := did(t, didA)
initiator := did(t, didB)
- if err := q.Enqueue(ctx, target, initiator); err != nil {
+ if err := q.Enqueue(ctx, target, initiator, ""); err != nil {
t.Fatalf("enqueue: %v", err)
}
items, err := q.List(ctx, target, 0)
@@ -71,7 +71,7 @@ func TestQueue_EnqueueIdempotent(t *testing.T) {
initiator := did(t, didB)
for i := 0; i < 3; i++ {
- if err := q.Enqueue(ctx, target, initiator); err != nil {
+ if err := q.Enqueue(ctx, target, initiator, ""); err != nil {
t.Fatalf("enqueue #%d: %v", i, err)
}
}
@@ -87,7 +87,7 @@ func TestQueue_EnqueueIdempotent(t *testing.T) {
func TestQueue_RejectsSelfConnect(t *testing.T) {
q := newTestQueue(t)
target := did(t, didA)
- err := q.Enqueue(context.Background(), target, target)
+ err := q.Enqueue(context.Background(), target, target, "")
if err == nil {
t.Fatal("expected error for self-connect")
}
@@ -95,10 +95,10 @@ func TestQueue_RejectsSelfConnect(t *testing.T) {
func TestQueue_RejectsEmptyDID(t *testing.T) {
q := newTestQueue(t)
- if err := q.Enqueue(context.Background(), "", did(t, didA)); err == nil {
+ if err := q.Enqueue(context.Background(), "", did(t, didA), ""); err == nil {
t.Error("expected error for empty target")
}
- if err := q.Enqueue(context.Background(), did(t, didA), ""); err == nil {
+ if err := q.Enqueue(context.Background(), did(t, didA), "", ""); err == nil {
t.Error("expected error for empty initiator")
}
}
@@ -109,7 +109,7 @@ func TestQueue_MultipleInitiatorsForSameTarget(t *testing.T) {
target := did(t, didA)
for _, init := range []syntax.DID{did(t, didB), did(t, didC)} {
- if err := q.Enqueue(ctx, target, init); err != nil {
+ if err := q.Enqueue(ctx, target, init, ""); err != nil {
t.Fatalf("enqueue: %v", err)
}
}
@@ -126,8 +126,8 @@ func TestQueue_ListLimit(t *testing.T) {
q := newTestQueue(t)
ctx := context.Background()
target := did(t, didA)
- _ = q.Enqueue(ctx, target, did(t, didB))
- _ = q.Enqueue(ctx, target, did(t, didC))
+ _ = q.Enqueue(ctx, target, did(t, didB), "")
+ _ = q.Enqueue(ctx, target, did(t, didC), "")
items, err := q.List(ctx, target, 1)
if err != nil {
@@ -142,7 +142,7 @@ func TestQueue_Delete(t *testing.T) {
q := newTestQueue(t)
ctx := context.Background()
target := did(t, didA)
- if err := q.Enqueue(ctx, target, did(t, didB)); err != nil {
+ if err := q.Enqueue(ctx, target, did(t, didB), ""); err != nil {
t.Fatalf("enqueue: %v", err)
}
items, _ := q.List(ctx, target, 0)
@@ -161,7 +161,7 @@ func TestQueue_ListPreservesInsertionOrder(t *testing.T) {
target := did(t, didA)
order := []syntax.DID{did(t, didB), did(t, didC)}
for _, init := range order {
- if err := q.Enqueue(ctx, target, init); err != nil {
+ if err := q.Enqueue(ctx, target, init, ""); err != nil {
t.Fatalf("enqueue: %v", err)
}
}
@@ -176,8 +176,8 @@ func TestQueue_ListPreservesInsertionOrder(t *testing.T) {
func TestQueue_CountIsolatedByTarget(t *testing.T) {
q := newTestQueue(t)
ctx := context.Background()
- _ = q.Enqueue(ctx, did(t, didA), did(t, didB))
- _ = q.Enqueue(ctx, did(t, didC), did(t, didB))
+ _ = q.Enqueue(ctx, did(t, didA), did(t, didB), "")
+ _ = q.Enqueue(ctx, did(t, didC), did(t, didB), "")
if c, _ := q.Count(ctx, did(t, didA)); c != 1 {
t.Errorf("count(A) = %d, want 1", c)
}
diff --git a/internal/db/migrations/008_pending_event_uri.sql b/internal/db/migrations/008_pending_event_uri.sql
new file mode 100644
index 0000000..709e93c
--- /dev/null
+++ b/internal/db/migrations/008_pending_event_uri.sql
@@ -0,0 +1,12 @@
+-- Add event_uri to pending_connections so reciprocal writes carry the event
+-- linkage from the original connection.
+--
+-- The unique index is expanded to include event_uri so the same pair can
+-- queue at different events (but not twice for the same event).
+
+ALTER TABLE pending_connections ADD COLUMN event_uri TEXT NOT NULL DEFAULT '';
+
+-- Recreate the unique index to include event_uri.
+DROP INDEX IF EXISTS pending_connections_unique_idx;
+CREATE UNIQUE INDEX pending_connections_unique_idx
+ ON pending_connections (target_did, initiator_did, event_uri);
diff --git a/internal/event/event.go b/internal/event/event.go
index 28266b2..247c519 100644
--- a/internal/event/event.go
+++ b/internal/event/event.go
@@ -41,6 +41,7 @@ type Record struct {
Location string
Geofence *Geofence
OrganizerDID syntax.DID
+ ExpectedAttendees int // from the event record; 0 means unset
}
// ErrNotFound is returned by Get when no cached event matches.
@@ -97,7 +98,8 @@ func Cache(ctx context.Context, db *sql.DB, r Record) error {
func Get(ctx context.Context, db *sql.DB, uri string) (Record, error) {
row := db.QueryRowContext(ctx, `
SELECT uri, name, start_time, end_time, location,
- geofence_lat, geofence_lng, geofence_radius, organizer_did
+ geofence_lat, geofence_lng, geofence_radius, organizer_did,
+ expected_attendees
FROM events
WHERE uri = ?
`, uri)
@@ -119,6 +121,7 @@ func scanRow(s scanner) (Record, error) {
err := s.Scan(
&r.URI, &r.Name, &r.StartTime, &r.EndTime, &r.Location,
&lat, &lng, &radius, &organizer,
+ &r.ExpectedAttendees,
)
if err == sql.ErrNoRows {
return Record{}, ErrNotFound
diff --git a/internal/event/list.go b/internal/event/list.go
index c095bc0..625fe8b 100644
--- a/internal/event/list.go
+++ b/internal/event/list.go
@@ -10,7 +10,8 @@ import (
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
+ geofence_lat, geofence_lng, geofence_radius, organizer_did,
+ expected_attendees
FROM events
ORDER BY start_time DESC
`)
diff --git a/internal/event/put.go b/internal/event/put.go
index acdc2ab..39b42f7 100644
--- a/internal/event/put.go
+++ b/internal/event/put.go
@@ -218,7 +218,8 @@ func LookupByQRToken(ctx context.Context, db *sql.DB, token string) (Record, err
}
row := db.QueryRowContext(ctx, `
SELECT uri, name, start_time, end_time, location,
- geofence_lat, geofence_lng, geofence_radius, organizer_did
+ geofence_lat, geofence_lng, geofence_radius, organizer_did,
+ expected_attendees
FROM events
WHERE qr_token = ?
`, token)
diff --git a/router/router.go b/router/router.go
index be86085..464721f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -3,10 +3,15 @@ package router
import (
"context"
"database/sql"
+ "encoding/json"
+ "fmt"
+ "log/slog"
"net/http"
"sync"
+ "time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+ "github.com/bluesky-social/indigo/atproto/syntax"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/go-chi/chi/v5"
"github.com/starfederation/datastar-go/datastar"
@@ -44,17 +49,23 @@ func SetupRoutes(
signer *admincrypto.Signer,
) (err error) {
+ // Build the auth handlers once; other features depend on them for
+ // RequireSession / ResumeSession.
+ connQueue := connection.NewQueue(conn)
+ authH := auth.NewHandlers(conn, oauthApp, sess)
+ authH.ConnQueue = connQueue
+
+ // Drain pending reciprocal connections on every authenticated page load,
+ // debounced to once per 30 seconds per user.
+ router.Use(auth.DrainMiddleware(authH, connQueue, 30*time.Second))
+
if config.Global.Environment == config.Dev {
setupReload(router)
+ setupDevDebug(router, authH, conn)
}
router.Handle("/static/*", resources.Handler())
- // Build the auth handlers once; other features depend on them for
- // RequireSession / ResumeSession.
- connQueue := connection.NewQueue(conn)
- authH := auth.NewHandlers(conn, oauthApp, sess)
- authH.ConnQueue = connQueue
auth.SetupRoutes(router, authH)
profile.SetupRoutes(router, conn, authH)
connect.SetupRoutes(router, conn, authH, connQueue)
@@ -99,3 +110,145 @@ func setupReload(router chi.Router) {
w.Write([]byte("OK"))
})
}
+
+// setupDevDebug registers development-only debug routes. These are NEVER
+// available in production — the `config.Dev` gate in SetupRoutes ensures that.
+func setupDevDebug(router chi.Router, authH *auth.Handlers, db *sql.DB) {
+ // POST /debug/purge-pds — delete all quest.atmo.* records from the
+ // currently logged-in user's PDS and clear local cache tables.
+ router.Post("/debug/purge-pds", func(w http.ResponseWriter, r *http.Request) {
+ did, sess, ok := authH.RequireSession(w, r)
+ if !ok {
+ return
+ }
+
+ collections := []string{
+ "quest.atmo.connection",
+ "quest.atmo.checkin",
+ "quest.atmo.badge",
+ "quest.atmo.event",
+ "quest.atmo.profile",
+ }
+
+ type deleteResult struct {
+ Collection string `json:"collection"`
+ Deleted int `json:"deleted"`
+ Errors int `json:"errors"`
+ }
+ var results []deleteResult
+
+ apiClient := sess.APIClient()
+ for _, coll := range collections {
+ dr := deleteResult{Collection: coll}
+
+ // List all records in this collection.
+ cursor := ""
+ for {
+ params := map[string]any{
+ "repo": did.String(),
+ "collection": coll,
+ "limit": 100,
+ }
+ if cursor != "" {
+ params["cursor"] = cursor
+ }
+ var resp struct {
+ Records []struct {
+ URI string `json:"uri"`
+ } `json:"records"`
+ Cursor string `json:"cursor,omitempty"`
+ }
+ if err := apiClient.Get(r.Context(), syntax.NSID("com.atproto.repo.listRecords"), params, &resp); err != nil {
+ slog.Warn("debug purge: list", "collection", coll, "err", err)
+ break
+ }
+ for _, rec := range resp.Records {
+ rkey := rkeyFromURI(rec.URI)
+ if rkey == "" {
+ dr.Errors++
+ continue
+ }
+ input := map[string]any{
+ "repo": did.String(),
+ "collection": coll,
+ "rkey": rkey,
+ }
+ if err := apiClient.Post(r.Context(), syntax.NSID("com.atproto.repo.deleteRecord"), input, nil); err != nil {
+ slog.Warn("debug purge: delete", "uri", rec.URI, "err", err)
+ dr.Errors++
+ } else {
+ dr.Deleted++
+ }
+ }
+ if resp.Cursor == "" || len(resp.Records) == 0 {
+ break
+ }
+ cursor = resp.Cursor
+ }
+ results = append(results, dr)
+ }
+
+ // Also clear local cache tables.
+ for _, stmt := range []string{
+ `DELETE FROM checkins WHERE did = ?`,
+ `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?`,
+ `DELETE FROM connection_notes WHERE viewer_did = ?`,
+ } {
+ if stmt == `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?` {
+ _, _ = db.ExecContext(r.Context(), stmt, did.String(), did.String())
+ } else {
+ _, _ = db.ExecContext(r.Context(), stmt, did.String())
+ }
+ }
+
+ slog.Info("debug purge complete", "did", did.String(), "results", fmt.Sprintf("%+v", results))
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "did": did.String(),
+ "results": results,
+ })
+ })
+
+ // GET /debug/purge-pds — simple confirmation page.
+ router.Get("/debug/purge-pds", func(w http.ResponseWriter, r *http.Request) {
+ didStr, _ := authH.Sessions.Get(r)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprintf(w, `
+
debug: purge PDS
+
+
+
⚠ Purge all quest.atmo.* records
+
DID: %s
+
This will delete all quest.atmo.connection, checkin, badge, event, and profile records from your PDS, plus clear local cache tables.
+
Delete everything
+
+`, didStr)
+ })
+}
+
+// rkeyFromURI extracts the rkey (last path segment) from an at-uri like
+// "at://did:plc:xxx/quest.atmo.connection/3abc123".
+func rkeyFromURI(uri string) string {
+ // at-uri format: at://
//
+ for i := len(uri) - 1; i >= 0; i-- {
+ if uri[i] == '/' {
+ return uri[i+1:]
+ }
+ }
+ return ""
+}
diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css
index f95f8e7..b7dcd9f 100644
--- a/web/resources/static/css/terminal.css
+++ b/web/resources/static/css/terminal.css
@@ -529,21 +529,22 @@ h1 .cursor {
.profile-flip-inner:focus-visible {
outline: 2px solid var(--peach);
outline-offset: 6px;
- border-radius: 50%;
+ border-radius: 18px;
}
.profile-flip-face {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
- border-radius: 50%;
+ border-radius: 18px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
- padding: 3px;
- background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue));
+ padding: 0;
+ border: 3px solid var(--peach);
+ background: var(--surface);
overflow: hidden;
}
.profile-flip-front .profile-avatar,
@@ -562,7 +563,7 @@ h1 .cursor {
height: 100%;
object-fit: contain;
display: block;
- border-radius: 50%;
+ border-radius: 12px;
background: var(--text);
}
.profile-flip-hint {
@@ -580,16 +581,17 @@ h1 .cursor {
.profile-avatar-wrap {
width: 128px;
height: 128px;
- border-radius: 50%;
- padding: 3px;
- background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue));
+ border-radius: 18px;
+ padding: 0;
+ border: 3px solid var(--peach);
+ background: var(--surface);
margin-bottom: 14px;
flex-shrink: 0;
}
.profile-avatar {
width: 100%;
height: 100%;
- border-radius: 50%;
+ border-radius: 14px;
object-fit: cover;
display: block;
background: var(--surface);
@@ -1028,40 +1030,66 @@ footer a:hover { color: var(--lavender); }
/* event card on the home dashboard */
.home-event-card {
- border: 1px solid var(--overlay);
- border-left: 3px solid var(--peach);
- border-radius: 6px;
- padding: 18px 20px;
- background: rgba(250, 179, 135, 0.04);
+ background: linear-gradient(135deg, rgba(250, 179, 135, 0.1) 0%, rgba(245, 194, 231, 0.05) 100%);
+ border: 1px solid rgba(250, 179, 135, 0.25);
+ border-radius: 12px;
+ padding: 14px 16px;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 6px;
+ cursor: pointer;
+ transition: transform 0.15s, border-color 0.15s;
+}
+.home-event-card:hover {
+ transform: translateY(-1px);
+ border-color: var(--peach);
}
.home-event-card.is-empty {
- border-left-color: var(--overlay);
background: transparent;
+ border-color: var(--overlay);
text-align: center;
+ cursor: default;
+}
+.home-event-card.is-empty:hover {
+ transform: none;
+ border-color: var(--overlay);
}
.home-event-label {
- font-size: 10px;
- letter-spacing: 0.12em;
+ font-size: 9px;
+ letter-spacing: 0.15em;
text-transform: uppercase;
- color: var(--lavender);
+ color: var(--peach);
+ margin-bottom: 3px;
}
.home-event-name {
margin: 0;
- font-size: 18px;
- font-weight: 600;
+ font-size: 13px;
+ font-weight: 500;
color: var(--text);
}
.home-event-meta {
- font-size: 12px;
- color: var(--subtext);
- display: flex;
- flex-wrap: wrap;
- gap: 4px 12px;
+ font-size: 10px;
+ color: var(--overlay);
+ margin: 0;
}
.home-event-meta-sep { color: var(--overlay); }
+.home-event-banner-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+.home-event-banner-body {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.home-event-arrow {
+ color: var(--peach);
+ font-size: 16px;
+ flex-shrink: 0;
+}
.home-event-ctas {
display: flex;
gap: 8px;
@@ -2224,56 +2252,271 @@ footer a:hover { color: var(--lavender); }
.summary-conn-followup { color: var(--peach); font-size: 0.7rem; }
/* ── Event Detail ───────────────────────────────── */
-.event-detail { margin: 12px 0; }
-.attendees-section { margin-top: 16px; }
-.attendees-filter { display: flex; gap: 8px; margin-bottom: 10px; }
+.event-detail { padding: 24px 20px; }
+.event-detail-header { margin-bottom: 24px; }
+.event-detail-meta {
+ font-size: 9px;
+ color: var(--overlay);
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+.event-detail-meta .live {
+ color: var(--green);
+}
+.event-detail-meta .live::before {
+ content: '●';
+ margin-right: 4px;
+}
+.event-detail-meta .status-upcoming {
+ color: var(--peach);
+}
+.event-detail-meta .status-complete {
+ color: var(--overlay);
+}
+@keyframes event-blink { 50% { opacity: 0.3; } }
+.event-detail-title {
+ font-family: 'Instrument Serif', serif;
+ font-size: 30px;
+ color: var(--text);
+ line-height: 1.1;
+ margin-bottom: 6px;
+ font-weight: 400;
+}
+.event-detail-dates {
+ font-size: 11px;
+ color: var(--subtext1);
+}
+
+/* Progress card */
+.progress-card {
+ background: var(--mantle);
+ border: 1px solid var(--surface0);
+ border-radius: 12px;
+ padding: 18px;
+ margin-bottom: 20px;
+}
+.progress-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 10px;
+}
+.progress-label {
+ font-size: 10px;
+ color: var(--lavender);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.progress-count {
+ font-family: 'Instrument Serif', serif;
+ font-size: 22px;
+ color: var(--peach);
+}
+.progress-count .of {
+ color: var(--overlay);
+ font-size: 14px;
+ font-family: 'JetBrains Mono', monospace;
+}
+.progress-bar-track {
+ height: 6px;
+ background: var(--surface0);
+ border-radius: 100px;
+ overflow: hidden;
+ position: relative;
+ margin-bottom: 16px;
+}
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--peach), var(--pink));
+ border-radius: 100px;
+ position: relative;
+ transition: width 0.4s ease;
+ animation: shimmer 3s ease-in-out infinite;
+}
+@keyframes shimmer {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.85; }
+}
+.progress-bar-fill::after {
+ content: '';
+ position: absolute;
+ right: -2px;
+ top: -3px;
+ width: 8px;
+ height: 12px;
+ background: var(--peach);
+ border-radius: 100px;
+ box-shadow: 0 0 12px var(--peach);
+}
+.progress-ticks {
+ display: flex;
+ justify-content: space-between;
+ position: relative;
+ height: 24px;
+}
+.progress-tick {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 9px;
+ color: var(--overlay);
+ letter-spacing: 0.05em;
+ position: relative;
+}
+.progress-tick::before {
+ content: '';
+ width: 1px;
+ height: 6px;
+ background: var(--surface1);
+ margin-bottom: 3px;
+}
+.progress-tick.unlocked { color: var(--green); }
+.progress-tick.unlocked::before { background: var(--green); }
+.progress-tick.next { color: var(--peach); }
+.progress-tick.next::before { background: var(--peach); }
+
+/* Rewards section */
+.rewards-section { margin-bottom: 20px; }
+.reward {
+ display: grid;
+ grid-template-columns: 36px 1fr auto;
+ gap: 14px;
+ align-items: center;
+ padding: 14px 16px;
+ margin-bottom: 8px;
+ background: var(--mantle);
+ border: 1px solid var(--surface0);
+ border-radius: 10px;
+ transition: border-color 0.15s;
+}
+.reward.unlocked { border-color: rgba(166, 227, 161, 0.3); }
+.reward.locked { opacity: 0.55; }
+.reward-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ background: var(--surface0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+}
+.reward.unlocked .reward-icon {
+ background: rgba(166, 227, 161, 0.15);
+ color: var(--green);
+}
+.reward-body { min-width: 0; }
+.reward-title {
+ font-size: 12px;
+ color: var(--text);
+ font-weight: 500;
+ margin-bottom: 2px;
+}
+.reward-desc {
+ font-size: 10px;
+ color: var(--overlay);
+ line-height: 1.4;
+}
+.reward-status {
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--overlay);
+ padding: 3px 7px;
+ border-radius: 3px;
+ background: var(--surface0);
+}
+.reward.unlocked .reward-status {
+ color: var(--green);
+ background: rgba(166, 227, 161, 0.1);
+}
+
+/* Attendees */
+.attendees-section { margin-top: 8px; }
+.attendees-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px dashed var(--surface1);
+}
+.attendees-header h2,
+.attendees-header .summary-section-label {
+ font-size: 11px;
+ color: var(--lavender);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ font-weight: 500;
+ margin: 0;
+}
+.attendees-filter {
+ display: flex;
+ gap: 4px;
+ font-size: 9px;
+}
+.attendees-filter .btn {
+ font-size: 9px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
.attendees-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
- gap: 8px;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 10px;
}
.attendee-card {
display: flex;
flex-direction: column;
align-items: center;
- gap: 2px;
+ gap: 4px;
text-decoration: none;
+ color: inherit;
+ transition: transform 0.15s;
}
+.attendee-card:hover { transform: translateY(-2px); }
.attendee-avatar {
- width: 42px;
- height: 42px;
+ width: 48px;
+ height: 48px;
border-radius: 50%;
object-fit: cover;
- border: 2px solid transparent;
+ border: 1.5px solid transparent;
}
.attendee-avatar.connected { border-color: var(--peach); }
-.attendee-avatar.not-connected { filter: grayscale(100%); opacity: 0.6; }
+.attendee-avatar.not-connected { filter: grayscale(100%); opacity: 0.6; border-color: var(--surface1); }
.attendee-name {
- font-size: 0.55rem;
+ font-size: 9px;
color: var(--subtext1);
text-align: center;
- max-width: 52px;
+ max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ line-height: 1.2;
}
-/* ── Unlock Teasers ─────────────────────────────── */
-.unlock-teaser {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 14px;
- margin: 10px 0;
- background: var(--surface0);
- border-radius: 6px;
- border: 1px dashed var(--surface1);
+/* Filter tabs */
+.filter-tab {
+ background: none;
+ border: 1px solid var(--surface0);
+ color: var(--overlay);
+ font-family: inherit;
+ font-size: 9px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ text-decoration: none;
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
}
-.unlock-icon { font-size: 1rem; }
-.unlock-text {
- font-size: 0.75rem;
- color: var(--subtext0);
- font-style: italic;
+.filter-tab.active {
+ color: var(--peach);
+ border-color: var(--peach);
+ background: rgba(250, 179, 135, 0.08);
}
/* ── Interest Matching ──────────────────────────── */
@@ -2353,3 +2596,541 @@ footer a:hover { color: var(--lavender); }
.admin-badge-info { flex: 1; }
.admin-badge-name { font-weight: 600; font-size: 0.85rem; }
.admin-badge-meta { font-size: 0.7rem; color: var(--subtext0); }
+
+/* ── Connect URL Debug Helper ────────────────────── */
+.connect-url-debug {
+ margin-top: 10px;
+ padding: 8px 10px;
+ background: var(--crust);
+ border: 1px dashed var(--overlay);
+ border-radius: 6px;
+ font-size: 0.7rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+.connect-url-label {
+ color: var(--muted);
+ font-size: 0.65rem;
+ letter-spacing: 0.05em;
+}
+.connect-url-value {
+ color: var(--peach);
+ word-break: break-all;
+ font-size: 0.65rem;
+ flex: 1;
+ min-width: 0;
+}
+
+/* ── Connections List (redesigned) ───────────────── */
+.connections-content { padding: 24px 20px; }
+.connections-header { margin-bottom: 18px; }
+.connections-title {
+ font-family: 'Instrument Serif', serif;
+ font-size: 28px;
+ color: var(--text);
+ font-weight: 400;
+ margin-bottom: 4px;
+}
+.connections-title em { color: var(--peach); }
+.connections-sub {
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.05em;
+}
+
+.search-bar {
+ position: relative;
+ margin-bottom: 14px;
+}
+.search-input {
+ width: 100%;
+ background: var(--mantle);
+ border: 1px solid var(--overlay);
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ padding: 11px 14px 11px 36px;
+ border-radius: 8px;
+}
+.search-input:focus { outline: none; border-color: var(--peach); }
+.search-input::placeholder { color: var(--muted); }
+.search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--muted);
+ font-size: 13px;
+ pointer-events: none;
+}
+
+.filter-row {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 18px;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ -webkit-overflow-scrolling: touch;
+}
+.filter-row::-webkit-scrollbar { display: none; }
+.filter-chip {
+ background: none;
+ border: 1px solid var(--surface);
+ color: var(--muted);
+ font-family: inherit;
+ font-size: 10px;
+ padding: 6px 12px;
+ border-radius: 100px;
+ cursor: pointer;
+ white-space: nowrap;
+ letter-spacing: 0.04em;
+ transition: all 0.15s;
+}
+.filter-chip:hover { border-color: var(--overlay); color: var(--subtext); }
+.filter-chip.active {
+ color: var(--peach);
+ border-color: var(--peach);
+ background: rgba(250, 179, 135, 0.08);
+}
+.filter-chip .count {
+ opacity: 0.65;
+ margin-left: 4px;
+}
+
+.connection-row {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 12px 4px;
+ border-bottom: 1px solid rgba(69, 71, 90, 0.4);
+ cursor: pointer;
+ transition: background 0.12s;
+ text-decoration: none;
+ color: inherit;
+}
+.connection-row:hover { background: rgba(180, 190, 254, 0.04); }
+.connection-row:last-child { border-bottom: none; }
+
+.conn-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: 'Instrument Serif', serif;
+ font-size: 18px;
+ color: var(--crust);
+ background: linear-gradient(135deg, var(--blue), var(--lavender));
+ overflow: hidden;
+}
+.conn-avatar-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 50%;
+}
+.conn-info { min-width: 0; }
+.conn-name {
+ font-size: 13px;
+ color: var(--text);
+ font-weight: 500;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.conn-handle {
+ font-size: 10px;
+ color: var(--muted);
+ margin-bottom: 4px;
+}
+.conn-meta {
+ font-size: 9px;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.conn-meta .event-ref { color: var(--lavender); }
+.conn-meta .followup-flag { color: var(--peach); }
+.conn-meta .followup-flag::before {
+ content: '◆';
+ margin-right: 3px;
+}
+.conn-arrow {
+ color: var(--overlay);
+ font-size: 14px;
+}
+.conn-empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+/* Meta row (shared) */
+.meta-row {
+ font-size: 10px;
+ color: var(--muted);
+ letter-spacing: 0.05em;
+ text-align: center;
+ margin-top: 8px;
+}
+.meta-row .accent { color: var(--subtext); }
+
+/* ── Events List (redesigned) ────────────────────── */
+.events-content { padding: 24px 20px; }
+.events-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 4px;
+}
+.events-title {
+ font-family: 'Instrument Serif', serif;
+ font-size: 28px;
+ color: var(--text);
+ font-weight: 400;
+}
+.events-title em { color: var(--peach); }
+.events-sub {
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.05em;
+ margin-bottom: 24px;
+}
+.events-section { margin-bottom: 28px; }
+.events-section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px dashed var(--overlay);
+}
+.events-section-title {
+ font-size: 11px;
+ color: var(--lavender);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ font-weight: 500;
+}
+.events-section-count {
+ font-size: 10px;
+ color: var(--muted);
+}
+
+.event-card {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ background: var(--mantle);
+ border: 1px solid var(--surface);
+ border-radius: 10px;
+ padding: 14px 16px;
+ margin-bottom: 10px;
+ cursor: pointer;
+ transition: border-color 0.15s, transform 0.15s;
+}
+.event-card:hover {
+ border-color: var(--lavender);
+ transform: translateY(-1px);
+}
+.event-card.live {
+ border-color: rgba(250, 179, 135, 0.35);
+ background: linear-gradient(135deg, rgba(250, 179, 135, 0.05) 0%, rgba(245, 194, 231, 0.02) 100%);
+}
+.event-card.live:hover { border-color: var(--peach); }
+
+.event-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 6px;
+ gap: 8px;
+}
+.event-card-name {
+ font-size: 14px;
+ color: var(--text);
+ font-weight: 500;
+}
+.event-card-status {
+ font-size: 9px;
+ padding: 3px 7px;
+ border-radius: 3px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ flex-shrink: 0;
+}
+.event-card-status.live {
+ color: var(--peach);
+ background: rgba(250, 179, 135, 0.12);
+}
+.event-card-status.live::before {
+ content: '●';
+ margin-right: 4px;
+}
+.event-card-status.past {
+ color: var(--muted);
+ background: var(--surface);
+}
+.event-card-status.upcoming {
+ color: var(--blue);
+ background: rgba(137, 180, 250, 0.12);
+}
+
+.event-card-meta {
+ font-size: 10px;
+ color: var(--muted);
+ margin-bottom: 10px;
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.event-card-meta .sep { color: var(--overlay); }
+
+.event-card-stats {
+ display: flex;
+ gap: 18px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(69, 71, 90, 0.4);
+}
+.event-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.event-stat-num {
+ font-family: 'Instrument Serif', serif;
+ font-size: 16px;
+ color: var(--text);
+ line-height: 1;
+}
+.event-stat-num.accent { color: var(--peach); }
+.event-stat-label {
+ font-size: 9px;
+ color: var(--muted);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.create-event-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ background: none;
+ border: 1px dashed var(--overlay);
+ color: var(--muted);
+ font-family: inherit;
+ font-size: 12px;
+ padding: 16px;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.create-event-btn:hover {
+ border-color: var(--peach);
+ color: var(--peach);
+ border-style: solid;
+}
+.create-event-btn.disabled {
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+.create-event-btn.disabled:hover {
+ border-color: var(--overlay);
+ color: var(--muted);
+ border-style: dashed;
+}
+.create-event-hint {
+ font-size: 9px;
+ color: var(--muted);
+ text-align: center;
+ margin-top: 8px;
+ letter-spacing: 0.04em;
+}
+
+/* ── Settings (redesigned) ───────────────────────── */
+.settings-content { padding: 24px 20px 32px; }
+.settings-title {
+ font-family: 'Instrument Serif', serif;
+ font-size: 28px;
+ color: var(--text);
+ font-weight: 400;
+ margin-bottom: 4px;
+}
+.settings-title em { color: var(--peach); }
+.settings-sub {
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.05em;
+ margin-bottom: 24px;
+}
+
+.settings-account-card {
+ background: var(--mantle);
+ border: 1px solid var(--surface);
+ border-radius: 12px;
+ padding: 16px;
+ margin-bottom: 24px;
+ display: grid;
+ grid-template-columns: 52px 1fr;
+ gap: 14px;
+ align-items: center;
+}
+.settings-account-avatar {
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--peach), var(--pink));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: 'Instrument Serif', serif;
+ font-size: 24px;
+ color: var(--crust);
+ text-transform: uppercase;
+}
+.settings-account-name {
+ font-size: 15px;
+ color: var(--text);
+ font-weight: 500;
+ margin-bottom: 2px;
+}
+.settings-account-handle {
+ font-size: 11px;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+.settings-account-handle .at { color: var(--overlay); }
+.settings-account-pds {
+ font-size: 9px;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+}
+.settings-account-pds .pds-name { color: var(--lavender); }
+
+.settings-group { margin-bottom: 24px; }
+.settings-group-label {
+ font-size: 10px;
+ color: var(--lavender);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ margin-bottom: 10px;
+ padding-left: 4px;
+}
+.settings-list {
+ background: var(--mantle);
+ border: 1px solid var(--surface);
+ border-radius: 10px;
+ overflow: hidden;
+}
+.settings-row {
+ display: grid;
+ grid-template-columns: 32px 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 13px 14px;
+ border-bottom: 1px solid rgba(69, 71, 90, 0.4);
+ cursor: pointer;
+ transition: background 0.12s;
+ text-decoration: none;
+ color: inherit;
+}
+.settings-row:last-child { border-bottom: none; }
+.settings-row:hover { background: rgba(180, 190, 254, 0.04); }
+.settings-row.danger:hover { background: rgba(243, 139, 168, 0.06); }
+.settings-row-icon {
+ color: var(--lavender);
+ font-size: 14px;
+ text-align: center;
+}
+.settings-row.danger .settings-row-icon { color: var(--red); }
+.settings-row-body { min-width: 0; }
+.settings-row-title {
+ font-size: 13px;
+ color: var(--text);
+ margin-bottom: 1px;
+}
+.settings-row.danger .settings-row-title { color: var(--red); }
+.settings-row-desc {
+ font-size: 10px;
+ color: var(--muted);
+ line-height: 1.4;
+}
+.settings-row-value {
+ font-size: 11px;
+ color: var(--muted);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.settings-row-value .arrow {
+ color: var(--overlay);
+ font-size: 12px;
+}
+
+/* Toggle switch */
+.toggle {
+ width: 38px;
+ height: 22px;
+ border-radius: 100px;
+ background: var(--surface);
+ border: 1px solid var(--overlay);
+ position: relative;
+ cursor: pointer;
+ transition: background 0.2s;
+ flex-shrink: 0;
+}
+.toggle::after {
+ content: '';
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--muted);
+ top: 2px;
+ left: 2px;
+ transition: all 0.2s;
+}
+.toggle.on {
+ background: rgba(250, 179, 135, 0.25);
+ border-color: var(--peach);
+}
+.toggle.on::after {
+ background: var(--peach);
+ left: 17px;
+}
+
+.settings-footer {
+ text-align: center;
+ margin-top: 32px;
+ padding-top: 20px;
+ border-top: 1px dashed var(--surface);
+ font-size: 10px;
+ color: var(--muted);
+ line-height: 1.6;
+}
+.settings-footer a {
+ color: var(--lavender);
+ text-decoration: none;
+}
+.settings-footer .version {
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--overlay);
+ margin-top: 6px;
+}
+
+.events-empty {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--muted);
+ font-size: 12px;
+}