Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
19 kB · 494 lines
at feat-explore
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495package 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
// Assets provides cache-busted CSS and JS hrefs without requiring template // rendering to consult the package-level asset registries. Assets assets.Manifest
// 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 d.Assets.StylesheetHref(d.AppName)}
func (d *LayoutData) scriptHref(name string) string { return d.Assets.ScriptHref(name)}
func (d *LayoutData) ScriptHref(name string) string { return d.scriptHref(name)}
// 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> [v-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 petite-vue) 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> <!-- Dropdown manager module (used by brew-form). --> <script src={ data.scriptHref("dropdown-manager.js") }></script> <!-- data-action event delegation for CSP-safe button handlers. --> <script src={ data.scriptHref("actions.js") }></script> <!-- Petite-vue factories defined by sibling scripts. Order matters: these set window.* globals that arabica-vue.js picks up at mount. --> <script src={ data.scriptHref("theme.js") }></script> <script src={ data.scriptHref("brew-form.js") }></script> <script src={ data.scriptHref("entity-suggest.js") }></script> <script src={ data.scriptHref("combo-select.js") }></script> <script src={ data.scriptHref("manage-page.js") }></script> <script src={ data.scriptHref("recipe-explore.js") }></script> <script src={ data.scriptHref("save-as-recipe.js") }></script> <!-- Petite-vue runtime + bootstrap. --> <script src={ data.scriptHref("petite-vue.iife.js") }></script> <script src={ data.scriptHref("arabica-vue.js") }></script> <!-- Load HTMX and other utilities --> <script src={ data.scriptHref("htmx.min.js") }></script> <script src={ data.scriptHref("transitions.js") }></script> <script src={ data.scriptHref("entity-helpers.js") }></script> <script src={ data.scriptHref("data-cache.js") }></script> <script src={ data.scriptHref("sw-register.js") }></script> <script src={ data.scriptHref("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, HandleAutocompleteScriptHref: data.scriptHref("handle-autocomplete.js"), }) <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> <!-- Toast notifications (fed by `notify` window events and `HX-Trigger: {"notify":{"message":"..."}}` response headers). --> <div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="false"></div> <script nonce={ data.CSPNonce }> (function () { var region = document.getElementById("toast-region"); if (!region) return; function showToast(message) { if (!message) return; var el = document.createElement("div"); el.className = "toast"; el.setAttribute("role", "status"); el.textContent = String(message); region.appendChild(el); // Match animation: 180ms in + 2400ms delay + 220ms out = ~2800ms. setTimeout(function () { el.remove(); }, 2900); } function extractMessage(detail) { if (!detail) return ""; // HX-Trigger: {"notify": {"message": "..."}} → detail.value if (detail.value && typeof detail.value === "object") { return detail.value.message || ""; } // HX-Trigger: {"notify": "string"} or window CustomEvent path if (typeof detail.value === "string") return detail.value; if (detail.message) return detail.message; return ""; } // One listener at the window level: htmx fires HX-Trigger // events on document.body (they bubble) and scope code // dispatches bubbling CustomEvents — both reach window. // Listening on both window and body would double-toast. window.addEventListener("notify", function (e) { showToast(extractMessage(e.detail)); }); // Auto-close <dialog> by id, optionally delayed. // HX-Trigger: {"close-dialog": {"id": "...", "delay": 2000}} document.body.addEventListener("close-dialog", function (e) { var v = e.detail && e.detail.value; if (!v) return; var id = typeof v === "string" ? v : v.id; var delay = (typeof v === "object" && v.delay) || 0; if (!id) return; setTimeout(function () { var d = document.getElementById(id); if (d && d.close) d.close(); }, delay); }); // Generic dismiss: any `[data-dialog-close]` element closes // its nearest <dialog> on click. Survives htmx swaps because // the listener is delegated on document.body. document.body.addEventListener("click", function (e) { var t = e.target.closest && e.target.closest("[data-dialog-close]"); if (!t) return; var d = t.closest("dialog"); if (d && d.close) d.close(); }); })(); </script> </body> </html>}