Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
9.9 kB · 271 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272package components
import ( "tangled.org/oolong.social/oolong/internal/profileprefs" "tangled.org/oolong.social/oolong/internal/web/assets" "tangled.org/oolong.social/oolong/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 UserPreferences profileprefs.UserPreferences
// Brand strings, populated from domain.BrandConfig. Empty values fall // back to the oolong defaults via the helper methods below — keeps // existing call sites that build LayoutData directly working until // every handler threads the brand through. BrandName string // "Oolong" BrandTagline string // "Your brew, your data"
// AppName is the running app's lowercase identifier (e.g. "oolong", // "oolong"). Used to pick which compiled CSS file to load — empty // falls back to oolong'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// "Oolong" 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 "Oolong"}
// brandTagline returns the configured brand tagline, defaulting to the// oolong 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 d.siteDescription()}
func (d *LayoutData) siteDescription() string { if d.AppName == "oolong" { return d.brandName() + " is a tea tracking app built on AT Protocol. Your steep logs, teas, and teaware are stored in your own Personal Data Server, giving you full ownership and portability." } return d.brandName() + " is a tea brew tracking app built on AT Protocol. Your brewing data is stored in your own Personal Data Server, giving you full ownership and portability."}
func (d *LayoutData) lightThemeColor() string { if d.AppName == "oolong" { return "#b8d5aa" } return "#4a2c2a"}
func (d *LayoutData) darkThemeColor() string { if d.AppName == "oolong" { return "#162018" } return "#0F0A08"}
// 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('oolong-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.siteDescription() }/> <!-- 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={ data.lightThemeColor() } media="(prefers-color-scheme: light)"/> <meta name="theme-color" content={ data.darkThemeColor() } 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() }/> <link rel="manifest" href="/static/manifest.json"/> <meta name="htmx-config" content='{"globalViewTransitions":false,"historyCacheSize":20,"allowEval":false}'/> <!-- Load HTMX and other utilities --> <script src={ data.scriptHref("htmx.min.js") }></script> <script type="module" src={ data.scriptHref("svelte-islands.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> <!-- 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> </body> </html>}