Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
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}