Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
1package components
2
3import (
4 "fmt"
5 "tangled.org/arabica.social/arabica/internal/web/bff"
6)
7
8// HeaderProps contains all properties for the header component
9type HeaderProps struct {
10 IsAuthenticated bool
11 UserProfile *bff.UserProfile
12 UserDID string
13 IsModerator bool // Show admin link in dropdown
14 UnreadNotificationCount int // Badge count for bell icon
15}
16
17templ Header(isAuthenticated bool, userProfile *bff.UserProfile, userDID string) {
18 @HeaderWithProps(HeaderProps{
19 IsAuthenticated: isAuthenticated,
20 UserProfile: userProfile,
21 UserDID: userDID,
22 })
23}
24
25templ HeaderWithProps(props HeaderProps) {
26 <nav class="sticky top-0 z-50 text-white" style="background: linear-gradient(135deg, var(--header-bg-from), var(--header-bg-to)); border-bottom: 1px solid var(--header-border); box-shadow: var(--shadow-sm); view-transition-name: header-nav;">
27 <div class="container mx-auto px-4 py-3">
28 <div class="flex items-center justify-between">
29 <!-- Logo - always visible -->
30 <a href="/" class="flex items-center gap-2 hover:opacity-80 transition">
31 <h1 class="text-2xl font-bold">☕ Arabica</h1>
32 <span class="text-[10px] bg-amber-400 text-brown-900 px-1.5 py-0.5 rounded font-semibold">ALPHA</span>
33 </a>
34 <!-- Navigation links -->
35 <div class="flex items-center gap-4">
36 if !props.IsAuthenticated {
37 <div class="flex items-center gap-4">
38 <button
39 x-data
40 @click="$dispatch('open-login')"
41 class="text-sm font-semibold text-brown-100 hover:text-white transition-colors"
42 >
43 Log In
44 </button>
45 </div>
46 }
47 if props.IsAuthenticated {
48 <!-- Notification bell -->
49 <a href="/notifications" class="relative hover:opacity-80 transition p-2" title="Notifications" aria-label="Notifications">
50 <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
51 <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"></path>
52 </svg>
53 if props.UnreadNotificationCount > 0 {
54 <span class="absolute -top-1 -right-1 bg-amber-400 text-brown-900 text-xs font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow-sm">
55 { formatNotificationCount(props.UnreadNotificationCount) }
56 </span>
57 }
58 </a>
59 <!-- Create new dropdown -->
60 <div x-data="{ open: false }" class="relative">
61 <button @click="open = !open" @click.outside="open = false" class="hover:opacity-80 transition p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded" title="Create new" aria-label="Create new" :aria-expanded="open">
62 <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
63 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
64 </svg>
65 </button>
66 <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu w-52" role="menu">
67 <div class="dropdown-header">
68 <p class="text-xs font-semibold uppercase tracking-wider text-brown-500">Log</p>
69 </div>
70 <a href="/brews/new" class="dropdown-item flex items-center gap-2" @click="open = false" role="menuitem">
71 @IconCoffee()
72 New Brew
73 </a>
74 // <button
75 // class="dropdown-item flex items-center gap-2 w-full text-left"
76 // hx-get="/api/modals/drink/new"
77 // hx-target="#modal-container"
78 // hx-swap="innerHTML"
79 // @click="open = false"
80 // >
81 // @IconDroplet() Log Drink
82 // </button>
83 <div class="dropdown-divider">
84 <p class="px-4 pt-1 text-xs font-semibold uppercase tracking-wider text-brown-500">Add</p>
85 </div>
86 <button
87 class="dropdown-item flex items-center gap-2 w-full text-left"
88 hx-get="/api/modals/bean/new"
89 hx-target="#modal-container"
90 hx-swap="innerHTML"
91 @click="open = false"
92 role="menuitem"
93 >
94 @IconLeaf()
95 Bean
96 </button>
97 <button
98 class="dropdown-item flex items-center gap-2 w-full text-left"
99 hx-get="/api/modals/roaster/new"
100 hx-target="#modal-container"
101 hx-swap="innerHTML"
102 @click="open = false"
103 role="menuitem"
104 >
105 @IconStore()
106 Roaster
107 </button>
108 // <button
109 // class="dropdown-item flex items-center gap-2 w-full text-left"
110 // hx-get="/api/modals/cafe/new"
111 // hx-target="#modal-container"
112 // hx-swap="innerHTML"
113 // @click="open = false"
114 // >
115 // @IconMapPin() Cafe
116 // </button>
117 <button
118 class="dropdown-item flex items-center gap-2 w-full text-left"
119 hx-get="/api/modals/grinder/new"
120 hx-target="#modal-container"
121 hx-swap="innerHTML"
122 @click="open = false"
123 role="menuitem"
124 >
125 @IconDisc()
126 Grinder
127 </button>
128 <button
129 class="dropdown-item flex items-center gap-2 w-full text-left"
130 hx-get="/api/modals/brewer/new"
131 hx-target="#modal-container"
132 hx-swap="innerHTML"
133 @click="open = false"
134 role="menuitem"
135 >
136 @IconBrewer()
137 Brewer
138 </button>
139 <button
140 class="dropdown-item flex items-center gap-2 w-full text-left"
141 hx-get="/api/modals/recipe/new"
142 hx-target="#modal-container"
143 hx-swap="innerHTML"
144 @click="open = false"
145 role="menuitem"
146 >
147 @IconFileText()
148 Recipe
149 </button>
150 </div>
151 </div>
152 <!-- User profile dropdown -->
153 <div x-data="{ open: false }" class="relative">
154 <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded" aria-label="User menu" :aria-expanded="open">
155 @Avatar(AvatarProps{
156 AvatarURL: getHeaderAvatarURL(props.UserProfile),
157 DisplayName: getHeaderDisplayName(props.UserProfile),
158 Size: "sm",
159 })
160 <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
161 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
162 </svg>
163 </button>
164 <!-- Dropdown menu -->
165 <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu" role="menu">
166 if props.UserProfile != nil && props.UserProfile.Handle != "" {
167 <div class="dropdown-header">
168 <p class="text-sm font-medium text-brown-900 truncate">
169 if props.UserProfile.DisplayName != "" {
170 { props.UserProfile.DisplayName }
171 } else {
172 { props.UserProfile.Handle }
173 }
174 </p>
175 <p class="text-xs text-brown-500 truncate">{ "@" + props.UserProfile.Handle }</p>
176 </div>
177 }
178 <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item" role="menuitem">
179 View Profile
180 </a>
181 <a href="/my-coffee" class="dropdown-item" role="menuitem">
182 My Coffee
183 </a>
184 <a href="/recipes" class="dropdown-item" role="menuitem">
185 Recipes
186 </a>
187 <a href="/settings" class="dropdown-item" role="menuitem">
188 Settings
189 </a>
190 if props.IsModerator {
191 <div class="dropdown-divider"></div>
192 <a href="/_mod" class="dropdown-item dropdown-item-mod" role="menuitem">
193 <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
194 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"></path>
195 </svg>
196 Moderation
197 </a>
198 }
199 <div class="dropdown-divider">
200 <form action="/logout" method="POST" @submit="if(window.ArabicaCache)window.ArabicaCache.invalidateCache()">
201 <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" role="menuitem">
202 Logout
203 </button>
204 </form>
205 </div>
206 </div>
207 </div>
208 }
209 </div>
210 </div>
211 </div>
212 </nav>
213 if !props.IsAuthenticated {
214 @LoginModal()
215 }
216}
217
218// LoginModal renders the login dialog for unauthenticated users
219templ LoginModal() {
220 <dialog id="login-modal" class="modal-dialog" x-data @open-login.window="$el.showModal()">
221 <div class="modal-content">
222 <div class="flex items-center justify-between mb-4">
223 <h2 class="modal-title mb-0">Log in with your Atmosphere account</h2>
224 <button
225 type="button"
226 @click="$el.closest('dialog').close()"
227 class="text-brown-400 hover:text-brown-600 transition-colors"
228 aria-label="Close"
229 >
230 @IconX()
231 </button>
232 </div>
233 <form method="POST" action="/auth/login" class="space-y-4">
234 <div class="relative">
235 <label { "for" }="login-handle" class="block text-sm font-medium text-brown-900 mb-2">Handle</label>
236 <input
237 type="text"
238 id="login-handle"
239 name="handle"
240 placeholder="your-handle.bsky.social"
241 autocomplete="off"
242 required
243 class="w-full form-input-lg"
244 />
245 <div id="autocomplete-results" class="hidden handle-dropdown"></div>
246 </div>
247 <button
248 type="submit"
249 class="btn-primary w-full py-3 font-semibold"
250 >
251 Log In
252 </button>
253 </form>
254 <div class="mt-4 text-sm text-brown-600 text-center">
255 <a href="/join/create" class="font-medium text-brown-800 hover:text-brown-900 transition-colors hover:underline">Create an account</a>
256 <span class="mx-1.5 text-brown-400">·</span>
257 <a href="/about" class="text-brown-600 hover:text-brown-800 transition-colors hover:underline">Learn more</a>
258 </div>
259 <details class="mt-4">
260 <summary class="text-brown-500 text-sm cursor-pointer hover:text-brown-700 transition-colors">What's an Atmosphere account?</summary>
261 <p class="text-brown-600 mt-2 text-sm leading-relaxed">
262 One account { "for" } the entire <a href="/atproto" class="link">AT Protocol</a> ecosystem. Sign up once and use it across Arabica, <a href="https://bsky.app" class="link" target="_blank" rel="noopener noreferrer">Bluesky</a>, <a href="https://leaflet.pub" class="link" target="_blank" rel="noopener noreferrer">Leaflet</a>, and more. Your data is portable — you own it.
263 </p>
264 </details>
265 <script src="/static/js/handle-autocomplete.js?v=0.2.0"></script>
266 </div>
267 </dialog>
268}
269
270// Helper function to get profile identifier (handle or DID)
271// Prefers handle if available, falls back to DID
272func getProfileIdentifier(userProfile *bff.UserProfile, userDID string) string {
273 // Prefer handle if available (canonical URL format)
274 if userProfile != nil && userProfile.Handle != "" {
275 return userProfile.Handle
276 }
277 // Fall back to DID if handle not available
278 if userDID != "" {
279 return userDID
280 }
281 // Last resort: empty string (should not happen for authenticated users)
282 return ""
283}
284
285// Helper functions for Avatar component
286func getHeaderAvatarURL(userProfile *bff.UserProfile) string {
287 if userProfile != nil {
288 return userProfile.Avatar
289 }
290 return ""
291}
292
293func getHeaderDisplayName(userProfile *bff.UserProfile) string {
294 if userProfile != nil {
295 return userProfile.DisplayName
296 }
297 return ""
298}
299
300// formatNotificationCount formats the notification badge count (99+ for large numbers)
301func formatNotificationCount(count int) string {
302 if count > 99 {
303 return "99+"
304 }
305 return fmt.Sprintf("%d", count)
306}