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.") {
+
+ ● 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, "
")
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.
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.