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