diff --git a/features/index/handlers.go b/features/index/handlers.go index 5e1b6c5..793291a 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -1,25 +1,204 @@ +// Package index hosts the root "/" landing page handler. +// +// The page renders two distinct flavors: +// +// - Guest mode: the marketing/onboarding pitch shown to logged-out +// visitors. It's a pure templ render with no DB or session lookups. +// +// - Authed home: a small dashboard for signed-in users — display name, +// avatar, a flip-card QR for their connect URL, and a "currently checked +// into" card. Every enrichment step is soft and degrades to a sparser +// render rather than blocking the page, so a Bluesky outage or empty +// event cache never 500s the home page. +// +// We intentionally do NOT resume the OAuth session here. The home page +// is public — a stale or expired cookie should not force a sign-out +// redirect from the landing page. We only need the DID claim from the +// cookie to enrich the view. package index import ( "database/sql" + "log/slog" "net/http" + "time" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/features/auth" "atmoquest/features/index/pages" + "atmoquest/internal/checkin" + "atmoquest/internal/event" + "atmoquest/internal/profile" ) -// Handlers holds dependencies the index feature needs. Right now that's -// just a DB handle; threading it through unblocks the upcoming -// "currently checked in" home-page query without churning signatures later. +// Handlers holds the dependencies the index feature needs: +// - DB for the "currently checked in" lookup and the PDS-from-session +// fallback; +// - Auth for the soft session cookie inspection; +// - Directory to resolve a DID to its current atproto handle for display +// (defaults to identity.DefaultDirectory which wraps a CacheDirectory). type Handlers struct { - DB *sql.DB + DB *sql.DB + Auth *auth.Handlers + Directory identity.Directory } -func NewHandlers(conn *sql.DB) *Handlers { - return &Handlers{DB: conn} +// NewHandlers wires the index feature. A nil authH is unusual but supported +// — the handler degrades to guest-only rendering in that case so tests can +// exercise the marketing page without setting up a session manager. +func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { + return &Handlers{ + DB: conn, + Auth: authH, + Directory: identity.DefaultDirectory(), + } } +// IndexPage renders the landing page. Behavior: +// +// - Logged-out (or no auth wired): renders the marketing landing. +// - Logged-in (session cookie present): renders the authed dashboard with +// handle, display name, avatar, QR, and current-event card. Each +// enrichment is independent and soft-fails. func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { - if err := pages.IndexPage().Render(r.Context(), w); err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + view := pages.IndexView{} + + // Soft session inspection — no ResumeSession. If we can read a DID + // from the cookie, treat the user as logged-in for rendering + // purposes. If something downstream needs a fresh OAuth session + // it'll force a re-sign on its own. + if h.Auth != nil && h.Auth.Sessions != nil { + didStr, sid := h.Auth.Sessions.Get(r) + if didStr != "" && sid != "" { + view.LoggedIn = true + view.Handle = h.resolveHandleForDisplay(r, didStr) + + if did, err := syntax.ParseDID(didStr); err == nil { + h.fillAuthedView(r, did, &view) + } + } + } + + if err := pages.IndexPage(view).Render(r.Context(), w); err != nil { + slog.Error("render index", "err", err) + } +} + +// fillAuthedView enriches the view for a signed-in user. Each step is +// independent and soft-fails — failures fall through to a sparser render +// rather than blocking the page. +func (h *Handlers) fillAuthedView(r *http.Request, did syntax.DID, view *pages.IndexView) { + // Display name + avatar come from the public app.bsky.actor.profile + // record, so we don't need to resume the OAuth session here. We do + // need a PDS host though — same trick the connect handler uses. + pds := h.lookupPDSForDID(r, did) + if pds != "" { + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err == nil && bsky != nil { + view.DisplayName = bsky.DisplayName + if bsky.Avatar != nil { + view.AvatarURL = profile.AvatarURL(pds, did, bsky.Avatar.CID()) + } + } else if err != nil { + slog.Debug("index: bluesky profile fetch", "did", did.String(), "err", err) + } + } + + // Same QR endpoint the profile page uses — auth-gated, scoped to the + // signed-in user. + view.QRURL = "/profile/qr.svg" + + // Current event: most recent ongoing check-in for this user. If the + // user has no check-ins, no events, or no rows match, the card + // renders an empty state instead. + if h.DB == nil { + return + } + evURI, ok, err := checkin.Current(r.Context(), h.DB, did) + if err != nil { + slog.Debug("index: checkin.Current", "did", did.String(), "err", err) + return + } + if !ok { + return + } + rec, err := event.Get(r.Context(), h.DB, evURI) + if err != nil { + slog.Debug("index: event.Get", "uri", evURI, "err", err) + return + } + view.CurrentEvent = &pages.IndexEventCard{ + Name: rec.Name, + Location: rec.Location, + StartTime: formatEventTime(rec.StartTime), + EndTime: formatEventTime(rec.EndTime), + EventURI: rec.URI, + } +} + +// formatEventTime renders an event time for the home card. We use the +// server's local timezone for now (v1 events are CascadiaJS, Pacific). A +// future enhancement could send the user's TZ from the browser. +func formatEventTime(t time.Time) string { + return t.Format("Mon Jan 2 · 3:04 PM MST") +} + +// resolveHandleForDisplay returns a user-facing label for the signed-in DID. +// Prefers the verified atproto handle; falls back to a short DID form if +// the directory lookup fails (network blip, slow resolver) so the page +// still renders something useful. Errors here are non-fatal — the worst +// case is we show the DID instead of the handle. +func (h *Handlers) resolveHandleForDisplay(r *http.Request, didStr string) string { + did, err := syntax.ParseDID(didStr) + if err != nil { + return shortDIDLabel(didStr) + } + if h.Directory == nil { + return shortDIDLabel(didStr) + } + ident, err := h.Directory.LookupDID(r.Context(), did) + if err != nil || ident == nil { + slog.Debug("index: handle lookup failed", "did", didStr, "err", err) + return shortDIDLabel(didStr) + } + hStr := ident.Handle.String() + if hStr == "" || hStr == "handle.invalid" { + return shortDIDLabel(didStr) + } + return hStr +} + +// shortDIDLabel collapses a full DID into a compact form for display when +// the handle isn't available +// (`did:plc:abcdefghij1234567890` → `did:plc:abcde…`). +func shortDIDLabel(did string) string { + if len(did) <= 18 { + return did + } + return did[:18] + "…" +} + +// lookupPDSForDID returns the PDS host for the given DID, using whatever we +// can find without a network roundtrip. v1: if we have a session row for +// the DID, use its HostURL; otherwise fall back to bsky.social. +// +// Mirrors features/connect/handlers.go — a follow-up could centralize this +// behind a small internal helper. +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" } diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ index 88c4764..2ef0b3e 100644 --- a/features/index/pages/index.templ +++ b/features/index/pages/index.templ @@ -4,10 +4,158 @@ import ( "atmoquest/features/common/layouts" ) -// IndexPage renders the guest (logged-out) marketing landing. Once we wire -// OAuth + session into the new repo this will branch on session state and -// render an authed dashboard instead. -templ IndexPage() { +// IndexView passes session-aware values into the landing page. When a user +// is signed in, Handle is their verified atproto handle (or a short DID +// fallback when handle resolution failed). When not signed in, all fields +// are empty and the page falls back to "guest mode" copy. +type IndexView struct { + LoggedIn bool + Handle string + + // --- Fields below only populated when LoggedIn=true --- + + // DisplayName comes from the user's app.bsky.actor.profile (read-only + // here; the atmo.quest profile editor doesn't touch it). + DisplayName string + AvatarURL string + // QRURL serves the SVG QR for this user's connect link, shown on the + // back of the flip card. + QRURL string + + // CurrentEvent is the user's currently-ongoing check-in target, if any. + // Nil pointer means "no active event right now" — the home page renders + // an empty-state card in that case. + CurrentEvent *IndexEventCard +} + +// IndexEventCard is the small projection of an event we render on the home +// page. Kept separate from the storage-layer Record type so the view stays +// loosely coupled and we don't accidentally leak organizer DIDs etc. +type IndexEventCard struct { + Name string + Location string + StartTime string // pre-formatted by the handler + EndTime string + // EventURI lets the card link to a future event-detail page; we render + // it as a tiny pill so users can verify the at-uri matches what they + // expect. + EventURI string +} + +// IndexPage renders either the authed dashboard or the guest landing +// depending on v.LoggedIn. +templ IndexPage(v IndexView) { + if v.LoggedIn { + @indexAuthed(v) + } else { + @indexGuest() + } +} + +// indexAuthed renders the post-login dashboard: flip card (avatar / QR) + +// current-event card. We deliberately keep it lighter-weight than the +// marketing landing so it feels like an "app home", not a re-hit of the +// pitch. +templ indexAuthed(v IndexView) { + @layouts.Base("atmo.quest — your quest", "Your atmo.quest home.") { +
+
+
+
+
+
~/atmo.quest — home
+
+
+
+ you{ "@" }atmo.quest:~$ + whoami --short +
+ +
+
+
+ +

▸ tap your photo to share a QR · tap to flip back

+
+
+ if v.DisplayName != "" { +

{ v.DisplayName }

+ } else { +

(no display name)

+ } + if v.Handle != "" { +

{ "@" + v.Handle }

+ } + +
+
+ + @indexEventCard(v.CurrentEvent) +
+
+
+
+ ● ATProto · OAuth ready · signed in as + if v.Handle != "" { + { "@" + v.Handle } + } +
+
--:--:--
+
+
+ + + + } +} + +// indexEventCard renders the "what are you currently checked into" card. We +// render it whether or not there's an active event so the slot stays stable +// as users navigate. +templ indexEventCard(ev *IndexEventCard) { + +} + +// indexGuest is the original marketing landing page. +templ indexGuest() { @layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.") {
@@ -98,3 +246,11 @@ templ IndexPage() { } } + +// indexAvatarAlt builds a friendly alt string for the user's avatar. +func indexAvatarAlt(displayName string) string { + if displayName == "" { + return "your profile photo" + } + return displayName + " — profile photo" +} diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index be2f3fc..8187103 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -12,10 +12,47 @@ import ( "atmoquest/features/common/layouts" ) -// IndexPage renders the guest (logged-out) marketing landing. Once we wire -// OAuth + session into the new repo this will branch on session state and -// render an authed dashboard instead. -func IndexPage() templ.Component { +// IndexView passes session-aware values into the landing page. When a user +// is signed in, Handle is their verified atproto handle (or a short DID +// fallback when handle resolution failed). When not signed in, all fields +// are empty and the page falls back to "guest mode" copy. +type IndexView struct { + LoggedIn bool + Handle string + + // --- Fields below only populated when LoggedIn=true --- + + // DisplayName comes from the user's app.bsky.actor.profile (read-only + // here; the atmo.quest profile editor doesn't touch it). + DisplayName string + AvatarURL string + // QRURL serves the SVG QR for this user's connect link, shown on the + // back of the flip card. + QRURL string + + // CurrentEvent is the user's currently-ongoing check-in target, if any. + // Nil pointer means "no active event right now" — the home page renders + // an empty-state card in that case. + CurrentEvent *IndexEventCard +} + +// IndexEventCard is the small projection of an event we render on the home +// page. Kept separate from the storage-layer Record type so the view stays +// loosely coupled and we don't accidentally leak organizer DIDs etc. +type IndexEventCard struct { + Name string + Location string + StartTime string // pre-formatted by the handler + EndTime string + // EventURI lets the card link to a future event-detail page; we render + // it as a tiny pill so users can verify the at-uri matches what they + // expect. + EventURI string +} + +// IndexPage renders either the authed dashboard or the guest landing +// depending on v.LoggedIn. +func IndexPage(v IndexView) 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 { @@ -36,7 +73,47 @@ func IndexPage() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + if v.LoggedIn { + templ_7745c5c3_Err = indexAuthed(v).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = indexGuest().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// indexAuthed renders the post-login dashboard: flip card (avatar / QR) + +// current-event card. We deliberately keep it lighter-weight than the +// marketing landing so it feels like an "app home", not a re-hit of the +// pitch. +func indexAuthed(v IndexView) 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_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var3 := 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 { @@ -48,52 +125,168 @@ func IndexPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — zsh
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — home
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 21, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 70, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + _, 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, 2, "cascadiajs:~$ whoami
a person at a conference. you just met someone interesting and now their handle is gone.
init: v0.1 · CascadiaJS 2026

atmo.quest 

An event companion ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ whoami --short

▸ tap your photo to share a QR · tap to flip back

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.

● ATProto · OAuth ready · guest mode enabled
--:--:--
quest_log.md

Side Quests ") + if v.DisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 95, Col: 45} + } + _, 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, 12, "

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

(no display name)

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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, 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: 100, Col: 47} + } + _, 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("// available") + templ_7745c5c3_Err = indexEventCard(v.CurrentEvent).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 55, Col: 57} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
● ATProto · OAuth ready · signed in as ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+1 badge first connect
◆
Check in to CascadiaJS
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
unlocks with first connection
◆
Help unlock the conference rewards
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
group quest see /unlocks
built on atproto · open source · made by PDX ATProto
") + if v.Handle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, 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, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
--:--:--
built on atproto · open source · made by PDX ATProto
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("atmo.quest — your quest", "Your atmo.quest home.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -101,4 +294,208 @@ func IndexPage() templ.Component { }) } +// indexEventCard renders the "what are you currently checked into" card. We +// render it whether or not there's an active event so the slot stays stable +// as users navigate. +func indexEventCard(ev *IndexEventCard) 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, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// indexGuest is the original marketing landing page. +func indexGuest() 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_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var17 := 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, 31, "
~/atmo.quest — zsh
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, 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} + } + _, 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, 32, "cascadiajs:~$ whoami
a person at a conference. you just met someone interesting and now their handle is gone.
init: v0.1 · CascadiaJS 2026

atmo.quest 

An event companion ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, 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} + } + _, 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, 33, " the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.

● ATProto · OAuth ready · guest mode enabled
--:--:--
quest_log.md

Side Quests ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+1 badge first connect
◆
Check in to CascadiaJS
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
unlocks with first connection
◆
Help unlock the conference rewards
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
group quest see /unlocks
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// indexAvatarAlt builds a friendly alt string for the user's avatar. +func indexAvatarAlt(displayName string) string { + if displayName == "" { + return "your profile photo" + } + return displayName + " — profile photo" +} + var _ = templruntime.GeneratedTemplate diff --git a/features/index/routes.go b/features/index/routes.go index c1e42ef..f9b9274 100644 --- a/features/index/routes.go +++ b/features/index/routes.go @@ -4,13 +4,15 @@ import ( "database/sql" "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" ) -// SetupRoutes wires the index feature's routes. The DB handle is held by the -// handler struct so future endpoints (e.g. "currently checked into" lookups) -// can query it without changing the registration site. -func SetupRoutes(router chi.Router, conn *sql.DB) { - handlers := NewHandlers(conn) +// SetupRoutes wires the index feature's routes. The Auth handle lets the +// index page do a soft session lookup (no OAuth resume) to switch between +// guest landing and authed dashboard rendering. +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) { + handlers := NewHandlers(conn, authH) router.Get("/", handlers.IndexPage) } diff --git a/router/router.go b/router/router.go index 1b0b74d..69d4b13 100644 --- a/router/router.go +++ b/router/router.go @@ -55,7 +55,7 @@ func SetupRoutes( connect.SetupRoutes(router, conn, authH, connQueue) event.SetupRoutes(router, conn, authH) admin.SetupRoutes(router, conn, authH, signer) - index.SetupRoutes(router, conn) + index.SetupRoutes(router, conn, authH) return nil }