Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 392 lines 13 kB view raw
1package components 2 3import ( 4 "arabica/internal/models" 5 "arabica/internal/web/bff" 6 "fmt" 7) 8 9// BeanSummaryProps defines props for displaying bean metadata inline. 10// Used in both bean feed cards and brew feed cards. 11type BeanSummaryProps struct { 12 Bean *models.Bean 13 CoffeeAmount int // if > 0, shows "⚖️ Xg" (brew context) 14} 15 16// BeanSummary renders the bean name, roaster, and attribute tags. 17templ BeanSummary(props BeanSummaryProps) { 18 if props.Bean != nil { 19 <div class="font-bold text-brown-900 text-base"> 20 if props.Bean.Name != "" { 21 { props.Bean.Name } 22 } else { 23 { props.Bean.Origin } 24 } 25 </div> 26 if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" { 27 <div class="text-sm text-brown-700 mt-0.5"> 28 <span class="font-medium">🏭 { props.Bean.Roaster.Name }</span> 29 </div> 30 } 31 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 32 if props.Bean.Origin != "" { 33 <span class="inline-flex items-center gap-0.5">📍 { props.Bean.Origin }</span> 34 } 35 if props.Bean.RoastLevel != "" { 36 <span class="inline-flex items-center gap-0.5">🔥 { props.Bean.RoastLevel }</span> 37 } 38 if props.Bean.Variety != "" { 39 <span class="inline-flex items-center gap-0.5">🌿 { props.Bean.Variety }</span> 40 } 41 if props.Bean.Process != "" { 42 <span class="inline-flex items-center gap-0.5">🌱 { props.Bean.Process }</span> 43 } 44 if props.CoffeeAmount > 0 { 45 <span class="inline-flex items-center gap-0.5">⚖️ { fmt.Sprintf("%dg", props.CoffeeAmount) }</span> 46 } 47 </div> 48 } 49} 50 51// EmptyStateProps defines properties for empty state messaging 52type EmptyStateProps struct { 53 Message string 54 SubMessage string 55 ActionURL string 56 ActionText string 57} 58 59// EmptyState renders an empty state card 60templ EmptyState(props EmptyStateProps) { 61 @Card(CardProps{InnerCard: true, Class: "text-center"}, emptyStateContent(props)) 62} 63 64templ emptyStateContent(props EmptyStateProps) { 65 <p class="text-brown-800 text-lg mb-4 font-medium">{ props.Message }</p> 66 if props.SubMessage != "" { 67 <p class="text-sm text-brown-700 mb-4">{ props.SubMessage }</p> 68 } 69 if props.ActionURL != "" && props.ActionText != "" { 70 <a 71 href={ templ.SafeURL(props.ActionURL) } 72 class="inline-block btn-primary py-3 px-6 rounded-lg shadow-lg hover:shadow-xl" 73 > 74 { props.ActionText } 75 </a> 76 } 77} 78 79// DetailFieldProps defines properties for a labeled detail field 80type DetailFieldProps struct { 81 Label string 82 Value string 83 LinkHref string // Optional: wraps value in a link 84} 85 86// DetailField renders a labeled value with a "Not specified" fallback 87templ DetailField(props DetailFieldProps) { 88 <div class="section-box"> 89 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ props.Label }</h3> 90 if props.Value != "" && props.LinkHref != "" { 91 <a href={ templ.SafeURL(props.LinkHref) } class="font-semibold text-brown-900 hover:underline">{ props.Value }</a> 92 } else if props.Value != "" { 93 <div class="font-semibold text-brown-900">{ props.Value }</div> 94 } else { 95 <span class="text-brown-400">Not specified</span> 96 } 97 </div> 98} 99 100type PageHeaderProps struct { 101 Title string 102 BackURL string 103 ActionURL string 104 ActionText string 105 ActionClass string // Optional custom class for action button 106} 107 108templ PageHeader(props PageHeaderProps) { 109 <div class="mb-6 flex items-center justify-between"> 110 <div class="flex items-center gap-3"> 111 if props.BackURL != "" { 112 @BackButton() 113 } 114 <h2 class="text-3xl font-bold text-brown-900">{ props.Title }</h2> 115 </div> 116 if props.ActionURL != "" && props.ActionText != "" { 117 <a 118 href={ templ.SafeURL(props.ActionURL) } 119 class={ templ.Classes( 120 templ.KV("btn-primary shadow-lg hover:shadow-xl", props.ActionClass == ""), 121 templ.KV(props.ActionClass, props.ActionClass != ""), 122 ) } 123 > 124 { props.ActionText } 125 </a> 126 } 127 </div> 128} 129 130type LoadingSkeletonTableProps struct { 131 Columns int 132 Rows int 133} 134 135templ LoadingSkeletonTable(props LoadingSkeletonTableProps) { 136 <div class="animate-pulse"> 137 <div class="table-container overflow-x-auto"> 138 <table class="table"> 139 <thead class="table-header"> 140 <tr> 141 for i := 0; i < props.Columns; i++ { 142 <th class="table-th"> 143 <div class="h-3 bg-brown-300 rounded w-20"></div> 144 </th> 145 } 146 </tr> 147 </thead> 148 <tbody class="table-body"> 149 for i := 0; i < props.Rows; i++ { 150 <tr> 151 for j := 0; j < props.Columns; j++ { 152 <td class="px-6 py-4"> 153 <div class="h-4 bg-brown-300 rounded w-24"></div> 154 </td> 155 } 156 </tr> 157 } 158 </tbody> 159 </table> 160 </div> 161 </div> 162} 163 164type WelcomeCardProps struct { 165 IsAuthenticated bool 166 UserDID string 167} 168 169templ WelcomeCard(props WelcomeCardProps) { 170 <div class="card p-8 mb-8"> 171 <div class="flex items-center gap-3 mb-4"> 172 <h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2> 173 <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span> 174 </div> 175 <p class="text-brown-800 mb-2 text-lg">Track your coffee brewing journey with detailed logs of every cup.</p> 176 <p class="text-sm text-brown-700 italic mb-6">Note: Arabica is currently in alpha. Features and data structures may change.</p> 177 if props.IsAuthenticated { 178 @WelcomeAuthenticated(props.UserDID) 179 } else { 180 @WelcomeUnauthenticated() 181 } 182 </div> 183} 184 185templ WelcomeAuthenticated(userDID string) { 186 <div class="mb-6"> 187 <p class="text-sm text-brown-700"> 188 Logged in as: <span class="font-mono text-brown-900 font-semibold">{ userDID }</span> 189 <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a> 190 </p> 191 </div> 192 <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 193 <a 194 href="/brews/new" 195 class="btn-primary block text-center py-4 px-6 rounded-xl shadow-lg hover:shadow-xl" 196 hx-get="/brews/new" 197 hx-target="main" 198 hx-swap="innerHTML show:top" 199 hx-select="main > *" 200 hx-push-url="true" 201 > 202 <span class="text-xl font-semibold">☕ Add New Brew</span> 203 </a> 204 <a 205 href="/brews" 206 class="btn-tertiary block text-center py-4 px-6 rounded-xl shadow-lg hover:shadow-xl" 207 hx-get="/brews" 208 hx-target="main" 209 hx-swap="innerHTML show:top" 210 hx-select="main > *" 211 hx-push-url="true" 212 > 213 <span class="text-xl font-semibold">📋 View All Brews</span> 214 </a> 215 </div> 216} 217 218templ WelcomeUnauthenticated() { 219 <div> 220 <p class="text-brown-800 mb-2 text-center text-lg">Connect with your Atmosphere account to start tracking your brews.</p> 221 <details class="mb-6 max-w-md mx-auto"> 222 <summary class="text-brown-600 text-sm italic cursor-pointer text-center hover:text-brown-800 transition-colors">What's an Atmosphere account?</summary> 223 <p class="text-brown-600 mt-2 text-sm italic"> 224 Arabica uses the <a href="/atproto" class="link">AT Protocol</a> to power its social features, allowing you to own your data and use one account for all compatible applications. Once you create an account, you can use other apps like <a href="https://bsky.app" class="link" target="_blank" rel="noopener noreferrer">Bluesky</a> and <a href="https://leaflet.pub" class="link" target="_blank" rel="noopener noreferrer">Leaflet</a> with the same account. 225 <a href="/join/create" class="link">(Create an account)</a> 226 </p> 227 </details> 228 <form method="POST" action="/auth/login" class="max-w-md mx-auto"> 229 <div class="relative"> 230 <label for="handle" class="block text-sm font-medium text-brown-900 mb-2">Handle</label> 231 <input 232 type="text" 233 id="handle" 234 name="handle" 235 placeholder="alice.arabica.systems" 236 autocomplete="off" 237 required 238 class="w-full px-4 py-3 border-2 border-brown-300 rounded-lg focus:ring-2 focus:ring-brown-600 focus:border-brown-600 bg-white" 239 /> 240 <div id="autocomplete-results" class="hidden absolute z-10 w-full mt-1 bg-brown-50 border-2 border-brown-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"></div> 241 </div> 242 <button 243 type="submit" 244 class="btn-primary w-full mt-4 py-3 px-8 text-lg font-semibold shadow-lg hover:shadow-xl" 245 > 246 Log In 247 </button> 248 </form> 249 <script src="/static/js/handle-autocomplete.js?v=0.2.0"></script> 250 </div> 251} 252 253templ AboutInfoCard() { 254 // TODO: only show this at the bottom of the page when authenticated already? 255 <div class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg mb-6"> 256 <h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> 257 <ul class="text-brown-800 space-y-2 leading-relaxed mb-3"> 258 <li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li> 259 <li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li> 260 <li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li> 261 <li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li> 262 <li class="flex items-start"><span class="mr-2">🔒</span><span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> 263 </ul> 264 // TODO: include a link to the about page here somewhere 265 // <div class="text-large text-brown-900"><a href="/about" class="text-brown-700 hover:text-brown-900 transition-colors">Learn more</a></div> 266 </div> 267} 268 269// UserBadgeProps defines properties for user badge (avatar + name + handle) 270type UserBadgeProps struct { 271 ProfileURL string // URL to link to (e.g., "/profile/handle") 272 AvatarURL string 273 DisplayName string 274 Handle string 275 TimeAgo string // Optional timestamp to display 276 Size string // "sm" or "md" - defaults to "md" 277} 278 279// UserBadge renders a user avatar with display name and handle, properly aligned 280templ UserBadge(props UserBadgeProps) { 281 <div class="flex items-center gap-3"> 282 <a href={ templ.SafeURL(props.ProfileURL) } class="flex-shrink-0"> 283 @Avatar(AvatarProps{ 284 AvatarURL: props.AvatarURL, 285 DisplayName: props.DisplayName, 286 Size: props.Size, 287 }) 288 </a> 289 <div class="flex-1 min-w-0"> 290 <div 291 class={ templ.Classes( 292 "flex items-center gap-2", 293 templ.KV("flex-wrap", props.Size == "sm"), 294 ) } 295 > 296 if props.DisplayName != "" { 297 <a 298 href={ templ.SafeURL(props.ProfileURL) } 299 class={ templ.Classes( 300 "text-brown-900 truncate", 301 templ.KV("font-medium hover:text-brown-700", props.Size == "sm"), 302 templ.KV("link-bold", props.Size == "md" || props.Size == ""), 303 ) } 304 > 305 { props.DisplayName } 306 </a> 307 } 308 <a 309 href={ templ.SafeURL(props.ProfileURL) } 310 class={ templ.Classes( 311 "truncate", 312 templ.KV("text-sm text-brown-600 hover:text-brown-800", props.Size == "sm"), 313 templ.KV("link text-sm", props.Size == "md" || props.Size == ""), 314 ) } 315 > 316 { "@" + props.Handle } 317 </a> 318 if props.Size == "sm" && props.TimeAgo != "" { 319 <span class="text-brown-500 text-sm">{ props.TimeAgo }</span> 320 } 321 </div> 322 if (props.Size == "md" || props.Size == "") && props.TimeAgo != "" { 323 <span class="text-brown-500 text-sm">{ props.TimeAgo }</span> 324 } 325 </div> 326 </div> 327} 328 329// AvatarProps defines properties for avatar rendering 330type AvatarProps struct { 331 AvatarURL string 332 DisplayName string 333 Size string // "sm", "md", "lg" - defaults to "md" if empty 334} 335 336// Avatar renders a user avatar with safety checks and fallback 337// Supports three sizes: sm (w-8 h-8), md (w-12 h-12), lg (w-20 h-20) 338templ Avatar(props AvatarProps) { 339 if props.AvatarURL != "" && bff.SafeAvatarURL(props.AvatarURL) != "" { 340 <img 341 src={ bff.SafeAvatarURL(props.AvatarURL) } 342 alt="" 343 class={ avatarClass(props.Size) } 344 /> 345 } else { 346 <div class={ avatarPlaceholderClass(props.Size) }> 347 <span class={ avatarTextClass(props.Size) }> 348 if props.DisplayName != "" && len(props.DisplayName) > 0 { 349 { props.DisplayName[:1] } 350 } else { 351 ? 352 } 353 </span> 354 </div> 355 } 356} 357 358// avatarClass returns the CSS class for avatar images based on size 359func avatarClass(size string) string { 360 switch size { 361 case "sm": 362 return "avatar-sm" 363 case "lg": 364 return "avatar-lg" 365 default: 366 return "avatar-md" 367 } 368} 369 370// avatarPlaceholderClass returns the CSS class for avatar placeholders based on size 371func avatarPlaceholderClass(size string) string { 372 switch size { 373 case "sm": 374 return "avatar-placeholder-sm" 375 case "lg": 376 return "avatar-placeholder-lg" 377 default: 378 return "avatar-placeholder-md" 379 } 380} 381 382// avatarTextClass returns the CSS class for avatar text based on size 383func avatarTextClass(size string) string { 384 switch size { 385 case "sm": 386 return "avatar-text-sm" 387 case "lg": 388 return "avatar-text-lg" 389 default: 390 return "avatar-text-md" 391 } 392}