Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
1package components
2
3import (
4 "arabica/internal/atproto"
5 "arabica/internal/models"
6 "strconv"
7)
8
9// ProfileContentPartialProps defines props for profile content partial
10type ProfileContentPartialProps struct {
11 Brews []*models.Brew
12 Beans []*models.Bean
13 Roasters []*models.Roaster
14 Grinders []*models.Grinder
15 Brewers []*models.Brewer
16 IsOwnProfile bool
17 ProfileHandle string
18 Profile *atproto.Profile
19 // Like state for brews (keyed by brew RKey)
20 BrewLikeCounts map[string]int
21 BrewLikedByUser map[string]bool
22 BrewCIDs map[string]string // CIDs keyed by brew RKey
23 // Comment counts for brews (keyed by brew RKey)
24 BrewCommentCounts map[string]int
25 IsAuthenticated bool
26}
27
28// ProfileContentPartial renders the profile tabs content (for HTMX loading)
29templ ProfileContentPartial(props ProfileContentPartialProps) {
30 <!-- Hidden div with stats data for JavaScript -->
31 <div id="profile-stats-data" class="hidden" data-brews={ strconv.Itoa(len(props.Brews)) } data-beans={ strconv.Itoa(len(props.Beans)) } data-roasters={ strconv.Itoa(len(props.Roasters)) } data-grinders={ strconv.Itoa(len(props.Grinders)) } data-brewers={ strconv.Itoa(len(props.Brewers)) }></div>
32 <!-- Brews Tab -->
33 <div x-show="activeTab === 'brews'">
34 @ProfileBrewCards(ProfileBrewCardsProps{
35 Brews: props.Brews,
36 IsOwnProfile: props.IsOwnProfile,
37 ProfileHandle: props.ProfileHandle,
38 Profile: props.Profile,
39 LikeCounts: props.BrewLikeCounts,
40 CommentCounts: props.BrewCommentCounts,
41 LikedByUser: props.BrewLikedByUser,
42 BrewCIDs: props.BrewCIDs,
43 IsAuthenticated: props.IsAuthenticated,
44 })
45 </div>
46 <!-- Beans Tab -->
47 <div x-show="activeTab === 'beans'">
48 @ProfileBeansTab(props.Beans, props.Roasters, props.IsOwnProfile, props.ProfileHandle)
49 </div>
50 <!-- Equipment Tab -->
51 <div x-show="activeTab === 'equipment'">
52 @ProfileEquipmentTab(props.Grinders, props.Brewers, props.IsOwnProfile, props.ProfileHandle)
53 </div>
54}
55
56// ProfileBeansTab renders the beans and roasters tab for profile
57templ ProfileBeansTab(beans []*models.Bean, roasters []*models.Roaster, isOwnProfile bool, profileHandle string) {
58 <div class="space-y-6">
59 <!-- Open Bags Section -->
60 <div>
61 <h4 class="text-lg font-semibold text-brown-900 mb-3">Open Bags</h4>
62 if len(filterOpenBeans(beans)) == 0 {
63 if isOwnProfile {
64 @EmptyState(EmptyStateProps{
65 Message: "No open bags yet! Add your first bean.",
66 ActionURL: "/manage",
67 ActionText: "Go to Manage",
68 })
69 } else {
70 @EmptyState(EmptyStateProps{
71 Message: "No open bags yet.",
72 })
73 }
74 } else {
75 @BeanCards(BeanCardsProps{
76 Beans: filterOpenBeans(beans),
77 ShowActions: false,
78 OwnerHandle: profileHandle,
79 })
80 }
81 </div>
82 <!-- Roasters Section -->
83 <div>
84 <h4 class="text-lg font-semibold text-brown-900 mb-3">Roasters</h4>
85 if len(roasters) == 0 {
86 if isOwnProfile {
87 @EmptyState(EmptyStateProps{Message: "No roasters added yet."})
88 } else {
89 @EmptyState(EmptyStateProps{Message: "No roasters yet."})
90 }
91 } else {
92 @RoastersTable(RoastersTableProps{
93 Roasters: roasters,
94 ShowActions: false,
95 OwnerHandle: profileHandle,
96 })
97 }
98 </div>
99 <!-- Closed Bags Section -->
100 <div>
101 <h4 class="text-lg font-semibold text-brown-900 mb-3">Closed Bags</h4>
102 if len(filterClosedBeans(beans)) == 0 {
103 @EmptyState(EmptyStateProps{
104 Message: "No closed bags.",
105 })
106 } else {
107 @BeanCards(BeanCardsProps{
108 Beans: filterClosedBeans(beans),
109 ShowActions: false,
110 OwnerHandle: profileHandle,
111 })
112 }
113 </div>
114 </div>
115}
116
117// filterOpenBeans returns only beans that are not closed
118func filterOpenBeans(beans []*models.Bean) []*models.Bean {
119 var openBeans []*models.Bean
120 for _, bean := range beans {
121 if !bean.Closed {
122 openBeans = append(openBeans, bean)
123 }
124 }
125 return openBeans
126}
127
128// filterClosedBeans returns only beans that are closed
129func filterClosedBeans(beans []*models.Bean) []*models.Bean {
130 var closedBeans []*models.Bean
131 for _, bean := range beans {
132 if bean.Closed {
133 closedBeans = append(closedBeans, bean)
134 }
135 }
136 return closedBeans
137}
138
139// ProfileBrewCardsProps defines props for the profile brew cards component
140type ProfileBrewCardsProps struct {
141 Brews []*models.Brew
142 IsOwnProfile bool
143 ProfileHandle string
144 Profile *atproto.Profile
145 LikeCounts map[string]int
146 CommentCounts map[string]int
147 LikedByUser map[string]bool
148 IsAuthenticated bool
149 BrewCIDs map[string]string // CIDs keyed by brew RKey
150}
151
152// ProfileBrewCards renders brews as feed-style cards
153templ ProfileBrewCards(props ProfileBrewCardsProps) {
154 if len(props.Brews) == 0 {
155 if props.IsOwnProfile {
156 @EmptyState(EmptyStateProps{
157 Message: "No brews recorded yet! Start tracking your coffee journey.",
158 ActionURL: "/brews/new",
159 ActionText: "Add Your First Brew",
160 })
161 } else {
162 @EmptyState(EmptyStateProps{
163 Message: "No brews yet.",
164 })
165 }
166 } else {
167 <div class="space-y-4">
168 for _, brew := range props.Brews {
169 @ProfileBrewCard(ProfileBrewCardProps{
170 Brew: brew,
171 IsOwnProfile: props.IsOwnProfile,
172 ProfileHandle: props.ProfileHandle,
173 Profile: props.Profile,
174 LikeCount: getLikeCount(props.LikeCounts, brew.RKey),
175 CommentCount: getCommentCount(props.CommentCounts, brew.RKey),
176 IsLiked: isLikedByUser(props.LikedByUser, brew.RKey),
177 IsAuthenticated: props.IsAuthenticated,
178 SubjectCID: getBrewCID(props.BrewCIDs, brew.RKey),
179 })
180 }
181 </div>
182 }
183}
184
185func getLikeCount(counts map[string]int, rkey string) int {
186 if counts == nil {
187 return 0
188 }
189 return counts[rkey]
190}
191
192func isLikedByUser(liked map[string]bool, rkey string) bool {
193 if liked == nil {
194 return false
195 }
196 return liked[rkey]
197}
198
199func getBrewCID(cids map[string]string, rkey string) string {
200 if cids == nil {
201 return ""
202 }
203 return cids[rkey]
204}
205
206func getCommentCount(counts map[string]int, rkey string) int {
207 if counts == nil {
208 return 0
209 }
210 return counts[rkey]
211}
212
213// ProfileEquipmentTab renders the equipment tab for profile (grinders and brewers only)
214templ ProfileEquipmentTab(grinders []*models.Grinder, brewers []*models.Brewer, isOwnProfile bool, profileHandle string) {
215 <div class="space-y-6">
216 <!-- Grinders Section -->
217 <div>
218 <h4 class="text-lg font-semibold text-brown-900 mb-3">Grinders</h4>
219 if len(grinders) == 0 {
220 if isOwnProfile {
221 @EmptyState(EmptyStateProps{Message: "No grinders added yet."})
222 } else {
223 @EmptyState(EmptyStateProps{Message: "No grinders yet."})
224 }
225 } else {
226 @GrindersTable(GrindersTableProps{
227 Grinders: grinders,
228 ShowActions: false,
229 OwnerHandle: profileHandle,
230 })
231 }
232 </div>
233 <!-- Brewers Section -->
234 <div>
235 <h4 class="text-lg font-semibold text-brown-900 mb-3">Brewers</h4>
236 if len(brewers) == 0 {
237 if isOwnProfile {
238 @EmptyState(EmptyStateProps{Message: "No brewing devices added yet."})
239 } else {
240 @EmptyState(EmptyStateProps{Message: "No brewing devices yet."})
241 }
242 } else {
243 @BrewersTable(BrewersTableProps{
244 Brewers: brewers,
245 ShowActions: false,
246 OwnerHandle: profileHandle,
247 })
248 }
249 </div>
250 </div>
251}