Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
16 kB · 416 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417package components
import ( "tangled.org/arabica.social/arabica/internal/web/assets" "tangled.org/arabica.social/arabica/internal/web/bff")
// LayoutData contains all the data needed for the layouttype LayoutData struct { Title string IsAuthenticated bool UserDID string UserProfile *bff.UserProfile CSPNonce string IsModerator bool // User has moderation permissions UnreadNotificationCount int // Number of unread notifications
// Brand strings, populated from domain.BrandConfig. Empty values fall // back to the arabica defaults via the helper methods below — keeps // existing call sites that build LayoutData directly working until // every handler threads the brand through. BrandName string // "Arabica", "Oolong" BrandTagline string // "Your brew, your data"
// AppName is the running app's lowercase identifier (e.g. "arabica", // "oolong"). Used to pick which compiled CSS file to load — empty // falls back to arabica's stylesheet, keeping legacy call sites that // build LayoutData directly working unchanged. AppName string
// OpenGraph metadata (optional, uses defaults if empty) OGTitle string // Falls back to Title + " - " + brand name OGDescription string // Falls back to site description OGImage string // If set, renders og:image tag OGImageAlt string // Alt text for OG image; falls back to OGTitle + OGDescription OGType string // Falls back to "website" OGUrl string // Canonical URL for the page}
// stylesheetHref returns the cache-busted CSS URL for the running app.// The bundle is concatenated at server startup from internal/web/assets/css/;// the ?h=<hash> query param invalidates automatically when content changes.func (d *LayoutData) stylesheetHref() string { return assets.HrefFor(d.AppName)}
// brandName returns the configured brand display name, defaulting to// "Arabica" when unset (covers tests and any pre-Phase-G call site that// hasn't been threaded yet).func (d *LayoutData) brandName() string { if d.BrandName != "" { return d.BrandName } return "Arabica"}
// brandTagline returns the configured brand tagline, defaulting to the// arabica tagline when unset.func (d *LayoutData) brandTagline() string { if d.BrandTagline != "" { return d.BrandTagline } return "Your brew, your data"}
// ogTitle returns the OpenGraph title, falling back to the page titlefunc (d *LayoutData) ogTitle() string { if d.OGTitle != "" { return d.OGTitle } return d.pageTitle()}
// pageTitle returns the <title> text, collapsing the home page to just// the brand name.func (d *LayoutData) pageTitle() string { if d.Title == "" || d.Title == "Home" { return d.brandName() } return d.Title + " - " + d.brandName()}
// ogDescription returns the OpenGraph description, falling back to site defaultfunc (d *LayoutData) ogDescription() string { if d.OGDescription != "" { return d.OGDescription } return "Track your coffee brewing journey. Built on AT Protocol, your data stays yours."}
// ogType returns the OpenGraph type, falling back to "website"func (d *LayoutData) ogType() string { if d.OGType != "" { return d.OGType } return "website"}
// ogImageAlt returns descriptive alt text for the OG image.func (d *LayoutData) ogImageAlt() string { if d.OGImageAlt != "" { return d.OGImageAlt } alt := d.ogTitle() if d.OGDescription != "" { alt += " — " + d.OGDescription } return alt}
// twitterCardType returns "summary_large_image" when an OG image is set,// otherwise "summary" for the default compact card.func (d *LayoutData) twitterCardType() string { if d.OGImage != "" { return "summary_large_image" } return "summary"}
templ Layout(data *LayoutData, content templ.Component) { <!DOCTYPE html> <html lang="en" class="h-full" style="background-color: var(--page-bg);"> <head> <script nonce={ data.CSPNonce }> // Apply theme before render to prevent flash (function() { var t = localStorage.getItem('arabica-theme'); if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t); })(); </script> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="description" content={ data.brandName() + " 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." }/> <!-- OpenGraph metadata --> <meta property="og:title" content={ data.ogTitle() }/> <meta property="og:description" content={ data.ogDescription() }/> <meta property="og:type" content={ data.ogType() }/> <meta property="og:site_name" content={ data.brandName() }/> if data.OGUrl != "" { <meta property="og:url" content={ data.OGUrl }/> } if data.OGImage != "" { <meta property="og:image" content={ data.OGImage }/> <meta property="og:image:width" content="1200"/> <meta property="og:image:height" content="630"/> <meta property="og:image:alt" content={ data.ogImageAlt() }/> } <!-- Twitter Card metadata --> <meta name="twitter:card" content={ data.twitterCardType() }/> <meta name="twitter:title" content={ data.ogTitle() }/> <meta name="twitter:description" content={ data.ogDescription() }/> if data.OGImage != "" { <meta name="twitter:image" content={ data.OGImage }/> } <meta name="theme-color" content="#4a2c2a" media="(prefers-color-scheme: light)"/> <meta name="theme-color" content="#0F0A08" media="(prefers-color-scheme: dark)"/> <title>{ data.pageTitle() }</title> <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> <link rel="apple-touch-icon" href="/static/icon-192.svg"/> <link rel="stylesheet" href={ data.stylesheetHref() }/> <style> [x-cloak] { display: none !important; } </style> <link rel="manifest" href="/static/manifest.json"/> <script nonce={ data.CSPNonce }> // Show the session-expired modal and populate return path window.__showSessionExpiredModal = function() { var modal = document.getElementById('session-expired-modal'); if (modal && !modal.open) { var returnInput = document.getElementById('reauth-return-to'); if (returnInput) returnInput.value = window.location.pathname; modal.showModal(); } };
// Save form data to sessionStorage before re-auth redirect window.__saveFormBeforeReauth = function() { var form = document.querySelector('main form'); if (!form) return; var data = {}; var inputs = form.querySelectorAll('input, select, textarea'); for (var i = 0; i < inputs.length; i++) { var el = inputs[i]; if (!el.name || el.type === 'hidden' || el.type === 'submit' || el.type === 'button') continue; if (el.type === 'checkbox' || el.type === 'radio') { data[el.name] = el.checked; } else { data[el.name] = el.value; } } if (Object.keys(data).length > 0) { sessionStorage.setItem('arabica_form_restore', JSON.stringify({ path: window.location.pathname, data: data })); } };
// Wire up session-expired modal buttons (no inline handlers due to CSP) document.addEventListener('DOMContentLoaded', function() { var reauthForm = document.getElementById('reauth-form'); if (reauthForm) { reauthForm.addEventListener('submit', function() { window.__saveFormBeforeReauth(); }); } var dismissBtn = document.getElementById('session-expired-dismiss'); if (dismissBtn) { dismissBtn.addEventListener('click', function() { document.getElementById('session-expired-modal').close(); }); } });
// Restore saved form data after re-auth document.addEventListener('DOMContentLoaded', function() { var saved = sessionStorage.getItem('arabica_form_restore'); if (!saved) return; try { var parsed = JSON.parse(saved); if (parsed.path !== window.location.pathname) { sessionStorage.removeItem('arabica_form_restore'); return; } // Retry until the form and dropdown options are populated var attempts = 0; var maxAttempts = 30; // 30 x 500ms = 15 seconds max var interval = setInterval(function() { attempts++; var form = document.querySelector('main form'); if (!form) { if (attempts >= maxAttempts) clearInterval(interval); return; } // Wait until selects have options (dropdowns populated by Alpine) var selects = form.querySelectorAll('select'); var ready = true; for (var i = 0; i < selects.length; i++) { if (selects[i].options.length <= 1) { ready = false; break; } } if (!ready && attempts < maxAttempts) return; clearInterval(interval); // Restore values var formData = parsed.data; for (var key in formData) { var el = form.querySelector('[name="' + key + '"]'); if (!el) continue; if (el.type === 'checkbox' || el.type === 'radio') { el.checked = formData[key]; } else { el.value = formData[key]; el.dispatchEvent(new Event('change', { bubbles: true })); } } sessionStorage.removeItem('arabica_form_restore'); }, 500); } catch(e) { sessionStorage.removeItem('arabica_form_restore'); } });
// Format <time data-local="..."> elements in the user's local timezone document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('time[data-local]').forEach(function(el) { var dt = new Date(el.getAttribute('datetime')); if (isNaN(dt.getTime())) return; var fmt = el.getAttribute('data-local'); var text; try { if (fmt === 'date') { text = dt.toLocaleDateString(undefined, {month: 'short', day: 'numeric'}); } else if (fmt === 'year') { text = String(dt.getFullYear()); } else if (fmt === 'long') { text = dt.toLocaleDateString(undefined, {month: 'long', day: 'numeric', year: 'numeric'}) + ' at ' + dt.toLocaleTimeString(undefined, {hour: 'numeric', minute: '2-digit'}); } else if (fmt === 'short') { text = dt.toLocaleDateString(undefined, {month: 'short', day: 'numeric', year: 'numeric'}) + ' ' + dt.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'}); } if (text) el.textContent = text; } catch(e) {} }); });
// Configure HTMX before it loads document.addEventListener('DOMContentLoaded', function() { if (typeof htmx !== 'undefined') { // Disable view transitions - using component-level animations instead // View transitions were causing double-fade issues htmx.config.globalViewTransitions = false;
// Increase history cache size to prevent cache misses htmx.config.historyCacheSize = 20;
// Propagate W3C traceparent to HTMX requests so sub-requests // (e.g. /api/feed, /api/popular-recipes) share the page's trace var tp = document.querySelector('meta[name="traceparent"]'); if (tp) { document.body.addEventListener('htmx:configRequest', function(evt) { evt.detail.headers['traceparent'] = tp.content; }); }
// Show session-expired modal on 401 responses document.body.addEventListener('htmx:afterRequest', function(evt) { if (evt.detail.xhr && evt.detail.xhr.status === 401) { window.__showSessionExpiredModal(); } });
// Clean up styles before taking history snapshot document.body.addEventListener('htmx:beforeHistorySave', function(evt) { // Remove all transition classes and styles from elements being cached const allElements = document.querySelectorAll('main, main *, body'); allElements.forEach(function(el) { if (el) { el.classList.remove('htmx-swapping', 'htmx-transitioning', 'htmx-settling', 'htmx-added', 'transitioning'); // Reset inline styles that might hide content if (el.style.opacity !== '' || el.style.transform !== '' || el.style.visibility !== '') { el.style.opacity = ''; el.style.transform = ''; el.style.visibility = ''; } } }); }); } }); </script> <!-- Load entity manager and dropdown manager modules before Alpine components --> <script src={ assets.JSHrefFor("entity-manager.js") }></script> <script src={ assets.JSHrefFor("dropdown-manager.js") }></script> <!-- Load Alpine components BEFORE Alpine.js initializes --> <script src={ assets.JSHrefFor("theme.js") }></script> <script src={ assets.JSHrefFor("brew-form.js") }></script> <script src={ assets.JSHrefFor("entity-suggest.js") }></script> <script src={ assets.JSHrefFor("combo-select.js") }></script> <!-- Load Alpine.js core with defer (will initialize after DOM loads) --> <script src={ assets.JSHrefFor("alpine.min.js") } defer></script> <!-- Load HTMX and other utilities --> <script src={ assets.JSHrefFor("htmx.min.js") }></script> <script src={ assets.JSHrefFor("transitions.js") }></script> <script src={ assets.JSHrefFor("entity-helpers.js") }></script> <script src={ assets.JSHrefFor("data-cache.js") }></script> <script src={ assets.JSHrefFor("sw-register.js") }></script> <script src={ assets.JSHrefFor("feed-masonry.js") }></script> </head> if tp := bff.Traceparent(ctx); tp != "" { <meta name="traceparent" content={ tp }/> } <body class="min-h-full flex flex-col font-sans" style="background-color: var(--page-bg); color: var(--page-text);" if data.UserDID != "" { data-user-did={ data.UserDID } } if data.AppName != "" { data-app={ data.AppName } } > <!-- Session expired modal --> <dialog id="session-expired-modal" class="modal-dialog"> <div class="modal-content text-center"> <div class="mb-4"> <svg class="w-12 h-12 mx-auto text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> <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> </svg> </div> <h3 class="modal-title text-center">Session Expired</h3> <p class="text-emphasis text-sm mb-6"> Your login session has expired. Log back in to continue where you left off. </p> <div class="flex flex-col gap-3"> <form id="reauth-form" method="POST" action="/reauth"> if data.UserProfile != nil && data.UserProfile.Handle != "" { <input type="hidden" name="handle" value={ data.UserProfile.Handle }/> } <input type="hidden" name="return_to" id="reauth-return-to"/> <button type="submit" class="btn-primary w-full" > Log In Again </button> </form> <button type="button" id="session-expired-dismiss" class="btn-secondary w-full" > Dismiss </button> </div> </div> </dialog> @HeaderWithProps(HeaderProps{ IsAuthenticated: data.IsAuthenticated, UserProfile: data.UserProfile, UserDID: data.UserDID, IsModerator: data.IsModerator, UnreadNotificationCount: data.UnreadNotificationCount, BrandName: data.brandName(), AppName: data.AppName, }) <main class="grow container mx-auto py-8" data-transition> @content </main> @FooterWithBrand(data.brandName(), data.brandTagline()) <!-- Modal container for entity dialogs --> <div id="modal-container"></div> </body> </html>}