Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 306 lines 13 kB view raw
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">&middot;</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}