Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 147 lines 6.3 kB view raw
1package components 2 3import ( 4 "fmt" 5 "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 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600" style="view-transition-name: header-nav;"> 27 <div class="container mx-auto px-4 py-4"> 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-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span> 33 </a> 34 <!-- Navigation links --> 35 <div class="flex items-center gap-4"> 36 if props.IsAuthenticated { 37 <!-- Notification bell --> 38 <a href="/notifications" class="relative hover:opacity-80 transition p-1" title="Notifications"> 39 <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 40 <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> 41 </svg> 42 if props.UnreadNotificationCount > 0 { 43 <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"> 44 { formatNotificationCount(props.UnreadNotificationCount) } 45 </span> 46 } 47 </a> 48 <!-- User profile dropdown --> 49 <div x-data="{ open: false }" class="relative"> 50 <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"> 51 @Avatar(AvatarProps{ 52 AvatarURL: getHeaderAvatarURL(props.UserProfile), 53 DisplayName: getHeaderDisplayName(props.UserProfile), 54 Size: "sm", 55 }) 56 <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 57 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> 58 </svg> 59 </button> 60 <!-- Dropdown menu --> 61 <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"> 62 if props.UserProfile != nil && props.UserProfile.Handle != "" { 63 <div class="dropdown-header"> 64 <p class="text-sm font-medium text-brown-900 truncate"> 65 if props.UserProfile.DisplayName != "" { 66 { props.UserProfile.DisplayName } 67 } else { 68 { props.UserProfile.Handle } 69 } 70 </p> 71 <p class="text-xs text-brown-500 truncate">{ "@" + props.UserProfile.Handle }</p> 72 </div> 73 } 74 <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item"> 75 View Profile 76 </a> 77 <a href="/brews" class="dropdown-item"> 78 My Brews 79 </a> 80 <a href="/manage" class="dropdown-item"> 81 Manage Records 82 </a> 83 <a href="#" class="dropdown-item-disabled"> 84 Settings (coming soon) 85 </a> 86 if props.IsModerator { 87 <div class="dropdown-divider"></div> 88 <a href="/_mod" class="dropdown-item dropdown-item-mod"> 89 <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 90 <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> 91 </svg> 92 Moderation 93 </a> 94 } 95 <div class="dropdown-divider"> 96 <form action="/logout" method="POST" @submit="if(window.ArabicaCache)window.ArabicaCache.invalidateCache()"> 97 <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 98 Logout 99 </button> 100 </form> 101 </div> 102 </div> 103 </div> 104 } 105 </div> 106 </div> 107 </div> 108 </nav> 109} 110 111// Helper function to get profile identifier (handle or DID) 112// Prefers handle if available, falls back to DID 113func getProfileIdentifier(userProfile *bff.UserProfile, userDID string) string { 114 // Prefer handle if available (canonical URL format) 115 if userProfile != nil && userProfile.Handle != "" { 116 return userProfile.Handle 117 } 118 // Fall back to DID if handle not available 119 if userDID != "" { 120 return userDID 121 } 122 // Last resort: empty string (should not happen for authenticated users) 123 return "" 124} 125 126// Helper functions for Avatar component 127func getHeaderAvatarURL(userProfile *bff.UserProfile) string { 128 if userProfile != nil { 129 return userProfile.Avatar 130 } 131 return "" 132} 133 134func getHeaderDisplayName(userProfile *bff.UserProfile) string { 135 if userProfile != nil { 136 return userProfile.DisplayName 137 } 138 return "" 139} 140 141// formatNotificationCount formats the notification badge count (99+ for large numbers) 142func formatNotificationCount(count int) string { 143 if count > 99 { 144 return "99+" 145 } 146 return fmt.Sprintf("%d", count) 147}