From fcffc587ace125570836210fbfbe3ef08d896983 Mon Sep 17 00:00:00 2001 From: pdewey Date: Sun, 11 Jan 2026 14:17:15 -0500 Subject: [PATCH] feat: user profile --- internal/bff/render.go | 33 +++++++++++++--- internal/handlers/handlers.go | 73 +++++++++++++++++++++++++++++++---- templates/layout.tmpl | 66 +++++++++++++++++++++++++++++-- 3 files changed, 155 insertions(+), 17 deletions(-) diff --git a/internal/bff/render.go b/internal/bff/render.go index 075b4c7..34d1a35 100644 --- a/internal/bff/render.go +++ b/internal/bff/render.go @@ -101,6 +101,13 @@ func parsePartialTemplate() (*template.Template, error) { return t, nil } +// UserProfile contains user profile data for header display +type UserProfile struct { + Handle string + DisplayName string + Avatar string +} + // PageData contains data for rendering pages type PageData struct { Title string @@ -113,6 +120,7 @@ type PageData struct { FeedItems []*feed.FeedItem IsAuthenticated bool UserDID string + UserProfile *UserProfile } // BrewData wraps a brew with pre-serialized JSON for pours @@ -138,8 +146,14 @@ func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { return t.ExecuteTemplate(w, "layout", data) } +// RenderTemplateWithProfile renders a template with layout and user profile +func RenderTemplateWithProfile(w http.ResponseWriter, tmpl string, data *PageData, userProfile *UserProfile) error { + data.UserProfile = userProfile + return RenderTemplate(w, tmpl, data) +} + // RenderHome renders the home page -func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { +func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, userProfile *UserProfile, feedItems []*feed.FeedItem) error { t, err := parsePageTemplate("home.tmpl") if err != nil { return err @@ -148,13 +162,14 @@ func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, fee Title: "Home", IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, FeedItems: feedItems, } return t.ExecuteTemplate(w, "layout", data) } // RenderBrewList renders the brew list page -func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error { +func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile) error { t, err := parsePageTemplate("brew_list.tmpl") if err != nil { return err @@ -174,12 +189,13 @@ func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated Brews: brewList, IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, } return t.ExecuteTemplate(w, "layout", data) } // RenderBrewForm renders the brew form page -func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, brew *models.Brew, isAuthenticated bool, userDID string) error { +func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, brew *models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile) error { t, err := parsePageTemplate("brew_form.tmpl") if err != nil { return err @@ -204,12 +220,13 @@ func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*mod Brew: brewData, IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, } return t.ExecuteTemplate(w, "layout", data) } // RenderManage renders the manage page -func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error { +func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string, userProfile *UserProfile) error { t, err := parsePageTemplate("manage.tmpl") if err != nil { return err @@ -222,6 +239,7 @@ func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*model Brewers: brewers, IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, } return t.ExecuteTemplate(w, "layout", data) } @@ -292,10 +310,11 @@ type ProfilePageData struct { Brewers []*models.Brewer IsAuthenticated bool UserDID string + UserProfile *UserProfile } // RenderProfile renders a user's public profile page -func RenderProfile(w http.ResponseWriter, profile *atproto.Profile, brews []*models.Brew, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error { +func RenderProfile(w http.ResponseWriter, profile *atproto.Profile, brews []*models.Brew, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string, userProfile *UserProfile) error { t, err := parsePageTemplate("profile.tmpl") if err != nil { return err @@ -316,12 +335,13 @@ func RenderProfile(w http.ResponseWriter, profile *atproto.Profile, brews []*mod Brewers: brewers, IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, } return t.ExecuteTemplate(w, "layout", data) } // Render404 renders the 404 not found page -func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string) error { +func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string, userProfile *UserProfile) error { t, err := parsePageTemplate("404.tmpl") if err != nil { return err @@ -330,6 +350,7 @@ func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string) erro Title: "Page Not Found", IsAuthenticated: isAuthenticated, UserDID: userDID, + UserProfile: userProfile, } w.WriteHeader(http.StatusNotFound) return t.ExecuteTemplate(w, "layout", data) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 5ababf3..99577f5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -81,6 +81,33 @@ func validateOptionalRKey(rkey, fieldName string) string { return "" } +// getUserProfile fetches the profile for an authenticated user. +// Returns nil if unable to fetch profile (non-fatal error). +func (h *Handler) getUserProfile(ctx context.Context, did string) *bff.UserProfile { + if did == "" { + return nil + } + + publicClient := atproto.NewPublicClient() + profile, err := publicClient.GetProfile(ctx, did) + if err != nil { + log.Warn().Err(err).Str("did", did).Msg("Failed to fetch user profile for header") + return nil + } + + userProfile := &bff.UserProfile{ + Handle: profile.Handle, + } + if profile.DisplayName != nil { + userProfile.DisplayName = *profile.DisplayName + } + if profile.Avatar != nil { + userProfile.Avatar = *profile.Avatar + } + + return userProfile +} + // getAtprotoStore creates a user-scoped atproto store from the request context. // Returns the store and true if authenticated, or nil and false if not authenticated. func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { @@ -113,8 +140,14 @@ func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil && didStr != "" + // Fetch user profile for authenticated users + var userProfile *bff.UserProfile + if isAuthenticated { + userProfile = h.getUserProfile(r.Context(), didStr) + } + // Don't fetch feed items here - let them load async via HTMX - if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil { + if err := bff.RenderHome(w, isAuthenticated, didStr, userProfile, nil); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render home page") } @@ -221,9 +254,10 @@ func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { } didStr, _ := atproto.GetAuthenticatedDID(r.Context()) + userProfile := h.getUserProfile(r.Context(), didStr) // Don't fetch brews here - let them load async via HTMX - if err := bff.RenderBrewList(w, nil, authenticated, didStr); err != nil { + if err := bff.RenderBrewList(w, nil, authenticated, didStr, userProfile); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render brew list page") } @@ -239,10 +273,11 @@ func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) { } didStr, _ := atproto.GetAuthenticatedDID(r.Context()) + userProfile := h.getUserProfile(r.Context(), didStr) // Don't fetch data from PDS - client will populate dropdowns from cache // This makes the page load much faster - if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr); err != nil { + if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render brew form") } @@ -263,6 +298,7 @@ func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) { } didStr, _ := atproto.GetAuthenticatedDID(r.Context()) + userProfile := h.getUserProfile(r.Context(), didStr) brew, err := store.GetBrewByRKey(r.Context(), rkey) if err != nil { @@ -273,7 +309,7 @@ func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) { // Don't fetch dropdown data from PDS - client will populate from cache // This makes the page load much faster - if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr); err != nil { + if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr, userProfile); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render brew edit form") } @@ -730,9 +766,10 @@ func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) { } didStr, _ := atproto.GetAuthenticatedDID(r.Context()) + userProfile := h.getUserProfile(r.Context(), didStr) // Don't fetch data here - let it load async via HTMX - if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr); err != nil { + if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render manage page") } @@ -1084,10 +1121,16 @@ func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil && didStr != "" + var userProfile *bff.UserProfile + if isAuthenticated { + userProfile = h.getUserProfile(r.Context(), didStr) + } + data := &bff.PageData{ Title: "About", IsAuthenticated: isAuthenticated, UserDID: didStr, + UserProfile: userProfile, } if err := bff.RenderTemplate(w, "about.tmpl", data); err != nil { @@ -1102,10 +1145,16 @@ func (h *Handler) HandleTerms(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil && didStr != "" + var userProfile *bff.UserProfile + if isAuthenticated { + userProfile = h.getUserProfile(r.Context(), didStr) + } + data := &bff.PageData{ Title: "Terms of Service", IsAuthenticated: isAuthenticated, UserDID: didStr, + UserProfile: userProfile, } if err := bff.RenderTemplate(w, "terms.tmpl", data); err != nil { @@ -1360,8 +1409,13 @@ func (h *Handler) HandleProfile(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(ctx) isAuthenticated := err == nil && didStr != "" + var userProfile *bff.UserProfile + if isAuthenticated { + userProfile = h.getUserProfile(ctx, didStr) + } + // Render profile page - if err := bff.RenderProfile(w, profile, brews, beans, roasters, grinders, brewers, isAuthenticated, didStr); err != nil { + if err := bff.RenderProfile(w, profile, brews, beans, roasters, grinders, brewers, isAuthenticated, didStr, userProfile); err != nil { http.Error(w, "Failed to render page", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render profile page") } @@ -1373,7 +1427,12 @@ func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil && didStr != "" - if err := bff.Render404(w, isAuthenticated, didStr); err != nil { + var userProfile *bff.UserProfile + if isAuthenticated { + userProfile = h.getUserProfile(r.Context(), didStr) + } + + if err := bff.Render404(w, isAuthenticated, didStr, userProfile); err != nil { http.Error(w, "Page not found", http.StatusNotFound) log.Error().Err(err).Msg("Failed to render 404 page") } diff --git a/templates/layout.tmpl b/templates/layout.tmpl index f01a024..50b4676 100644 --- a/templates/layout.tmpl +++ b/templates/layout.tmpl @@ -28,16 +28,74 @@