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: async record fetching for brews and manage pages

pdewey 71a76dcc f41de4cc

+612 -445
+37
internal/bff/render.go
··· 235 235 return t.ExecuteTemplate(w, "feed", data) 236 236 } 237 237 238 + // RenderBrewListPartial renders just the brew list partial (for HTMX async loading) 239 + func RenderBrewListPartial(w http.ResponseWriter, brews []*models.Brew) error { 240 + t, err := parsePartialTemplate() 241 + if err != nil { 242 + return err 243 + } 244 + brewList := make([]*BrewListData, len(brews)) 245 + for i, brew := range brews { 246 + brewList[i] = &BrewListData{ 247 + Brew: brew, 248 + TempFormatted: FormatTemp(brew.Temperature), 249 + TimeFormatted: FormatTime(brew.TimeSeconds), 250 + RatingFormatted: FormatRating(brew.Rating), 251 + } 252 + } 253 + 254 + data := &PageData{ 255 + Brews: brewList, 256 + } 257 + return t.ExecuteTemplate(w, "brew_list_content", data) 258 + } 259 + 260 + // RenderManagePartial renders just the manage partial (for HTMX async loading) 261 + func RenderManagePartial(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer) error { 262 + t, err := parsePartialTemplate() 263 + if err != nil { 264 + return err 265 + } 266 + data := &PageData{ 267 + Beans: beans, 268 + Roasters: roasters, 269 + Grinders: grinders, 270 + Brewers: brewers, 271 + } 272 + return t.ExecuteTemplate(w, "manage_content", data) 273 + } 274 + 238 275 // findTemplatePath finds the correct path to a template file 239 276 func findTemplatePath(name string) string { 240 277 dir := getTemplateDir()
+96 -67
internal/handlers/handlers.go
··· 106 106 } 107 107 } 108 108 109 - // List all brews 110 - func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 109 + // Brew list partial (loaded async via HTMX) 110 + func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) { 111 111 // Require authentication 112 112 store, authenticated := h.getAtprotoStore(r) 113 113 if !authenticated { 114 - http.Redirect(w, r, "/login", http.StatusFound) 114 + http.Error(w, "Authentication required", http.StatusUnauthorized) 115 115 return 116 116 } 117 117 118 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 119 - 120 118 brews, err := store.ListBrews(1) // User ID is not used with atproto 121 119 if err != nil { 122 - http.Error(w, err.Error(), http.StatusInternalServerError) 120 + http.Error(w, "Failed to fetch brews: "+err.Error(), http.StatusInternalServerError) 121 + return 122 + } 123 + 124 + if err := bff.RenderBrewListPartial(w, brews); err != nil { 125 + http.Error(w, "Failed to render: "+err.Error(), http.StatusInternalServerError) 126 + } 127 + } 128 + 129 + // Manage page partial (loaded async via HTMX) 130 + func (h *Handler) HandleManagePartial(w http.ResponseWriter, r *http.Request) { 131 + // Require authentication 132 + store, authenticated := h.getAtprotoStore(r) 133 + if !authenticated { 134 + http.Error(w, "Authentication required", http.StatusUnauthorized) 123 135 return 124 136 } 125 137 126 - if err := bff.RenderBrewList(w, brews, authenticated, didStr); err != nil { 138 + // Fetch all collections in parallel for better performance 139 + type result struct { 140 + beans []*models.Bean 141 + roasters []*models.Roaster 142 + grinders []*models.Grinder 143 + brewers []*models.Brewer 144 + err error 145 + which string 146 + } 147 + 148 + results := make(chan result, 4) 149 + 150 + // Launch parallel fetches 151 + go func() { 152 + beans, err := store.ListBeans() 153 + results <- result{beans: beans, err: err, which: "beans"} 154 + }() 155 + go func() { 156 + roasters, err := store.ListRoasters() 157 + results <- result{roasters: roasters, err: err, which: "roasters"} 158 + }() 159 + go func() { 160 + grinders, err := store.ListGrinders() 161 + results <- result{grinders: grinders, err: err, which: "grinders"} 162 + }() 163 + go func() { 164 + brewers, err := store.ListBrewers() 165 + results <- result{brewers: brewers, err: err, which: "brewers"} 166 + }() 167 + 168 + // Collect results 169 + var beans []*models.Bean 170 + var roasters []*models.Roaster 171 + var grinders []*models.Grinder 172 + var brewers []*models.Brewer 173 + 174 + for i := 0; i < 4; i++ { 175 + res := <-results 176 + if res.err != nil { 177 + http.Error(w, "Failed to fetch "+res.which+": "+res.err.Error(), http.StatusInternalServerError) 178 + return 179 + } 180 + switch res.which { 181 + case "beans": 182 + beans = res.beans 183 + case "roasters": 184 + roasters = res.roasters 185 + case "grinders": 186 + grinders = res.grinders 187 + case "brewers": 188 + brewers = res.brewers 189 + } 190 + } 191 + 192 + // Link beans to their roasters 193 + atproto.LinkBeansToRoasters(beans, roasters) 194 + 195 + if err := bff.RenderManagePartial(w, beans, roasters, grinders, brewers); err != nil { 196 + http.Error(w, "Failed to render: "+err.Error(), http.StatusInternalServerError) 197 + } 198 + } 199 + 200 + // List all brews 201 + func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 202 + // Require authentication 203 + _, authenticated := h.getAtprotoStore(r) 204 + if !authenticated { 205 + http.Redirect(w, r, "/login", http.StatusFound) 206 + return 207 + } 208 + 209 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 210 + 211 + // Don't fetch brews here - let them load async via HTMX 212 + if err := bff.RenderBrewList(w, nil, authenticated, didStr); err != nil { 127 213 http.Error(w, err.Error(), http.StatusInternalServerError) 128 214 } 129 215 } ··· 497 583 // Manage page 498 584 func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) { 499 585 // Require authentication 500 - store, authenticated := h.getAtprotoStore(r) 586 + _, authenticated := h.getAtprotoStore(r) 501 587 if !authenticated { 502 588 http.Redirect(w, r, "/login", http.StatusFound) 503 589 return ··· 505 591 506 592 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 507 593 508 - // Fetch all collections in parallel for better performance 509 - type result struct { 510 - beans []*models.Bean 511 - roasters []*models.Roaster 512 - grinders []*models.Grinder 513 - brewers []*models.Brewer 514 - err error 515 - which string 516 - } 517 - 518 - results := make(chan result, 4) 519 - 520 - // Launch parallel fetches 521 - go func() { 522 - beans, err := store.ListBeans() 523 - results <- result{beans: beans, err: err, which: "beans"} 524 - }() 525 - go func() { 526 - roasters, err := store.ListRoasters() 527 - results <- result{roasters: roasters, err: err, which: "roasters"} 528 - }() 529 - go func() { 530 - grinders, err := store.ListGrinders() 531 - results <- result{grinders: grinders, err: err, which: "grinders"} 532 - }() 533 - go func() { 534 - brewers, err := store.ListBrewers() 535 - results <- result{brewers: brewers, err: err, which: "brewers"} 536 - }() 537 - 538 - // Collect results 539 - var beans []*models.Bean 540 - var roasters []*models.Roaster 541 - var grinders []*models.Grinder 542 - var brewers []*models.Brewer 543 - 544 - for i := 0; i < 4; i++ { 545 - res := <-results 546 - if res.err != nil { 547 - http.Error(w, res.err.Error(), http.StatusInternalServerError) 548 - return 549 - } 550 - switch res.which { 551 - case "beans": 552 - beans = res.beans 553 - case "roasters": 554 - roasters = res.roasters 555 - case "grinders": 556 - grinders = res.grinders 557 - case "brewers": 558 - brewers = res.brewers 559 - } 560 - } 561 - 562 - // Link beans to their roasters using the pre-fetched roasters 563 - // This avoids N+1 queries when using ATProto store 564 - atproto.LinkBeansToRoasters(beans, roasters) 565 - 566 - if err := bff.RenderManage(w, beans, roasters, grinders, brewers, authenticated, didStr); err != nil { 594 + // Don't fetch data here - let it load async via HTMX 595 + if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr); err != nil { 567 596 http.Error(w, err.Error(), http.StatusInternalServerError) 568 597 } 569 598 }
+6
internal/routing/routing.go
··· 40 40 // Community feed partial (loaded async via HTMX) 41 41 mux.HandleFunc("GET /api/feed", h.HandleFeedPartial) 42 42 43 + // Brew list partial (loaded async via HTMX) 44 + mux.HandleFunc("GET /api/brews", h.HandleBrewListPartial) 45 + 46 + // Manage page partial (loaded async via HTMX) 47 + mux.HandleFunc("GET /api/manage", h.HandleManagePartial) 48 + 43 49 // Page routes (must come before static files) 44 50 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 45 51 mux.HandleFunc("GET /manage", h.HandleManage)
+47 -75
templates/brew_list.tmpl
··· 5 5 <div class="flex flex-col sm:flex-row gap-4"> 6 6 <a href="/brews/new" 7 7 class="bg-brown-600 text-white py-2 px-4 rounded hover:bg-brown-700 transition text-center"> 8 - ➕ New Brew 8 + + New Brew 9 9 </a> 10 10 <a href="/brews/export" 11 11 class="bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 transition text-center"> 12 - 📥 Export JSON 12 + Export JSON 13 13 </a> 14 14 </div> 15 15 </div> 16 16 17 - {{if not .Brews}} 18 - <div class="bg-white rounded-lg shadow-md p-8 text-center"> 19 - <p class="text-gray-600 text-lg mb-4">No brews yet! Start tracking your coffee journey.</p> 20 - <a href="/brews/new" 21 - class="inline-block bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition"> 22 - Add Your First Brew 23 - </a> 24 - </div> 25 - {{else}} 26 - <div class="overflow-x-auto bg-white rounded-lg shadow-md"> 27 - <table class="min-w-full divide-y divide-gray-200"> 28 - <thead class="bg-gray-50"> 29 - <tr> 30 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> 31 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bean</th> 32 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roaster</th> 33 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating</th> 35 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> 36 - </tr> 37 - </thead> 38 - <tbody class="bg-white divide-y divide-gray-200"> 39 - {{range .Brews}} 40 - <tr class="hover:bg-gray-50"> 41 - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 42 - {{.CreatedAt.Format "Jan 2, 2006"}} 43 - </td> 44 - <td class="px-6 py-4 text-sm text-gray-900"> 45 - {{if .Bean.Name}} 46 - <div class="font-medium">{{.Bean.Name}}</div> 47 - <div class="text-gray-500 text-xs">{{.Bean.Origin}} - {{.Bean.RoastLevel}}</div> 48 - {{else}} 49 - <div class="font-medium">{{.Bean.Origin}}</div> 50 - <div class="text-gray-500">{{.Bean.RoastLevel}}</div> 51 - {{end}} 52 - </td> 53 - <td class="px-6 py-4 text-sm text-gray-900"> 54 - {{if and .Bean .Bean.Roaster .Bean.Roaster.Name}} 55 - {{.Bean.Roaster.Name}} 56 - {{else}} 57 - <span class="text-gray-400">-</span> 58 - {{end}} 59 - </td> 60 - <td class="px-6 py-4 text-sm text-gray-900"> 61 - <div>{{.Method}}</div> 62 - <div class="text-gray-500 text-xs"> 63 - {{.TempFormatted}} • {{.TimeFormatted}} 64 - </div> 65 - {{if .Pours}} 66 - <div class="text-gray-500 text-xs mt-1"> 67 - {{len .Pours}} pours 68 - </div> 69 - {{end}} 70 - </td> 71 - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 72 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> 73 - ⭐ {{.RatingFormatted}} 74 - </span> 75 - </td> 76 - <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> 77 - <a href="/brews/{{.RKey}}" 78 - class="text-blue-600 hover:text-blue-900">View</a> 79 - <button hx-delete="/brews/{{.RKey}}" 80 - hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 81 - hx-swap="outerHTML swap:1s" class="text-red-600 hover:text-red-900"> 82 - Delete 83 - </button> 84 - </td> 85 - </tr> 86 - {{end}} 87 - </tbody> 88 - </table> 17 + <div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML"> 18 + <!-- Loading skeleton --> 19 + <div class="overflow-x-auto bg-white rounded-lg shadow-md"> 20 + <table class="min-w-full divide-y divide-gray-200"> 21 + <thead class="bg-gray-50"> 22 + <tr> 23 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> 24 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bean</th> 25 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roaster</th> 26 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th> 27 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating</th> 28 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> 29 + </tr> 30 + </thead> 31 + <tbody class="bg-white divide-y divide-gray-200"> 32 + {{range iterate 5}} 33 + <tr class="animate-pulse"> 34 + <td class="px-6 py-4 whitespace-nowrap"> 35 + <div class="h-4 bg-gray-200 rounded w-20"></div> 36 + </td> 37 + <td class="px-6 py-4"> 38 + <div class="h-4 bg-gray-200 rounded w-32 mb-2"></div> 39 + <div class="h-3 bg-gray-100 rounded w-24"></div> 40 + </td> 41 + <td class="px-6 py-4"> 42 + <div class="h-4 bg-gray-200 rounded w-24"></div> 43 + </td> 44 + <td class="px-6 py-4"> 45 + <div class="h-4 bg-gray-200 rounded w-20 mb-2"></div> 46 + <div class="h-3 bg-gray-100 rounded w-16"></div> 47 + </td> 48 + <td class="px-6 py-4"> 49 + <div class="h-5 bg-yellow-100 rounded-full w-14"></div> 50 + </td> 51 + <td class="px-6 py-4"> 52 + <div class="flex gap-2"> 53 + <div class="h-4 bg-gray-200 rounded w-10"></div> 54 + <div class="h-4 bg-gray-200 rounded w-12"></div> 55 + </div> 56 + </td> 57 + </tr> 58 + {{end}} 59 + </tbody> 60 + </table> 61 + </div> 89 62 </div> 90 - {{end}} 91 63 </div> 92 64 {{end}}
+37 -303
templates/manage.tmpl
··· 30 30 </nav> 31 31 </div> 32 32 33 - <!-- Beans Tab --> 34 - <div x-show="tab === 'beans'"> 35 - <div class="mb-4 flex justify-between items-center"> 36 - <h3 class="text-xl font-semibold">Coffee Beans</h3> 37 - <button 38 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 39 - class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 40 - + Add Bean 41 - </button> 42 - </div> 43 - 44 - {{if not .Beans}} 45 - <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 46 - No beans yet. Add your first bean to get started! 47 - </div> 48 - {{else}} 49 - <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 50 - <table class="min-w-full divide-y divide-gray-200"> 51 - <thead class="bg-gray-50"> 52 - <tr> 53 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 54 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Origin</th> 55 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Roaster</th> 56 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Roast Level</th> 57 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Process</th> 58 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th> 59 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 60 - </tr> 61 - </thead> 62 - <tbody class="bg-white divide-y divide-gray-200"> 63 - {{range .Beans}} 64 - <tr> 65 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 66 - <td class="px-6 py-4 text-sm text-gray-900">{{.Origin}}</td> 67 - <td class="px-6 py-4 text-sm text-gray-900"> 68 - {{if and .Roaster .Roaster.Name}} 69 - {{.Roaster.Name}} 70 - {{else}} 71 - <span class="text-gray-400">-</span> 72 - {{end}} 73 - </td> 74 - <td class="px-6 py-4 text-sm text-gray-900">{{.RoastLevel}}</td> 75 - <td class="px-6 py-4 text-sm text-gray-900">{{.Process}}</td> 76 - <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 77 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 78 - <button @click="editBean('{{.RKey}}', '{{.Name}}', '{{.Origin}}', '{{.RoastLevel}}', '{{.Process}}', '{{.Description}}', '{{.RoasterRKey}}')" 79 - class="text-blue-600 hover:text-blue-900">Edit</button> 80 - <button @click="deleteBean('{{.RKey}}')" 81 - class="text-red-600 hover:text-red-900">Delete</button> 82 - </td> 83 - </tr> 84 - {{end}} 85 - </tbody> 86 - </table> 87 - </div> 88 - {{end}} 89 - </div> 90 - 91 - <!-- Roasters Tab --> 92 - <div x-show="tab === 'roasters'"> 93 - <div class="mb-4 flex justify-between items-center"> 94 - <h3 class="text-xl font-semibold">Roasters</h3> 95 - <button 96 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 97 - class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 98 - + Add Roaster 99 - </button> 100 - </div> 101 - 102 - {{if not .Roasters}} 103 - <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 104 - No roasters yet. Add your first roaster! 105 - </div> 106 - {{else}} 107 - <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 108 - <table class="min-w-full divide-y divide-gray-200"> 109 - <thead class="bg-gray-50"> 110 - <tr> 111 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 112 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th> 113 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Website</th> 114 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 115 - </tr> 116 - </thead> 117 - <tbody class="bg-white divide-y divide-gray-200"> 118 - {{range .Roasters}} 119 - <tr> 120 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 121 - <td class="px-6 py-4 text-sm text-gray-900">{{.Location}}</td> 122 - <td class="px-6 py-4 text-sm text-gray-900"> 123 - {{if .Website}} 124 - <a href="{{.Website}}" target="_blank" 125 - class="text-blue-600 hover:underline">{{.Website}}</a> 126 - {{end}} 127 - </td> 128 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 129 - <button @click="editRoaster('{{.RKey}}', '{{.Name}}', '{{.Location}}', '{{.Website}}')" 130 - class="text-blue-600 hover:text-blue-900">Edit</button> 131 - <button @click="deleteRoaster('{{.RKey}}')" 132 - class="text-red-600 hover:text-red-900">Delete</button> 133 - </td> 134 - </tr> 135 - {{end}} 136 - </tbody> 137 - </table> 138 - </div> 139 - {{end}} 140 - </div> 141 - 142 - <!-- Grinders Tab --> 143 - <div x-show="tab === 'grinders'"> 144 - <div class="mb-4 flex justify-between items-center"> 145 - <h3 class="text-xl font-semibold">Grinders</h3> 146 - <button 147 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 148 - class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 149 - + Add Grinder 150 - </button> 151 - </div> 152 - 153 - {{if not .Grinders}} 154 - <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 155 - No grinders yet. Add your first grinder! 156 - </div> 157 - {{else}} 158 - <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 159 - <table class="min-w-full divide-y divide-gray-200"> 160 - <thead class="bg-gray-50"> 161 - <tr> 162 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 163 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Grinder Type</th> 164 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Burr Type</th> 165 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th> 166 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 167 - </tr> 168 - </thead> 169 - <tbody class="bg-white divide-y divide-gray-200"> 170 - {{range .Grinders}} 171 - <tr> 172 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 173 - <td class="px-6 py-4 text-sm text-gray-900">{{.GrinderType}}</td> 174 - <td class="px-6 py-4 text-sm text-gray-900">{{.BurrType}}</td> 175 - <td class="px-6 py-4 text-sm text-gray-500">{{.Notes}}</td> 176 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 177 - <button @click="editGrinder('{{.RKey}}', '{{.Name}}', '{{.GrinderType}}', '{{.BurrType}}', '{{.Notes}}')" 178 - class="text-blue-600 hover:text-blue-900">Edit</button> 179 - <button @click="deleteGrinder('{{.RKey}}')" 180 - class="text-red-600 hover:text-red-900">Delete</button> 181 - </td> 182 - </tr> 183 - {{end}} 184 - </tbody> 185 - </table> 186 - </div> 187 - {{end}} 188 - </div> 189 - 190 - <!-- Brewers Tab --> 191 - <div x-show="tab === 'brewers'"> 192 - <div class="mb-4 flex justify-between items-center"> 193 - <h3 class="text-xl font-semibold">Brewers</h3> 194 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', description: ''}" 195 - class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 196 - + Add Brewer 197 - </button> 198 - </div> 199 - 200 - {{if not .Brewers}} 201 - <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 202 - No brewers yet. Add your first brewer! 203 - </div> 204 - {{else}} 205 - <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 206 - <table class="min-w-full divide-y divide-gray-200"> 207 - <thead class="bg-gray-50"> 208 - <tr> 209 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 210 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th> 211 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 212 - </tr> 213 - </thead> 214 - <tbody class="bg-white divide-y divide-gray-200"> 215 - {{range .Brewers}} 216 - <tr> 217 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 218 - <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 219 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 220 - <button @click="editBrewer('{{.RKey}}', '{{.Name}}', '{{.Description}}')" 221 - class="text-blue-600 hover:text-blue-900">Edit</button> 222 - <button @click="deleteBrewer('{{.RKey}}')" 223 - class="text-red-600 hover:text-red-900">Delete</button> 224 - </td> 225 - </tr> 226 - {{end}} 227 - </tbody> 228 - </table> 229 - </div> 230 - {{end}} 231 - </div> 232 - 233 - <!-- Bean Form Modal --> 234 - <div x-show="showBeanForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 235 - <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 236 - <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 237 - <div class="space-y-4"> 238 - <input type="text" x-model="beanForm.name" placeholder="Name *" 239 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 240 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 241 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 242 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 243 - <option value="">Select Roaster (Optional)</option> 244 - {{range .Roasters}} 245 - <option value="{{.RKey}}">{{.Name}}</option> 246 - {{end}} 247 - </select> 248 - <select x-model="beanForm.roast_level" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 249 - <option value="">Select Roast Level (Optional)</option> 250 - <option value="Ultra-Light">Ultra-Light</option> 251 - <option value="Light">Light</option> 252 - <option value="Medium-Light">Medium-Light</option> 253 - <option value="Medium">Medium</option> 254 - <option value="Medium-Dark">Medium-Dark</option> 255 - <option value="Dark">Dark</option> 256 - </select> 257 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 258 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 259 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 260 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 261 - <div class="flex gap-2"> 262 - <button @click="saveBean()" 263 - class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 264 - <button @click="showBeanForm = false" 265 - class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 266 - </div> 267 - </div> 268 - </div> 269 - </div> 270 - 271 - <!-- Roaster Form Modal --> 272 - <div x-show="showRoasterForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 273 - <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 274 - <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 275 - <div class="space-y-4"> 276 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 277 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 278 - <input type="text" x-model="roasterForm.location" placeholder="Location" 279 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 280 - <input type="url" x-model="roasterForm.website" placeholder="Website" 281 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 282 - <div class="flex gap-2"> 283 - <button @click="saveRoaster()" 284 - class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 285 - <button @click="showRoasterForm = false" 286 - class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 287 - </div> 33 + <div hx-get="/api/manage" hx-trigger="load" hx-swap="innerHTML"> 34 + <!-- Loading skeleton for the active tab --> 35 + <div class="animate-pulse"> 36 + <!-- Header skeleton --> 37 + <div class="mb-4 flex justify-between items-center"> 38 + <div class="h-6 bg-gray-200 rounded w-32"></div> 39 + <div class="h-10 bg-brown-200 rounded w-28"></div> 288 40 </div> 289 - </div> 290 - </div> 291 - 292 - <!-- Grinder Form Modal --> 293 - <div x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 294 - <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 295 - <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 296 - <div class="space-y-4"> 297 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 298 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 299 - <select x-model="grinderForm.grinder_type" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 300 - <option value="">Select Grinder Type *</option> 301 - <option value="Hand">Hand</option> 302 - <option value="Electric">Electric</option> 303 - <option value="Portable Electric">Portable Electric</option> 304 - </select> 305 - <select x-model="grinderForm.burr_type" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 306 - <option value="">Select Burr Type (Optional)</option> 307 - <option value="Conical">Conical</option> 308 - <option value="Flat">Flat</option> 309 - </select> 310 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 311 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 312 - <div class="flex gap-2"> 313 - <button @click="saveGrinder()" 314 - class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 315 - <button @click="showGrinderForm = false" 316 - class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 317 - </div> 318 - </div> 319 - </div> 320 - </div> 321 - 322 - <!-- Brewer Form Modal --> 323 - <div x-show="showBrewerForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 324 - <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 325 - <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 326 - <div class="space-y-4"> 327 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 328 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 329 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 330 - class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 331 - <div class="flex gap-2"> 332 - <button @click="saveBrewer()" 333 - class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 334 - <button @click="showBrewerForm = false" 335 - class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 336 - </div> 41 + 42 + <!-- Table skeleton --> 43 + <div class="bg-white shadow-md rounded-lg overflow-hidden"> 44 + <table class="min-w-full divide-y divide-gray-200"> 45 + <thead class="bg-gray-50"> 46 + <tr> 47 + <th class="px-6 py-3 text-left"><div class="h-3 bg-gray-200 rounded w-16"></div></th> 48 + <th class="px-6 py-3 text-left"><div class="h-3 bg-gray-200 rounded w-16"></div></th> 49 + <th class="px-6 py-3 text-left"><div class="h-3 bg-gray-200 rounded w-20"></div></th> 50 + <th class="px-6 py-3 text-left"><div class="h-3 bg-gray-200 rounded w-24"></div></th> 51 + <th class="px-6 py-3 text-left"><div class="h-3 bg-gray-200 rounded w-16"></div></th> 52 + </tr> 53 + </thead> 54 + <tbody class="bg-white divide-y divide-gray-200"> 55 + {{range iterate 4}} 56 + <tr> 57 + <td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-24"></div></td> 58 + <td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-20"></div></td> 59 + <td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-28"></div></td> 60 + <td class="px-6 py-4"><div class="h-4 bg-gray-200 rounded w-16"></div></td> 61 + <td class="px-6 py-4"> 62 + <div class="flex gap-2"> 63 + <div class="h-4 bg-gray-200 rounded w-10"></div> 64 + <div class="h-4 bg-gray-200 rounded w-12"></div> 65 + </div> 66 + </td> 67 + </tr> 68 + {{end}} 69 + </tbody> 70 + </table> 337 71 </div> 338 72 </div> 339 73 </div>
+80
templates/partials/brew_list_content.tmpl
··· 1 + {{define "brew_list_content"}} 2 + {{if not .Brews}} 3 + <div class="bg-white rounded-lg shadow-md p-8 text-center"> 4 + <p class="text-gray-600 text-lg mb-4">No brews yet! Start tracking your coffee journey.</p> 5 + <a href="/brews/new" 6 + class="inline-block bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition"> 7 + Add Your First Brew 8 + </a> 9 + </div> 10 + {{else}} 11 + <div class="overflow-x-auto bg-white rounded-lg shadow-md"> 12 + <table class="min-w-full divide-y divide-gray-200"> 13 + <thead class="bg-gray-50"> 14 + <tr> 15 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> 16 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bean</th> 17 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roaster</th> 18 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th> 19 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating</th> 20 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> 21 + </tr> 22 + </thead> 23 + <tbody class="bg-white divide-y divide-gray-200"> 24 + {{range .Brews}} 25 + <tr class="hover:bg-gray-50"> 26 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 27 + {{.CreatedAt.Format "Jan 2, 2006"}} 28 + </td> 29 + <td class="px-6 py-4 text-sm text-gray-900"> 30 + {{if .Bean}} 31 + {{if .Bean.Name}} 32 + <div class="font-medium">{{.Bean.Name}}</div> 33 + <div class="text-gray-500 text-xs">{{.Bean.Origin}} - {{.Bean.RoastLevel}}</div> 34 + {{else}} 35 + <div class="font-medium">{{.Bean.Origin}}</div> 36 + <div class="text-gray-500">{{.Bean.RoastLevel}}</div> 37 + {{end}} 38 + {{else}} 39 + <span class="text-gray-400">-</span> 40 + {{end}} 41 + </td> 42 + <td class="px-6 py-4 text-sm text-gray-900"> 43 + {{if and .Bean .Bean.Roaster .Bean.Roaster.Name}} 44 + {{.Bean.Roaster.Name}} 45 + {{else}} 46 + <span class="text-gray-400">-</span> 47 + {{end}} 48 + </td> 49 + <td class="px-6 py-4 text-sm text-gray-900"> 50 + <div>{{.Method}}</div> 51 + <div class="text-gray-500 text-xs"> 52 + {{.TempFormatted}} • {{.TimeFormatted}} 53 + </div> 54 + {{if .Pours}} 55 + <div class="text-gray-500 text-xs mt-1"> 56 + {{len .Pours}} pours 57 + </div> 58 + {{end}} 59 + </td> 60 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 61 + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> 62 + ⭐ {{.RatingFormatted}} 63 + </span> 64 + </td> 65 + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> 66 + <a href="/brews/{{.RKey}}" 67 + class="text-blue-600 hover:text-blue-900">View</a> 68 + <button hx-delete="/brews/{{.RKey}}" 69 + hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 70 + hx-swap="outerHTML swap:1s" class="text-red-600 hover:text-red-900"> 71 + Delete 72 + </button> 73 + </td> 74 + </tr> 75 + {{end}} 76 + </tbody> 77 + </table> 78 + </div> 79 + {{end}} 80 + {{end}}
+309
templates/partials/manage_content.tmpl
··· 1 + {{define "manage_content"}} 2 + <!-- Beans Tab --> 3 + <div x-show="tab === 'beans'"> 4 + <div class="mb-4 flex justify-between items-center"> 5 + <h3 class="text-xl font-semibold">Coffee Beans</h3> 6 + <button 7 + @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 8 + class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 9 + + Add Bean 10 + </button> 11 + </div> 12 + 13 + {{if not .Beans}} 14 + <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 15 + No beans yet. Add your first bean to get started! 16 + </div> 17 + {{else}} 18 + <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 19 + <table class="min-w-full divide-y divide-gray-200"> 20 + <thead class="bg-gray-50"> 21 + <tr> 22 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 23 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Origin</th> 24 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Roaster</th> 25 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Roast Level</th> 26 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Process</th> 27 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th> 28 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 29 + </tr> 30 + </thead> 31 + <tbody class="bg-white divide-y divide-gray-200"> 32 + {{range .Beans}} 33 + <tr> 34 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 35 + <td class="px-6 py-4 text-sm text-gray-900">{{.Origin}}</td> 36 + <td class="px-6 py-4 text-sm text-gray-900"> 37 + {{if and .Roaster .Roaster.Name}} 38 + {{.Roaster.Name}} 39 + {{else}} 40 + <span class="text-gray-400">-</span> 41 + {{end}} 42 + </td> 43 + <td class="px-6 py-4 text-sm text-gray-900">{{.RoastLevel}}</td> 44 + <td class="px-6 py-4 text-sm text-gray-900">{{.Process}}</td> 45 + <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 46 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 47 + <button @click="editBean('{{.RKey}}', '{{.Name}}', '{{.Origin}}', '{{.RoastLevel}}', '{{.Process}}', '{{.Description}}', '{{.RoasterRKey}}')" 48 + class="text-blue-600 hover:text-blue-900">Edit</button> 49 + <button @click="deleteBean('{{.RKey}}')" 50 + class="text-red-600 hover:text-red-900">Delete</button> 51 + </td> 52 + </tr> 53 + {{end}} 54 + </tbody> 55 + </table> 56 + </div> 57 + {{end}} 58 + </div> 59 + 60 + <!-- Roasters Tab --> 61 + <div x-show="tab === 'roasters'"> 62 + <div class="mb-4 flex justify-between items-center"> 63 + <h3 class="text-xl font-semibold">Roasters</h3> 64 + <button 65 + @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 66 + class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 67 + + Add Roaster 68 + </button> 69 + </div> 70 + 71 + {{if not .Roasters}} 72 + <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 73 + No roasters yet. Add your first roaster! 74 + </div> 75 + {{else}} 76 + <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 77 + <table class="min-w-full divide-y divide-gray-200"> 78 + <thead class="bg-gray-50"> 79 + <tr> 80 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 81 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th> 82 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Website</th> 83 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 84 + </tr> 85 + </thead> 86 + <tbody class="bg-white divide-y divide-gray-200"> 87 + {{range .Roasters}} 88 + <tr> 89 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 90 + <td class="px-6 py-4 text-sm text-gray-900">{{.Location}}</td> 91 + <td class="px-6 py-4 text-sm text-gray-900"> 92 + {{if .Website}} 93 + <a href="{{.Website}}" target="_blank" 94 + class="text-blue-600 hover:underline">{{.Website}}</a> 95 + {{end}} 96 + </td> 97 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 98 + <button @click="editRoaster('{{.RKey}}', '{{.Name}}', '{{.Location}}', '{{.Website}}')" 99 + class="text-blue-600 hover:text-blue-900">Edit</button> 100 + <button @click="deleteRoaster('{{.RKey}}')" 101 + class="text-red-600 hover:text-red-900">Delete</button> 102 + </td> 103 + </tr> 104 + {{end}} 105 + </tbody> 106 + </table> 107 + </div> 108 + {{end}} 109 + </div> 110 + 111 + <!-- Grinders Tab --> 112 + <div x-show="tab === 'grinders'"> 113 + <div class="mb-4 flex justify-between items-center"> 114 + <h3 class="text-xl font-semibold">Grinders</h3> 115 + <button 116 + @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 117 + class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 118 + + Add Grinder 119 + </button> 120 + </div> 121 + 122 + {{if not .Grinders}} 123 + <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 124 + No grinders yet. Add your first grinder! 125 + </div> 126 + {{else}} 127 + <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 128 + <table class="min-w-full divide-y divide-gray-200"> 129 + <thead class="bg-gray-50"> 130 + <tr> 131 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 132 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Grinder Type</th> 133 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Burr Type</th> 134 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th> 135 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 136 + </tr> 137 + </thead> 138 + <tbody class="bg-white divide-y divide-gray-200"> 139 + {{range .Grinders}} 140 + <tr> 141 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 142 + <td class="px-6 py-4 text-sm text-gray-900">{{.GrinderType}}</td> 143 + <td class="px-6 py-4 text-sm text-gray-900">{{.BurrType}}</td> 144 + <td class="px-6 py-4 text-sm text-gray-500">{{.Notes}}</td> 145 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 146 + <button @click="editGrinder('{{.RKey}}', '{{.Name}}', '{{.GrinderType}}', '{{.BurrType}}', '{{.Notes}}')" 147 + class="text-blue-600 hover:text-blue-900">Edit</button> 148 + <button @click="deleteGrinder('{{.RKey}}')" 149 + class="text-red-600 hover:text-red-900">Delete</button> 150 + </td> 151 + </tr> 152 + {{end}} 153 + </tbody> 154 + </table> 155 + </div> 156 + {{end}} 157 + </div> 158 + 159 + <!-- Brewers Tab --> 160 + <div x-show="tab === 'brewers'"> 161 + <div class="mb-4 flex justify-between items-center"> 162 + <h3 class="text-xl font-semibold">Brewers</h3> 163 + <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', description: ''}" 164 + class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 165 + + Add Brewer 166 + </button> 167 + </div> 168 + 169 + {{if not .Brewers}} 170 + <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 171 + No brewers yet. Add your first brewer! 172 + </div> 173 + {{else}} 174 + <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 175 + <table class="min-w-full divide-y divide-gray-200"> 176 + <thead class="bg-gray-50"> 177 + <tr> 178 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 179 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th> 180 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 181 + </tr> 182 + </thead> 183 + <tbody class="bg-white divide-y divide-gray-200"> 184 + {{range .Brewers}} 185 + <tr> 186 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 187 + <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 188 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 189 + <button @click="editBrewer('{{.RKey}}', '{{.Name}}', '{{.Description}}')" 190 + class="text-blue-600 hover:text-blue-900">Edit</button> 191 + <button @click="deleteBrewer('{{.RKey}}')" 192 + class="text-red-600 hover:text-red-900">Delete</button> 193 + </td> 194 + </tr> 195 + {{end}} 196 + </tbody> 197 + </table> 198 + </div> 199 + {{end}} 200 + </div> 201 + 202 + <!-- Bean Form Modal --> 203 + <div x-show="showBeanForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 204 + <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 205 + <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 206 + <div class="space-y-4"> 207 + <input type="text" x-model="beanForm.name" placeholder="Name *" 208 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 209 + <input type="text" x-model="beanForm.origin" placeholder="Origin *" 210 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 211 + <select x-model="beanForm.roaster_rkey" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 212 + <option value="">Select Roaster (Optional)</option> 213 + {{range .Roasters}} 214 + <option value="{{.RKey}}">{{.Name}}</option> 215 + {{end}} 216 + </select> 217 + <select x-model="beanForm.roast_level" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 218 + <option value="">Select Roast Level (Optional)</option> 219 + <option value="Ultra-Light">Ultra-Light</option> 220 + <option value="Light">Light</option> 221 + <option value="Medium-Light">Medium-Light</option> 222 + <option value="Medium">Medium</option> 223 + <option value="Medium-Dark">Medium-Dark</option> 224 + <option value="Dark">Dark</option> 225 + </select> 226 + <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 227 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 228 + <textarea x-model="beanForm.description" placeholder="Description" rows="3" 229 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 230 + <div class="flex gap-2"> 231 + <button @click="saveBean()" 232 + class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 233 + <button @click="showBeanForm = false" 234 + class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 235 + </div> 236 + </div> 237 + </div> 238 + </div> 239 + 240 + <!-- Roaster Form Modal --> 241 + <div x-show="showRoasterForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 242 + <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 243 + <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 244 + <div class="space-y-4"> 245 + <input type="text" x-model="roasterForm.name" placeholder="Name *" 246 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 247 + <input type="text" x-model="roasterForm.location" placeholder="Location" 248 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 249 + <input type="url" x-model="roasterForm.website" placeholder="Website" 250 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 251 + <div class="flex gap-2"> 252 + <button @click="saveRoaster()" 253 + class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 254 + <button @click="showRoasterForm = false" 255 + class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 256 + </div> 257 + </div> 258 + </div> 259 + </div> 260 + 261 + <!-- Grinder Form Modal --> 262 + <div x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 263 + <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 264 + <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 265 + <div class="space-y-4"> 266 + <input type="text" x-model="grinderForm.name" placeholder="Name *" 267 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 268 + <select x-model="grinderForm.grinder_type" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 269 + <option value="">Select Grinder Type *</option> 270 + <option value="Hand">Hand</option> 271 + <option value="Electric">Electric</option> 272 + <option value="Portable Electric">Portable Electric</option> 273 + </select> 274 + <select x-model="grinderForm.burr_type" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 275 + <option value="">Select Burr Type (Optional)</option> 276 + <option value="Conical">Conical</option> 277 + <option value="Flat">Flat</option> 278 + </select> 279 + <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 280 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 281 + <div class="flex gap-2"> 282 + <button @click="saveGrinder()" 283 + class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 284 + <button @click="showGrinderForm = false" 285 + class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 286 + </div> 287 + </div> 288 + </div> 289 + </div> 290 + 291 + <!-- Brewer Form Modal --> 292 + <div x-show="showBrewerForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 293 + <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 294 + <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 295 + <div class="space-y-4"> 296 + <input type="text" x-model="brewerForm.name" placeholder="Name *" 297 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3" /> 298 + <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 299 + class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"></textarea> 300 + <div class="flex gap-2"> 301 + <button @click="saveBrewer()" 302 + class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 303 + <button @click="showBrewerForm = false" 304 + class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 305 + </div> 306 + </div> 307 + </div> 308 + </div> 309 + {{end}}