Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: user profile

pdewey fcffc587 530c8dd5

+155 -17
+27 -6
internal/bff/render.go
··· 101 101 return t, nil 102 102 } 103 103 104 + // UserProfile contains user profile data for header display 105 + type UserProfile struct { 106 + Handle string 107 + DisplayName string 108 + Avatar string 109 + } 110 + 104 111 // PageData contains data for rendering pages 105 112 type PageData struct { 106 113 Title string ··· 113 120 FeedItems []*feed.FeedItem 114 121 IsAuthenticated bool 115 122 UserDID string 123 + UserProfile *UserProfile 116 124 } 117 125 118 126 // BrewData wraps a brew with pre-serialized JSON for pours ··· 138 146 return t.ExecuteTemplate(w, "layout", data) 139 147 } 140 148 149 + // RenderTemplateWithProfile renders a template with layout and user profile 150 + func RenderTemplateWithProfile(w http.ResponseWriter, tmpl string, data *PageData, userProfile *UserProfile) error { 151 + data.UserProfile = userProfile 152 + return RenderTemplate(w, tmpl, data) 153 + } 154 + 141 155 // RenderHome renders the home page 142 - func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { 156 + func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, userProfile *UserProfile, feedItems []*feed.FeedItem) error { 143 157 t, err := parsePageTemplate("home.tmpl") 144 158 if err != nil { 145 159 return err ··· 148 162 Title: "Home", 149 163 IsAuthenticated: isAuthenticated, 150 164 UserDID: userDID, 165 + UserProfile: userProfile, 151 166 FeedItems: feedItems, 152 167 } 153 168 return t.ExecuteTemplate(w, "layout", data) 154 169 } 155 170 156 171 // RenderBrewList renders the brew list page 157 - func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error { 172 + func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 158 173 t, err := parsePageTemplate("brew_list.tmpl") 159 174 if err != nil { 160 175 return err ··· 174 189 Brews: brewList, 175 190 IsAuthenticated: isAuthenticated, 176 191 UserDID: userDID, 192 + UserProfile: userProfile, 177 193 } 178 194 return t.ExecuteTemplate(w, "layout", data) 179 195 } 180 196 181 197 // RenderBrewForm renders the brew form page 182 - 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 { 198 + 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 { 183 199 t, err := parsePageTemplate("brew_form.tmpl") 184 200 if err != nil { 185 201 return err ··· 204 220 Brew: brewData, 205 221 IsAuthenticated: isAuthenticated, 206 222 UserDID: userDID, 223 + UserProfile: userProfile, 207 224 } 208 225 return t.ExecuteTemplate(w, "layout", data) 209 226 } 210 227 211 228 // RenderManage renders the manage page 212 - func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error { 229 + func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 213 230 t, err := parsePageTemplate("manage.tmpl") 214 231 if err != nil { 215 232 return err ··· 222 239 Brewers: brewers, 223 240 IsAuthenticated: isAuthenticated, 224 241 UserDID: userDID, 242 + UserProfile: userProfile, 225 243 } 226 244 return t.ExecuteTemplate(w, "layout", data) 227 245 } ··· 292 310 Brewers []*models.Brewer 293 311 IsAuthenticated bool 294 312 UserDID string 313 + UserProfile *UserProfile 295 314 } 296 315 297 316 // RenderProfile renders a user's public profile page 298 - 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 { 317 + 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 { 299 318 t, err := parsePageTemplate("profile.tmpl") 300 319 if err != nil { 301 320 return err ··· 316 335 Brewers: brewers, 317 336 IsAuthenticated: isAuthenticated, 318 337 UserDID: userDID, 338 + UserProfile: userProfile, 319 339 } 320 340 return t.ExecuteTemplate(w, "layout", data) 321 341 } 322 342 323 343 // Render404 renders the 404 not found page 324 - func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string) error { 344 + func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 325 345 t, err := parsePageTemplate("404.tmpl") 326 346 if err != nil { 327 347 return err ··· 330 350 Title: "Page Not Found", 331 351 IsAuthenticated: isAuthenticated, 332 352 UserDID: userDID, 353 + UserProfile: userProfile, 333 354 } 334 355 w.WriteHeader(http.StatusNotFound) 335 356 return t.ExecuteTemplate(w, "layout", data)
+66 -7
internal/handlers/handlers.go
··· 81 81 return "" 82 82 } 83 83 84 + // getUserProfile fetches the profile for an authenticated user. 85 + // Returns nil if unable to fetch profile (non-fatal error). 86 + func (h *Handler) getUserProfile(ctx context.Context, did string) *bff.UserProfile { 87 + if did == "" { 88 + return nil 89 + } 90 + 91 + publicClient := atproto.NewPublicClient() 92 + profile, err := publicClient.GetProfile(ctx, did) 93 + if err != nil { 94 + log.Warn().Err(err).Str("did", did).Msg("Failed to fetch user profile for header") 95 + return nil 96 + } 97 + 98 + userProfile := &bff.UserProfile{ 99 + Handle: profile.Handle, 100 + } 101 + if profile.DisplayName != nil { 102 + userProfile.DisplayName = *profile.DisplayName 103 + } 104 + if profile.Avatar != nil { 105 + userProfile.Avatar = *profile.Avatar 106 + } 107 + 108 + return userProfile 109 + } 110 + 84 111 // getAtprotoStore creates a user-scoped atproto store from the request context. 85 112 // Returns the store and true if authenticated, or nil and false if not authenticated. 86 113 func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { ··· 113 140 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 114 141 isAuthenticated := err == nil && didStr != "" 115 142 143 + // Fetch user profile for authenticated users 144 + var userProfile *bff.UserProfile 145 + if isAuthenticated { 146 + userProfile = h.getUserProfile(r.Context(), didStr) 147 + } 148 + 116 149 // Don't fetch feed items here - let them load async via HTMX 117 - if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil { 150 + if err := bff.RenderHome(w, isAuthenticated, didStr, userProfile, nil); err != nil { 118 151 http.Error(w, "Failed to render page", http.StatusInternalServerError) 119 152 log.Error().Err(err).Msg("Failed to render home page") 120 153 } ··· 221 254 } 222 255 223 256 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 257 + userProfile := h.getUserProfile(r.Context(), didStr) 224 258 225 259 // Don't fetch brews here - let them load async via HTMX 226 - if err := bff.RenderBrewList(w, nil, authenticated, didStr); err != nil { 260 + if err := bff.RenderBrewList(w, nil, authenticated, didStr, userProfile); err != nil { 227 261 http.Error(w, "Failed to render page", http.StatusInternalServerError) 228 262 log.Error().Err(err).Msg("Failed to render brew list page") 229 263 } ··· 239 273 } 240 274 241 275 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 276 + userProfile := h.getUserProfile(r.Context(), didStr) 242 277 243 278 // Don't fetch data from PDS - client will populate dropdowns from cache 244 279 // This makes the page load much faster 245 - if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr); err != nil { 280 + if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil { 246 281 http.Error(w, "Failed to render page", http.StatusInternalServerError) 247 282 log.Error().Err(err).Msg("Failed to render brew form") 248 283 } ··· 263 298 } 264 299 265 300 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 301 + userProfile := h.getUserProfile(r.Context(), didStr) 266 302 267 303 brew, err := store.GetBrewByRKey(r.Context(), rkey) 268 304 if err != nil { ··· 273 309 274 310 // Don't fetch dropdown data from PDS - client will populate from cache 275 311 // This makes the page load much faster 276 - if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr); err != nil { 312 + if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr, userProfile); err != nil { 277 313 http.Error(w, "Failed to render page", http.StatusInternalServerError) 278 314 log.Error().Err(err).Msg("Failed to render brew edit form") 279 315 } ··· 730 766 } 731 767 732 768 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 769 + userProfile := h.getUserProfile(r.Context(), didStr) 733 770 734 771 // Don't fetch data here - let it load async via HTMX 735 - if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr); err != nil { 772 + if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil { 736 773 http.Error(w, "Failed to render page", http.StatusInternalServerError) 737 774 log.Error().Err(err).Msg("Failed to render manage page") 738 775 } ··· 1084 1121 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1085 1122 isAuthenticated := err == nil && didStr != "" 1086 1123 1124 + var userProfile *bff.UserProfile 1125 + if isAuthenticated { 1126 + userProfile = h.getUserProfile(r.Context(), didStr) 1127 + } 1128 + 1087 1129 data := &bff.PageData{ 1088 1130 Title: "About", 1089 1131 IsAuthenticated: isAuthenticated, 1090 1132 UserDID: didStr, 1133 + UserProfile: userProfile, 1091 1134 } 1092 1135 1093 1136 if err := bff.RenderTemplate(w, "about.tmpl", data); err != nil { ··· 1102 1145 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1103 1146 isAuthenticated := err == nil && didStr != "" 1104 1147 1148 + var userProfile *bff.UserProfile 1149 + if isAuthenticated { 1150 + userProfile = h.getUserProfile(r.Context(), didStr) 1151 + } 1152 + 1105 1153 data := &bff.PageData{ 1106 1154 Title: "Terms of Service", 1107 1155 IsAuthenticated: isAuthenticated, 1108 1156 UserDID: didStr, 1157 + UserProfile: userProfile, 1109 1158 } 1110 1159 1111 1160 if err := bff.RenderTemplate(w, "terms.tmpl", data); err != nil { ··· 1360 1409 didStr, err := atproto.GetAuthenticatedDID(ctx) 1361 1410 isAuthenticated := err == nil && didStr != "" 1362 1411 1412 + var userProfile *bff.UserProfile 1413 + if isAuthenticated { 1414 + userProfile = h.getUserProfile(ctx, didStr) 1415 + } 1416 + 1363 1417 // Render profile page 1364 - if err := bff.RenderProfile(w, profile, brews, beans, roasters, grinders, brewers, isAuthenticated, didStr); err != nil { 1418 + if err := bff.RenderProfile(w, profile, brews, beans, roasters, grinders, brewers, isAuthenticated, didStr, userProfile); err != nil { 1365 1419 http.Error(w, "Failed to render page", http.StatusInternalServerError) 1366 1420 log.Error().Err(err).Msg("Failed to render profile page") 1367 1421 } ··· 1373 1427 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1374 1428 isAuthenticated := err == nil && didStr != "" 1375 1429 1376 - if err := bff.Render404(w, isAuthenticated, didStr); err != nil { 1430 + var userProfile *bff.UserProfile 1431 + if isAuthenticated { 1432 + userProfile = h.getUserProfile(r.Context(), didStr) 1433 + } 1434 + 1435 + if err := bff.Render404(w, isAuthenticated, didStr, userProfile); err != nil { 1377 1436 http.Error(w, "Page not found", http.StatusNotFound) 1378 1437 log.Error().Err(err).Msg("Failed to render 404 page") 1379 1438 }
+62 -4
templates/layout.tmpl
··· 28 28 <nav class="bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600"> 29 29 <div class="container mx-auto px-4 py-4"> 30 30 <div class="flex items-center justify-between"> 31 - <a href="/" class="hidden md:flex items-center gap-2 hover:opacity-80 transition"> 31 + <!-- Logo - always visible --> 32 + <a href="/" class="flex items-center gap-2 hover:opacity-80 transition"> 32 33 <h1 class="text-2xl font-bold">☕ Arabica</h1> 33 34 <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span> 34 35 </a> 35 - <div class="space-x-4"> 36 - <a href="/" class="hover:text-brown-100 transition-colors font-medium">Home</a> 36 + 37 + <!-- Navigation links --> 38 + <div class="flex items-center gap-4"> 39 + <!-- Home link - mobile only --> 40 + <a href="/" class="md:hidden hover:text-brown-100 transition-colors font-medium">Home</a> 41 + 37 42 {{if .IsAuthenticated}} 43 + <!-- Brews link --> 38 44 <a href="/brews" class="hover:text-brown-100 transition-colors font-medium">Brews</a> 45 + 46 + <!-- New Brew button - commented out for now 39 47 <a href="/brews/new" class="hover:text-brown-100 transition-colors font-medium">New Brew</a> 40 - <a href="/manage" class="hover:text-brown-100 transition-colors font-medium">Manage</a> 48 + --> 49 + 50 + <!-- User profile dropdown --> 51 + <div x-data="{ open: false }" class="relative"> 52 + <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"> 53 + {{if and .UserProfile .UserProfile.Avatar}} 54 + {{$safeAvatar := safeAvatarURL .UserProfile.Avatar}} 55 + {{if $safeAvatar}} 56 + <img src="{{$safeAvatar}}" alt="" class="w-8 h-8 rounded-full object-cover ring-2 ring-brown-600" /> 57 + {{else}} 58 + <div class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500"> 59 + <span class="text-sm font-medium">{{if and .UserProfile .UserProfile.DisplayName}}{{slice .UserProfile.DisplayName 0 1}}{{else}}?{{end}}</span> 60 + </div> 61 + {{end}} 62 + {{else}} 63 + <div class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500"> 64 + <span class="text-sm font-medium">{{if and .UserProfile .UserProfile.DisplayName}}{{slice .UserProfile.DisplayName 0 1}}{{else}}?{{end}}</span> 65 + </div> 66 + {{end}} 67 + <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 68 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> 69 + </svg> 70 + </button> 71 + 72 + <!-- Dropdown menu --> 73 + <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-brown-200 py-1 z-50"> 74 + {{if and .UserProfile .UserProfile.Handle}} 75 + <div class="px-4 py-2 border-b border-brown-100"> 76 + <p class="text-sm font-medium text-brown-900 truncate">{{if .UserProfile.DisplayName}}{{.UserProfile.DisplayName}}{{else}}{{.UserProfile.Handle}}{{end}}</p> 77 + <p class="text-xs text-brown-500 truncate">@{{.UserProfile.Handle}}</p> 78 + </div> 79 + {{end}} 80 + <a href="/profile/{{if and .UserProfile .UserProfile.Handle}}{{.UserProfile.Handle}}{{else}}{{.UserDID}}{{end}}" class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 81 + View Profile 82 + </a> 83 + <a href="/manage" class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 84 + Manage Records 85 + </a> 86 + <a href="#" class="block px-4 py-2 text-sm text-brown-400 cursor-not-allowed"> 87 + Settings (coming soon) 88 + </a> 89 + <div class="border-t border-brown-100 mt-1 pt-1"> 90 + <form action="/logout" method="POST"> 91 + <input type="hidden" name="csrf_token" class="csrf-token-field"> 92 + <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 93 + Logout 94 + </button> 95 + </form> 96 + </div> 97 + </div> 98 + </div> 41 99 {{end}} 42 100 </div> 43 101 </div>