diff --git a/internal/bff/render.go b/internal/bff/render.go index 25f301b..666f612 100644 --- a/internal/bff/render.go +++ b/internal/bff/render.go @@ -11,17 +11,16 @@ import ( ) var ( - templates *template.Template - templatesOnce sync.Once - templatesErr error + templateFuncs template.FuncMap + funcsOnce sync.Once + templateDir string + templateDirMu sync.Once ) -// loadTemplates initializes templates lazily - only when first needed -func loadTemplates() error { - templatesOnce.Do(func() { - // Parse all template files including partials - templates = template.New("") - templates = templates.Funcs(template.FuncMap{ +// getTemplateFuncs returns the function map used by all templates +func getTemplateFuncs() template.FuncMap { + funcsOnce.Do(func() { + templateFuncs = template.FuncMap{ "formatTemp": FormatTemp, "formatTime": FormatTime, "formatRating": FormatRating, @@ -35,52 +34,68 @@ func loadTemplates() error { "iterateRemaining": IterateRemaining, "hasTemp": HasTemp, "hasValue": HasValue, - }) - - // Try to find templates relative to working directory - // This supports both running from project root and from package directory - paths := []string{ - "templates/*.tmpl", - "../../templates/*.tmpl", // for when tests run from internal/bff - "../../../templates/*.tmpl", // for deeper test directories - } - - var err error - for _, path := range paths { - dir := path[:len(path)-6] // Remove *.tmpl - if _, statErr := os.Stat(dir); statErr == nil { - templates, err = templates.ParseGlob(path) - if err == nil { - break - } - } - } - if err != nil { - templatesErr = err - return } + }) + return templateFuncs +} - // Parse partials - partialPaths := []string{ - "templates/partials/*.tmpl", - "../../templates/partials/*.tmpl", - "../../../templates/partials/*.tmpl", +// getTemplateDir finds the template directory +func getTemplateDir() string { + templateDirMu.Do(func() { + dirs := []string{ + "templates", + "../../templates", + "../../../templates", } - - for _, path := range partialPaths { - dir := path[:len(path)-6] - if _, statErr := os.Stat(dir); statErr == nil { - templates, err = templates.ParseGlob(path) - if err == nil { - break - } + for _, dir := range dirs { + if _, err := os.Stat(dir); err == nil { + templateDir = dir + return } } - if err != nil { - templatesErr = err - } + templateDir = "templates" // fallback }) - return templatesErr + return templateDir +} + +// parsePageTemplate parses a complete page template with layout and partials +func parsePageTemplate(pageName string) (*template.Template, error) { + dir := getTemplateDir() + t := template.New("").Funcs(getTemplateFuncs()) + + // Parse layout first + t, err := t.ParseFiles(dir + "/layout.tmpl") + if err != nil { + return nil, err + } + + // Parse all partials + t, err = t.ParseGlob(dir + "/partials/*.tmpl") + if err != nil { + return nil, err + } + + // Parse the specific page template + t, err = t.ParseFiles(dir + "/" + pageName) + if err != nil { + return nil, err + } + + return t, nil +} + +// parsePartialTemplate parses just the partials (for partial-only renders) +func parsePartialTemplate() (*template.Template, error) { + dir := getTemplateDir() + t := template.New("").Funcs(getTemplateFuncs()) + + // Parse all partials + t, err := t.ParseGlob(dir + "/partials/*.tmpl") + if err != nil { + return nil, err + } + + return t, nil } // PageData contains data for rendering pages @@ -113,16 +128,17 @@ type BrewListData struct { // RenderTemplate renders a template with layout func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { - if err := loadTemplates(); err != nil { + t, err := parsePageTemplate(tmpl) + if err != nil { return err } - // Execute the layout template which calls the content template - return templates.ExecuteTemplate(w, "layout", data) + return t.ExecuteTemplate(w, "layout", data) } // RenderHome renders the home page func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem) error { - if err := loadTemplates(); err != nil { + t, err := parsePageTemplate("home.tmpl") + if err != nil { return err } data := &PageData{ @@ -131,15 +147,13 @@ func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, fee UserDID: userDID, FeedItems: feedItems, } - // Need to execute layout with the home template - t := template.Must(templates.Clone()) - t = template.Must(t.ParseFiles(findTemplatePath("home.tmpl"))) 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 { - if err := loadTemplates(); err != nil { + t, err := parsePageTemplate("brew_list.tmpl") + if err != nil { return err } brewList := make([]*BrewListData, len(brews)) @@ -158,14 +172,13 @@ func RenderBrewList(w http.ResponseWriter, brews []*models.Brew, isAuthenticated IsAuthenticated: isAuthenticated, UserDID: userDID, } - t := template.Must(templates.Clone()) - t = template.Must(t.ParseFiles(findTemplatePath("brew_list.tmpl"))) 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 { - if err := loadTemplates(); err != nil { + t, err := parsePageTemplate("brew_form.tmpl") + if err != nil { return err } var brewData *BrewData @@ -189,14 +202,13 @@ func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*mod IsAuthenticated: isAuthenticated, UserDID: userDID, } - t := template.Must(templates.Clone()) - t = template.Must(t.ParseFiles(findTemplatePath("brew_form.tmpl"))) 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 { - if err := loadTemplates(); err != nil { + t, err := parsePageTemplate("manage.tmpl") + if err != nil { return err } data := &PageData{ @@ -208,23 +220,23 @@ func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*model IsAuthenticated: isAuthenticated, UserDID: userDID, } - t := template.Must(templates.Clone()) - t = template.Must(t.ParseFiles(findTemplatePath("manage.tmpl"))) return t.ExecuteTemplate(w, "layout", data) } +// RenderFeedPartial renders just the feed partial (for HTMX async loading) +func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem) error { + t, err := parsePartialTemplate() + if err != nil { + return err + } + data := &PageData{ + FeedItems: feedItems, + } + return t.ExecuteTemplate(w, "feed", data) +} + // findTemplatePath finds the correct path to a template file func findTemplatePath(name string) string { - paths := []string{ - "templates/" + name, - "../../templates/" + name, - "../../../templates/" + name, - } - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - return path - } - } - // Return the default path even if it doesn't exist - will fail at parse time - return "templates/" + name + dir := getTemplateDir() + return dir + "/" + name } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 26946b7..b48a50f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -88,13 +88,20 @@ func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { didStr, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil && didStr != "" - // Fetch feed items (if feed service is configured) + // Don't fetch feed items here - let them load async via HTMX + if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// Community feed partial (loaded async via HTMX) +func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { var feedItems []*feed.FeedItem if h.feedService != nil { feedItems, _ = h.feedService.GetRecentBrews(r.Context(), 20) } - if err := bff.RenderHome(w, isAuthenticated, didStr, feedItems); err != nil { + if err := bff.RenderFeedPartial(w, feedItems); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } diff --git a/internal/routing/routing.go b/internal/routing/routing.go index d154213..4b175fd 100644 --- a/internal/routing/routing.go +++ b/internal/routing/routing.go @@ -37,6 +37,9 @@ func SetupRouter(cfg Config) http.Handler { // API route for fetching all user data (used by client-side cache) mux.HandleFunc("GET /api/data", h.HandleAPIListAll) + // Community feed partial (loaded async via HTMX) + mux.HandleFunc("GET /api/feed", h.HandleFeedPartial) + // Page routes (must come before static files) mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match mux.HandleFunc("GET /manage", h.HandleManage) diff --git a/templates/home.tmpl b/templates/home.tmpl index ce36269..3727398 100644 --- a/templates/home.tmpl +++ b/templates/home.tmpl @@ -61,7 +61,26 @@

Community Feed

- {{template "feed" .}} +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+