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

feat: async community feed rendering on homepage

pdewey.com 3a3f3c6c a5eaa01a

verified
+121 -80
+89 -77
internal/bff/render.go
··· 11 ) 12 13 var ( 14 - templates *template.Template 15 - templatesOnce sync.Once 16 - templatesErr error 17 ) 18 19 - // loadTemplates initializes templates lazily - only when first needed 20 - func loadTemplates() error { 21 - templatesOnce.Do(func() { 22 - // Parse all template files including partials 23 - templates = template.New("") 24 - templates = templates.Funcs(template.FuncMap{ 25 "formatTemp": FormatTemp, 26 "formatTime": FormatTime, 27 "formatRating": FormatRating, ··· 35 "iterateRemaining": IterateRemaining, 36 "hasTemp": HasTemp, 37 "hasValue": HasValue, 38 - }) 39 - 40 - // Try to find templates relative to working directory 41 - // This supports both running from project root and from package directory 42 - paths := []string{ 43 - "templates/*.tmpl", 44 - "../../templates/*.tmpl", // for when tests run from internal/bff 45 - "../../../templates/*.tmpl", // for deeper test directories 46 } 47 48 - var err error 49 - for _, path := range paths { 50 - dir := path[:len(path)-6] // Remove *.tmpl 51 - if _, statErr := os.Stat(dir); statErr == nil { 52 - templates, err = templates.ParseGlob(path) 53 - if err == nil { 54 - break 55 - } 56 - } 57 } 58 - if err != nil { 59 - templatesErr = err 60 - return 61 - } 62 - 63 - // Parse partials 64 - partialPaths := []string{ 65 - "templates/partials/*.tmpl", 66 - "../../templates/partials/*.tmpl", 67 - "../../../templates/partials/*.tmpl", 68 - } 69 - 70 - for _, path := range partialPaths { 71 - dir := path[:len(path)-6] 72 - if _, statErr := os.Stat(dir); statErr == nil { 73 - templates, err = templates.ParseGlob(path) 74 - if err == nil { 75 - break 76 - } 77 } 78 } 79 - if err != nil { 80 - templatesErr = err 81 - } 82 }) 83 - return templatesErr 84 } 85 86 // PageData contains data for rendering pages ··· 113 114 // RenderTemplate renders a template with layout 115 func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { 116 - if err := loadTemplates(); err != nil { 117 return err 118 } 119 - // Execute the layout template which calls the content template 120 - return templates.ExecuteTemplate(w, "layout", data) 121 } 122 123 // RenderHome renders the home page 124 func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { 125 - if err := loadTemplates(); err != nil { 126 return err 127 } 128 data := &PageData{ ··· 131 UserDID: userDID, 132 FeedItems: feedItems, 133 } 134 - // Need to execute layout with the home template 135 - t := template.Must(templates.Clone()) 136 - t = template.Must(t.ParseFiles(findTemplatePath("home.tmpl"))) 137 return t.ExecuteTemplate(w, "layout", data) 138 } 139 140 // RenderBrewList renders the brew list page 141 func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error { 142 - if err := loadTemplates(); err != nil { 143 return err 144 } 145 brewList := make([]*BrewListData, len(brews)) ··· 158 IsAuthenticated: isAuthenticated, 159 UserDID: userDID, 160 } 161 - t := template.Must(templates.Clone()) 162 - t = template.Must(t.ParseFiles(findTemplatePath("brew_list.tmpl"))) 163 return t.ExecuteTemplate(w, "layout", data) 164 } 165 166 // RenderBrewForm renders the brew form page 167 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 { 168 - if err := loadTemplates(); err != nil { 169 return err 170 } 171 var brewData *BrewData ··· 189 IsAuthenticated: isAuthenticated, 190 UserDID: userDID, 191 } 192 - t := template.Must(templates.Clone()) 193 - t = template.Must(t.ParseFiles(findTemplatePath("brew_form.tmpl"))) 194 return t.ExecuteTemplate(w, "layout", data) 195 } 196 197 // RenderManage renders the manage page 198 func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error { 199 - if err := loadTemplates(); err != nil { 200 return err 201 } 202 data := &PageData{ ··· 208 IsAuthenticated: isAuthenticated, 209 UserDID: userDID, 210 } 211 - t := template.Must(templates.Clone()) 212 - t = template.Must(t.ParseFiles(findTemplatePath("manage.tmpl"))) 213 return t.ExecuteTemplate(w, "layout", data) 214 } 215 216 // findTemplatePath finds the correct path to a template file 217 func findTemplatePath(name string) string { 218 - paths := []string{ 219 - "templates/" + name, 220 - "../../templates/" + name, 221 - "../../../templates/" + name, 222 - } 223 - for _, path := range paths { 224 - if _, err := os.Stat(path); err == nil { 225 - return path 226 - } 227 - } 228 - // Return the default path even if it doesn't exist - will fail at parse time 229 - return "templates/" + name 230 }
··· 11 ) 12 13 var ( 14 + templateFuncs template.FuncMap 15 + funcsOnce sync.Once 16 + templateDir string 17 + templateDirMu sync.Once 18 ) 19 20 + // getTemplateFuncs returns the function map used by all templates 21 + func getTemplateFuncs() template.FuncMap { 22 + funcsOnce.Do(func() { 23 + templateFuncs = template.FuncMap{ 24 "formatTemp": FormatTemp, 25 "formatTime": FormatTime, 26 "formatRating": FormatRating, ··· 34 "iterateRemaining": IterateRemaining, 35 "hasTemp": HasTemp, 36 "hasValue": HasValue, 37 } 38 + }) 39 + return templateFuncs 40 + } 41 42 + // getTemplateDir finds the template directory 43 + func getTemplateDir() string { 44 + templateDirMu.Do(func() { 45 + dirs := []string{ 46 + "templates", 47 + "../../templates", 48 + "../../../templates", 49 } 50 + for _, dir := range dirs { 51 + if _, err := os.Stat(dir); err == nil { 52 + templateDir = dir 53 + return 54 } 55 } 56 + templateDir = "templates" // fallback 57 }) 58 + return templateDir 59 + } 60 + 61 + // parsePageTemplate parses a complete page template with layout and partials 62 + func parsePageTemplate(pageName string) (*template.Template, error) { 63 + dir := getTemplateDir() 64 + t := template.New("").Funcs(getTemplateFuncs()) 65 + 66 + // Parse layout first 67 + t, err := t.ParseFiles(dir + "/layout.tmpl") 68 + if err != nil { 69 + return nil, err 70 + } 71 + 72 + // Parse all partials 73 + t, err = t.ParseGlob(dir + "/partials/*.tmpl") 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + // Parse the specific page template 79 + t, err = t.ParseFiles(dir + "/" + pageName) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + return t, nil 85 + } 86 + 87 + // parsePartialTemplate parses just the partials (for partial-only renders) 88 + func parsePartialTemplate() (*template.Template, error) { 89 + dir := getTemplateDir() 90 + t := template.New("").Funcs(getTemplateFuncs()) 91 + 92 + // Parse all partials 93 + t, err := t.ParseGlob(dir + "/partials/*.tmpl") 94 + if err != nil { 95 + return nil, err 96 + } 97 + 98 + return t, nil 99 } 100 101 // PageData contains data for rendering pages ··· 128 129 // RenderTemplate renders a template with layout 130 func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { 131 + t, err := parsePageTemplate(tmpl) 132 + if err != nil { 133 return err 134 } 135 + return t.ExecuteTemplate(w, "layout", data) 136 } 137 138 // RenderHome renders the home page 139 func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { 140 + t, err := parsePageTemplate("home.tmpl") 141 + if err != nil { 142 return err 143 } 144 data := &PageData{ ··· 147 UserDID: userDID, 148 FeedItems: feedItems, 149 } 150 return t.ExecuteTemplate(w, "layout", data) 151 } 152 153 // RenderBrewList renders the brew list page 154 func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error { 155 + t, err := parsePageTemplate("brew_list.tmpl") 156 + if err != nil { 157 return err 158 } 159 brewList := make([]*BrewListData, len(brews)) ··· 172 IsAuthenticated: isAuthenticated, 173 UserDID: userDID, 174 } 175 return t.ExecuteTemplate(w, "layout", data) 176 } 177 178 // RenderBrewForm renders the brew form page 179 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 { 180 + t, err := parsePageTemplate("brew_form.tmpl") 181 + if err != nil { 182 return err 183 } 184 var brewData *BrewData ··· 202 IsAuthenticated: isAuthenticated, 203 UserDID: userDID, 204 } 205 return t.ExecuteTemplate(w, "layout", data) 206 } 207 208 // RenderManage renders the manage page 209 func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string) error { 210 + t, err := parsePageTemplate("manage.tmpl") 211 + if err != nil { 212 return err 213 } 214 data := &PageData{ ··· 220 IsAuthenticated: isAuthenticated, 221 UserDID: userDID, 222 } 223 return t.ExecuteTemplate(w, "layout", data) 224 } 225 226 + // RenderFeedPartial renders just the feed partial (for HTMX async loading) 227 + func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem) error { 228 + t, err := parsePartialTemplate() 229 + if err != nil { 230 + return err 231 + } 232 + data := &PageData{ 233 + FeedItems: feedItems, 234 + } 235 + return t.ExecuteTemplate(w, "feed", data) 236 + } 237 + 238 // findTemplatePath finds the correct path to a template file 239 func findTemplatePath(name string) string { 240 + dir := getTemplateDir() 241 + return dir + "/" + name 242 }
+9 -2
internal/handlers/handlers.go
··· 88 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 89 isAuthenticated := err == nil && didStr != "" 90 91 - // Fetch feed items (if feed service is configured) 92 var feedItems []*feed.FeedItem 93 if h.feedService != nil { 94 feedItems, _ = h.feedService.GetRecentBrews(r.Context(), 20) 95 } 96 97 - if err := bff.RenderHome(w, isAuthenticated, didStr, feedItems); err != nil { 98 http.Error(w, err.Error(), http.StatusInternalServerError) 99 } 100 }
··· 88 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 89 isAuthenticated := err == nil && didStr != "" 90 91 + // Don't fetch feed items here - let them load async via HTMX 92 + if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil { 93 + http.Error(w, err.Error(), http.StatusInternalServerError) 94 + } 95 + } 96 + 97 + // Community feed partial (loaded async via HTMX) 98 + func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 99 var feedItems []*feed.FeedItem 100 if h.feedService != nil { 101 feedItems, _ = h.feedService.GetRecentBrews(r.Context(), 20) 102 } 103 104 + if err := bff.RenderFeedPartial(w, feedItems); err != nil { 105 http.Error(w, err.Error(), http.StatusInternalServerError) 106 } 107 }
+3
internal/routing/routing.go
··· 37 // API route for fetching all user data (used by client-side cache) 38 mux.HandleFunc("GET /api/data", h.HandleAPIListAll) 39 40 // Page routes (must come before static files) 41 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 42 mux.HandleFunc("GET /manage", h.HandleManage)
··· 37 // API route for fetching all user data (used by client-side cache) 38 mux.HandleFunc("GET /api/data", h.HandleAPIListAll) 39 40 + // Community feed partial (loaded async via HTMX) 41 + mux.HandleFunc("GET /api/feed", h.HandleFeedPartial) 42 + 43 // Page routes (must come before static files) 44 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 45 mux.HandleFunc("GET /manage", h.HandleManage)
+20 -1
templates/home.tmpl
··· 61 <!-- Community Feed --> 62 <div class="bg-white rounded-lg shadow-md p-6 mb-8"> 63 <h3 class="text-xl font-semibold text-gray-800 mb-4">Community Feed</h3> 64 - {{template "feed" .}} 65 </div> 66 67 <div class="bg-blue-50 rounded-lg p-6 border border-blue-200">
··· 61 <!-- Community Feed --> 62 <div class="bg-white rounded-lg shadow-md p-6 mb-8"> 63 <h3 class="text-xl font-semibold text-gray-800 mb-4">Community Feed</h3> 64 + <div hx-get="/api/feed" hx-trigger="load" hx-swap="innerHTML"> 65 + <!-- Loading state --> 66 + <div class="space-y-4"> 67 + <div class="animate-pulse"> 68 + <div class="bg-gray-100 rounded-lg p-4"> 69 + <div class="flex items-center gap-3 mb-3"> 70 + <div class="w-10 h-10 rounded-full bg-gray-300"></div> 71 + <div class="flex-1"> 72 + <div class="h-4 bg-gray-300 rounded w-1/4 mb-2"></div> 73 + <div class="h-3 bg-gray-200 rounded w-1/6"></div> 74 + </div> 75 + </div> 76 + <div class="bg-gray-200 rounded-lg p-3"> 77 + <div class="h-4 bg-gray-300 rounded w-3/4 mb-2"></div> 78 + <div class="h-3 bg-gray-200 rounded w-1/2"></div> 79 + </div> 80 + </div> 81 + </div> 82 + </div> 83 + </div> 84 </div> 85 86 <div class="bg-blue-50 rounded-lg p-6 border border-blue-200">