Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 290 lines 11 kB view raw
1package components 2 3import "arabica/internal/web/bff" 4 5// LayoutData contains all the data needed for the layout 6type LayoutData struct { 7 Title string 8 IsAuthenticated bool 9 UserDID string 10 UserProfile *bff.UserProfile 11 CSPNonce string 12 IsModerator bool // User has moderation permissions 13 UnreadNotificationCount int // Number of unread notifications 14 15 // OpenGraph metadata (optional, uses defaults if empty) 16 OGTitle string // Falls back to Title + " - Arabica" 17 OGDescription string // Falls back to site description 18 OGImage string // If set, renders og:image tag 19 OGType string // Falls back to "website" 20 OGUrl string // Canonical URL for the page 21} 22 23// ogTitle returns the OpenGraph title, falling back to the page title 24func (d *LayoutData) ogTitle() string { 25 if d.OGTitle != "" { 26 return d.OGTitle 27 } 28 return d.Title + " - Arabica" 29} 30 31// ogDescription returns the OpenGraph description, falling back to site default 32func (d *LayoutData) ogDescription() string { 33 if d.OGDescription != "" { 34 return d.OGDescription 35 } 36 return "Track your coffee brewing journey. Built on AT Protocol, your data stays yours." 37} 38 39// ogType returns the OpenGraph type, falling back to "website" 40func (d *LayoutData) ogType() string { 41 if d.OGType != "" { 42 return d.OGType 43 } 44 return "website" 45} 46 47templ Layout(data *LayoutData, content templ.Component) { 48 <!DOCTYPE html> 49 <html lang="en" class="h-full" style="background-color: #fdf8f6;"> 50 <head> 51 <meta charset="UTF-8"/> 52 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 53 <meta name="description" content="Arabica is a coffee brew tracking app built on AT Protocol. Your brewing data is stored in your own Personal Data Server, giving you full ownership and portability."/> 54 <!-- OpenGraph metadata --> 55 <meta property="og:title" content={ data.ogTitle() }/> 56 <meta property="og:description" content={ data.ogDescription() }/> 57 <meta property="og:type" content={ data.ogType() }/> 58 <meta property="og:site_name" content="Arabica"/> 59 if data.OGUrl != "" { 60 <meta property="og:url" content={ data.OGUrl }/> 61 } 62 if data.OGImage != "" { 63 <meta property="og:image" content={ data.OGImage }/> 64 <meta property="og:image:alt" content={ data.ogTitle() }/> 65 } 66 <!-- Twitter Card metadata --> 67 <meta name="twitter:card" content="summary"/> 68 <meta name="twitter:title" content={ data.ogTitle() }/> 69 <meta name="twitter:description" content={ data.ogDescription() }/> 70 if data.OGImage != "" { 71 <meta name="twitter:image" content={ data.OGImage }/> 72 } 73 <meta name="theme-color" content="#4a2c2a"/> 74 <title>{ data.Title } - Arabica</title> 75 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> 76 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> 77 <link rel="apple-touch-icon" href="/static/icon-192.svg"/> 78 <link rel="stylesheet" href="/static/css/output.css?v=0.6.1"/> 79 <style> 80 [x-cloak] { display: none !important; } 81 </style> 82 <link rel="manifest" href="/static/manifest.json"/> 83 <script nonce={ data.CSPNonce }> 84 // Show the session-expired modal and populate return path 85 window.__showSessionExpiredModal = function() { 86 var modal = document.getElementById('session-expired-modal'); 87 if (modal && !modal.open) { 88 var returnInput = document.getElementById('reauth-return-to'); 89 if (returnInput) returnInput.value = window.location.pathname; 90 modal.showModal(); 91 } 92 }; 93 94 // Save form data to sessionStorage before re-auth redirect 95 window.__saveFormBeforeReauth = function() { 96 var form = document.querySelector('main form'); 97 if (!form) return; 98 var data = {}; 99 var inputs = form.querySelectorAll('input, select, textarea'); 100 for (var i = 0; i < inputs.length; i++) { 101 var el = inputs[i]; 102 if (!el.name || el.type === 'hidden' || el.type === 'submit' || el.type === 'button') continue; 103 if (el.type === 'checkbox' || el.type === 'radio') { 104 data[el.name] = el.checked; 105 } else { 106 data[el.name] = el.value; 107 } 108 } 109 if (Object.keys(data).length > 0) { 110 sessionStorage.setItem('arabica_form_restore', JSON.stringify({ 111 path: window.location.pathname, 112 data: data 113 })); 114 } 115 }; 116 117 // Wire up session-expired modal buttons (no inline handlers due to CSP) 118 document.addEventListener('DOMContentLoaded', function() { 119 var reauthForm = document.getElementById('reauth-form'); 120 if (reauthForm) { 121 reauthForm.addEventListener('submit', function() { 122 window.__saveFormBeforeReauth(); 123 }); 124 } 125 var dismissBtn = document.getElementById('session-expired-dismiss'); 126 if (dismissBtn) { 127 dismissBtn.addEventListener('click', function() { 128 document.getElementById('session-expired-modal').close(); 129 }); 130 } 131 }); 132 133 // Restore saved form data after re-auth 134 document.addEventListener('DOMContentLoaded', function() { 135 var saved = sessionStorage.getItem('arabica_form_restore'); 136 if (!saved) return; 137 try { 138 var parsed = JSON.parse(saved); 139 if (parsed.path !== window.location.pathname) { 140 sessionStorage.removeItem('arabica_form_restore'); 141 return; 142 } 143 // Retry until the form and dropdown options are populated 144 var attempts = 0; 145 var maxAttempts = 30; // 30 x 500ms = 15 seconds max 146 var interval = setInterval(function() { 147 attempts++; 148 var form = document.querySelector('main form'); 149 if (!form) { 150 if (attempts >= maxAttempts) clearInterval(interval); 151 return; 152 } 153 // Wait until selects have options (dropdowns populated by Alpine) 154 var selects = form.querySelectorAll('select'); 155 var ready = true; 156 for (var i = 0; i < selects.length; i++) { 157 if (selects[i].options.length <= 1) { ready = false; break; } 158 } 159 if (!ready && attempts < maxAttempts) return; 160 clearInterval(interval); 161 // Restore values 162 var formData = parsed.data; 163 for (var key in formData) { 164 var el = form.querySelector('[name="' + key + '"]'); 165 if (!el) continue; 166 if (el.type === 'checkbox' || el.type === 'radio') { 167 el.checked = formData[key]; 168 } else { 169 el.value = formData[key]; 170 el.dispatchEvent(new Event('change', { bubbles: true })); 171 } 172 } 173 sessionStorage.removeItem('arabica_form_restore'); 174 }, 500); 175 } catch(e) { 176 sessionStorage.removeItem('arabica_form_restore'); 177 } 178 }); 179 180 // Configure HTMX before it loads 181 document.addEventListener('DOMContentLoaded', function() { 182 if (typeof htmx !== 'undefined') { 183 // Disable view transitions - using component-level animations instead 184 // View transitions were causing double-fade issues 185 htmx.config.globalViewTransitions = false; 186 187 // Increase history cache size to prevent cache misses 188 htmx.config.historyCacheSize = 20; 189 190 // Show session-expired modal on 401 responses 191 document.body.addEventListener('htmx:afterRequest', function(evt) { 192 if (evt.detail.xhr && evt.detail.xhr.status === 401) { 193 window.__showSessionExpiredModal(); 194 } 195 }); 196 197 // Clean up styles before taking history snapshot 198 document.body.addEventListener('htmx:beforeHistorySave', function(evt) { 199 // Remove all transition classes and styles from elements being cached 200 const allElements = document.querySelectorAll('main, main *, body'); 201 allElements.forEach(function(el) { 202 if (el) { 203 el.classList.remove('htmx-swapping', 'htmx-transitioning', 'htmx-settling', 'htmx-added', 'transitioning'); 204 // Reset inline styles that might hide content 205 if (el.style.opacity !== '' || el.style.transform !== '' || el.style.visibility !== '') { 206 el.style.opacity = ''; 207 el.style.transform = ''; 208 el.style.visibility = ''; 209 } 210 } 211 }); 212 }); 213 } 214 }); 215 </script> 216 <!-- Load entity manager and dropdown manager modules before Alpine components --> 217 <script src="/static/js/entity-manager.js?v=0.1.0"></script> 218 <script src="/static/js/dropdown-manager.js?v=0.1.0"></script> 219 <!-- Load Alpine components BEFORE Alpine.js initializes --> 220 <script src="/static/js/brew-form.js?v=0.3.2"></script> 221 <script src="/static/js/entity-suggest.js?v=0.1.0"></script> 222 <!-- Load Alpine.js core with defer (will initialize after DOM loads) --> 223 <script src="/static/js/alpine.min.js?v=0.2.0" defer></script> 224 <!-- Load HTMX and other utilities --> 225 <script src="/static/js/htmx.min.js?v=0.2.0"></script> 226 <script src="/static/js/transitions.js?v=0.3.3"></script> 227 <script src="/static/js/entity-helpers.js?v=0.8.0"></script> 228 if data.IsAuthenticated { 229 <script src="/static/js/data-cache.js?v=0.2.0"></script> 230 } 231 <script src="/static/js/sw-register.js?v=0.2.0"></script> 232 </head> 233 <body 234 class="bg-brown-50 min-h-full flex flex-col" 235 style="background-color: #fdf8f6;" 236 if data.UserDID != "" { 237 data-user-did={ data.UserDID } 238 } 239 > 240 <!-- Session expired modal --> 241 <dialog id="session-expired-modal" class="modal-dialog"> 242 <div class="modal-content text-center"> 243 <div class="mb-4"> 244 <svg class="w-12 h-12 mx-auto text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 245 <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"></path> 246 </svg> 247 </div> 248 <h3 class="modal-title text-center">Session Expired</h3> 249 <p class="text-brown-700 text-sm mb-6"> 250 Your login session has expired. Log back in to continue where you left off. 251 </p> 252 <div class="flex flex-col gap-3"> 253 <form id="reauth-form" method="POST" action="/reauth"> 254 if data.UserProfile != nil && data.UserProfile.Handle != "" { 255 <input type="hidden" name="handle" value={ data.UserProfile.Handle }/> 256 } 257 <input type="hidden" name="return_to" id="reauth-return-to"/> 258 <button 259 type="submit" 260 class="btn-primary w-full" 261 > 262 Log In Again 263 </button> 264 </form> 265 <button 266 type="button" 267 id="session-expired-dismiss" 268 class="btn-secondary w-full" 269 > 270 Dismiss 271 </button> 272 </div> 273 </div> 274 </dialog> 275 @HeaderWithProps(HeaderProps{ 276 IsAuthenticated: data.IsAuthenticated, 277 UserProfile: data.UserProfile, 278 UserDID: data.UserDID, 279 IsModerator: data.IsModerator, 280 UnreadNotificationCount: data.UnreadNotificationCount, 281 }) 282 <main class="flex-grow container mx-auto py-8" data-transition> 283 @content 284 </main> 285 @Footer() 286 <!-- Modal container for entity dialogs --> 287 <div id="modal-container"></div> 288 </body> 289 </html> 290}