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