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 11 ) 12 12 13 13 var ( 14 - templates *template.Template 15 - templatesOnce sync.Once 16 - templatesErr error 14 + templateFuncs template.FuncMap 15 + funcsOnce sync.Once 16 + templateDir string 17 + templateDirMu sync.Once 17 18 ) 18 19 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{ 20 + // getTemplateFuncs returns the function map used by all templates 21 + func getTemplateFuncs() template.FuncMap { 22 + funcsOnce.Do(func() { 23 + templateFuncs = template.FuncMap{ 25 24 "formatTemp": FormatTemp, 26 25 "formatTime": FormatTime, 27 26 "formatRating": FormatRating, ··· 35 34 "iterateRemaining": IterateRemaining, 36 35 "hasTemp": HasTemp, 37 36 "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 37 } 38 + }) 39 + return templateFuncs 40 + } 47 41 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 - } 42 + // getTemplateDir finds the template directory 43 + func getTemplateDir() string { 44 + templateDirMu.Do(func() { 45 + dirs := []string{ 46 + "templates", 47 + "../../templates", 48 + "../../../templates", 57 49 } 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 - } 50 + for _, dir := range dirs { 51 + if _, err := os.Stat(dir); err == nil { 52 + templateDir = dir 53 + return 77 54 } 78 55 } 79 - if err != nil { 80 - templatesErr = err 81 - } 56 + templateDir = "templates" // fallback 82 57 }) 83 - return templatesErr 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 84 99 } 85 100 86 101 // PageData contains data for rendering pages ··· 113 128 114 129 // RenderTemplate renders a template with layout 115 130 func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { 116 - if err := loadTemplates(); err != nil { 131 + t, err := parsePageTemplate(tmpl) 132 + if err != nil { 117 133 return err 118 134 } 119 - // Execute the layout template which calls the content template 120 - return templates.ExecuteTemplate(w, "layout", data) 135 + return t.ExecuteTemplate(w, "layout", data) 121 136 } 122 137 123 138 // RenderHome renders the home page 124 139 func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { 125 - if err := loadTemplates(); err != nil { 140 + t, err := parsePageTemplate("home.tmpl") 141 + if err != nil { 126 142 return err 127 143 } 128 144 data := &PageData{ ··· 131 147 UserDID: userDID, 132 148 FeedItems: feedItems, 133 149 } 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 150 return t.ExecuteTemplate(w, "layout", data) 138 151 } 139 152 140 153 // RenderBrewList renders the brew list page 141 154 func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated bool, userDID string) error { 142 - if err := loadTemplates(); err != nil { 155 + t, err := parsePageTemplate("brew_list.tmpl") 156 + if err != nil { 143 157 return err 144 158 } 145 159 brewList := make([]*BrewListData, len(brews)) ··· 158 172 IsAuthenticated: isAuthenticated, 159 173 UserDID: userDID, 160 174 } 161 - t := template.Must(templates.Clone()) 162 - t = template.Must(t.ParseFiles(findTemplatePath("brew_list.tmpl"))) 163 175 return t.ExecuteTemplate(w, "layout", data) 164 176 } 165 177 166 178 // RenderBrewForm renders the brew form page 167 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 { 168 - if err := loadTemplates(); err != nil { 180 + t, err := parsePageTemplate("brew_form.tmpl") 181 + if err != nil { 169 182 return err 170 183 } 171 184 var brewData *BrewData ··· 189 202 IsAuthenticated: isAuthenticated, 190 203 UserDID: userDID, 191 204 } 192 - t := template.Must(templates.Clone()) 193 - t = template.Must(t.ParseFiles(findTemplatePath("brew_form.tmpl"))) 194 205 return t.ExecuteTemplate(w, "layout", data) 195 206 } 196 207 197 208 // RenderManage renders the manage page 198 209 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 { 210 + t, err := parsePageTemplate("manage.tmpl") 211 + if err != nil { 200 212 return err 201 213 } 202 214 data := &PageData{ ··· 208 220 IsAuthenticated: isAuthenticated, 209 221 UserDID: userDID, 210 222 } 211 - t := template.Must(templates.Clone()) 212 - t = template.Must(t.ParseFiles(findTemplatePath("manage.tmpl"))) 213 223 return t.ExecuteTemplate(w, "layout", data) 214 224 } 215 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 + 216 238 // findTemplatePath finds the correct path to a template file 217 239 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 240 + dir := getTemplateDir() 241 + return dir + "/" + name 230 242 }
+9 -2
internal/handlers/handlers.go
··· 88 88 didStr, err := atproto.GetAuthenticatedDID(r.Context()) 89 89 isAuthenticated := err == nil && didStr != "" 90 90 91 - // Fetch feed items (if feed service is configured) 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) { 92 99 var feedItems []*feed.FeedItem 93 100 if h.feedService != nil { 94 101 feedItems, _ = h.feedService.GetRecentBrews(r.Context(), 20) 95 102 } 96 103 97 - if err := bff.RenderHome(w, isAuthenticated, didStr, feedItems); err != nil { 104 + if err := bff.RenderFeedPartial(w, feedItems); err != nil { 98 105 http.Error(w, err.Error(), http.StatusInternalServerError) 99 106 } 100 107 }
+3
internal/routing/routing.go
··· 37 37 // API route for fetching all user data (used by client-side cache) 38 38 mux.HandleFunc("GET /api/data", h.HandleAPIListAll) 39 39 40 + // Community feed partial (loaded async via HTMX) 41 + mux.HandleFunc("GET /api/feed", h.HandleFeedPartial) 42 + 40 43 // Page routes (must come before static files) 41 44 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 42 45 mux.HandleFunc("GET /manage", h.HandleManage)
+20 -1
templates/home.tmpl
··· 61 61 <!-- Community Feed --> 62 62 <div class="bg-white rounded-lg shadow-md p-6 mb-8"> 63 63 <h3 class="text-xl font-semibold text-gray-800 mb-4">Community Feed</h3> 64 - {{template "feed" .}} 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> 65 84 </div> 66 85 67 86 <div class="bg-blue-50 rounded-lg p-6 border border-blue-200">