Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
14 kB · 315 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316package components
import ( "fmt" "tangled.org/arabica.social/arabica/internal/web/assets" "tangled.org/arabica.social/arabica/internal/web/bff" "tangled.org/pdewey.com/atp")
// HeaderProps contains all properties for the header componenttype HeaderProps struct { IsAuthenticated bool UserProfile *bff.UserProfile UserDID string IsModerator bool // Show admin link in dropdown UnreadNotificationCount int // Badge count for bell icon BrandName string // "Arabica"; falls back to "Arabica" when empty AppName string // "arabica" or "oolong" — selects the create-new dropdown contents}
// brandName returns the configured brand name with the arabica default// applied so existing call sites that don't thread it stay working.func (p HeaderProps) brandName() string { if p.BrandName != "" { return p.BrandName } return "Arabica"}
templ Header(isAuthenticated bool, userProfile *bff.UserProfile, userDID string) { @HeaderWithProps(HeaderProps{ IsAuthenticated: isAuthenticated, UserProfile: userProfile, UserDID: userDID, })}
templ HeaderWithProps(props HeaderProps) { <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;"> <div class="container mx-auto px-4 py-3"> <div class="flex items-center justify-between"> <!-- Logo - always visible --> <a href="/" class="flex items-center gap-2 hover:opacity-80 transition"> <h1 class="text-2xl font-bold">☕ { props.brandName() }</h1> <span class="badge-alpha">ALPHA</span> </a> <!-- Navigation links --> <div class="flex items-center gap-4"> if !props.IsAuthenticated { <div class="flex items-center gap-4"> <button x-data @click="$dispatch('open-login')" class="text-sm font-semibold text-brown-100 hover:text-white transition-colors" > Log In </button> </div> } if props.IsAuthenticated { <!-- Notification bell --> <a href="/notifications" class="relative hover:opacity-80 transition p-2" title="Notifications" aria-label="Notifications"> <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> <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> </svg> if props.UnreadNotificationCount > 0 { <span class="absolute -top-1 -right-1 bg-amber-400 text-primary text-xs font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow-xs"> { formatNotificationCount(props.UnreadNotificationCount) } </span> } </a> <!-- Create new dropdown --> <div x-data="{ open: false }" class="relative"> <button @click="open = !open" @click.outside="open = false" class="hover:opacity-80 transition p-2 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded-sm" title="Create new" aria-label="Create new" :aria-expanded="open"> <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path> </svg> </button> <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"> if props.AppName == "oolong" { @oolongCreateDropdownItems() } else { @arabicaCreateDropdownItems() } </div> </div> <!-- User profile dropdown --> <div x-data="{ open: false }" class="relative"> <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-hidden focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded-sm" aria-label="User menu" :aria-expanded="open"> @Avatar(AvatarProps{ AvatarURL: getHeaderAvatarURL(props.UserProfile), DisplayName: getHeaderDisplayName(props.UserProfile), Size: "sm", }) <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> </svg> </button> <!-- Dropdown menu --> <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"> if props.UserProfile != nil && props.UserProfile.Handle != "" { <div class="dropdown-header"> <p class="text-sm font-medium text-primary truncate"> if props.UserProfile.DisplayName != "" { { props.UserProfile.DisplayName } } else { { atp.DisplayHandle(props.UserProfile.Handle) } } </p> <p class="text-xs text-faint truncate">{ "@" + atp.DisplayHandle(props.UserProfile.Handle) }</p> </div> } <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item" role="menuitem"> View Profile </a> if props.AppName == "oolong" { <a href="/my-tea" class="dropdown-item" role="menuitem"> My Tea </a> } else { <a href="/my-coffee" class="dropdown-item" role="menuitem"> My Coffee </a> <a href="/recipes" class="dropdown-item" role="menuitem"> Recipes </a> } <a href="/settings" class="dropdown-item" role="menuitem"> Settings </a> if props.IsModerator { <div class="dropdown-divider"></div> <a href="/_mod" class="dropdown-item dropdown-item-mod" role="menuitem"> <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> <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> </svg> Moderation </a> } <div class="dropdown-divider"> <form action="/logout" method="POST" @submit="if(window.ArabicaCache)window.ArabicaCache.invalidateCache()"> <button type="submit" class="w-full text-left px-4 py-2 text-sm text-emphasis hover:bg-brown-50 transition-colors" role="menuitem"> Logout </button> </form> </div> </div> </div> } </div> </div> </div> </nav> if !props.IsAuthenticated { @LoginModal() }}
// LoginModal renders the login dialog for unauthenticated userstempl LoginModal() { <dialog id="login-modal" class="modal-dialog" x-data @open-login.window="$el.showModal()"> <div class="modal-content"> <div class="flex items-center justify-between mb-4"> <h2 class="modal-title mb-0">Log in with your Atmosphere account</h2> <button type="button" @click="$el.closest('dialog').close()" class="text-placeholder hover:text-muted transition-colors" aria-label="Close" > @IconX() </button> </div> <form method="POST" action="/auth/login" class="space-y-4"> <div class="relative"> <label { "for" }="login-handle" class="block text-sm font-medium text-primary mb-2">Handle</label> <input type="text" id="login-handle" name="handle" placeholder="your-handle.bsky.social" autocomplete="off" required class="w-full form-input-lg" /> <div id="autocomplete-results" class="hidden handle-dropdown"></div> </div> <button type="submit" class="btn-primary w-full py-3 font-semibold" > Log In </button> </form> <div class="mt-4 text-sm text-muted text-center"> <a href="/join/create" class="font-medium text-secondary hover:text-primary transition-colors hover:underline">Create an account</a> <span class="mx-1.5 text-placeholder">·</span> <a href="/about" class="text-muted hover:text-secondary transition-colors hover:underline">Learn more</a> </div> <details class="mt-4"> <summary class="text-faint text-sm cursor-pointer hover:text-emphasis transition-colors">What's an Atmosphere account?</summary> <p class="text-muted mt-2 text-sm leading-relaxed"> 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. </p> </details> <script src={ assets.JSHrefFor("handle-autocomplete.js") }></script> </div> </dialog>}
// Helper function to get profile identifier (handle or DID)// Prefers handle if available, falls back to DIDfunc getProfileIdentifier(userProfile *bff.UserProfile, userDID string) string { // Prefer handle if available (canonical URL format) if userProfile != nil && userProfile.Handle != "" { return userProfile.Handle } // Fall back to DID if handle not available if userDID != "" { return userDID } // Last resort: empty string (should not happen for authenticated users) return ""}
// Helper functions for Avatar componentfunc getHeaderAvatarURL(userProfile *bff.UserProfile) string { if userProfile != nil { return userProfile.Avatar } return ""}
func getHeaderDisplayName(userProfile *bff.UserProfile) string { if userProfile != nil { return userProfile.DisplayName } return ""}
// formatNotificationCount formats the notification badge count (99+ for large numbers)func formatNotificationCount(count int) string { if count > 99 { return "99+" } return fmt.Sprintf("%d", count)}
// arabicaCreateDropdownItems renders the "+" menu entries for the// coffee app: log a brew, add a bean / roaster / grinder / brewer /// recipe. Each Add-* button opens the relevant modal via HTMX.templ arabicaCreateDropdownItems() { <div class="dropdown-header"> <p class="text-xs font-semibold uppercase tracking-wider text-faint">Log</p> </div> <a href="/brews/new" class="dropdown-item" @click="open = false" role="menuitem"> @IconCoffee() New Brew </a> <div class="dropdown-divider"> <p class="px-4 pt-1 text-xs font-semibold uppercase tracking-wider text-faint">Add</p> </div> <button class="dropdown-item w-full text-left" hx-get="/api/modals/bean/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconLeaf() Bean </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/roaster/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconStore() Roaster </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/grinder/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconDisc() Grinder </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/brewer/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconBrewer() Brewer </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/recipe/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconFileText() Recipe </button>}
// oolongCreateDropdownItems renders the "+" menu entries for the tea// app: open the steep/tea/vendor/vessel/infuser modals. Cafe, drink,// and recipe entries are deferred for v1.templ oolongCreateDropdownItems() { <div class="dropdown-header"> <p class="text-xs font-semibold uppercase tracking-wider text-faint">Log</p> </div> <a href="/brews/new" class="dropdown-item w-full text-left" @click="open = false" role="menuitem"> @IconDroplet() New Steep </a> <div class="dropdown-divider"> <p class="px-4 pt-1 text-xs font-semibold uppercase tracking-wider text-faint">Add</p> </div> <a href="/teas/new" class="dropdown-item w-full text-left" @click="open = false" role="menuitem"> @IconLeaf() Tea </a> <button class="dropdown-item w-full text-left" hx-get="/api/modals/vendor/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconStore() Vendor </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/vessel/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconBrewer() Vessel </button> <button class="dropdown-item w-full text-left" hx-get="/api/modals/infuser/new" hx-target="#modal-container" hx-swap="innerHTML" @click="open = false" role="menuitem"> @IconDisc() Infuser </button>}