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: switch to html/template

+908 -939
-44
.air.toml
··· 1 - root = "." 2 - testdata_dir = "testdata" 3 - tmp_dir = "tmp" 4 - 5 - [build] 6 - args_bin = [] 7 - bin = "./tmp/main" 8 - cmd = "make build" 9 - delay = 1000 10 - exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 - exclude_file = [] 12 - exclude_regex = ["_test.go", "_templ.go"] 13 - exclude_unchanged = false 14 - follow_symlink = false 15 - full_bin = "" 16 - include_dir = [] 17 - include_ext = ["go", "tpl", "tmpl", "templ", "html"] 18 - include_file = [] 19 - kill_delay = "0s" 20 - log = "build-errors.log" 21 - poll = false 22 - poll_interval = 0 23 - rerun = false 24 - rerun_delay = 500 25 - send_interrupt = false 26 - stop_on_error = false 27 - 28 - [color] 29 - app = "" 30 - build = "yellow" 31 - main = "magenta" 32 - runner = "green" 33 - watcher = "cyan" 34 - 35 - [log] 36 - main_only = false 37 - time = false 38 - 39 - [misc] 40 - clean_on_exit = false 41 - 42 - [screen] 43 - clear_on_rebuild = false 44 - keep_scroll = true
-29
Makefile
··· 1 - .PHONY: build run dev templ css clean 2 - 3 - # Build the application 4 - build: templ css 5 - go build -o bin/arabica cmd/server/main.go 6 - 7 - # Generate templ files 8 - templ: 9 - templ generate 10 - 11 - # Build CSS with Tailwind 12 - css: 13 - tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 14 - 15 - # Run the application 16 - run: build 17 - ./bin/arabica 18 - 19 - # Clean build artifacts 20 - clean: 21 - rm -rf bin/ 22 - rm -f arabica.db 23 - rm -f web/static/css/output.css 24 - find . -name "*_templ.go" -delete 25 - 26 - # Initialize database (for testing) 27 - init-db: 28 - rm -f arabica.db 29 - @echo "Database will be created on first run"
+12 -51
README.md
··· 1 1 # Arabica - Coffee Brew Tracker 2 2 3 - A self-hosted web application for tracking your coffee brewing journey. Built with Go, Templ, and SQLite. 3 + A self-hosted web application for tracking your coffee brewing journey. Built with Go and SQLite. 4 4 5 5 ## Features 6 6 ··· 16 16 17 17 - **Backend**: Go 1.22+ (using stdlib router) 18 18 - **Database**: SQLite (via modernc.org/sqlite - pure Go, no CGO) 19 - - **Templates**: Templ (type-safe HTML templates) 19 + - **Templates**: html/template (Go standard library) 20 20 - **Frontend**: HTMX + Alpine.js 21 21 - **CSS**: Tailwind CSS 22 22 - **PWA**: Service Worker for offline support ··· 30 30 │ ├── database/ # Database interface & SQLite implementation 31 31 │ ├── models/ # Data models 32 32 │ ├── handlers/ # HTTP handlers 33 - │ └── templates/ # Templ templates 33 + │ └── templates/ # HTML templates 34 34 ├── web/static/ # Static assets (CSS, JS, PWA files) 35 - ├── migrations/ # Database migrations 36 - └── Makefile # Build commands 35 + └── migrations/ # Database migrations 37 36 ``` 38 37 39 38 ## Getting Started 40 39 41 40 ### Prerequisites 42 41 43 - - Go 1.22+ 44 - - Templ CLI 45 - - Tailwind CSS CLI 46 - - (Optional) Air for hot reload 47 - 48 - Or use Nix: 42 + Use Nix for a reproducible development environment with all dependencies: 49 43 50 44 ```bash 51 45 nix develop 52 46 ``` 53 47 54 - ### Installation 55 - 56 - 1. Clone the repository: 57 - ```bash 58 - cd arabica-site 59 - ``` 48 + ### Running the Application 60 49 61 - 2. Install dependencies: 50 + 1. Enter the Nix development environment: 62 51 ```bash 63 - make install-deps 52 + nix develop 64 53 ``` 65 54 66 - 3. Build the application: 55 + 2. Build and run the server: 67 56 ```bash 68 - make build 69 - ``` 70 - 71 - 4. Run the server: 72 - ```bash 73 - make run 57 + go run ./cmd/server 74 58 ``` 75 59 76 60 The application will be available at `http://localhost:8080` 77 - 78 - ### Development 79 - 80 - For hot reload during development: 81 - 82 - ```bash 83 - make dev 84 - ``` 85 - 86 - This uses Air to automatically rebuild when you change Go files or templates. 87 - 88 - ### Building Assets 89 - 90 - ```bash 91 - # Generate templ files 92 - make templ 93 - 94 - # Build Tailwind CSS 95 - make css 96 - 97 - # Or build everything 98 - make build 99 - ``` 100 61 101 62 ## Usage 102 63 ··· 164 125 165 126 - **Go**: Fast compilation, single binary deployment, excellent stdlib 166 127 - **modernc.org/sqlite**: Pure Go SQLite (no CGO), easy cross-compilation 167 - - **Templ**: Type-safe templates, better than text/template for HTML 128 + - **html/template**: Built-in Go templates, no external dependencies 168 129 - **HTMX**: Progressive enhancement without heavy JS framework 169 - - **Nix**: Reproducible builds across environments 130 + - **Nix**: Reproducible development environment 170 131 171 132 ### Database Schema 172 133
-22
build.sh
··· 1 - #!/usr/bin/env bash 2 - set -e 3 - 4 - echo "🔧 Building Arabica..." 5 - 6 - # Generate templ files 7 - echo "📝 Generating templates..." 8 - templ generate 9 - 10 - # Build CSS 11 - echo "🎨 Building CSS..." 12 - tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 13 - 14 - # Build Go binary 15 - echo "🚀 Building Go application..." 16 - mkdir -p bin 17 - go build -o bin/arabica cmd/server/main.go 18 - 19 - echo "✅ Build complete!" 20 - echo "" 21 - echo "Run './bin/arabica' to start the server" 22 - echo "Or run 'make dev' for hot reload development mode"
+5 -7
flake.nix
··· 9 9 in { 10 10 devShells = forAllSystems (pkgs: system: { 11 11 default = 12 - pkgs.mkShell { packages = with pkgs; [ go templ tailwindcss ]; }; 12 + pkgs.mkShell { packages = with pkgs; [ go tailwindcss ]; }; 13 13 }); 14 14 15 15 packages = forAllSystems (pkgs: system: rec { ··· 19 19 src = ./.; 20 20 21 21 # Vendor hash for Go dependencies 22 - vendorHash = "sha256-7QYmui8+jyG/QOds0YfZfgsKqZcvm/RLQCkDFUk+xUc="; 22 + vendorHash = "sha256-4Z6KAxox3EY9RGtFKUcqxtB/kj3Ed+o+ggPwtLSPctU="; 23 23 24 - nativeBuildInputs = with pkgs; [ templ tailwindcss ]; 24 + nativeBuildInputs = with pkgs; [ tailwindcss ]; 25 25 26 26 preBuild = '' 27 - # Generate templates before building 28 - templ generate 29 - 30 27 # Build Tailwind CSS 31 28 tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 32 29 ''; ··· 42 39 mkdir -p $out/bin 43 40 mkdir -p $out/share/arabica 44 41 45 - # Copy static files and migrations 42 + # Copy static files, migrations, and templates 46 43 cp -r web $out/share/arabica/ 47 44 cp -r migrations $out/share/arabica/ 45 + cp -r internal $out/share/arabica/ 48 46 49 47 # Install the actual binary 50 48 cp arabica $out/bin/arabica-unwrapped
+1 -4
go.mod
··· 2 2 3 3 go 1.25.4 4 4 5 - require ( 6 - github.com/a-h/templ v0.3.960 7 - modernc.org/sqlite v1.42.2 8 - ) 5 + require modernc.org/sqlite v1.42.2 9 6 10 7 require ( 11 8 github.com/dustin/go-humanize v1.0.1 // indirect
-4
go.sum
··· 1 - github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= 2 - github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= 3 1 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 2 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 5 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 3 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 8 4 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 9 5 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+15 -5
internal/handlers/handlers.go
··· 20 20 21 21 // Home page 22 22 func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 23 - templates.Home().Render(r.Context(), w) 23 + if err := templates.RenderHome(w); err != nil { 24 + http.Error(w, err.Error(), http.StatusInternalServerError) 25 + } 24 26 } 25 27 26 28 // List all brews ··· 31 33 return 32 34 } 33 35 34 - templates.BrewList(brews).Render(r.Context(), w) 36 + if err := templates.RenderBrewList(w, brews); err != nil { 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + } 35 39 } 36 40 37 41 // Show new brew form ··· 60 64 return 61 65 } 62 66 63 - templates.BrewForm(beans, roasters, grinders, brewers, nil).Render(r.Context(), w) 67 + if err := templates.RenderBrewForm(w, beans, roasters, grinders, brewers, nil); err != nil { 68 + http.Error(w, err.Error(), http.StatusInternalServerError) 69 + } 64 70 } 65 71 66 72 // Show edit brew form ··· 102 108 return 103 109 } 104 110 105 - templates.BrewForm(beans, roasters, grinders, brewers, brew).Render(r.Context(), w) 111 + if err := templates.RenderBrewForm(w, beans, roasters, grinders, brewers, brew); err != nil { 112 + http.Error(w, err.Error(), http.StatusInternalServerError) 113 + } 106 114 } 107 115 108 116 // Create new brew ··· 382 390 return 383 391 } 384 392 385 - templates.ManagePage(beans, roasters, grinders, brewers).Render(r.Context(), w) 393 + if err := templates.RenderManage(w, beans, roasters, grinders, brewers); err != nil { 394 + http.Error(w, err.Error(), http.StatusInternalServerError) 395 + } 386 396 } 387 397 388 398 // Bean update/delete handlers
-419
internal/templates/brew_form.templ
··· 1 - package templates 2 - 3 - import "arabica/internal/models" 4 - 5 - templ BrewForm(beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, brew *models.Brew) { 6 - @Layout("New Brew") { 7 - <div class="max-w-2xl mx-auto"> 8 - <div class="bg-white rounded-lg shadow-md p-8"> 9 - <h2 class="text-3xl font-bold text-gray-800 mb-6"> 10 - if brew != nil { 11 - Edit Brew 12 - } else { 13 - New Brew 14 - } 15 - </h2> 16 - 17 - <form 18 - if brew != nil { 19 - hx-put={ "/brews/" + formatID(brew.ID) } 20 - } else { 21 - hx-post="/brews" 22 - } 23 - hx-target="body" 24 - class="space-y-6" 25 - x-data="brewForm()" 26 - x-init="rating = $el.querySelector('input[name=rating]').value" 27 - if brew != nil && len(brew.Pours) > 0 { 28 - data-pours={ poursToJSON(brew.Pours) } 29 - }> 30 - 31 - <!-- Bean Selection --> 32 - <div> 33 - <label class="block text-sm font-medium text-gray-700 mb-2">Coffee Bean</label> 34 - <div class="flex gap-2"> 35 - <select 36 - name="bean_id" 37 - required 38 - class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 39 - <option value="">Select a bean...</option> 40 - for _, bean := range beans { 41 - <option 42 - value={ formatID(bean.ID) } 43 - if brew != nil && brew.BeanID == bean.ID { 44 - selected 45 - } 46 - class="truncate"> 47 - if bean.Name != "" { 48 - { bean.Name } ({ bean.Origin } - { bean.RoastLevel }) 49 - } else { 50 - { bean.Origin } - { bean.RoastLevel } 51 - } 52 - </option> 53 - } 54 - </select> 55 - <button 56 - type="button" 57 - @click="showNewBean = true" 58 - class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 59 - + New 60 - </button> 61 - </div> 62 - 63 - <!-- New Bean Modal --> 64 - <div x-show="showNewBean" class="mt-4 p-4 bg-gray-50 rounded border"> 65 - <h4 class="font-medium mb-3">Add New Bean</h4> 66 - <div class="space-y-3"> 67 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 py-2 px-3"/> 68 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 py-2 px-3"/> 69 - <select x-model.number="newBean.roasterId" class="w-full rounded-md border-gray-300 py-2 px-3"> 70 - <option value="">Select Roaster (Optional)</option> 71 - for _, roaster := range roasters { 72 - <option value={ formatID(roaster.ID) }>{ roaster.Name }</option> 73 - } 74 - </select> 75 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 py-2 px-3"> 76 - <option value="">Select Roast Level (Optional)</option> 77 - <option value="Ultra-Light">Ultra-Light</option> 78 - <option value="Light">Light</option> 79 - <option value="Medium-Light">Medium-Light</option> 80 - <option value="Medium">Medium</option> 81 - <option value="Medium-Dark">Medium-Dark</option> 82 - <option value="Dark">Dark</option> 83 - </select> 84 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 85 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 86 - <div class="flex gap-2"> 87 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 88 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 89 - </div> 90 - </div> 91 - </div> 92 - </div> 93 - 94 - <!-- Grinder --> 95 - <div> 96 - <label class="block text-sm font-medium text-gray-700 mb-2">Grinder</label> 97 - <div class="flex gap-2"> 98 - <select 99 - name="grinder_id" 100 - class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 101 - <option value="">Select a grinder...</option> 102 - for _, grinder := range grinders { 103 - <option 104 - value={ formatID(grinder.ID) } 105 - if brew != nil && brew.GrinderID != nil && *brew.GrinderID == grinder.ID { 106 - selected 107 - } 108 - class="truncate"> 109 - { grinder.Name } 110 - </option> 111 - } 112 - </select> 113 - <button 114 - type="button" 115 - @click="showNewGrinder = true" 116 - class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 117 - + New 118 - </button> 119 - </div> 120 - 121 - <!-- New Grinder Modal --> 122 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-gray-50 rounded border"> 123 - <h4 class="font-medium mb-3">Add New Grinder</h4> 124 - <div class="space-y-3"> 125 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 py-2 px-3"/> 126 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 py-2 px-3"> 127 - <option value="">Grinder Type (Optional)</option> 128 - <option value="Hand">Hand</option> 129 - <option value="Electric">Electric</option> 130 - <option value="Electric Hand">Electric Hand</option> 131 - </select> 132 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 py-2 px-3"> 133 - <option value="">Burr Type (Optional)</option> 134 - <option value="Conical">Conical</option> 135 - <option value="Flat">Flat</option> 136 - </select> 137 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 138 - <div class="flex gap-2"> 139 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 140 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 141 - </div> 142 - </div> 143 - </div> 144 - </div> 145 - 146 - <!-- Grind Size --> 147 - <div> 148 - <label class="block text-sm font-medium text-gray-700 mb-2">Grind Size</label> 149 - <input 150 - type="text" 151 - name="grind_size" 152 - if brew != nil { 153 - value={ brew.GrindSize } 154 - } 155 - placeholder="e.g. 18, Medium, 3.5, Fine" 156 - class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 157 - <p class="text-sm text-gray-500 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p> 158 - </div> 159 - 160 - <!-- Brew Method --> 161 - <div> 162 - <label class="block text-sm font-medium text-gray-700 mb-2">Brew Method</label> 163 - <div class="flex gap-2"> 164 - <select 165 - name="brewer_id" 166 - class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 167 - <option value="">Select brew method...</option> 168 - for _, brewer := range brewers { 169 - <option 170 - value={ formatID(brewer.ID) } 171 - if brew != nil && brew.BrewerID != nil && *brew.BrewerID == brewer.ID { 172 - selected 173 - } 174 - class="truncate"> 175 - { brewer.Name } 176 - </option> 177 - } 178 - </select> 179 - <button 180 - type="button" 181 - @click="showNewBrewer = true" 182 - class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 183 - + New 184 - </button> 185 - </div> 186 - 187 - <!-- New Brewer Modal --> 188 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-gray-50 rounded border"> 189 - <h4 class="font-medium mb-3">Add New Brewer</h4> 190 - <div class="space-y-3"> 191 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 py-2 px-3"/> 192 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 193 - <div class="flex gap-2"> 194 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 195 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 196 - </div> 197 - </div> 198 - </div> 199 - </div> 200 - 201 - <!-- Water Amount --> 202 - <div> 203 - <label class="block text-sm font-medium text-gray-700 mb-2">Water Amount (grams)</label> 204 - <input 205 - type="number" 206 - name="water_amount" 207 - step="1" 208 - if brew != nil && brew.WaterAmount > 0 { 209 - value={ formatInt(brew.WaterAmount) } 210 - } 211 - placeholder="e.g. 250" 212 - class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 213 - <p class="text-sm text-gray-500 mt-1">Total water used (or leave empty if using pours below)</p> 214 - </div> 215 - 216 - <!-- Pours Section --> 217 - <div> 218 - <div class="flex items-center justify-between mb-2"> 219 - <label class="block text-sm font-medium text-gray-700">Pours (Optional)</label> 220 - <button 221 - type="button" 222 - @click="addPour()" 223 - class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded hover:bg-gray-300"> 224 - + Add Pour 225 - </button> 226 - </div> 227 - <p class="text-sm text-gray-500 mb-3">Track individual pours for bloom and subsequent additions</p> 228 - 229 - <div class="space-y-3"> 230 - <template x-for="(pour, index) in pours" :key="index"> 231 - <div class="flex gap-2 items-center bg-gray-50 p-3 rounded"> 232 - <div class="flex-1"> 233 - <label class="text-xs text-gray-600" x-text="'Pour ' + (index + 1)"></label> 234 - <input 235 - type="number" 236 - :name="'pour_water_' + index" 237 - x-model="pour.water" 238 - placeholder="Water (g)" 239 - class="w-full rounded-md border-gray-300 text-sm py-2 px-3 mt-1"/> 240 - </div> 241 - <div class="flex-1"> 242 - <label class="text-xs text-gray-600">Time (sec)</label> 243 - <input 244 - type="number" 245 - :name="'pour_time_' + index" 246 - x-model="pour.time" 247 - placeholder="e.g. 45" 248 - class="w-full rounded-md border-gray-300 text-sm py-2 px-3 mt-1"/> 249 - </div> 250 - <button 251 - type="button" 252 - @click="removePour(index)" 253 - class="text-red-600 hover:text-red-800 mt-5" 254 - x-show="pours.length > 0"> 255 - 256 - </button> 257 - </div> 258 - </template> 259 - </div> 260 - </div> 261 - 262 - <!-- Temperature --> 263 - <div> 264 - <label class="block text-sm font-medium text-gray-700 mb-2">Temperature</label> 265 - <input 266 - type="number" 267 - name="temperature" 268 - step="0.1" 269 - if brew != nil && brew.Temperature > 0 { 270 - value={ formatTempValue(brew.Temperature) } 271 - } 272 - placeholder="e.g. 93.5" 273 - class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 274 - </div> 275 - 276 - <!-- Brew Time --> 277 - <div> 278 - <label class="block text-sm font-medium text-gray-700 mb-2">Brew Time (seconds)</label> 279 - <input 280 - type="number" 281 - name="time_seconds" 282 - if brew != nil && brew.TimeSeconds > 0 { 283 - value={ formatInt(brew.TimeSeconds) } 284 - } 285 - placeholder="e.g. 180" 286 - class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 287 - </div> 288 - 289 - <!-- Tasting Notes --> 290 - <div> 291 - <label class="block text-sm font-medium text-gray-700 mb-2">Tasting Notes</label> 292 - <textarea 293 - name="tasting_notes" 294 - rows="4" 295 - placeholder="Describe the flavors, aroma, and your thoughts..." 296 - class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4">if brew != nil { 297 - { brew.TastingNotes }}</textarea> 298 - </div> 299 - 300 - <!-- Rating --> 301 - <div> 302 - <label class="block text-sm font-medium text-gray-700 mb-2">Rating</label> 303 - <input 304 - type="range" 305 - name="rating" 306 - min="1" 307 - max="10" 308 - if brew != nil { 309 - value={ formatInt(brew.Rating) } 310 - } else { 311 - value="5" 312 - } 313 - x-model="rating" 314 - class="w-full"/> 315 - <div class="text-center text-2xl font-bold text-brown-600"> 316 - <span x-text="rating"></span>/10 317 - </div> 318 - </div> 319 - 320 - <!-- Submit --> 321 - <div> 322 - <button 323 - type="submit" 324 - class="w-full bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition font-medium text-lg"> 325 - if brew != nil { 326 - Update Brew 327 - } else { 328 - Save Brew 329 - } 330 - </button> 331 - </div> 332 - </form> 333 - </div> 334 - </div> 335 - 336 - <script> 337 - function brewForm() { 338 - return { 339 - showNewBean: false, 340 - showNewGrinder: false, 341 - showNewBrewer: false, 342 - rating: 5, 343 - pours: [], 344 - newBean: { name: '', origin: '', roasterId: null, roastLevel: '', process: '', description: '' }, 345 - newGrinder: { name: '', grinderType: '', burrType: '', notes: '' }, 346 - newBrewer: { name: '', description: '' }, 347 - init() { 348 - // Load existing pours if editing 349 - const poursData = this.$el.getAttribute('data-pours'); 350 - if (poursData) { 351 - try { 352 - this.pours = JSON.parse(poursData); 353 - } catch (e) { 354 - console.error('Failed to parse pours data:', e); 355 - this.pours = []; 356 - } 357 - } 358 - }, 359 - addPour() { 360 - this.pours.push({ water: '', time: '' }); 361 - }, 362 - removePour(index) { 363 - this.pours.splice(index, 1); 364 - }, 365 - async addBean() { 366 - if (!this.newBean.name || !this.newBean.origin) { 367 - alert('Bean name and origin are required'); 368 - return; 369 - } 370 - const payload = { 371 - name: this.newBean.name, 372 - origin: this.newBean.origin, 373 - roast_level: this.newBean.roastLevel, 374 - process: this.newBean.process, 375 - description: this.newBean.description, 376 - roaster_id: this.newBean.roasterId || null 377 - }; 378 - const response = await fetch('/api/beans', { 379 - method: 'POST', 380 - headers: { 'Content-Type': 'application/json' }, 381 - body: JSON.stringify(payload) 382 - }); 383 - if (response.ok) { 384 - window.location.reload(); 385 - } 386 - }, 387 - async addGrinder() { 388 - if (!this.newGrinder.name) { 389 - alert('Grinder name is required'); 390 - return; 391 - } 392 - const response = await fetch('/api/grinders', { 393 - method: 'POST', 394 - headers: { 'Content-Type': 'application/json' }, 395 - body: JSON.stringify(this.newGrinder) 396 - }); 397 - if (response.ok) { 398 - window.location.reload(); 399 - } 400 - }, 401 - async addBrewer() { 402 - if (!this.newBrewer.name) { 403 - alert('Brewer name is required'); 404 - return; 405 - } 406 - const response = await fetch('/api/brewers', { 407 - method: 'POST', 408 - headers: { 'Content-Type': 'application/json' }, 409 - body: JSON.stringify(this.newBrewer) 410 - }); 411 - if (response.ok) { 412 - window.location.reload(); 413 - } 414 - } 415 - } 416 - } 417 - </script> 418 - } 419 - }
+321
internal/templates/brew_form.tmpl
··· 1 + {{define "content"}} 2 + <script> 3 + function brewForm() { 4 + return { 5 + showNewBean: false, 6 + showNewGrinder: false, 7 + showNewBrewer: false, 8 + rating: 5, 9 + pours: [], 10 + newBean: { name: '', origin: '', roasterId: null, roastLevel: '', process: '', description: '' }, 11 + newGrinder: { name: '', grinderType: '', burrType: '', notes: '' }, 12 + newBrewer: { name: '', description: '' }, 13 + init() { 14 + // Load existing pours if editing 15 + const poursData = this.$el.getAttribute('data-pours'); 16 + if (poursData) { 17 + try { 18 + this.pours = JSON.parse(poursData); 19 + } catch (e) { 20 + console.error('Failed to parse pours data:', e); 21 + this.pours = []; 22 + } 23 + } 24 + }, 25 + addPour() { 26 + this.pours.push({ water: '', time: '' }); 27 + }, 28 + removePour(index) { 29 + this.pours.splice(index, 1); 30 + }, 31 + async addBean() { 32 + if (!this.newBean.name || !this.newBean.origin) { 33 + alert('Bean name and origin are required'); 34 + return; 35 + } 36 + const payload = { 37 + name: this.newBean.name, 38 + origin: this.newBean.origin, 39 + roast_level: this.newBean.roastLevel, 40 + process: this.newBean.process, 41 + description: this.newBean.description, 42 + roaster_id: this.newBean.roasterId || null 43 + }; 44 + const response = await fetch('/api/beans', { 45 + method: 'POST', 46 + headers: { 'Content-Type': 'application/json' }, 47 + body: JSON.stringify(payload) 48 + }); 49 + if (response.ok) { 50 + window.location.reload(); 51 + } 52 + }, 53 + async addGrinder() { 54 + if (!this.newGrinder.name) { 55 + alert('Grinder name is required'); 56 + return; 57 + } 58 + const response = await fetch('/api/grinders', { 59 + method: 'POST', 60 + headers: { 'Content-Type': 'application/json' }, 61 + body: JSON.stringify(this.newGrinder) 62 + }); 63 + if (response.ok) { 64 + window.location.reload(); 65 + } 66 + }, 67 + async addBrewer() { 68 + if (!this.newBrewer.name) { 69 + alert('Brewer name is required'); 70 + return; 71 + } 72 + const response = await fetch('/api/brewers', { 73 + method: 'POST', 74 + headers: { 'Content-Type': 'application/json' }, 75 + body: JSON.stringify(this.newBrewer) 76 + }); 77 + if (response.ok) { 78 + window.location.reload(); 79 + } 80 + } 81 + } 82 + } 83 + </script> 84 + 85 + <div class="max-w-2xl mx-auto"> 86 + <div class="bg-white rounded-lg shadow-md p-8"> 87 + <h2 class="text-3xl font-bold text-gray-800 mb-6"> 88 + {{if .Brew}}Edit Brew{{else}}New Brew{{end}} 89 + </h2> 90 + 91 + <form 92 + {{if .Brew}} 93 + hx-put="/brews/{{.Brew.ID}}" 94 + {{else}} 95 + hx-post="/brews" 96 + {{end}} 97 + hx-target="body" 98 + class="space-y-6" 99 + x-data="brewForm()" 100 + {{if and .Brew .Brew.Pours}} 101 + data-pours='{{.Brew.PoursJSON}}' 102 + {{end}}> 103 + 104 + <!-- Bean Selection --> 105 + <div> 106 + <label class="block text-sm font-medium text-gray-700 mb-2">Coffee Bean</label> 107 + <div class="flex gap-2"> 108 + <select 109 + name="bean_id" 110 + required 111 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 112 + <option value="">Select a bean...</option> 113 + {{range .Beans}} 114 + <option 115 + value="{{.ID}}" 116 + {{if and $.Brew (eq $.Brew.BeanID .ID)}}selected{{end}} 117 + class="truncate"> 118 + {{if .Name}}{{.Name}} ({{.Origin}} - {{.RoastLevel}}){{else}}{{.Origin}} - {{.RoastLevel}}{{end}} 119 + </option> 120 + {{end}} 121 + </select> 122 + <button 123 + type="button" 124 + @click="showNewBean = true" 125 + class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 126 + + New 127 + </button> 128 + </div> 129 + 130 + {{template "new_bean_form" .}} 131 + </div> 132 + 133 + <!-- Grinder --> 134 + <div> 135 + <label class="block text-sm font-medium text-gray-700 mb-2">Grinder</label> 136 + <div class="flex gap-2"> 137 + <select 138 + name="grinder_id" 139 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 140 + <option value="">Select a grinder...</option> 141 + {{range .Grinders}} 142 + <option 143 + value="{{.ID}}" 144 + {{if and $.Brew (intPtrEquals $.Brew.GrinderID .ID)}}selected{{end}} 145 + class="truncate"> 146 + {{.Name}} 147 + </option> 148 + {{end}} 149 + </select> 150 + <button 151 + type="button" 152 + @click="showNewGrinder = true" 153 + class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 154 + + New 155 + </button> 156 + </div> 157 + 158 + {{template "new_grinder_form" .}} 159 + </div> 160 + 161 + <!-- Grind Size --> 162 + <div> 163 + <label class="block text-sm font-medium text-gray-700 mb-2">Grind Size</label> 164 + <input 165 + type="text" 166 + name="grind_size" 167 + {{if .Brew}}value="{{.Brew.GrindSize}}"{{end}} 168 + placeholder="e.g. 18, Medium, 3.5, Fine" 169 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 170 + <p class="text-sm text-gray-500 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p> 171 + </div> 172 + 173 + <!-- Brew Method --> 174 + <div> 175 + <label class="block text-sm font-medium text-gray-700 mb-2">Brew Method</label> 176 + <div class="flex gap-2"> 177 + <select 178 + name="brewer_id" 179 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4 truncate max-w-full"> 180 + <option value="">Select brew method...</option> 181 + {{range .Brewers}} 182 + <option 183 + value="{{.ID}}" 184 + {{if and $.Brew (intPtrEquals $.Brew.BrewerID .ID)}}selected{{end}} 185 + class="truncate"> 186 + {{.Name}} 187 + </option> 188 + {{end}} 189 + </select> 190 + <button 191 + type="button" 192 + @click="showNewBrewer = true" 193 + class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 194 + + New 195 + </button> 196 + </div> 197 + 198 + {{template "new_brewer_form" .}} 199 + </div> 200 + 201 + <!-- Water Amount --> 202 + <div> 203 + <label class="block text-sm font-medium text-gray-700 mb-2">Water Amount (grams)</label> 204 + <input 205 + type="number" 206 + name="water_amount" 207 + step="1" 208 + {{if and .Brew (gt .Brew.WaterAmount 0)}}value="{{.Brew.WaterAmount}}"{{end}} 209 + placeholder="e.g. 250" 210 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 211 + <p class="text-sm text-gray-500 mt-1">Total water used (or leave empty if using pours below)</p> 212 + </div> 213 + 214 + <!-- Pours Section --> 215 + <div> 216 + <div class="flex items-center justify-between mb-2"> 217 + <label class="block text-sm font-medium text-gray-700">Pours (Optional)</label> 218 + <button 219 + type="button" 220 + @click="addPour()" 221 + class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded hover:bg-gray-300"> 222 + + Add Pour 223 + </button> 224 + </div> 225 + <p class="text-sm text-gray-500 mb-3">Track individual pours for bloom and subsequent additions</p> 226 + 227 + <div class="space-y-3"> 228 + <template x-for="(pour, index) in pours" :key="index"> 229 + <div class="flex gap-2 items-center bg-gray-50 p-3 rounded"> 230 + <div class="flex-1"> 231 + <label class="text-xs text-gray-600" x-text="'Pour ' + (index + 1)"></label> 232 + <input 233 + type="number" 234 + :name="'pour_water_' + index" 235 + x-model="pour.water" 236 + placeholder="Water (g)" 237 + class="w-full rounded-md border-gray-300 text-sm py-2 px-3 mt-1"/> 238 + </div> 239 + <div class="flex-1"> 240 + <label class="text-xs text-gray-600">Time (sec)</label> 241 + <input 242 + type="number" 243 + :name="'pour_time_' + index" 244 + x-model="pour.time" 245 + placeholder="e.g. 45" 246 + class="w-full rounded-md border-gray-300 text-sm py-2 px-3 mt-1"/> 247 + </div> 248 + <button 249 + type="button" 250 + @click="removePour(index)" 251 + class="text-red-600 hover:text-red-800 mt-5" 252 + x-show="pours.length > 0"> 253 + 254 + </button> 255 + </div> 256 + </template> 257 + </div> 258 + </div> 259 + 260 + <!-- Temperature --> 261 + <div> 262 + <label class="block text-sm font-medium text-gray-700 mb-2">Temperature</label> 263 + <input 264 + type="number" 265 + name="temperature" 266 + step="0.1" 267 + {{if and .Brew (gt .Brew.Temperature 0.0)}}value="{{printf "%.1f" .Brew.Temperature}}"{{end}} 268 + placeholder="e.g. 93.5" 269 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 270 + </div> 271 + 272 + <!-- Brew Time --> 273 + <div> 274 + <label class="block text-sm font-medium text-gray-700 mb-2">Brew Time (seconds)</label> 275 + <input 276 + type="number" 277 + name="time_seconds" 278 + {{if and .Brew (gt .Brew.TimeSeconds 0)}}value="{{.Brew.TimeSeconds}}"{{end}} 279 + placeholder="e.g. 180" 280 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 281 + </div> 282 + 283 + <!-- Tasting Notes --> 284 + <div> 285 + <label class="block text-sm font-medium text-gray-700 mb-2">Tasting Notes</label> 286 + <textarea 287 + name="tasting_notes" 288 + rows="4" 289 + placeholder="Describe the flavors, aroma, and your thoughts..." 290 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4">{{if .Brew}}{{.Brew.TastingNotes}}{{end}}</textarea> 291 + </div> 292 + 293 + <!-- Rating --> 294 + <div> 295 + <label class="block text-sm font-medium text-gray-700 mb-2">Rating</label> 296 + <input 297 + type="range" 298 + name="rating" 299 + min="1" 300 + max="10" 301 + {{if .Brew}}value="{{.Brew.Rating}}"{{else}}value="5"{{end}} 302 + x-model="rating" 303 + x-init="rating = $el.value" 304 + class="w-full"/> 305 + <div class="text-center text-2xl font-bold text-brown-600"> 306 + <span x-text="rating"></span>/10 307 + </div> 308 + </div> 309 + 310 + <!-- Submit --> 311 + <div> 312 + <button 313 + type="submit" 314 + class="w-full bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition font-medium text-lg"> 315 + {{if .Brew}}Update Brew{{else}}Save Brew{{end}} 316 + </button> 317 + </div> 318 + </form> 319 + </div> 320 + </div> 321 + {{end}}
+32 -43
internal/templates/brew_list.templ internal/templates/brew_list.tmpl
··· 1 - package templates 2 - 3 - import "arabica/internal/models" 4 - 5 - templ BrewList(brews []*models.Brew) { 6 - @Layout("All Brews") { 1 + {{define "content"}} 7 2 <div class="max-w-6xl mx-auto"> 8 3 <div class="mb-6"> 9 4 <h2 class="text-3xl font-bold text-gray-800 mb-4">Your Brews</h2> ··· 19 14 </div> 20 15 </div> 21 16 22 - if len(brews) == 0 { 17 + {{if not .Brews}} 23 18 <div class="bg-white rounded-lg shadow-md p-8 text-center"> 24 19 <p class="text-gray-600 text-lg mb-4">No brews yet! Start tracking your coffee journey.</p> 25 20 <a href="/brews/new" ··· 27 22 Add Your First Brew 28 23 </a> 29 24 </div> 30 - } else { 25 + {{else}} 31 26 <div class="overflow-x-auto bg-white rounded-lg shadow-md"> 32 27 <table class="min-w-full divide-y divide-gray-200"> 33 28 <thead class="bg-gray-50"> 34 29 <tr> 35 30 <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> 36 31 <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bean</th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roaster 38 - </th> 39 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method 40 - </th> 41 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions 44 - </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> 45 36 </tr> 46 37 </thead> 47 38 <tbody class="bg-white divide-y divide-gray-200"> 48 - for _, brew := range brews { 39 + {{range .Brews}} 49 40 <tr class="hover:bg-gray-50"> 50 41 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 51 - { brew.CreatedAt.Format("Jan 2, 2006") } 42 + {{.CreatedAt.Format "Jan 2, 2006"}} 52 43 </td> 53 44 <td class="px-6 py-4 text-sm text-gray-900"> 54 - if brew.Bean.Name != "" { 55 - <div class="font-medium">{ brew.Bean.Name }</div> 56 - <div class="text-gray-500 text-xs">{ brew.Bean.Origin } - { brew.Bean.RoastLevel }</div> 57 - } else { 58 - <div class="font-medium">{ brew.Bean.Origin }</div> 59 - <div class="text-gray-500">{ brew.Bean.RoastLevel }</div> 60 - } 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}} 61 52 </td> 62 53 <td class="px-6 py-4 text-sm text-gray-900"> 63 - if brew.Bean != nil && brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { 64 - { brew.Bean.Roaster.Name } 65 - } else { 54 + {{if and .Bean .Bean.Roaster .Bean.Roaster.Name}} 55 + {{.Bean.Roaster.Name}} 56 + {{else}} 66 57 <span class="text-gray-400">-</span> 67 - } 58 + {{end}} 68 59 </td> 69 60 <td class="px-6 py-4 text-sm text-gray-900"> 70 - <div>{ brew.Method }</div> 61 + <div>{{.Method}}</div> 71 62 <div class="text-gray-500 text-xs"> 72 - { formatTemp(brew.Temperature) } • { formatTime(brew.TimeSeconds) } 63 + {{.TempFormatted}} • {{.TimeFormatted}} 73 64 </div> 74 - if len(brew.Pours) > 0 { 65 + {{if .Pours}} 75 66 <div class="text-gray-500 text-xs mt-1"> 76 - { formatInt(len(brew.Pours)) } pours 67 + {{len .Pours}} pours 77 68 </div> 78 - } 69 + {{end}} 79 70 </td> 80 71 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 81 - <span 82 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> 83 - ⭐ { formatRating(brew.Rating) } 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}} 84 74 </span> 85 75 </td> 86 76 <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> 87 - <a href={ templ.SafeURL("/brews/" + formatID(brew.ID)) } 77 + <a href="/brews/{{.ID}}" 88 78 class="text-blue-600 hover:text-blue-900">View</a> 89 - <button hx-delete={ "/brews/" + formatID(brew.ID) } 79 + <button hx-delete="/brews/{{.ID}}" 90 80 hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 91 81 hx-swap="outerHTML swap:1s" class="text-red-600 hover:text-red-900"> 92 82 Delete 93 83 </button> 94 84 </td> 95 85 </tr> 96 - } 86 + {{end}} 97 87 </tbody> 98 88 </table> 99 89 </div> 100 - } 90 + {{end}} 101 91 </div> 102 - } 103 - } 92 + {{end}}
+16
internal/templates/helpers.go
··· 81 81 82 82 return string(jsonBytes) 83 83 } 84 + 85 + // intPtrEquals checks if a *int pointer equals an int value 86 + func intPtrEquals(ptr *int, val int) bool { 87 + if ptr == nil { 88 + return false 89 + } 90 + return *ptr == val 91 + } 92 + 93 + // intPtrValue returns the dereferenced value of a *int, or 0 if nil 94 + func intPtrValue(ptr *int) int { 95 + if ptr == nil { 96 + return 0 97 + } 98 + return *ptr 99 + }
+2 -6
internal/templates/home.templ internal/templates/home.tmpl
··· 1 - package templates 2 - 3 - templ Home() { 4 - @Layout("Home") { 1 + {{define "content"}} 5 2 <div class="max-w-4xl mx-auto"> 6 3 <div class="bg-white rounded-lg shadow-md p-8 mb-8"> 7 4 <h2 class="text-3xl font-bold text-gray-800 mb-4">Welcome to Arabica</h2> ··· 29 26 </ul> 30 27 </div> 31 28 </div> 32 - } 33 - } 29 + {{end}}
+4 -9
internal/templates/layout.templ internal/templates/layout.tmpl
··· 1 - package templates 2 - 3 - templ Layout(title string) { 1 + {{define "layout"}} 4 2 <!DOCTYPE html> 5 3 <html lang="en"> 6 - 7 4 <head> 8 5 <meta charset="UTF-8" /> 9 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 10 7 <meta name="description" content="Arabica - Coffee brew tracker" /> 11 8 <meta name="theme-color" content="#4a2c2a" /> 12 - <title>{ title } - Arabica</title> 9 + <title>{{.Title}} - Arabica</title> 13 10 <link rel="stylesheet" href="/static/css/output.css" /> 14 11 <link rel="manifest" href="/static/manifest.json" /> 15 12 <script src="https://unpkg.com/htmx.org@2.0.8"></script> ··· 22 19 } 23 20 </script> 24 21 </head> 25 - 26 22 <body class="bg-gray-50 min-h-screen"> 27 23 <nav class="bg-brown-800 text-white shadow-lg"> 28 24 <div class="container mx-auto px-4 py-4"> ··· 38 34 </div> 39 35 </nav> 40 36 <main class="container mx-auto px-4 py-8"> 41 - { children... } 37 + {{template "content" .}} 42 38 </main> 43 39 </body> 44 - 45 40 </html> 46 - } 41 + {{end}}
+275 -281
internal/templates/manage.templ internal/templates/manage.tmpl
··· 1 - package templates 1 + {{define "content"}} 2 + <script> 3 + function managePage() { 4 + return { 5 + tab: localStorage.getItem('manageTab') || 'beans', 6 + showBeanForm: false, 7 + showRoasterForm: false, 8 + showGrinderForm: false, 9 + showBrewerForm: false, 10 + editingBean: null, 11 + editingRoaster: null, 12 + editingGrinder: null, 13 + editingBrewer: null, 14 + beanForm: {name: '', origin: '', roast_level: '', process: '', description: '', roaster_id: null}, 15 + roasterForm: {name: '', location: '', website: ''}, 16 + grinderForm: {name: '', grinder_type: '', burr_type: '', notes: ''}, 17 + brewerForm: {name: '', description: ''}, 18 + 19 + init() { 20 + this.$watch('tab', value => { 21 + localStorage.setItem('manageTab', value); 22 + }); 23 + }, 24 + 25 + editBean(id, name, origin, roast_level, process, description, roaster_id) { 26 + this.editingBean = id; 27 + this.beanForm = {name, origin, roast_level, process, description, roaster_id: roaster_id || null}; 28 + this.showBeanForm = true; 29 + }, 30 + 31 + async saveBean() { 32 + if (!this.beanForm.name || !this.beanForm.origin) { 33 + alert('Name and Origin are required'); 34 + return; 35 + } 36 + 37 + const url = this.editingBean ? `/api/beans/${this.editingBean}` : '/api/beans'; 38 + const method = this.editingBean ? 'PUT' : 'POST'; 39 + 40 + const response = await fetch(url, { 41 + method, 42 + headers: {'Content-Type': 'application/json'}, 43 + body: JSON.stringify(this.beanForm) 44 + }); 45 + 46 + if (response.ok) { 47 + window.location.reload(); 48 + } else { 49 + alert('Failed to save bean'); 50 + } 51 + }, 52 + 53 + async deleteBean(id) { 54 + if (!confirm('Are you sure you want to delete this bean?')) return; 55 + 56 + const response = await fetch(`/api/beans/${id}`, {method: 'DELETE'}); 57 + if (response.ok) { 58 + window.location.reload(); 59 + } else { 60 + alert('Failed to delete bean'); 61 + } 62 + }, 63 + 64 + editRoaster(id, name, location, website) { 65 + this.editingRoaster = id; 66 + this.roasterForm = {name, location, website}; 67 + this.showRoasterForm = true; 68 + }, 69 + 70 + async saveRoaster() { 71 + if (!this.roasterForm.name) { 72 + alert('Name is required'); 73 + return; 74 + } 75 + 76 + const url = this.editingRoaster ? `/api/roasters/${this.editingRoaster}` : '/api/roasters'; 77 + const method = this.editingRoaster ? 'PUT' : 'POST'; 78 + 79 + const response = await fetch(url, { 80 + method, 81 + headers: {'Content-Type': 'application/json'}, 82 + body: JSON.stringify(this.roasterForm) 83 + }); 84 + 85 + if (response.ok) { 86 + window.location.reload(); 87 + } else { 88 + alert('Failed to save roaster'); 89 + } 90 + }, 91 + 92 + async deleteRoaster(id) { 93 + if (!confirm('Are you sure you want to delete this roaster?')) return; 94 + 95 + const response = await fetch(`/api/roasters/${id}`, {method: 'DELETE'}); 96 + if (response.ok) { 97 + window.location.reload(); 98 + } else { 99 + alert('Failed to delete roaster'); 100 + } 101 + }, 102 + 103 + editGrinder(id, name, grinder_type, burr_type, notes) { 104 + this.editingGrinder = id; 105 + this.grinderForm = {name, grinder_type, burr_type, notes}; 106 + this.showGrinderForm = true; 107 + }, 108 + 109 + async saveGrinder() { 110 + if (!this.grinderForm.name || !this.grinderForm.grinder_type) { 111 + alert('Name and Grinder Type are required'); 112 + return; 113 + } 2 114 3 - import "arabica/internal/models" 115 + const url = this.editingGrinder ? `/api/grinders/${this.editingGrinder}` : '/api/grinders'; 116 + const method = this.editingGrinder ? 'PUT' : 'POST'; 4 117 5 - templ ManagePage(beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer) { 6 - @Layout("Manage") { 118 + const response = await fetch(url, { 119 + method, 120 + headers: {'Content-Type': 'application/json'}, 121 + body: JSON.stringify(this.grinderForm) 122 + }); 123 + 124 + if (response.ok) { 125 + window.location.reload(); 126 + } else { 127 + alert('Failed to save grinder'); 128 + } 129 + }, 130 + 131 + async deleteGrinder(id) { 132 + if (!confirm('Are you sure you want to delete this grinder?')) return; 133 + 134 + const response = await fetch(`/api/grinders/${id}`, {method: 'DELETE'}); 135 + if (response.ok) { 136 + window.location.reload(); 137 + } else { 138 + alert('Failed to delete grinder'); 139 + } 140 + }, 141 + 142 + editBrewer(id, name, description) { 143 + this.editingBrewer = id; 144 + this.brewerForm = {name, description}; 145 + this.showBrewerForm = true; 146 + }, 147 + 148 + async saveBrewer() { 149 + if (!this.brewerForm.name) { 150 + alert('Name is required'); 151 + return; 152 + } 153 + 154 + const url = this.editingBrewer ? `/api/brewers/${this.editingBrewer}` : '/api/brewers'; 155 + const method = this.editingBrewer ? 'PUT' : 'POST'; 156 + 157 + const response = await fetch(url, { 158 + method, 159 + headers: {'Content-Type': 'application/json'}, 160 + body: JSON.stringify(this.brewerForm) 161 + }); 162 + 163 + if (response.ok) { 164 + window.location.reload(); 165 + } else { 166 + alert('Failed to save brewer'); 167 + } 168 + }, 169 + 170 + async deleteBrewer(id) { 171 + if (!confirm('Are you sure you want to delete this brewer?')) return; 172 + 173 + const response = await fetch(`/api/brewers/${id}`, {method: 'DELETE'}); 174 + if (response.ok) { 175 + window.location.reload(); 176 + } else { 177 + alert('Failed to delete brewer'); 178 + } 179 + } 180 + } 181 + } 182 + </script> 183 + 7 184 <div class="max-w-6xl mx-auto" x-data="managePage()"> 8 185 <h2 class="text-3xl font-bold text-gray-800 mb-6">Manage</h2> 9 186 ··· 44 221 </button> 45 222 </div> 46 223 47 - if len(beans) == 0 { 224 + {{if not .Beans}} 48 225 <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 49 226 No beans yet. Add your first bean to get started! 50 227 </div> 51 - } else { 228 + {{else}} 52 229 <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 53 230 <table class="min-w-full divide-y divide-gray-200"> 54 231 <thead class="bg-gray-50"> ··· 63 240 </tr> 64 241 </thead> 65 242 <tbody class="bg-white divide-y divide-gray-200"> 66 - for _, bean := range beans { 243 + {{range .Beans}} 67 244 <tr> 68 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{ bean.Name }</td> 69 - <td class="px-6 py-4 text-sm text-gray-900">{ bean.Origin }</td> 245 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 246 + <td class="px-6 py-4 text-sm text-gray-900">{{.Origin}}</td> 70 247 <td class="px-6 py-4 text-sm text-gray-900"> 71 - if bean.Roaster != nil && bean.Roaster.Name != "" { 72 - { bean.Roaster.Name } 73 - } else { 248 + {{if and .Roaster .Roaster.Name}} 249 + {{.Roaster.Name}} 250 + {{else}} 74 251 <span class="text-gray-400">-</span> 75 - } 252 + {{end}} 253 + </td> 254 + <td class="px-6 py-4 text-sm text-gray-900">{{.RoastLevel}}</td> 255 + <td class="px-6 py-4 text-sm text-gray-900">{{.Process}}</td> 256 + <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 257 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 258 + <button @click="editBean({{.ID}}, '{{.Name}}', '{{.Origin}}', '{{.RoastLevel}}', '{{.Process}}', '{{.Description}}', {{if .RoasterID}}{{.RoasterID}}{{else}}null{{end}})" 259 + class="text-blue-600 hover:text-blue-900">Edit</button> 260 + <button @click="deleteBean({{.ID}})" 261 + class="text-red-600 hover:text-red-900">Delete</button> 76 262 </td> 77 - <td class="px-6 py-4 text-sm text-gray-900">{ bean.RoastLevel }</td> 78 - <td class="px-6 py-4 text-sm text-gray-900">{ bean.Process }</td> 79 - <td class="px-6 py-4 text-sm text-gray-500">{ bean.Description }</td> 80 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 81 - <button @click={ "editBean(" + formatID(bean.ID) + ", '" + bean.Name + "', '" + bean.Origin + "', '" + bean.RoastLevel + "', '" + bean.Process + "', '" + bean.Description + "', " + formatRoasterID(bean.RoasterID) + ")" } 82 - class="text-blue-600 hover:text-blue-900">Edit</button> 83 - <button @click={ "deleteBean(" + formatID(bean.ID) + ")" } 84 - class="text-red-600 hover:text-red-900">Delete</button> 85 - </td> 86 263 </tr> 87 - } 264 + {{end}} 88 265 </tbody> 89 266 </table> 90 267 </div> 91 - } 268 + {{end}} 92 269 </div> 93 270 94 271 <!-- Roasters Tab --> ··· 102 279 </button> 103 280 </div> 104 281 105 - if len(roasters) == 0 { 282 + {{if not .Roasters}} 106 283 <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 107 284 No roasters yet. Add your first roaster! 108 285 </div> 109 - } else { 286 + {{else}} 110 287 <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 111 288 <table class="min-w-full divide-y divide-gray-200"> 112 289 <thead class="bg-gray-50"> ··· 118 295 </tr> 119 296 </thead> 120 297 <tbody class="bg-white divide-y divide-gray-200"> 121 - for _, roaster := range roasters { 298 + {{range .Roasters}} 122 299 <tr> 123 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{ roaster.Name }</td> 124 - <td class="px-6 py-4 text-sm text-gray-900">{ roaster.Location }</td> 300 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 301 + <td class="px-6 py-4 text-sm text-gray-900">{{.Location}}</td> 125 302 <td class="px-6 py-4 text-sm text-gray-900"> 126 - if roaster.Website != "" { 127 - <a href={ templ.SafeURL(roaster.Website) } target="_blank" 128 - class="text-blue-600 hover:underline">{ roaster.Website }</a> 129 - } 303 + {{if .Website}} 304 + <a href="{{.Website}}" target="_blank" 305 + class="text-blue-600 hover:underline">{{.Website}}</a> 306 + {{end}} 130 307 </td> 131 308 <td class="px-6 py-4 text-sm font-medium space-x-2"> 132 - <button @click={ "editRoaster(" + formatID(roaster.ID) + ", '" + roaster.Name + "', '" + 133 - roaster.Location + "', '" + roaster.Website + "')" } 309 + <button @click="editRoaster({{.ID}}, '{{.Name}}', '{{.Location}}', '{{.Website}}')" 134 310 class="text-blue-600 hover:text-blue-900">Edit</button> 135 - <button @click={ "deleteRoaster(" + formatID(roaster.ID) + ")" } 311 + <button @click="deleteRoaster({{.ID}})" 136 312 class="text-red-600 hover:text-red-900">Delete</button> 137 313 </td> 138 314 </tr> 139 - } 315 + {{end}} 140 316 </tbody> 141 317 </table> 142 318 </div> 143 - } 319 + {{end}} 144 320 </div> 145 321 146 322 <!-- Grinders Tab --> ··· 148 324 <div class="mb-4 flex justify-between items-center"> 149 325 <h3 class="text-xl font-semibold">Grinders</h3> 150 326 <button 151 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 327 + @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 152 328 class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 153 329 + Add Grinder 154 330 </button> 155 331 </div> 156 332 157 - if len(grinders) == 0 { 333 + {{if not .Grinders}} 158 334 <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 159 335 No grinders yet. Add your first grinder! 160 336 </div> 161 - } else { 337 + {{else}} 162 338 <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 163 339 <table class="min-w-full divide-y divide-gray-200"> 164 340 <thead class="bg-gray-50"> 165 - <tr> 166 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 167 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Grinder Type</th> 168 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Burr Type</th> 169 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th> 170 - <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 171 - </tr> 341 + <tr> 342 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th> 343 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Grinder Type</th> 344 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Burr Type</th> 345 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th> 346 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th> 347 + </tr> 172 348 </thead> 173 349 <tbody class="bg-white divide-y divide-gray-200"> 174 - for _, grinder := range grinders { 175 - <tr> 176 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{ grinder.Name }</td> 177 - <td class="px-6 py-4 text-sm text-gray-900">{ grinder.GrinderType }</td> 178 - <td class="px-6 py-4 text-sm text-gray-900">{ grinder.BurrType }</td> 179 - <td class="px-6 py-4 text-sm text-gray-500">{ grinder.Notes }</td> 180 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 181 - <button @click={ "editGrinder(" + formatID(grinder.ID) + ", '" + grinder.Name + "', '" + 182 - grinder.GrinderType + "', '" + grinder.BurrType + "', '" + grinder.Notes + "')" } 183 - class="text-blue-600 hover:text-blue-900">Edit</button> 184 - <button @click={ "deleteGrinder(" + formatID(grinder.ID) + ")" } 185 - class="text-red-600 hover:text-red-900">Delete</button> 186 - </td> 187 - </tr> 188 - } 350 + {{range .Grinders}} 351 + <tr> 352 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 353 + <td class="px-6 py-4 text-sm text-gray-900">{{.GrinderType}}</td> 354 + <td class="px-6 py-4 text-sm text-gray-900">{{.BurrType}}</td> 355 + <td class="px-6 py-4 text-sm text-gray-500">{{.Notes}}</td> 356 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 357 + <button @click="editGrinder({{.ID}}, '{{.Name}}', '{{.GrinderType}}', '{{.BurrType}}', '{{.Notes}}')" 358 + class="text-blue-600 hover:text-blue-900">Edit</button> 359 + <button @click="deleteGrinder({{.ID}})" 360 + class="text-red-600 hover:text-red-900">Delete</button> 361 + </td> 362 + </tr> 363 + {{end}} 189 364 </tbody> 190 365 </table> 191 366 </div> 192 - } 367 + {{end}} 193 368 </div> 194 369 195 370 <!-- Brewers Tab --> ··· 202 377 </button> 203 378 </div> 204 379 205 - if len(brewers) == 0 { 380 + {{if not .Brewers}} 206 381 <div class="bg-gray-50 rounded-lg p-8 text-center text-gray-600"> 207 382 No brewers yet. Add your first brewer! 208 383 </div> 209 - } else { 384 + {{else}} 210 385 <div class="bg-white shadow-md rounded-lg overflow-x-auto"> 211 386 <table class="min-w-full divide-y divide-gray-200"> 212 387 <thead class="bg-gray-50"> ··· 217 392 </tr> 218 393 </thead> 219 394 <tbody class="bg-white divide-y divide-gray-200"> 220 - for _, brewer := range brewers { 395 + {{range .Brewers}} 221 396 <tr> 222 - <td class="px-6 py-4 text-sm font-medium text-gray-900">{ brewer.Name }</td> 223 - <td class="px-6 py-4 text-sm text-gray-500">{ brewer.Description }</td> 397 + <td class="px-6 py-4 text-sm font-medium text-gray-900">{{.Name}}</td> 398 + <td class="px-6 py-4 text-sm text-gray-500">{{.Description}}</td> 224 399 <td class="px-6 py-4 text-sm font-medium space-x-2"> 225 - <button @click={ "editBrewer(" + formatID(brewer.ID) + ", '" + brewer.Name + "', '" + 226 - brewer.Description + "')" } class="text-blue-600 hover:text-blue-900">Edit</button> 227 - <button @click={ "deleteBrewer(" + formatID(brewer.ID) + ")" } 400 + <button @click="editBrewer({{.ID}}, '{{.Name}}', '{{.Description}}')" 401 + class="text-blue-600 hover:text-blue-900">Edit</button> 402 + <button @click="deleteBrewer({{.ID}})" 228 403 class="text-red-600 hover:text-red-900">Delete</button> 229 404 </td> 230 405 </tr> 231 - } 406 + {{end}} 232 407 </tbody> 233 408 </table> 234 409 </div> 235 - } 410 + {{end}} 236 411 </div> 237 412 238 413 <!-- Bean Form Modal --> ··· 246 421 class="w-full rounded-md border-gray-300 py-2 px-3" /> 247 422 <select x-model.number="beanForm.roaster_id" class="w-full rounded-md border-gray-300 py-2 px-3"> 248 423 <option value="">Select Roaster (Optional)</option> 249 - for _, roaster := range roasters { 250 - <option value={ formatID(roaster.ID) }>{ roaster.Name }</option> 251 - } 424 + {{range .Roasters}} 425 + <option value="{{.ID}}">{{.Name}}</option> 426 + {{end}} 252 427 </select> 253 428 <select x-model="beanForm.roast_level" class="w-full rounded-md border-gray-300 py-2 px-3"> 254 429 <option value="">Select Roast Level (Optional)</option> ··· 294 469 </div> 295 470 </div> 296 471 297 - <!-- Grinder Form Modal --> 298 - <div x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 299 - <div class="bg-white rounded-lg p-8 max-w-md w-full mx-4"> 300 - <h3 class="text-xl font-semibold mb-4" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 301 - <div class="space-y-4"> 302 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 303 - class="w-full rounded-md border-gray-300 py-2 px-3" /> 304 - <select x-model="grinderForm.grinder_type" class="w-full rounded-md border-gray-300 py-2 px-3"> 305 - <option value="">Select Grinder Type *</option> 306 - <option value="Hand">Hand</option> 307 - <option value="Electric">Electric</option> 308 - <option value="Electric Hand">Electric Hand</option> 309 - </select> 310 - <select x-model="grinderForm.burr_type" class="w-full rounded-md border-gray-300 py-2 px-3"> 311 - <option value="">Select Burr Type (Optional)</option> 312 - <option value="Conical">Conical</option> 313 - <option value="Flat">Flat</option> 314 - </select> 315 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 316 - class="w-full rounded-md border-gray-300 py-2 px-3"></textarea> 317 - <div class="flex gap-2"> 318 - <button @click="saveGrinder()" 472 + <!-- Grinder Form Modal --> 473 + <div x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 474 + <div class="bg-white rounded-lg p-8 max-w-md w-full mx-4"> 475 + <h3 class="text-xl font-semibold mb-4" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 476 + <div class="space-y-4"> 477 + <input type="text" x-model="grinderForm.name" placeholder="Name *" 478 + class="w-full rounded-md border-gray-300 py-2 px-3" /> 479 + <select x-model="grinderForm.grinder_type" class="w-full rounded-md border-gray-300 py-2 px-3"> 480 + <option value="">Select Grinder Type *</option> 481 + <option value="Hand">Hand</option> 482 + <option value="Electric">Electric</option> 483 + <option value="Electric Hand">Electric Hand</option> 484 + </select> 485 + <select x-model="grinderForm.burr_type" class="w-full rounded-md border-gray-300 py-2 px-3"> 486 + <option value="">Select Burr Type (Optional)</option> 487 + <option value="Conical">Conical</option> 488 + <option value="Flat">Flat</option> 489 + </select> 490 + <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 491 + class="w-full rounded-md border-gray-300 py-2 px-3"></textarea> 492 + <div class="flex gap-2"> 493 + <button @click="saveGrinder()" 319 494 class="flex-1 bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Save</button> 320 495 <button @click="showGrinderForm = false" 321 496 class="flex-1 bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> ··· 344 519 </div> 345 520 </div> 346 521 347 - <script> 348 - function managePage() { 349 - return { 350 - tab: localStorage.getItem('manageTab') || 'beans', 351 - showBeanForm: false, 352 - showRoasterForm: false, 353 - showGrinderForm: false, 354 - showBrewerForm: false, 355 - editingBean: null, 356 - editingRoaster: null, 357 - editingGrinder: null, 358 - editingBrewer: null, 359 - beanForm: {name: '', origin: '', roast_level: '', process: '', description: '', roaster_id: null}, 360 - roasterForm: {name: '', location: '', website: ''}, 361 - grinderForm: {name: '', grinder_type: '', burr_type: '', notes: ''}, 362 - brewerForm: {name: '', description: ''}, 363 522 364 - init() { 365 - this.$watch('tab', value => { 366 - localStorage.setItem('manageTab', value); 367 - }); 368 - }, 369 - 370 - editBean(id, name, origin, roast_level, process, description, roaster_id) { 371 - this.editingBean = id; 372 - this.beanForm = {name, origin, roast_level, process, description, roaster_id: roaster_id || null}; 373 - this.showBeanForm = true; 374 - }, 375 - 376 - async saveBean() { 377 - if (!this.beanForm.name || !this.beanForm.origin) { 378 - alert('Name and Origin are required'); 379 - return; 380 - } 381 - 382 - const url = this.editingBean ? `/api/beans/${this.editingBean}` : '/api/beans'; 383 - const method = this.editingBean ? 'PUT' : 'POST'; 384 - 385 - const response = await fetch(url, { 386 - method, 387 - headers: {'Content-Type': 'application/json'}, 388 - body: JSON.stringify(this.beanForm) 389 - }); 390 - 391 - if (response.ok) { 392 - window.location.reload(); 393 - } else { 394 - alert('Failed to save bean'); 395 - } 396 - }, 397 - 398 - async deleteBean(id) { 399 - if (!confirm('Are you sure you want to delete this bean?')) return; 400 - 401 - const response = await fetch(`/api/beans/${id}`, {method: 'DELETE'}); 402 - if (response.ok) { 403 - window.location.reload(); 404 - } else { 405 - alert('Failed to delete bean'); 406 - } 407 - }, 408 - 409 - editRoaster(id, name, location, website) { 410 - this.editingRoaster = id; 411 - this.roasterForm = {name, location, website}; 412 - this.showRoasterForm = true; 413 - }, 414 - 415 - async saveRoaster() { 416 - if (!this.roasterForm.name) { 417 - alert('Name is required'); 418 - return; 419 - } 420 - 421 - const url = this.editingRoaster ? `/api/roasters/${this.editingRoaster}` : '/api/roasters'; 422 - const method = this.editingRoaster ? 'PUT' : 'POST'; 423 - 424 - const response = await fetch(url, { 425 - method, 426 - headers: {'Content-Type': 'application/json'}, 427 - body: JSON.stringify(this.roasterForm) 428 - }); 429 - 430 - if (response.ok) { 431 - window.location.reload(); 432 - } else { 433 - alert('Failed to save roaster'); 434 - } 435 - }, 436 - 437 - async deleteRoaster(id) { 438 - if (!confirm('Are you sure you want to delete this roaster?')) return; 439 - 440 - const response = await fetch(`/api/roasters/${id}`, {method: 'DELETE'}); 441 - if (response.ok) { 442 - window.location.reload(); 443 - } else { 444 - alert('Failed to delete roaster'); 445 - } 446 - }, 447 - 448 - editGrinder(id, name, grinder_type, burr_type, notes) { 449 - this.editingGrinder = id; 450 - this.grinderForm = {name, grinder_type, burr_type, notes}; 451 - this.showGrinderForm = true; 452 - }, 453 - 454 - async saveGrinder() { 455 - if (!this.grinderForm.name || !this.grinderForm.grinder_type) { 456 - alert('Name and Grinder Type are required'); 457 - return; 458 - } 459 - 460 - const url = this.editingGrinder ? `/api/grinders/${this.editingGrinder}` : '/api/grinders'; 461 - const method = this.editingGrinder ? 'PUT' : 'POST'; 462 - 463 - const response = await fetch(url, { 464 - method, 465 - headers: {'Content-Type': 'application/json'}, 466 - body: JSON.stringify(this.grinderForm) 467 - }); 468 - 469 - if (response.ok) { 470 - window.location.reload(); 471 - } else { 472 - alert('Failed to save grinder'); 473 - } 474 - }, 475 - 476 - async deleteGrinder(id) { 477 - if (!confirm('Are you sure you want to delete this grinder?')) return; 478 - 479 - const response = await fetch(`/api/grinders/${id}`, {method: 'DELETE'}); 480 - if (response.ok) { 481 - window.location.reload(); 482 - } else { 483 - alert('Failed to delete grinder'); 484 - } 485 - }, 486 - 487 - editBrewer(id, name, description) { 488 - this.editingBrewer = id; 489 - this.brewerForm = {name, description}; 490 - this.showBrewerForm = true; 491 - }, 492 - 493 - async saveBrewer() { 494 - if (!this.brewerForm.name) { 495 - alert('Name is required'); 496 - return; 497 - } 498 - 499 - const url = this.editingBrewer ? `/api/brewers/${this.editingBrewer}` : '/api/brewers'; 500 - const method = this.editingBrewer ? 'PUT' : 'POST'; 501 - 502 - const response = await fetch(url, { 503 - method, 504 - headers: {'Content-Type': 'application/json'}, 505 - body: JSON.stringify(this.brewerForm) 506 - }); 507 - 508 - if (response.ok) { 509 - window.location.reload(); 510 - } else { 511 - alert('Failed to save brewer'); 512 - } 513 - }, 514 - 515 - async deleteBrewer(id) { 516 - if (!confirm('Are you sure you want to delete this brewer?')) return; 517 - 518 - const response = await fetch(`/api/brewers/${id}`, {method: 'DELETE'}); 519 - if (response.ok) { 520 - window.location.reload(); 521 - } else { 522 - alert('Failed to delete brewer'); 523 - } 524 - } 525 - } 526 - } 527 - </script> 528 - } 529 - } 523 + {{end}}
+31
internal/templates/partials/new_bean_form.tmpl
··· 1 + {{define "new_bean_form"}} 2 + <!-- New Bean Modal --> 3 + <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 + <h4 class="font-medium mb-3 text-gray-800">Add New Bean</h4> 5 + <div class="space-y-3"> 6 + <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 + <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 8 + <select x-model.number="newBean.roasterId" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 9 + <option value="">Select Roaster (Optional)</option> 10 + {{range .Roasters}} 11 + <option value="{{.ID}}">{{.Name}}</option> 12 + {{end}} 13 + </select> 14 + <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 15 + <option value="">Select Roast Level (Optional)</option> 16 + <option value="Ultra-Light">Ultra-Light</option> 17 + <option value="Light">Light</option> 18 + <option value="Medium-Light">Medium-Light</option> 19 + <option value="Medium">Medium</option> 20 + <option value="Medium-Dark">Medium-Dark</option> 21 + <option value="Dark">Dark</option> 22 + </select> 23 + <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 24 + <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 25 + <div class="flex gap-2"> 26 + <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 27 + <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 28 + </div> 29 + </div> 30 + </div> 31 + {{end}}
+14
internal/templates/partials/new_brewer_form.tmpl
··· 1 + {{define "new_brewer_form"}} 2 + <!-- New Brewer Modal --> 3 + <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 + <h4 class="font-medium mb-3 text-gray-800">Add New Brewer</h4> 5 + <div class="space-y-3"> 6 + <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 + <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 8 + <div class="flex gap-2"> 9 + <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 10 + <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 11 + </div> 12 + </div> 13 + </div> 14 + {{end}}
+25
internal/templates/partials/new_grinder_form.tmpl
··· 1 + {{define "new_grinder_form"}} 2 + <!-- New Grinder Modal --> 3 + <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 + <h4 class="font-medium mb-3 text-gray-800">Add New Grinder</h4> 5 + <div class="space-y-3"> 6 + <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 + <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 8 + <option value="">Grinder Type (Optional)</option> 9 + <option value="Hand">Hand</option> 10 + <option value="Electric">Electric</option> 11 + <option value="Electric Hand">Electric Hand</option> 12 + </select> 13 + <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 14 + <option value="">Burr Type (Optional)</option> 15 + <option value="Conical">Conical</option> 16 + <option value="Flat">Flat</option> 17 + </select> 18 + <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 19 + <div class="flex gap-2"> 20 + <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 21 + <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 22 + </div> 23 + </div> 24 + </div> 25 + {{end}}
+143
internal/templates/render.go
··· 1 + package templates 2 + 3 + import ( 4 + "fmt" 5 + "html/template" 6 + "net/http" 7 + 8 + "arabica/internal/models" 9 + ) 10 + 11 + var templates *template.Template 12 + 13 + // Initialize loads all template files 14 + func init() { 15 + var err error 16 + 17 + // Parse all template files including partials 18 + templates = template.New("") 19 + templates = templates.Funcs(template.FuncMap{ 20 + "formatTemp": formatTemp, 21 + "formatTime": formatTime, 22 + "formatRating": formatRating, 23 + "formatID": formatID, 24 + "formatInt": formatInt, 25 + "formatRoasterID": formatRoasterID, 26 + "poursToJSON": poursToJSON, 27 + "intPtrEquals": intPtrEquals, 28 + "intPtrValue": intPtrValue, 29 + }) 30 + 31 + // Parse all templates 32 + templates, err = templates.ParseGlob("internal/templates/*.tmpl") 33 + if err != nil { 34 + panic(fmt.Sprintf("Failed to parse templates: %v", err)) 35 + } 36 + 37 + // Parse partials 38 + templates, err = templates.ParseGlob("internal/templates/partials/*.tmpl") 39 + if err != nil { 40 + panic(fmt.Sprintf("Failed to parse partial templates: %v", err)) 41 + } 42 + } 43 + 44 + // Data structures for templates 45 + type PageData struct { 46 + Title string 47 + Beans []*models.Bean 48 + Roasters []*models.Roaster 49 + Grinders []*models.Grinder 50 + Brewers []*models.Brewer 51 + Brew *BrewData 52 + Brews []*BrewListData 53 + } 54 + 55 + type BrewData struct { 56 + *models.Brew 57 + PoursJSON string 58 + } 59 + 60 + type BrewListData struct { 61 + *models.Brew 62 + TempFormatted string 63 + TimeFormatted string 64 + RatingFormatted string 65 + } 66 + 67 + // RenderTemplate renders a template with layout 68 + func RenderTemplate(w http.ResponseWriter, tmpl string, data *PageData) error { 69 + // Execute the layout template which calls the content template 70 + return templates.ExecuteTemplate(w, "layout", data) 71 + } 72 + 73 + // RenderHome renders the home page 74 + func RenderHome(w http.ResponseWriter) error { 75 + data := &PageData{ 76 + Title: "Home", 77 + } 78 + // Need to execute layout with the home template 79 + t := template.Must(templates.Clone()) 80 + t = template.Must(t.ParseFiles("internal/templates/home.tmpl")) 81 + return t.ExecuteTemplate(w, "layout", data) 82 + } 83 + 84 + // RenderBrewList renders the brew list page 85 + func RenderBrewList(w http.ResponseWriter, brews []*models.Brew) error { 86 + brewList := make([]*BrewListData, len(brews)) 87 + for i, brew := range brews { 88 + brewList[i] = &BrewListData{ 89 + Brew: brew, 90 + TempFormatted: formatTemp(brew.Temperature), 91 + TimeFormatted: formatTime(brew.TimeSeconds), 92 + RatingFormatted: formatRating(brew.Rating), 93 + } 94 + } 95 + 96 + data := &PageData{ 97 + Title: "All Brews", 98 + Brews: brewList, 99 + } 100 + t := template.Must(templates.Clone()) 101 + t = template.Must(t.ParseFiles("internal/templates/brew_list.tmpl")) 102 + return t.ExecuteTemplate(w, "layout", data) 103 + } 104 + 105 + // RenderBrewForm renders the brew form page 106 + func RenderBrewForm(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, brew *models.Brew) error { 107 + var brewData *BrewData 108 + title := "New Brew" 109 + 110 + if brew != nil { 111 + title = "Edit Brew" 112 + brewData = &BrewData{ 113 + Brew: brew, 114 + PoursJSON: poursToJSON(brew.Pours), 115 + } 116 + } 117 + 118 + data := &PageData{ 119 + Title: title, 120 + Beans: beans, 121 + Roasters: roasters, 122 + Grinders: grinders, 123 + Brewers: brewers, 124 + Brew: brewData, 125 + } 126 + t := template.Must(templates.Clone()) 127 + t = template.Must(t.ParseFiles("internal/templates/brew_form.tmpl")) 128 + return t.ExecuteTemplate(w, "layout", data) 129 + } 130 + 131 + // RenderManage renders the manage page 132 + func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer) error { 133 + data := &PageData{ 134 + Title: "Manage", 135 + Beans: beans, 136 + Roasters: roasters, 137 + Grinders: grinders, 138 + Brewers: brewers, 139 + } 140 + t := template.Must(templates.Clone()) 141 + t = template.Must(t.ParseFiles("internal/templates/manage.tmpl")) 142 + return t.ExecuteTemplate(w, "layout", data) 143 + }
+12 -15
tailwind.config.js
··· 1 1 /** @type {import('tailwindcss').Config} */ 2 2 module.exports = { 3 - content: [ 4 - "./internal/templates/**/*.templ", 5 - "./web/**/*.html", 6 - ], 3 + content: ["./internal/templates/**/*.tmpl", "./web/**/*.html"], 7 4 theme: { 8 5 extend: { 9 6 colors: { 10 7 brown: { 11 - 50: '#fdf8f6', 12 - 100: '#f2e8e5', 13 - 200: '#eaddd7', 14 - 300: '#e0cec7', 15 - 400: '#d2bab0', 16 - 500: '#bfa094', 17 - 600: '#7f5539', 18 - 700: '#6b4423', 19 - 800: '#4a2c2a', 20 - 900: '#3d2319', 8 + 50: "#fdf8f6", 9 + 100: "#f2e8e5", 10 + 200: "#eaddd7", 11 + 300: "#e0cec7", 12 + 400: "#d2bab0", 13 + 500: "#bfa094", 14 + 600: "#7f5539", 15 + 700: "#6b4423", 16 + 800: "#4a2c2a", 17 + 900: "#3d2319", 21 18 }, 22 19 }, 23 20 }, 24 21 }, 25 22 plugins: [], 26 - } 23 + };