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.

fix: update loading skeletons to use cards instead of tables

authored by

Patrick Dewey and committed by tangled.org 1084407e 3a8cc0d0

+528 -570
+2 -2
cmd/server/main.go
··· 17 17 18 18 "arabica/internal/atproto" 19 19 "arabica/internal/backup" 20 - "arabica/internal/logging" 21 20 "arabica/internal/database/boltstore" 22 21 "arabica/internal/database/sqlitestore" 23 22 "arabica/internal/email" 24 23 "arabica/internal/feed" 25 24 "arabica/internal/firehose" 26 25 "arabica/internal/handlers" 26 + "arabica/internal/logging" 27 27 "arabica/internal/metrics" 28 28 "arabica/internal/moderation" 29 29 "arabica/internal/routing" 30 30 "arabica/internal/tracing" 31 31 32 - "go.opentelemetry.io/otel/attribute" 33 32 "github.com/prometheus/client_golang/prometheus/promhttp" 34 33 "github.com/rs/zerolog" 35 34 "github.com/rs/zerolog/log" 35 + "go.opentelemetry.io/otel/attribute" 36 36 ) 37 37 38 38 func main() {
+1 -1
internal/backup/backup_test.go
··· 8 8 "testing" 9 9 "time" 10 10 11 - _ "modernc.org/sqlite" 12 11 "github.com/stretchr/testify/assert" 13 12 "github.com/stretchr/testify/require" 13 + _ "modernc.org/sqlite" 14 14 ) 15 15 16 16 func TestSQLiteBackup(t *testing.T) {
-2
internal/handlers/handlers.go
··· 25 25 "github.com/rs/zerolog/log" 26 26 ) 27 27 28 - 29 28 // Config holds handler configuration options 30 29 type Config struct { 31 30 // SecureCookies sets the Secure flag on authentication cookies ··· 58 57 joinStore *boltstore.JoinStore 59 58 pdsAdminURL string 60 59 pdsAdminToken string 61 - 62 60 } 63 61 64 62 // NewHandler creates a new Handler with all required dependencies.
+11 -11
internal/models/models.go
··· 118 118 119 119 // Validation errors 120 120 var ( 121 - ErrNameRequired = errors.New("name is required") 122 - ErrNameTooLong = errors.New("name is too long") 123 - ErrLocationTooLong = errors.New("location is too long") 124 - ErrWebsiteTooLong = errors.New("website is too long") 125 - ErrDescTooLong = errors.New("description is too long") 126 - ErrNotesTooLong = errors.New("notes is too long") 127 - ErrOriginTooLong = errors.New("origin is too long") 128 - ErrFieldTooLong = errors.New("field value is too long") 121 + ErrNameRequired = errors.New("name is required") 122 + ErrNameTooLong = errors.New("name is too long") 123 + ErrLocationTooLong = errors.New("location is too long") 124 + ErrWebsiteTooLong = errors.New("website is too long") 125 + ErrDescTooLong = errors.New("description is too long") 126 + ErrNotesTooLong = errors.New("notes is too long") 127 + ErrOriginTooLong = errors.New("origin is too long") 128 + ErrFieldTooLong = errors.New("field value is too long") 129 129 ErrRatingOutOfRange = errors.New("rating must be between 1 and 10") 130 - ErrCommentRequired = errors.New("comment text is required") 131 - ErrCommentTooLong = errors.New("comment text is too long") 130 + ErrCommentRequired = errors.New("comment text is required") 131 + ErrCommentTooLong = errors.New("comment text is too long") 132 132 ) 133 133 134 134 // TODO: maybe add a "rating" field that can be updated when a bag is closed ··· 140 140 RoastLevel string `json:"roast_level"` 141 141 Process string `json:"process"` 142 142 Description string `json:"description"` 143 - RoasterRKey string `json:"roaster_rkey"` // AT Protocol reference 143 + RoasterRKey string `json:"roaster_rkey"` // AT Protocol reference 144 144 Rating *int `json:"rating,omitempty"` // User rating (1-10), nil means unrated 145 145 Closed bool `json:"closed"` // Whether the bag is closed/finished 146 146 SourceRef string `json:"source_ref,omitempty"`
+7 -7
internal/web/components/action_bar.templ
··· 16 16 LikeCount int 17 17 18 18 // Comment state 19 - CommentCount int 20 - ViewURL string // URL to view the item (for comment link) 21 - ShowComments bool // Whether to show the comment button 19 + CommentCount int 20 + ViewURL string // URL to view the item (for comment link) 21 + ShowComments bool // Whether to show the comment button 22 22 23 23 // Share props 24 24 ShareURL string ··· 37 37 IsAuthenticated bool 38 38 39 39 // Moderation state 40 - IsModerator bool // User has moderator role 41 - CanHideRecord bool // User has hide_record permission 42 - CanBlockUser bool // User has blacklist_user permission 43 - IsRecordHidden bool // This record is currently hidden 40 + IsModerator bool // User has moderator role 41 + CanHideRecord bool // User has hide_record permission 42 + CanBlockUser bool // User has blacklist_user permission 43 + IsRecordHidden bool // This record is currently hidden 44 44 AuthorDID string // DID of the content author (for block action) 45 45 } 46 46
+2 -4
internal/web/components/card.templ
··· 9 9 // Card renders a card container with optional inner styling 10 10 templ Card(props CardProps, content templ.Component) { 11 11 <div 12 - class={ 13 - templ.Classes( 12 + class={ templ.Classes( 14 13 templ.KV("card", true), 15 14 templ.KV("card-inner", props.InnerCard), 16 15 templ.KV(props.Class, props.Class != ""), 17 - ) 18 - } 16 + ) } 19 17 > 20 18 @content 21 19 </div>
-1
internal/web/components/comments.templ
··· 345 345 // We need to extract the DID and build a comment URI: at://did:plc:xxx/social.arabica.alpha.comment/rkey 346 346 return fmt.Sprintf("at://%s/social.arabica.alpha.comment/%s", comment.ActorDID, comment.RKey) 347 347 } 348 -
+21 -7
internal/web/components/dialog_modals.templ
··· 458 458 class="w-full form-select" 459 459 > 460 460 <option value="">Select type...</option> 461 - <option value="pourover" if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { selected }>Pour-over</option> 462 - <option value="espresso" if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { selected }>Espresso</option> 463 - <option value="immersion" if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { selected }>Immersion</option> 464 - <option value="mokapot" if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { selected }>Moka Pot</option> 465 - <option value="coldbrew" if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { selected }>Cold Brew</option> 466 - <option value="cupping" if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { selected }>Cupping</option> 467 - <option value="other" if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { selected }>Other</option> 461 + <option value="pourover" if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { 462 + selected 463 + }>Pour-over</option> 464 + <option value="espresso" if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { 465 + selected 466 + }>Espresso</option> 467 + <option value="immersion" if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { 468 + selected 469 + }>Immersion</option> 470 + <option value="mokapot" if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { 471 + selected 472 + }>Moka Pot</option> 473 + <option value="coldbrew" if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { 474 + selected 475 + }>Cold Brew</option> 476 + <option value="cupping" if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { 477 + selected 478 + }>Cupping</option> 479 + <option value="other" if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { 480 + selected 481 + }>Other</option> 468 482 </select> 469 483 <textarea 470 484 name="description"
+23 -23
internal/web/components/entity_tables.templ
··· 55 55 // BeanCardsProps defines props for the bean cards grid 56 56 type BeanCardsProps struct { 57 57 Beans []*models.Bean 58 - ShowActions bool // Whether to show Edit/Delete actions 59 - OwnerHandle string // If set, name links to view page with this owner 58 + ShowActions bool // Whether to show Edit/Delete actions 59 + OwnerHandle string // If set, name links to view page with this owner 60 60 BrewCounts map[string]int // bean AT-URI -> brew count (optional) 61 61 AvgBrewRatings map[string]float64 // bean AT-URI -> avg brew rating (optional) 62 62 OwnerDID string // DID of the entity owner (for count lookups) ··· 92 92 if bean.Roaster != nil && bean.Roaster.Name != "" { 93 93 <div class="text-sm text-brown-700 mt-0.5"> 94 94 <span class="font-medium inline-flex items-center gap-1"> 95 - @IconStore() 96 - { bean.Roaster.Name } 97 - </span> 95 + @IconStore() 96 + { bean.Roaster.Name } 97 + </span> 98 98 </div> 99 99 } 100 100 </div> ··· 108 108 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-1"> 109 109 if bean.Origin != "" { 110 110 <span class="inline-flex items-center gap-1"> 111 - @IconMapPin() 112 - { bean.Origin } 113 - </span> 111 + @IconMapPin() 112 + { bean.Origin } 113 + </span> 114 114 } 115 115 if bean.Variety != "" { 116 116 <span class="inline-flex items-center gap-1"> 117 - @IconLeaf() 118 - { bean.Variety } 119 - </span> 117 + @IconLeaf() 118 + { bean.Variety } 119 + </span> 120 120 } 121 121 if bean.RoastLevel != "" { 122 122 <span class="inline-flex items-center gap-1"> 123 - @IconFlame() 124 - { bean.RoastLevel } 125 - </span> 123 + @IconFlame() 124 + { bean.RoastLevel } 125 + </span> 126 126 } 127 127 if bean.Process != "" { 128 128 <span class="inline-flex items-center gap-1"> 129 - @IconSprout() 130 - { bean.Process } 131 - </span> 129 + @IconSprout() 130 + { bean.Process } 131 + </span> 132 132 } 133 133 </div> 134 134 if bean.Description != "" { ··· 165 165 // RoastersTableProps defines props for the shared roasters display 166 166 type RoastersTableProps struct { 167 167 Roasters []*models.Roaster 168 - ShowActions bool // Whether to show Edit/Delete actions 169 - OwnerHandle string // If set, name links to view page with this owner 168 + ShowActions bool // Whether to show Edit/Delete actions 169 + OwnerHandle string // If set, name links to view page with this owner 170 170 BeanCounts map[string]int // roaster AT-URI -> bean count (optional) 171 171 AvgBrewRatings map[string]float64 // roaster AT-URI -> avg brew rating (optional) 172 172 OwnerDID string // DID of the entity owner (for count lookups) ··· 242 242 // GrindersTableProps defines props for the shared grinders display 243 243 type GrindersTableProps struct { 244 244 Grinders []*models.Grinder 245 - ShowActions bool // Whether to show Edit/Delete actions 246 - OwnerHandle string // If set, name links to view page with this owner 245 + ShowActions bool // Whether to show Edit/Delete actions 246 + OwnerHandle string // If set, name links to view page with this owner 247 247 BrewCounts map[string]int // grinder AT-URI -> brew count (optional) 248 248 OwnerDID string // DID of the entity owner (for count lookups) 249 249 } ··· 309 309 // BrewersTableProps defines props for the shared brewers display 310 310 type BrewersTableProps struct { 311 311 Brewers []*models.Brewer 312 - ShowActions bool // Whether to show Edit/Delete actions 313 - OwnerHandle string // If set, name links to view page with this owner 312 + ShowActions bool // Whether to show Edit/Delete actions 313 + OwnerHandle string // If set, name links to view page with this owner 314 314 BrewCounts map[string]int // brewer AT-URI -> brew count (optional) 315 315 OwnerDID string // DID of the entity owner (for count lookups) 316 316 }
-1
internal/web/components/profile_brew_card.templ
··· 119 119 } 120 120 return fmt.Sprintf("/brews/%s", rkey) 121 121 } 122 -
+1 -3
internal/web/components/record_bean.templ
··· 1 1 package components 2 2 3 - import ( 4 - "arabica/internal/models" 5 - ) 3 + import "arabica/internal/models" 6 4 7 5 // BeanContent renders bean details inside a card 8 6 templ BeanContent(bean *models.Bean) {
+1 -1
internal/web/components/record_brew.templ
··· 20 20 if brew.Rating > 0 { 21 21 <span class="badge-rating"> 22 22 @IconStar() 23 - { fmt.Sprintf("%d/10", brew.Rating) } 23 + { fmt.Sprintf("%d/10", brew.Rating) } 24 24 </span> 25 25 } 26 26 </div>
+4 -6
internal/web/components/record_brewer.templ
··· 1 1 package components 2 2 3 - import ( 4 - "arabica/internal/models" 5 - ) 3 + import "arabica/internal/models" 6 4 7 5 // BrewerContent renders brewer details inside a card 8 6 templ BrewerContent(brewer *models.Brewer) { ··· 13 11 if brewer.BrewerType != "" { 14 12 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-1"> 15 13 <span class="inline-flex items-center gap-1"> 16 - @IconBrewer() 17 - { brewer.BrewerType } 18 - </span> 14 + @IconBrewer() 15 + { brewer.BrewerType } 16 + </span> 19 17 </div> 20 18 } 21 19 if brewer.Description != "" {
+1 -3
internal/web/components/record_grinder.templ
··· 1 1 package components 2 2 3 - import ( 4 - "arabica/internal/models" 5 - ) 3 + import "arabica/internal/models" 6 4 7 5 // GrinderContent renders grinder details inside a card 8 6 templ GrinderContent(grinder *models.Grinder) {
+3 -3
internal/web/components/record_roaster.templ
··· 14 14 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-1"> 15 15 if roaster.Location != "" { 16 16 <span class="inline-flex items-center gap-1"> 17 - @IconMapPin() 18 - { roaster.Location } 19 - </span> 17 + @IconMapPin() 18 + { roaster.Location } 19 + </span> 20 20 } 21 21 if roaster.Website != "" { 22 22 if safeWebsite := bff.SafeWebsiteURL(roaster.Website); safeWebsite != "" {
+119 -119
internal/web/pages/admin.templ
··· 29 29 } 30 30 31 31 type AdminProps struct { 32 - HiddenRecords []moderation.HiddenRecord 33 - AuditLog []moderation.AuditEntry 34 - Reports []EnrichedReport 35 - BlockedUsers []moderation.BlacklistedUser 36 - Labels []moderation.Label 37 - JoinRequests []*boltstore.JoinRequest 38 - Stats AdminStats 39 - CanHide bool 40 - CanUnhide bool 41 - CanViewLogs bool 42 - CanViewReports bool 43 - CanBlock bool 44 - CanUnblock bool 45 - CanResetAutoHide bool 46 - CanManageLabels bool 47 - IsAdmin bool 32 + HiddenRecords []moderation.HiddenRecord 33 + AuditLog []moderation.AuditEntry 34 + Reports []EnrichedReport 35 + BlockedUsers []moderation.BlacklistedUser 36 + Labels []moderation.Label 37 + JoinRequests []*boltstore.JoinRequest 38 + Stats AdminStats 39 + CanHide bool 40 + CanUnhide bool 41 + CanViewLogs bool 42 + CanViewReports bool 43 + CanBlock bool 44 + CanUnblock bool 45 + CanResetAutoHide bool 46 + CanManageLabels bool 47 + IsAdmin bool 48 48 } 49 49 50 50 templ Admin(layout *components.LayoutData, props AdminProps) { ··· 250 250 </div> 251 251 } 252 252 <!-- Labels Tab --> 253 - if props.CanManageLabels { 254 - <div x-show="activeTab === 'labels'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 255 - <div class="card card-inner"> 256 - <div class="flex items-center justify-between mb-4"> 257 - <h2 class="section-title mb-0">Labels</h2> 258 - <button 259 - type="button" 260 - x-data 261 - @click="$dispatch('open-add-label')" 262 - class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-3 py-1.5 rounded font-medium transition-colors" 263 - > 264 - Add Label 265 - </button> 253 + if props.CanManageLabels { 254 + <div x-show="activeTab === 'labels'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 255 + <div class="card card-inner"> 256 + <div class="flex items-center justify-between mb-4"> 257 + <h2 class="section-title mb-0">Labels</h2> 258 + <button 259 + type="button" 260 + x-data 261 + @click="$dispatch('open-add-label')" 262 + class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-3 py-1.5 rounded font-medium transition-colors" 263 + > 264 + Add Label 265 + </button> 266 + </div> 267 + if len(props.Labels) == 0 { 268 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 269 + <p>No labels have been applied yet.</p> 266 270 </div> 267 - if len(props.Labels) == 0 { 268 - <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 269 - <p>No labels have been applied yet.</p> 270 - </div> 271 - } else { 272 - <div class="space-y-3"> 273 - for _, label := range props.Labels { 274 - @LabelCard(label) 275 - } 276 - </div> 277 - } 278 - </div> 279 - <!-- Add Label Form --> 280 - <div 281 - x-data="{ open: false }" 282 - x-on:open-add-label.window="open = true" 283 - x-show="open" 284 - x-cloak 285 - x-transition 286 - class="card card-inner mt-4" 271 + } else { 272 + <div class="space-y-3"> 273 + for _, label := range props.Labels { 274 + @LabelCard(label) 275 + } 276 + </div> 277 + } 278 + </div> 279 + <!-- Add Label Form --> 280 + <div 281 + x-data="{ open: false }" 282 + x-on:open-add-label.window="open = true" 283 + x-show="open" 284 + x-cloak 285 + x-transition 286 + class="card card-inner mt-4" 287 + > 288 + <h3 class="section-title">Add Label</h3> 289 + <form 290 + hx-post="/_mod/label/add" 291 + hx-swap="none" 292 + class="space-y-4" 293 + x-ref="labelForm" 294 + @htmx:after-request.window="if ($event.detail.successful) { open = false; $refs.labelForm.reset() }" 287 295 > 288 - <h3 class="section-title">Add Label</h3> 289 - <form 290 - hx-post="/_mod/label/add" 291 - hx-swap="none" 292 - class="space-y-4" 293 - x-ref="labelForm" 294 - @htmx:after-request.window="if ($event.detail.successful) { open = false; $refs.labelForm.reset() }" 295 - > 296 - <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 297 - <div> 298 - <label class="block text-sm font-medium text-brown-700 mb-1">Entity Type</label> 299 - <select name="entity_type" required class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 300 - <option value="user">User (DID)</option> 301 - <option value="record">Record (AT-URI)</option> 302 - </select> 303 - </div> 304 - <div> 305 - <label class="block text-sm font-medium text-brown-700 mb-1">Entity ID</label> 306 - <input type="text" name="entity_id" required placeholder="did:plc:... or at://..." class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 307 - </div> 296 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 297 + <div> 298 + <label class="block text-sm font-medium text-brown-700 mb-1">Entity Type</label> 299 + <select name="entity_type" required class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 300 + <option value="user">User (DID)</option> 301 + <option value="record">Record (AT-URI)</option> 302 + </select> 308 303 </div> 309 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> 310 - <div> 311 - <label class="block text-sm font-medium text-brown-700 mb-1">Label</label> 312 - <input type="text" name="label" required placeholder="e.g. warned, trusted, spam" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 313 - </div> 314 - <div> 315 - <label class="block text-sm font-medium text-brown-700 mb-1">Value (optional)</label> 316 - <input type="text" name="value" placeholder="Optional value" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 317 - </div> 318 - <div> 319 - <label class="block text-sm font-medium text-brown-700 mb-1">Expires (optional)</label> 320 - <select name="expires" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 321 - <option value="">Never</option> 322 - <option value="24h">24 hours</option> 323 - <option value="168h">7 days</option> 324 - <option value="720h">30 days</option> 325 - <option value="2160h">90 days</option> 326 - </select> 327 - </div> 304 + <div> 305 + <label class="block text-sm font-medium text-brown-700 mb-1">Entity ID</label> 306 + <input type="text" name="entity_id" required placeholder="did:plc:... or at://..." class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 328 307 </div> 329 - <div class="flex gap-3"> 330 - <button type="submit" class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2 rounded font-medium transition-colors"> 331 - Add Label 332 - </button> 333 - <button type="button" @click="open = false" class="text-sm text-brown-600 hover:text-brown-800 px-4 py-2 rounded font-medium transition-colors"> 334 - Cancel 335 - </button> 308 + </div> 309 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> 310 + <div> 311 + <label class="block text-sm font-medium text-brown-700 mb-1">Label</label> 312 + <input type="text" name="label" required placeholder="e.g. warned, trusted, spam" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 336 313 </div> 337 - </form> 338 - </div> 339 - </div> 340 - } 341 - <!-- Join Requests Tab --> 342 - if props.IsAdmin { 343 - <div x-show="activeTab === 'join'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 344 - <div class="card card-inner"> 345 - <h2 class="section-title">Join Requests</h2> 346 - if len(props.JoinRequests) == 0 { 347 - <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 348 - <p>No join requests.</p> 314 + <div> 315 + <label class="block text-sm font-medium text-brown-700 mb-1">Value (optional)</label> 316 + <input type="text" name="value" placeholder="Optional value" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"/> 349 317 </div> 350 - } else { 351 - <div class="space-y-3"> 352 - for _, req := range props.JoinRequests { 353 - @JoinRequestCard(req) 354 - } 318 + <div> 319 + <label class="block text-sm font-medium text-brown-700 mb-1">Expires (optional)</label> 320 + <select name="expires" class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"> 321 + <option value="">Never</option> 322 + <option value="24h">24 hours</option> 323 + <option value="168h">7 days</option> 324 + <option value="720h">30 days</option> 325 + <option value="2160h">90 days</option> 326 + </select> 355 327 </div> 356 - } 357 - </div> 328 + </div> 329 + <div class="flex gap-3"> 330 + <button type="submit" class="text-sm bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2 rounded font-medium transition-colors"> 331 + Add Label 332 + </button> 333 + <button type="button" @click="open = false" class="text-sm text-brown-600 hover:text-brown-800 px-4 py-2 rounded font-medium transition-colors"> 334 + Cancel 335 + </button> 336 + </div> 337 + </form> 358 338 </div> 359 - } 339 + </div> 340 + } 341 + <!-- Join Requests Tab --> 342 + if props.IsAdmin { 343 + <div x-show="activeTab === 'join'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 344 + <div class="card card-inner"> 345 + <h2 class="section-title">Join Requests</h2> 346 + if len(props.JoinRequests) == 0 { 347 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 348 + <p>No join requests.</p> 349 + </div> 350 + } else { 351 + <div class="space-y-3"> 352 + for _, req := range props.JoinRequests { 353 + @JoinRequestCard(req) 354 + } 355 + </div> 356 + } 357 + </div> 358 + </div> 359 + } 360 360 <!-- Stats Tab (admin only) --> 361 361 if props.IsAdmin { 362 362 <div x-show="activeTab === 'stats'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 776 776 Unblock User 777 777 </span> 778 778 case moderation.AuditActionResetAutoHide: 779 - <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> 780 - Reset Auto-Hide 781 - </span> 782 - case moderation.AuditActionDismissJoinRequest: 779 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> 780 + Reset Auto-Hide 781 + </span> 782 + case moderation.AuditActionDismissJoinRequest: 783 783 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"> 784 784 Dismiss Join Request 785 785 </span>
+32 -32
internal/web/pages/brew_list.templ
··· 1 1 package pages 2 2 3 - import ( 4 - "arabica/internal/web/components" 5 - ) 3 + import "arabica/internal/web/components" 6 4 7 5 // BrewListProps defines the data for the brew list page 8 6 type BrewListProps struct { ··· 30 28 31 29 // BrewListLoadingSkeleton renders loading skeleton for brew list 32 30 templ BrewListLoadingSkeleton() { 33 - <div class="animate-pulse"> 34 - <div class="table-container overflow-x-auto"> 35 - <table class="table"> 36 - <thead class="table-header"> 37 - <tr> 38 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconCalendar() Date</span></th> 39 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconCoffee() Bean</span></th> 40 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconBrewer() Brewer</span></th> 41 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconWrench() Variables</span></th> 42 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></th> 43 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconStar() Rating</span></th> 44 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 45 - </tr> 46 - </thead> 47 - <tbody class="table-body"> 48 - for i := 0; i < 5; i++ { 49 - <tr> 50 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 51 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 52 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 53 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 54 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-40"></div></td> 55 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 56 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 57 - </tr> 58 - } 59 - </tbody> 60 - </table> 61 - </div> 31 + <div class="animate-pulse space-y-3"> 32 + for i := 0; i < 5; i++ { 33 + <div class="feed-card feed-card-brew"> 34 + <!-- Header: date + actions --> 35 + <div class="flex items-center justify-between mb-2"> 36 + <div class="h-4 bg-brown-300 rounded w-20"></div> 37 + <div class="flex gap-1"> 38 + <div class="h-5 bg-brown-300 rounded w-8"></div> 39 + <div class="h-5 bg-brown-300 rounded w-8"></div> 40 + <div class="h-5 bg-brown-300 rounded w-10"></div> 41 + </div> 42 + </div> 43 + <!-- Brew content --> 44 + <div class="feed-content-box"> 45 + <div class="flex items-start justify-between gap-3 mb-3"> 46 + <div class="flex-1 min-w-0"> 47 + <div class="h-5 bg-brown-300 rounded w-36 mb-1"></div> 48 + <div class="h-3 bg-brown-300 rounded w-24"></div> 49 + </div> 50 + <div class="h-6 bg-brown-300 rounded w-12 flex-shrink-0"></div> 51 + </div> 52 + <div class="h-4 bg-brown-300 rounded w-28 mb-2"></div> 53 + <div class="grid grid-cols-2 gap-x-4 gap-y-1"> 54 + <div class="h-3 bg-brown-300 rounded w-20"></div> 55 + <div class="h-3 bg-brown-300 rounded w-24"></div> 56 + <div class="h-3 bg-brown-300 rounded w-16"></div> 57 + <div class="h-3 bg-brown-300 rounded w-20"></div> 58 + </div> 59 + </div> 60 + </div> 61 + } 62 62 </div> 63 63 }
+32 -29
internal/web/pages/brew_view.templ
··· 13 13 Brew *models.Brew 14 14 IsOwnProfile bool 15 15 IsAuthenticated bool 16 - SubjectURI string // AT-URI of the brew (for like button) 17 - SubjectCID string // CID of the brew (for like button) 18 - IsLiked bool // Whether the current user has liked this brew 19 - LikeCount int // Number of likes on this brew 20 - CommentCount int // Number of comments on this brew 16 + SubjectURI string // AT-URI of the brew (for like button) 17 + SubjectCID string // CID of the brew (for like button) 18 + IsLiked bool // Whether the current user has liked this brew 19 + LikeCount int // Number of likes on this brew 20 + CommentCount int // Number of comments on this brew 21 21 Comments []firehose.IndexedComment // Comments on this brew 22 - CurrentUserDID string // DID of the current user (for delete buttons) 23 - ShareURL string // URL for sharing the brew 22 + CurrentUserDID string // DID of the current user (for delete buttons) 23 + ShareURL string // URL for sharing the brew 24 24 // Moderation state 25 25 IsModerator bool // User has moderator role 26 26 CanHideRecord bool // User has hide_record permission ··· 99 99 CanHideRecord: props.CanHideRecord, 100 100 CanBlockUser: props.CanBlockUser, 101 101 }, 102 - ViewURL: props.ShareURL, 102 + ViewURL: props.ShareURL, 103 103 }) 104 104 </div> 105 105 } ··· 128 128 // BrewBeanSection renders the coffee bean information 129 129 templ BrewBeanSection(brew *models.Brew, owner string) { 130 130 <div class="section-box"> 131 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconCoffee() Coffee Bean</span></h3> 131 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconCoffee() 132 + Coffee Bean</span></h3> 132 133 if brew.Bean != nil { 133 134 <div class="font-bold text-lg text-brown-900"> 134 135 <a href={ templ.SafeURL(fmt.Sprintf("/beans/%s?owner=%s", brew.Bean.RKey, owner)) } class="hover:underline"> ··· 152 153 <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 153 154 if brew.Bean.Origin != "" { 154 155 <span class="inline-flex items-center gap-1"> 155 - @components.IconMapPin() 156 - { brew.Bean.Origin } 157 - </span> 156 + @components.IconMapPin() 157 + { brew.Bean.Origin } 158 + </span> 158 159 } 159 160 if brew.Bean.RoastLevel != "" { 160 161 <span class="inline-flex items-center gap-1"> 161 - @components.IconFlame() 162 - { brew.Bean.RoastLevel } 163 - </span> 162 + @components.IconFlame() 163 + { brew.Bean.RoastLevel } 164 + </span> 164 165 } 165 166 </div> 166 167 } else { ··· 373 374 <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 374 375 if recipe.CoffeeAmount > 0 { 375 376 <span class="inline-flex items-center gap-1"> 376 - @components.IconCoffee() 377 - { fmt.Sprintf("%.1fg coffee", recipe.CoffeeAmount) } 378 - </span> 377 + @components.IconCoffee() 378 + { fmt.Sprintf("%.1fg coffee", recipe.CoffeeAmount) } 379 + </span> 379 380 } 380 381 if recipe.WaterAmount > 0 { 381 382 <span class="inline-flex items-center gap-1"> 382 - @components.IconDroplet() 383 - { fmt.Sprintf("%.1fg water", recipe.WaterAmount) } 384 - </span> 383 + @components.IconDroplet() 384 + { fmt.Sprintf("%.1fg water", recipe.WaterAmount) } 385 + </span> 385 386 } 386 387 if recipe.BrewerObj != nil { 387 388 <span class="inline-flex items-center gap-1"> 388 - @components.IconBrewer() 389 - { recipe.BrewerObj.Name } 390 - </span> 389 + @components.IconBrewer() 390 + { recipe.BrewerObj.Name } 391 + </span> 391 392 } else if recipe.BrewerType != "" { 392 393 <span class="inline-flex items-center gap-1"> 393 - @components.IconBrewer() 394 - { recipe.BrewerType } 395 - </span> 394 + @components.IconBrewer() 395 + { recipe.BrewerType } 396 + </span> 396 397 } 397 398 </div> 398 399 if recipe.Notes != "" { ··· 404 405 // BrewPoursSection renders the pours section 405 406 templ BrewPoursSection(pours []*models.Pour) { 406 407 <div class="section-box"> 407 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"><span class="inline-flex items-center gap-1">@components.IconDroplet() Pours</span></h3> 408 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"><span class="inline-flex items-center gap-1">@components.IconDroplet() 409 + Pours</span></h3> 408 410 <div class="space-y-2"> 409 411 for _, pour := range pours { 410 412 <div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200"> ··· 422 424 // BrewTastingNotes renders the tasting notes section 423 425 templ BrewTastingNotes(notes string) { 424 426 <div class="section-box"> 425 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Tasting Notes</span></h3> 427 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() 428 + Tasting Notes</span></h3> 426 429 <div class="text-brown-900 whitespace-pre-wrap">{ notes }</div> 427 430 </div> 428 431 }
+2 -1
internal/web/pages/brewer_view.templ
··· 45 45 @components.DetailField(components.DetailFieldProps{Icon: components.IconCoffee(), Label: "Type", Value: props.Brewer.BrewerType}) 46 46 if props.Brewer.Description != "" { 47 47 <div class="section-box"> 48 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Description</span></h3> 48 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() 49 + Description</span></h3> 49 50 <div class="text-brown-900 whitespace-pre-wrap">{ props.Brewer.Description }</div> 50 51 </div> 51 52 }
+29 -27
internal/web/pages/feed.templ
··· 174 174 175 175 // FeedCardWithModeration renders a single feed item card with moderation context 176 176 templ FeedCardWithModeration(item *feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext) { 177 - <div class={ templ.Classes( 177 + <div 178 + class={ templ.Classes( 178 179 "feed-card", 179 180 templ.KV("feed-card-brew", item.RecordType == lexicons.RecordTypeBrew), 180 181 templ.KV("feed-card-bean", item.RecordType == lexicons.RecordTypeBean), ··· 182 183 templ.KV("feed-card-roaster", item.RecordType == lexicons.RecordTypeRoaster), 183 184 templ.KV("feed-card-grinder", item.RecordType == lexicons.RecordTypeGrinder), 184 185 templ.KV("feed-card-brewer", item.RecordType == lexicons.RecordTypeBrewer), 185 - ) }> 186 + ) } 187 + > 186 188 <!-- Author row --> 187 189 <div class="mb-3"> 188 190 @components.UserBadge(components.UserBadgeProps{ ··· 355 357 if item.Brew.Bean.Roaster != nil && item.Brew.Bean.Roaster.Name != "" { 356 358 <div class="text-sm text-brown-700 mt-0.5"> 357 359 <span class="inline-flex items-center gap-1 font-medium"> 358 - @components.IconStore() 359 - { item.Brew.Bean.Roaster.Name } 360 - </span> 360 + @components.IconStore() 361 + { item.Brew.Bean.Roaster.Name } 362 + </span> 361 363 </div> 362 364 } 363 365 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-1"> 364 366 if item.Brew.Bean.Origin != "" { 365 367 <span class="inline-flex items-center gap-1"> 366 - @components.IconMapPin() 367 - { item.Brew.Bean.Origin } 368 - </span> 368 + @components.IconMapPin() 369 + { item.Brew.Bean.Origin } 370 + </span> 369 371 } 370 372 if item.Brew.Bean.RoastLevel != "" { 371 373 <span class="inline-flex items-center gap-1"> 372 - @components.IconFlame() 373 - { item.Brew.Bean.RoastLevel } 374 - </span> 374 + @components.IconFlame() 375 + { item.Brew.Bean.RoastLevel } 376 + </span> 375 377 } 376 378 if item.Brew.Bean.Process != "" { 377 379 <span class="inline-flex items-center gap-1"> 378 - @components.IconSprout() 379 - { item.Brew.Bean.Process } 380 - </span> 380 + @components.IconSprout() 381 + { item.Brew.Bean.Process } 382 + </span> 381 383 } 382 384 if item.Brew.CoffeeAmount > 0 { 383 385 <span class="inline-flex items-center gap-1"> 384 - @components.IconScale() 385 - { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) } 386 - </span> 386 + @components.IconScale() 387 + { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) } 388 + </span> 387 389 } 388 390 </div> 389 391 } ··· 391 393 if item.Brew.Rating > 0 { 392 394 <span class="badge-rating"> 393 395 @components.IconStar() 394 - { fmt.Sprintf("%d/10", item.Brew.Rating) } 396 + { fmt.Sprintf("%d/10", item.Brew.Rating) } 395 397 </span> 396 398 } 397 399 </div> ··· 465 467 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-1"> 466 468 if item.Recipe.CoffeeAmount > 0 { 467 469 <span class="inline-flex items-center gap-1"> 468 - @components.IconCoffee() 469 - { fmt.Sprintf("%.1fg", item.Recipe.CoffeeAmount) } 470 - </span> 470 + @components.IconCoffee() 471 + { fmt.Sprintf("%.1fg", item.Recipe.CoffeeAmount) } 472 + </span> 471 473 } 472 474 if item.Recipe.WaterAmount > 0 { 473 475 <span class="inline-flex items-center gap-1"> 474 - @components.IconDroplet() 475 - { fmt.Sprintf("%.1fg", item.Recipe.WaterAmount) } 476 - </span> 476 + @components.IconDroplet() 477 + { fmt.Sprintf("%.1fg", item.Recipe.WaterAmount) } 478 + </span> 477 479 } 478 480 if item.Recipe.BrewerObj != nil { 479 481 <span class="inline-flex items-center gap-1"> 480 - @components.IconBrewer() 481 - { item.Recipe.BrewerObj.Name } 482 - </span> 482 + @components.IconBrewer() 483 + { item.Recipe.BrewerObj.Name } 484 + </span> 483 485 } else if item.Recipe.BrewerType != "" { 484 486 <span class="inline-flex items-center gap-1"> 485 487 @components.IconBrewer()
+2 -2
internal/web/pages/grinder_view.templ
··· 48 48 </div> 49 49 if props.Grinder.Notes != "" { 50 50 <div class="section-box"> 51 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></h3> 51 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() 52 + Notes</span></h3> 52 53 <div class="text-brown-900 whitespace-pre-wrap">{ props.Grinder.Notes }</div> 53 54 </div> 54 55 } ··· 108 109 <p class="text-sm text-brown-600 mt-1"><time datetime={ bff.FormatISO(props.Grinder.CreatedAt) } data-local="long">{ props.Grinder.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</time></p> 109 110 </div> 110 111 } 111 -
+2 -4
internal/web/pages/home.templ
··· 19 19 }) 20 20 if props.IsAuthenticated { 21 21 <!-- Incomplete records loaded async --> 22 - <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 23 - </div> 22 + <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"></div> 24 23 <!-- Modal container for entity edit dialogs opened from dashboard --> 25 24 <div id="modal-container"></div> 26 25 } ··· 29 28 } 30 29 if props.IsAuthenticated { 31 30 <!-- Popular recipes loaded async --> 32 - <div hx-get="/api/popular-recipes" hx-trigger="load" hx-swap="innerHTML"> 33 - </div> 31 + <div hx-get="/api/popular-recipes" hx-trigger="load" hx-swap="innerHTML"></div> 34 32 } 35 33 @CommunityFeedSection(props.IsAuthenticated) 36 34 if props.IsAuthenticated {
+101 -142
internal/web/pages/manage.templ
··· 107 107 <h3 class="text-xl font-semibold text-brown-900">Coffee Beans</h3> 108 108 <div class="h-10 bg-brown-300 rounded w-28"></div> 109 109 </div> 110 - <div class="table-container overflow-x-auto"> 111 - <table class="table"> 112 - <thead class="table-header"> 113 - <tr> 114 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconBarChart() Status</span></th> 115 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconTag() Name</span></th> 116 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconMapPin() Origin</span></th> 117 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconCoffee() Roaster</span></th> 118 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFlame() Roast Level</span></th> 119 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconSprout() Process</span></th> 120 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFileText() Description</span></th> 121 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 122 - </tr> 123 - </thead> 124 - <tbody class="table-body"> 125 - for range ntabs { 126 - <tr class="table-row"> 127 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 128 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 129 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 130 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 131 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 132 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 133 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-40"></div></td> 134 - <td class="px-6 py-4"> 135 - <div class="flex gap-2"> 136 - <div class="h-4 bg-brown-300 rounded w-10"></div> 137 - <div class="h-4 bg-brown-300 rounded w-12"></div> 138 - </div> 139 - </td> 140 - </tr> 141 - } 142 - </tbody> 143 - </table> 110 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 111 + for range ntabs { 112 + <div class="feed-card feed-card-bean"> 113 + <div class="flex items-center justify-between mb-2"> 114 + <div class="h-3 bg-brown-300 rounded w-16"></div> 115 + <div class="flex gap-1"> 116 + <div class="h-5 bg-brown-300 rounded w-8"></div> 117 + <div class="h-5 bg-brown-300 rounded w-10"></div> 118 + </div> 119 + </div> 120 + <div class="feed-content-box-sm"> 121 + <div class="flex items-start justify-between gap-3 mb-2"> 122 + <div class="min-w-0"> 123 + <div class="h-4 bg-brown-300 rounded w-2/5 mb-1"></div> 124 + <div class="h-3 bg-brown-300 rounded w-1/4"></div> 125 + </div> 126 + <div class="h-6 bg-brown-300 rounded w-12 flex-shrink-0"></div> 127 + </div> 128 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 129 + <div class="h-3 bg-brown-300 rounded w-12"></div> 130 + <div class="h-3 bg-brown-300 rounded w-10"></div> 131 + <div class="h-3 bg-brown-300 rounded w-10"></div> 132 + </div> 133 + </div> 134 + </div> 135 + } 144 136 </div> 145 137 </div> 146 138 <!-- Roasters Tab Skeleton --> ··· 149 141 <h3 class="text-xl font-semibold text-brown-900">Roasters</h3> 150 142 <div class="h-10 bg-brown-300 rounded w-28"></div> 151 143 </div> 152 - <div class="table-container overflow-x-auto"> 153 - <table class="table"> 154 - <thead class="table-header"> 155 - <tr> 156 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconTag() Name</span></th> 157 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconMapPin() Location</span></th> 158 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGlobe() Website</span></th> 159 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 160 - </tr> 161 - </thead> 162 - <tbody class="table-body"> 163 - for range ntabs { 164 - <tr class="table-row"> 165 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 166 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 167 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 168 - <td class="px-6 py-4"> 169 - <div class="flex gap-2"> 170 - <div class="h-4 bg-brown-300 rounded w-10"></div> 171 - <div class="h-4 bg-brown-300 rounded w-12"></div> 172 - </div> 173 - </td> 174 - </tr> 175 - } 176 - </tbody> 177 - </table> 144 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 145 + for range ntabs { 146 + <div class="feed-card feed-card-roaster"> 147 + <div class="flex items-center justify-between mb-2"> 148 + <div class="h-3 bg-brown-300 rounded w-14"></div> 149 + <div class="flex gap-1"> 150 + <div class="h-5 bg-brown-300 rounded w-8"></div> 151 + <div class="h-5 bg-brown-300 rounded w-10"></div> 152 + </div> 153 + </div> 154 + <div class="feed-content-box-sm"> 155 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 156 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 157 + <div class="h-3 bg-brown-300 rounded w-14"></div> 158 + </div> 159 + </div> 160 + </div> 161 + } 178 162 </div> 179 163 </div> 180 164 <!-- Grinders Tab Skeleton --> ··· 183 167 <h3 class="text-xl font-semibold text-brown-900">Grinders</h3> 184 168 <div class="h-10 bg-brown-300 rounded w-28"></div> 185 169 </div> 186 - <div class="table-container overflow-x-auto"> 187 - <table class="table"> 188 - <thead class="table-header"> 189 - <tr> 190 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconTag() Name</span></th> 191 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconSliders() Type</span></th> 192 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconDisc() Burr Type</span></th> 193 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></th> 194 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 195 - </tr> 196 - </thead> 197 - <tbody class="table-body"> 198 - for range ntabs { 199 - <tr class="table-row"> 200 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 201 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 202 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 203 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 204 - <td class="px-6 py-4"> 205 - <div class="flex gap-2"> 206 - <div class="h-4 bg-brown-300 rounded w-10"></div> 207 - <div class="h-4 bg-brown-300 rounded w-12"></div> 208 - </div> 209 - </td> 210 - </tr> 211 - } 212 - </tbody> 213 - </table> 170 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 171 + for range ntabs { 172 + <div class="feed-card feed-card-grinder"> 173 + <div class="flex items-center justify-between mb-2"> 174 + <div class="h-3 bg-brown-300 rounded w-14"></div> 175 + <div class="flex gap-1"> 176 + <div class="h-5 bg-brown-300 rounded w-8"></div> 177 + <div class="h-5 bg-brown-300 rounded w-10"></div> 178 + </div> 179 + </div> 180 + <div class="feed-content-box-sm"> 181 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 182 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 183 + <div class="h-3 bg-brown-300 rounded w-10"></div> 184 + <div class="h-3 bg-brown-300 rounded w-12"></div> 185 + </div> 186 + </div> 187 + </div> 188 + } 214 189 </div> 215 190 </div> 216 191 <!-- Brewers Tab Skeleton --> ··· 219 194 <h3 class="text-xl font-semibold text-brown-900">Brewers</h3> 220 195 <div class="h-10 bg-brown-300 rounded w-28"></div> 221 196 </div> 222 - <div class="table-container overflow-x-auto"> 223 - <table class="table"> 224 - <thead class="table-header"> 225 - <tr> 226 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconTag() Name</span></th> 227 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFileText() Description</span></th> 228 - <th class="table-th whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 229 - </tr> 230 - </thead> 231 - <tbody class="table-body"> 232 - for range ntabs { 233 - <tr class="table-row"> 234 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 235 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-48"></div></td> 236 - <td class="px-6 py-4"> 237 - <div class="flex gap-2"> 238 - <div class="h-4 bg-brown-300 rounded w-10"></div> 239 - <div class="h-4 bg-brown-300 rounded w-12"></div> 240 - </div> 241 - </td> 242 - </tr> 243 - } 244 - </tbody> 245 - </table> 197 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 198 + for range ntabs { 199 + <div class="feed-card feed-card-brewer"> 200 + <div class="flex items-center justify-between mb-2"> 201 + <div class="h-3 bg-brown-300 rounded w-14"></div> 202 + <div class="flex gap-1"> 203 + <div class="h-5 bg-brown-300 rounded w-8"></div> 204 + <div class="h-5 bg-brown-300 rounded w-10"></div> 205 + </div> 206 + </div> 207 + <div class="feed-content-box-sm"> 208 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 209 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 210 + <div class="h-3 bg-brown-300 rounded w-12"></div> 211 + </div> 212 + </div> 213 + </div> 214 + } 246 215 </div> 247 216 </div> 248 217 <!-- Recipes Tab Skeleton --> ··· 251 220 <h3 class="text-xl font-semibold text-brown-900">Recipes</h3> 252 221 <div class="h-10 bg-brown-300 rounded w-28"></div> 253 222 </div> 254 - <div class="table-container overflow-x-auto"> 255 - <table class="table"> 256 - <thead class="table-header"> 257 - <tr> 258 - <th class="table-th whitespace-nowrap">Name</th> 259 - <th class="table-th whitespace-nowrap">Coffee</th> 260 - <th class="table-th whitespace-nowrap">Water</th> 261 - <th class="table-th whitespace-nowrap">Grind</th> 262 - <th class="table-th whitespace-nowrap">Brewer</th> 263 - <th class="table-th whitespace-nowrap">Actions</th> 264 - </tr> 265 - </thead> 266 - <tbody class="table-body"> 267 - for range ntabs { 268 - <tr class="table-row"> 269 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 270 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 271 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 272 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 273 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 274 - <td class="px-6 py-4"> 275 - <div class="flex gap-2"> 276 - <div class="h-4 bg-brown-300 rounded w-10"></div> 277 - <div class="h-4 bg-brown-300 rounded w-12"></div> 278 - </div> 279 - </td> 280 - </tr> 281 - } 282 - </tbody> 283 - </table> 223 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 224 + for range ntabs { 225 + <div class="feed-card feed-card-recipe"> 226 + <div class="flex items-center justify-between mb-2"> 227 + <div class="h-3 bg-brown-300 rounded w-14"></div> 228 + <div class="flex gap-1"> 229 + <div class="h-5 bg-brown-300 rounded w-8"></div> 230 + <div class="h-5 bg-brown-300 rounded w-10"></div> 231 + </div> 232 + </div> 233 + <div class="feed-content-box-sm"> 234 + <div class="h-4 bg-brown-300 rounded w-2/5 mb-1"></div> 235 + <div class="h-3 bg-brown-300 rounded w-1/4 mb-2"></div> 236 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 237 + <div class="h-3 bg-brown-300 rounded w-14"></div> 238 + <div class="h-3 bg-brown-300 rounded w-14"></div> 239 + </div> 240 + </div> 241 + </div> 242 + } 284 243 </div> 285 244 </div> 286 245 </div>
+112 -120
internal/web/pages/profile.templ
··· 128 128 <div class="animate-pulse"> 129 129 <!-- Brews Tab Skeleton --> 130 130 <div x-show="activeTab === 'brews'"> 131 - <div class="table-container overflow-x-auto"> 132 - <table class="table"> 133 - <thead class="table-header"> 134 - <tr> 135 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconCalendar() Date</span></th> 136 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconCoffee() Bean</span></th> 137 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconBrewer() Brewer</span></th> 138 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconWrench() Variables</span></th> 139 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></th> 140 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconStar() Rating</span></th> 141 - <th class="table-th px-4 whitespace-nowrap"><span class="inline-flex items-center gap-1">@components.IconGear() Actions</span></th> 142 - </tr> 143 - </thead> 144 - <tbody class="table-body"> 145 - for i := 0; i < 3; i++ { 146 - <tr> 147 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 148 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 149 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 150 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 151 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-40"></div></td> 152 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 153 - <td class="px-4 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 154 - </tr> 155 - } 156 - </tbody> 157 - </table> 131 + <div class="space-y-4"> 132 + for i := 0; i < 3; i++ { 133 + <div class="feed-card feed-card-brew"> 134 + <!-- Author row --> 135 + <div class="flex items-center gap-3 mb-3"> 136 + <div class="w-10 h-10 rounded-full bg-brown-300"></div> 137 + <div class="flex-1"> 138 + <div class="h-4 bg-brown-300 rounded w-24 mb-1"></div> 139 + <div class="h-3 bg-brown-300 rounded w-16"></div> 140 + </div> 141 + </div> 142 + <!-- Action text --> 143 + <div class="h-4 bg-brown-300 rounded w-28 mb-2"></div> 144 + <!-- Brew content --> 145 + <div class="feed-content-box"> 146 + <div class="flex items-start justify-between gap-3 mb-3"> 147 + <div class="flex-1 min-w-0"> 148 + <div class="h-5 bg-brown-300 rounded w-36 mb-1"></div> 149 + <div class="h-3 bg-brown-300 rounded w-24"></div> 150 + </div> 151 + <div class="h-6 bg-brown-300 rounded w-12 flex-shrink-0"></div> 152 + </div> 153 + <div class="h-4 bg-brown-300 rounded w-28 mb-2"></div> 154 + <div class="grid grid-cols-2 gap-x-4 gap-y-1"> 155 + <div class="h-3 bg-brown-300 rounded w-20"></div> 156 + <div class="h-3 bg-brown-300 rounded w-24"></div> 157 + </div> 158 + </div> 159 + <!-- Action bar --> 160 + <div class="flex items-center gap-4 mt-3"> 161 + <div class="h-4 bg-brown-300 rounded w-10"></div> 162 + <div class="h-4 bg-brown-300 rounded w-10"></div> 163 + <div class="h-4 bg-brown-300 rounded w-10"></div> 164 + </div> 165 + </div> 166 + } 158 167 </div> 159 168 </div> 160 169 <!-- Beans Tab Skeleton --> 161 170 <div x-show="activeTab === 'beans'"> 162 171 <div class="space-y-6"> 163 - <!-- Beans Section --> 172 + <!-- Open Bags Section --> 164 173 <div> 165 - <h4 class="text-lg font-semibold text-brown-900 mb-3">Beans</h4> 166 - <div class="table-container overflow-x-auto"> 167 - <table class="table"> 168 - <thead class="table-header"> 169 - <tr> 170 - <th class="table-th">Name</th> 171 - <th class="table-th">Origin</th> 172 - <th class="table-th">Roaster</th> 173 - <th class="table-th">Roast Level</th> 174 - <th class="table-th">Process</th> 175 - </tr> 176 - </thead> 177 - <tbody class="table-body"> 178 - for i := 0; i < 2; i++ { 179 - <tr class="table-row"> 180 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 181 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 182 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 183 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 184 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 185 - </tr> 186 - } 187 - </tbody> 188 - </table> 174 + <h4 class="text-lg font-semibold text-brown-900 mb-3">Open Bags</h4> 175 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 176 + for i := 0; i < 2; i++ { 177 + <div class="feed-card feed-card-bean"> 178 + <div class="flex items-center justify-between mb-2"> 179 + <div class="h-3 bg-brown-300 rounded w-16"></div> 180 + </div> 181 + <div class="feed-content-box-sm"> 182 + <div class="flex items-start justify-between gap-3 mb-2"> 183 + <div class="min-w-0"> 184 + <div class="h-4 bg-brown-300 rounded w-2/5 mb-1"></div> 185 + <div class="h-3 bg-brown-300 rounded w-1/4"></div> 186 + </div> 187 + <div class="h-6 bg-brown-300 rounded w-12 flex-shrink-0"></div> 188 + </div> 189 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 190 + <div class="h-3 bg-brown-300 rounded w-12"></div> 191 + <div class="h-3 bg-brown-300 rounded w-10"></div> 192 + <div class="h-3 bg-brown-300 rounded w-10"></div> 193 + </div> 194 + </div> 195 + </div> 196 + } 189 197 </div> 190 198 </div> 191 199 <!-- Roasters Section --> 192 200 <div> 193 201 <h4 class="text-lg font-semibold text-brown-900 mb-3">Roasters</h4> 194 - <div class="table-container overflow-x-auto"> 195 - <table class="table"> 196 - <thead class="table-header"> 197 - <tr> 198 - <th class="table-th">Name</th> 199 - <th class="table-th">Location</th> 200 - <th class="table-th">Website</th> 201 - </tr> 202 - </thead> 203 - <tbody class="table-body"> 204 - for i := 0; i < 2; i++ { 205 - <tr class="table-row"> 206 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 207 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 208 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 209 - </tr> 210 - } 211 - </tbody> 212 - </table> 202 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 203 + for i := 0; i < 2; i++ { 204 + <div class="feed-card feed-card-roaster"> 205 + <div class="flex items-center justify-between mb-2"> 206 + <div class="h-3 bg-brown-300 rounded w-14"></div> 207 + </div> 208 + <div class="feed-content-box-sm"> 209 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 210 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 211 + <div class="h-3 bg-brown-300 rounded w-14"></div> 212 + </div> 213 + </div> 214 + </div> 215 + } 213 216 </div> 214 217 </div> 215 218 </div> ··· 220 223 <!-- Grinders Section --> 221 224 <div> 222 225 <h4 class="text-lg font-semibold text-brown-900 mb-3">Grinders</h4> 223 - <div class="table-container overflow-x-auto"> 224 - <table class="table"> 225 - <thead class="table-header"> 226 - <tr> 227 - <th class="table-th">Name</th> 228 - <th class="table-th">Type</th> 229 - <th class="table-th">Burr Type</th> 230 - <th class="table-th">Notes</th> 231 - </tr> 232 - </thead> 233 - <tbody class="table-body"> 234 - for i := 0; i < 2; i++ { 235 - <tr class="table-row"> 236 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 237 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 238 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 239 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-32"></div></td> 240 - </tr> 241 - } 242 - </tbody> 243 - </table> 226 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 227 + for i := 0; i < 2; i++ { 228 + <div class="feed-card feed-card-grinder"> 229 + <div class="flex items-center justify-between mb-2"> 230 + <div class="h-3 bg-brown-300 rounded w-14"></div> 231 + </div> 232 + <div class="feed-content-box-sm"> 233 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 234 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 235 + <div class="h-3 bg-brown-300 rounded w-10"></div> 236 + <div class="h-3 bg-brown-300 rounded w-12"></div> 237 + </div> 238 + </div> 239 + </div> 240 + } 244 241 </div> 245 242 </div> 246 243 <!-- Brewers Section --> 247 244 <div> 248 245 <h4 class="text-lg font-semibold text-brown-900 mb-3">Brewers</h4> 249 - <div class="table-container overflow-x-auto"> 250 - <table class="table"> 251 - <thead class="table-header"> 252 - <tr> 253 - <th class="table-th">Name</th> 254 - <th class="table-th">Type</th> 255 - <th class="table-th">Description</th> 256 - </tr> 257 - </thead> 258 - <tbody class="table-body"> 259 - for i := 0; i < 2; i++ { 260 - <tr class="table-row"> 261 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 262 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 263 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-40"></div></td> 264 - </tr> 265 - } 266 - </tbody> 267 - </table> 246 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 247 + for i := 0; i < 2; i++ { 248 + <div class="feed-card feed-card-brewer"> 249 + <div class="flex items-center justify-between mb-2"> 250 + <div class="h-3 bg-brown-300 rounded w-14"></div> 251 + </div> 252 + <div class="feed-content-box-sm"> 253 + <div class="h-4 bg-brown-300 rounded w-3/5 mb-2"></div> 254 + <div class="flex flex-wrap gap-x-2 gap-y-1 mt-1"> 255 + <div class="h-3 bg-brown-300 rounded w-12"></div> 256 + </div> 257 + </div> 258 + </div> 259 + } 268 260 </div> 269 261 </div> 270 262 </div> ··· 388 380 <div class="space-y-4"> 389 381 <input type="text" x-model="brewerForm.name" placeholder="Name *" class="w-full form-input"/> 390 382 <select x-model="brewerForm.brewer_type" class="w-full form-select"> 391 - <option value="">Select type...</option> 392 - <option value="pourover">Pour-over</option> 393 - <option value="espresso">Espresso</option> 394 - <option value="immersion">Immersion</option> 395 - <option value="mokapot">Moka Pot</option> 396 - <option value="coldbrew">Cold Brew</option> 397 - <option value="cupping">Cupping</option> 398 - <option value="other">Other</option> 399 - </select> 383 + <option value="">Select type...</option> 384 + <option value="pourover">Pour-over</option> 385 + <option value="espresso">Espresso</option> 386 + <option value="immersion">Immersion</option> 387 + <option value="mokapot">Moka Pot</option> 388 + <option value="coldbrew">Cold Brew</option> 389 + <option value="cupping">Cupping</option> 390 + <option value="other">Other</option> 391 + </select> 400 392 <textarea x-model="brewerForm.description" placeholder="Description" rows="3" class="w-full form-textarea"></textarea> 401 393 <div class="flex gap-2"> 402 394 <button @click="saveBrewer()" class="flex-1 btn-primary">Save</button>
+4 -2
internal/web/pages/recipe_view.templ
··· 68 68 <!-- Pours --> 69 69 if len(props.Recipe.Pours) > 0 { 70 70 <div class="section-box"> 71 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconDroplet() Pours</span></h3> 71 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconDroplet() 72 + Pours</span></h3> 72 73 <div class="space-y-2"> 73 74 for i, pour := range props.Recipe.Pours { 74 75 <div class="flex items-center gap-4 bg-brown-50 rounded-lg px-3 py-2 border border-brown-200"> ··· 85 86 <!-- Notes --> 86 87 if props.Recipe.Notes != "" { 87 88 <div class="section-box"> 88 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></h3> 89 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() 90 + Notes</span></h3> 89 91 <div class="text-brown-900 whitespace-pre-wrap">{ props.Recipe.Notes }</div> 90 92 </div> 91 93 }
+2 -2
internal/web/pages/roaster_view.templ
··· 46 46 @components.DetailField(components.DetailFieldProps{Icon: components.IconMapPin(), Label: "Location", Value: props.Roaster.Location}) 47 47 if props.Roaster.Website != "" { 48 48 <div class="section-box"> 49 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconLink() Website</span></h3> 49 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconLink() 50 + Website</span></h3> 50 51 if safeWebsite := bff.SafeWebsiteURL(props.Roaster.Website); safeWebsite != "" { 51 52 <a href={ templ.SafeURL(safeWebsite) } target="_blank" rel="noopener noreferrer" class="font-semibold text-brown-900 hover:underline"> 52 53 { safeWebsite } ··· 113 114 <p class="text-sm text-brown-600 mt-1"><time datetime={ bff.FormatISO(props.Roaster.CreatedAt) } data-local="long">{ props.Roaster.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</time></p> 114 115 </div> 115 116 } 116 -
+1 -1
tests/integration/harness.go
··· 29 29 "github.com/rs/zerolog" 30 30 zlog "github.com/rs/zerolog/log" 31 31 "github.com/stretchr/testify/require" 32 - "tangled.org/pdewey.com/atp" 33 32 gormlogger "gorm.io/gorm/logger" 33 + "tangled.org/pdewey.com/atp" 34 34 ) 35 35 36 36 func init() {
+13 -13
tests/integration/snapshot_test.go
··· 540 540 )), "recipe") 541 541 542 542 rkeys := map[string]string{ 543 - "roaster": roasterRKey, 544 - "bean": beanRKey, 545 - "grinder": grinderRKey, 546 - "brewer": brewerRKey, 547 - "espBrewer": espBrewerRKey, 548 - "recipe": recipeRKey, 543 + "roaster": roasterRKey, 544 + "bean": beanRKey, 545 + "grinder": grinderRKey, 546 + "brewer": brewerRKey, 547 + "espBrewer": espBrewerRKey, 548 + "recipe": recipeRKey, 549 549 } 550 550 551 551 cases := []struct { ··· 707 707 brewRKey := data.Brews[0].RKey 708 708 709 709 subRkeys := map[string]string{ 710 - "roaster": subRoasterRKey, 711 - "bean": subBeanRKey, 712 - "grinder": subGrinderRKey, 713 - "brewer": subBrewerRKey, 714 - "espBrewer": subEspBrewerRKey, 715 - "recipe": subRecipeRKey, 716 - "brew": brewRKey, 710 + "roaster": subRoasterRKey, 711 + "bean": subBeanRKey, 712 + "grinder": subGrinderRKey, 713 + "brewer": subBrewerRKey, 714 + "espBrewer": subEspBrewerRKey, 715 + "recipe": subRecipeRKey, 716 + "brew": brewRKey, 717 717 } 718 718 snapPDSRecord(t, sub, "brew "+tc.name, atproto.NSIDBrew, brewRKey, subRkeys) 719 719 })
-1
tests/integration/social_test.go
··· 324 324 assert.Equal(t, 0, h.FeedIndex.GetLikeCount(context.Background(), subjectURI)) 325 325 assert.Empty(t, h.FeedIndex.GetThreadedCommentsForSubject(context.Background(), subjectURI, 100, h.PrimaryAccount.DID)) 326 326 } 327 -