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

refactor: moderation details compoment

pdewey.com 6c3fa527 bea02985

verified
+94 -170
+38 -68
internal/handlers/admin.go
··· 2 3 import ( 4 "context" 5 - "encoding/json" 6 "net/http" 7 "time" 8 ··· 13 "arabica/internal/web/components" 14 "arabica/internal/web/pages" 15 16 "github.com/rs/zerolog/log" 17 ) 18 ··· 33 34 // Check permission 35 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) { 36 http.Error(w, "Permission denied", http.StatusForbidden) 37 return 38 } 39 40 - // Parse request - support both JSON and form data 41 - var req hideRequest 42 - contentType := r.Header.Get("Content-Type") 43 - if contentType == "application/json" { 44 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 45 - http.Error(w, "Invalid request body", http.StatusBadRequest) 46 - return 47 - } 48 - } else { 49 - // Parse as form data (HTMX default) 50 - if err := r.ParseForm(); err != nil { 51 - http.Error(w, "Invalid request body", http.StatusBadRequest) 52 - return 53 - } 54 - req.URI = r.FormValue("uri") 55 - req.Reason = r.FormValue("reason") 56 } 57 58 if req.URI == "" { 59 http.Error(w, "URI is required", http.StatusBadRequest) ··· 110 111 // Check permission 112 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) { 113 http.Error(w, "Permission denied", http.StatusForbidden) 114 return 115 } 116 117 - // Parse request - support both JSON and form data 118 var req hideRequest 119 - contentType := r.Header.Get("Content-Type") 120 - if contentType == "application/json" { 121 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 122 - http.Error(w, "Invalid request body", http.StatusBadRequest) 123 - return 124 - } 125 - } else { 126 - // Parse as form data (HTMX default) 127 - if err := r.ParseForm(); err != nil { 128 - http.Error(w, "Invalid request body", http.StatusBadRequest) 129 - return 130 - } 131 - req.URI = r.FormValue("uri") 132 - req.Reason = r.FormValue("reason") 133 - } 134 135 if req.URI == "" { 136 http.Error(w, "URI is required", http.StatusBadRequest) ··· 167 w.WriteHeader(http.StatusOK) 168 } 169 170 - // generateTID generates a TID (timestamp-based identifier) 171 func generateTID() string { 172 - // Simple implementation using unix nano timestamp 173 - // In production, you might want a more sophisticated TID generator 174 - return time.Now().Format("20060102150405.000000000") 175 } 176 177 // buildAdminProps builds the admin dashboard props for the given moderator. ··· 241 242 // Check if user is a moderator 243 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 244 http.Error(w, "Access denied", http.StatusForbidden) 245 return 246 } ··· 272 } 273 274 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 275 http.Error(w, "Access denied", http.StatusForbidden) 276 return 277 } ··· 382 383 // Check permission 384 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) { 385 http.Error(w, "Permission denied", http.StatusForbidden) 386 return 387 } 388 389 - // Parse request - support both JSON and form data 390 var req blockRequest 391 - contentType := r.Header.Get("Content-Type") 392 - if contentType == "application/json" { 393 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 394 - http.Error(w, "Invalid request body", http.StatusBadRequest) 395 - return 396 - } 397 - } else { 398 - // Parse as form data (HTMX default) 399 - if err := r.ParseForm(); err != nil { 400 - http.Error(w, "Invalid request body", http.StatusBadRequest) 401 - return 402 - } 403 - req.DID = r.FormValue("did") 404 - req.Reason = r.FormValue("reason") 405 - } 406 407 if req.DID == "" { 408 http.Error(w, "DID is required", http.StatusBadRequest) ··· 457 458 // Check permission 459 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) { 460 http.Error(w, "Permission denied", http.StatusForbidden) 461 return 462 } 463 464 - // Parse request - support both JSON and form data 465 - var req blockRequest 466 - contentType := r.Header.Get("Content-Type") 467 - if contentType == "application/json" { 468 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 469 - http.Error(w, "Invalid request body", http.StatusBadRequest) 470 - return 471 - } 472 - } else { 473 - // Parse as form data (HTMX default) 474 - if err := r.ParseForm(); err != nil { 475 - http.Error(w, "Invalid request body", http.StatusBadRequest) 476 - return 477 - } 478 - req.DID = r.FormValue("did") 479 } 480 481 if req.DID == "" { 482 http.Error(w, "DID is required", http.StatusBadRequest) ··· 522 } 523 524 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) { 525 http.Error(w, "Permission denied", http.StatusForbidden) 526 return 527 } ··· 576 577 // Check permission 578 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) { 579 http.Error(w, "Permission denied", http.StatusForbidden) 580 return 581 }
··· 2 3 import ( 4 "context" 5 "net/http" 6 "time" 7 ··· 12 "arabica/internal/web/components" 13 "arabica/internal/web/pages" 14 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 "github.com/rs/zerolog/log" 17 ) 18 ··· 33 34 // Check permission 35 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) { 36 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/hide").Msg("Denied: insufficient permissions") 37 http.Error(w, "Permission denied", http.StatusForbidden) 38 return 39 } 40 41 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 42 + if err := r.ParseForm(); err != nil { 43 + http.Error(w, "Invalid request body", http.StatusBadRequest) 44 + return 45 } 46 + var req hideRequest 47 + req.URI = r.FormValue("uri") 48 + req.Reason = r.FormValue("reason") 49 50 if req.URI == "" { 51 http.Error(w, "URI is required", http.StatusBadRequest) ··· 102 103 // Check permission 104 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) { 105 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unhide").Msg("Denied: insufficient permissions") 106 http.Error(w, "Permission denied", http.StatusForbidden) 107 return 108 } 109 110 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 111 + if err := r.ParseForm(); err != nil { 112 + http.Error(w, "Invalid request body", http.StatusBadRequest) 113 + return 114 + } 115 var req hideRequest 116 + req.URI = r.FormValue("uri") 117 + req.Reason = r.FormValue("reason") 118 119 if req.URI == "" { 120 http.Error(w, "URI is required", http.StatusBadRequest) ··· 151 w.WriteHeader(http.StatusOK) 152 } 153 154 + // generateTID generates a TID (timestamp-based identifier) using the AT Protocol TID format. 155 func generateTID() string { 156 + return syntax.NewTIDNow(0).String() 157 } 158 159 // buildAdminProps builds the admin dashboard props for the given moderator. ··· 223 224 // Check if user is a moderator 225 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 226 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod").Msg("Denied: not a moderator") 227 http.Error(w, "Access denied", http.StatusForbidden) 228 return 229 } ··· 255 } 256 257 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 258 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/content").Msg("Denied: not a moderator") 259 http.Error(w, "Access denied", http.StatusForbidden) 260 return 261 } ··· 366 367 // Check permission 368 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) { 369 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/block").Msg("Denied: insufficient permissions") 370 http.Error(w, "Permission denied", http.StatusForbidden) 371 return 372 } 373 374 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 375 + if err := r.ParseForm(); err != nil { 376 + http.Error(w, "Invalid request body", http.StatusBadRequest) 377 + return 378 + } 379 var req blockRequest 380 + req.DID = r.FormValue("did") 381 + req.Reason = r.FormValue("reason") 382 383 if req.DID == "" { 384 http.Error(w, "DID is required", http.StatusBadRequest) ··· 433 434 // Check permission 435 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) { 436 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unblock").Msg("Denied: insufficient permissions") 437 http.Error(w, "Permission denied", http.StatusForbidden) 438 return 439 } 440 441 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 442 + if err := r.ParseForm(); err != nil { 443 + http.Error(w, "Invalid request body", http.StatusBadRequest) 444 + return 445 } 446 + var req blockRequest 447 + req.DID = r.FormValue("did") 448 449 if req.DID == "" { 450 http.Error(w, "DID is required", http.StatusBadRequest) ··· 490 } 491 492 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) { 493 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/reset-autohide").Msg("Denied: insufficient permissions") 494 http.Error(w, "Permission denied", http.StatusForbidden) 495 return 496 } ··· 545 546 // Check permission 547 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) { 548 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/dismiss-report").Msg("Denied: insufficient permissions") 549 http.Error(w, "Permission denied", http.StatusForbidden) 550 return 551 }
+7 -14
internal/handlers/report.go
··· 60 return 61 } 62 63 - // Parse request (supports both JSON and form data) 64 var req ReportRequest 65 - if isJSONRequest(r) { 66 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 67 - writeReportError(w, "Invalid JSON", http.StatusBadRequest) 68 - return 69 - } 70 - } else { 71 - if err := r.ParseForm(); err != nil { 72 - writeReportError(w, "Invalid form data", http.StatusBadRequest) 73 - return 74 - } 75 - req.SubjectURI = r.FormValue("subject_uri") 76 - req.SubjectCID = r.FormValue("subject_cid") 77 - req.Reason = r.FormValue("reason") 78 } 79 80 // Validate subject URI 81 if req.SubjectURI == "" {
··· 60 return 61 } 62 63 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 64 var req ReportRequest 65 + if err := r.ParseForm(); err != nil { 66 + writeReportError(w, "Invalid form data", http.StatusBadRequest) 67 + return 68 } 69 + req.SubjectURI = r.FormValue("subject_uri") 70 + req.SubjectCID = r.FormValue("subject_cid") 71 + req.Reason = r.FormValue("reason") 72 73 // Validate subject URI 74 if req.SubjectURI == "" {
+7 -6
internal/web/components/action_bar.templ
··· 277 submitting = true; 278 error = ''; 279 const dialog = document.getElementById('%s'); 280 fetch('/api/report', { 281 method: 'POST', 282 - headers: {'Content-Type': 'application/json'}, 283 - body: JSON.stringify({ 284 - subject_uri: '%s', 285 - subject_cid: '%s', 286 - reason: reason 287 - }) 288 }) 289 .then(r => r.json().then(data => ({ok: r.ok, data}))) 290 .then(({ok, data}) => {
··· 277 submitting = true; 278 error = ''; 279 const dialog = document.getElementById('%s'); 280 + const body = new URLSearchParams({ 281 + subject_uri: '%s', 282 + subject_cid: '%s', 283 + reason: reason 284 + }); 285 fetch('/api/report', { 286 method: 'POST', 287 + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 288 + body: body 289 }) 290 .then(r => r.json().then(data => ({ok: r.ok, data}))) 291 .then(({ok, data}) => {
+7 -6
internal/web/components/dialog_modals.templ
··· 343 submitting = true; 344 error = ''; 345 const dialog = document.getElementById('report-modal'); 346 fetch('/api/report', { 347 method: 'POST', 348 - headers: {'Content-Type': 'application/json'}, 349 - body: JSON.stringify({ 350 - subject_uri: $el.querySelector('[name=subject_uri]').value, 351 - subject_cid: $el.querySelector('[name=subject_cid]').value, 352 - reason: reason 353 - }) 354 }) 355 .then(r => r.json().then(data => ({ok: r.ok, data}))) 356 .then(({ok, data}) => {
··· 343 submitting = true; 344 error = ''; 345 const dialog = document.getElementById('report-modal'); 346 + const body = new URLSearchParams({ 347 + subject_uri: $el.querySelector('[name=subject_uri]').value, 348 + subject_cid: $el.querySelector('[name=subject_cid]').value, 349 + reason: reason 350 + }); 351 fetch('/api/report', { 352 method: 'POST', 353 + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 354 + body: body 355 }) 356 .then(r => r.json().then(data => ({ok: r.ok, data}))) 357 .then(({ok, data}) => {
+21
internal/web/components/shared.templ
··· 30 } 31 } 32 33 type PageHeaderProps struct { 34 Title string 35 BackURL string
··· 30 } 31 } 32 33 + // DetailFieldProps defines properties for a labeled detail field 34 + type DetailFieldProps struct { 35 + Label string 36 + Value string 37 + LinkHref string // Optional: wraps value in a link 38 + } 39 + 40 + // DetailField renders a labeled value with a "Not specified" fallback 41 + templ DetailField(props DetailFieldProps) { 42 + <div class="section-box"> 43 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ props.Label }</h3> 44 + if props.Value != "" && props.LinkHref != "" { 45 + <a href={ templ.SafeURL(props.LinkHref) } class="font-semibold text-brown-900 hover:underline">{ props.Value }</a> 46 + } else if props.Value != "" { 47 + <div class="font-semibold text-brown-900">{ props.Value }</div> 48 + } else { 49 + <span class="text-brown-400">Not specified</span> 50 + } 51 + </div> 52 + } 53 + 54 type PageHeaderProps struct { 55 Title string 56 BackURL string
+3 -14
internal/web/pages/bean_view.templ
··· 57 </div> 58 } 59 <div class="grid grid-cols-2 gap-4"> 60 - @BeanDetailField("๐Ÿ“ Origin", props.Bean.Origin) 61 - @BeanDetailField("๐Ÿ”ฅ Roast Level", props.Bean.RoastLevel) 62 - @BeanDetailField("๐ŸŒฑ Process", props.Bean.Process) 63 if props.Bean.Closed { 64 <div class="section-box"> 65 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3> ··· 125 } 126 </h2> 127 <p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 128 - </div> 129 - } 130 - 131 - templ BeanDetailField(label, value string) { 132 - <div class="section-box"> 133 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 134 - if value != "" { 135 - <div class="font-semibold text-brown-900">{ value }</div> 136 - } else { 137 - <span class="text-brown-400">Not specified</span> 138 - } 139 </div> 140 } 141
··· 57 </div> 58 } 59 <div class="grid grid-cols-2 gap-4"> 60 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ“ Origin", Value: props.Bean.Origin}) 61 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฅ Roast Level", Value: props.Bean.RoastLevel}) 62 + @components.DetailField(components.DetailFieldProps{Label: "๐ŸŒฑ Process", Value: props.Bean.Process}) 63 if props.Bean.Closed { 64 <div class="section-box"> 65 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3> ··· 125 } 126 </h2> 127 <p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 128 </div> 129 } 130
+7 -33
internal/web/pages/brew_view.templ
··· 155 // BrewParametersGrid renders the brew parameters in a grid 156 templ BrewParametersGrid(brew *models.Brew, owner string) { 157 <div class="grid grid-cols-2 gap-4"> 158 - @BrewParameter("โš–๏ธ Coffee", getCoffeeAmountDisplay(brew)) 159 - @BrewLinkedParameter("โ˜• Brew Method", getBrewerName(brew), getBrewerViewURL(brew, owner)) 160 - @BrewLinkedParameter("โš™๏ธ Grinder", getGrinderName(brew), getGrinderViewURL(brew, owner)) 161 - @BrewParameter("๐Ÿ”ฉ Grind Size", getGrindSizeDisplay(brew)) 162 - @BrewParameter("๐Ÿ’ง Water", getWaterAmountDisplay(brew)) 163 - @BrewParameter("๐ŸŒก๏ธ Temperature", getTemperatureDisplay(brew)) 164 <div class="col-span-2"> 165 - @BrewParameter("โฑ๏ธ Brew Time", getBrewTimeDisplay(brew)) 166 </div> 167 - </div> 168 - } 169 - 170 - // BrewParameter renders a single parameter in the grid 171 - templ BrewParameter(label string, value string) { 172 - <div class="section-box"> 173 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 174 - if value != "" { 175 - <div class="font-semibold text-brown-900">{ value }</div> 176 - } else { 177 - <span class="text-brown-400">Not specified</span> 178 - } 179 - </div> 180 - } 181 - 182 - // BrewLinkedParameter renders a parameter with a clickable link to the entity view page 183 - templ BrewLinkedParameter(label string, value string, href string) { 184 - <div class="section-box"> 185 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 186 - if value != "" && href != "" { 187 - <a href={ templ.SafeURL(href) } class="font-semibold text-brown-900 hover:underline">{ value }</a> 188 - } else if value != "" { 189 - <div class="font-semibold text-brown-900">{ value }</div> 190 - } else { 191 - <span class="text-brown-400">Not specified</span> 192 - } 193 </div> 194 } 195
··· 155 // BrewParametersGrid renders the brew parameters in a grid 156 templ BrewParametersGrid(brew *models.Brew, owner string) { 157 <div class="grid grid-cols-2 gap-4"> 158 + @components.DetailField(components.DetailFieldProps{Label: "โš–๏ธ Coffee", Value: getCoffeeAmountDisplay(brew)}) 159 + @components.DetailField(components.DetailFieldProps{Label: "โ˜• Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)}) 160 + @components.DetailField(components.DetailFieldProps{Label: "โš™๏ธ Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)}) 161 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฉ Grind Size", Value: getGrindSizeDisplay(brew)}) 162 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ’ง Water", Value: getWaterAmountDisplay(brew)}) 163 + @components.DetailField(components.DetailFieldProps{Label: "๐ŸŒก๏ธ Temperature", Value: getTemperatureDisplay(brew)}) 164 <div class="col-span-2"> 165 + @components.DetailField(components.DetailFieldProps{Label: "โฑ๏ธ Brew Time", Value: getBrewTimeDisplay(brew)}) 166 </div> 167 </div> 168 } 169
+1 -6
internal/web/pages/brewer_view.templ
··· 38 templ BrewerViewCard(props BrewerViewProps) { 39 @BrewerViewHeader(props) 40 <div class="space-y-6"> 41 - if props.Brewer.BrewerType != "" { 42 - <div class="section-box"> 43 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">โ˜• Type</h3> 44 - <div class="font-semibold text-brown-900">{ props.Brewer.BrewerType }</div> 45 - </div> 46 - } 47 if props.Brewer.Description != "" { 48 <div class="section-box"> 49 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ“ Description</h3>
··· 38 templ BrewerViewCard(props BrewerViewProps) { 39 @BrewerViewHeader(props) 40 <div class="space-y-6"> 41 + @components.DetailField(components.DetailFieldProps{Label: "โ˜• Type", Value: props.Brewer.BrewerType}) 42 if props.Brewer.Description != "" { 43 <div class="section-box"> 44 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ“ Description</h3>
+2 -12
internal/web/pages/grinder_view.templ
··· 39 @GrinderViewHeader(props) 40 <div class="space-y-6"> 41 <div class="grid grid-cols-2 gap-4"> 42 - @GrinderDetailField("โš™๏ธ Type", props.Grinder.GrinderType) 43 - @GrinderDetailField("๐Ÿ”ฉ Burr Type", props.Grinder.BurrType) 44 </div> 45 if props.Grinder.Notes != "" { 46 <div class="section-box"> ··· 97 </div> 98 } 99 100 - templ GrinderDetailField(label, value string) { 101 - <div class="section-box"> 102 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 103 - if value != "" { 104 - <div class="font-semibold text-brown-900">{ value }</div> 105 - } else { 106 - <span class="text-brown-400">Not specified</span> 107 - } 108 - </div> 109 - }
··· 39 @GrinderViewHeader(props) 40 <div class="space-y-6"> 41 <div class="grid grid-cols-2 gap-4"> 42 + @components.DetailField(components.DetailFieldProps{Label: "โš™๏ธ Type", Value: props.Grinder.GrinderType}) 43 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฉ Burr Type", Value: props.Grinder.BurrType}) 44 </div> 45 if props.Grinder.Notes != "" { 46 <div class="section-box"> ··· 97 </div> 98 } 99
+1 -11
internal/web/pages/roaster_view.templ
··· 40 @RoasterViewHeader(props) 41 <div class="space-y-6"> 42 <div class="grid grid-cols-2 gap-4"> 43 - @RoasterDetailField("๐Ÿ“ Location", props.Roaster.Location) 44 if props.Roaster.Website != "" { 45 <div class="section-box"> 46 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ”— Website</h3> ··· 103 </div> 104 } 105 106 - templ RoasterDetailField(label, value string) { 107 - <div class="section-box"> 108 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 109 - if value != "" { 110 - <div class="font-semibold text-brown-900">{ value }</div> 111 - } else { 112 - <span class="text-brown-400">Not specified</span> 113 - } 114 - </div> 115 - }
··· 40 @RoasterViewHeader(props) 41 <div class="space-y-6"> 42 <div class="grid grid-cols-2 gap-4"> 43 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ“ Location", Value: props.Roaster.Location}) 44 if props.Roaster.Website != "" { 45 <div class="section-box"> 46 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ”— Website</h3> ··· 103 </div> 104 } 105