Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: deduplicate newsletter widget, single-render timeline grid

the newsletter widget was being rendered twice on the timeline (once for
mobile, once for desktop) via hidden/visible wrapper divs. both copies
shared the same element ids (newsletter-widget, newsletter-widget-msg),
so desktop submissions swapped into the hidden mobile copy and appeared
to do nothing. collapse the two layouts into one responsive grid with
tailwind order/col-start utilities so the widget lives in the dom
exactly once, and drop the mutationobserver wiring in favour of a small
delegated click listener that also handles gfi banner widening on
dismiss.

consolidate the three signup form variants (banner, widget, home hero)
into a single newsletterForm fragment that takes a dict(Id, Variant).
each instance gets a unique response-span id (newsletter-msg-<Id>) so
multiple forms can coexist on a page without id collisions.

move the handler's inline html/tailwind response strings into a
newsletterResponse template rendered through pages, so the response id
round-trips with the form's hx-target via an hx-vals target field.
drop the stray blank line in base.html.

Signed-off-by: eti <eti@eti.tf>

authored by

eti and committed by
Tangled
97ca49ba b6076ed3

+155 -103
+12
appview/pages/pages.go
··· 523 523 return p.executePlain("banner", w, params) 524 524 } 525 525 526 + type NewsletterResponseParams struct { 527 + // Id identifies the calling form instance; the response span's id will 528 + // be "newsletter-msg-<Id>" so it round-trips with the form's hx-target. 529 + Id string 530 + // Error, when non-empty, switches the template to the error variant. 531 + Error string 532 + } 533 + 534 + func (p *Pages) NewsletterResponse(w io.Writer, params NewsletterResponseParams) error { 535 + return p.executePlain("timeline/fragments/newsletterResponse", w, params) 536 + } 537 + 526 538 type KnotsParams struct { 527 539 LoggedInUser *oauth.MultiAccountUser 528 540 Registrations []models.Registration
-1
appview/pages/templates/layouts/base.html
··· 61 61 {{ template "fragments/posthog" . }} 62 62 </head> 63 63 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 64 - 65 64 {{ block "topbarLayout" . }} 66 65 <header class="w-full col-span-full md:col-span-1 md:col-start-2 drop-shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 67 66
-16
appview/pages/templates/timeline/fragments/newsletter.html
··· 1 - {{ define "timeline/fragments/newsletter" }} 2 - <div id="newsletter-banner" class="mb-4"> 3 - <form hx-post="/newsletter/signup" hx-target="#newsletter-msg" hx-swap="outerHTML" 4 - class="relative flex flex-col gap-2 px-5 py-3 bg-green-50 dark:bg-green-950 border border-green-800/[0.125] dark:border-green-400/20 shadow-sm rounded"> 5 - <p class="pr-6">We've got a newsletter! Punch in your email to get our updates sent straight to your inbox.</p> 6 - <span id="newsletter-msg" class="flex items-center gap-4"> 7 - <input type="email" name="email" placeholder="your@email.com" required 8 - class="flex-1 min-w-0 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded" /> 9 - <button type="submit" class="btn whitespace-nowrap shrink-0">subscribe</button> 10 - </span> 11 - <button type="button" onclick="document.getElementById('newsletter-banner').remove(); localStorage.setItem('newsletter-dismissed','1')" 12 - class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" aria-label="Dismiss">✕</button> 13 - </form> 14 - </div> 15 - <script>if(localStorage.getItem('newsletter-dismissed'))document.getElementById('newsletter-banner').remove();</script> 16 - {{ end }}
+55
appview/pages/templates/timeline/fragments/newsletterForm.html
··· 1 + {{/* 2 + Shared newsletter signup form. Variants style the form differently, but the 3 + POST target, response-target id and request plumbing stay identical. 4 + 5 + Params (dict): 6 + Id string - unique id suffix (e.g. "widget", "home"); used to build 7 + the response span's id so multiple signup forms can 8 + coexist on one page without colliding. 9 + Variant string - "card" (compact sidebar card) | "hero" (large CTA) 10 + */}} 11 + {{ define "timeline/fragments/newsletterForm" }} 12 + {{ $id := .Id }} 13 + {{ $variant := .Variant }} 14 + {{ if eq $variant "hero" }} 15 + <form 16 + class="flex gap-2 items-stretch w-full md:max-w-md mx-auto p-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded shadow-sm" 17 + hx-post="/newsletter/signup" 18 + hx-target="#newsletter-msg-{{ $id }}" 19 + hx-swap="outerHTML" 20 + hx-vals='{"target":"{{ $id }}"}'> 21 + <span id="newsletter-msg-{{ $id }}" class="flex gap-2 items-stretch flex-1 min-w-0"> 22 + <input 23 + type="email" 24 + name="email" 25 + tabindex="4" 26 + required 27 + placeholder="Enter your email" 28 + class="py-2 w-full" /> 29 + <button class="btn-create flex items-center gap-2 text-base whitespace-nowrap" type="submit"> 30 + subscribe 31 + {{ i "arrow-right" "size-4" }} 32 + </button> 33 + </span> 34 + </form> 35 + {{ else }} 36 + <form 37 + hx-post="/newsletter/signup" 38 + hx-target="#newsletter-msg-{{ $id }}" 39 + hx-swap="outerHTML" 40 + hx-vals='{"target":"{{ $id }}"}'> 41 + <span id="newsletter-msg-{{ $id }}" class="flex items-center gap-2"> 42 + <input 43 + type="email" 44 + name="email" 45 + placeholder="your@email.com" 46 + required 47 + class="flex-1 min-w-0 text-sm" /> 48 + <button type="submit" class="btn whitespace-nowrap h-12 px-4 pb-0 gap-2"> 49 + {{ i "mail-plus" "size-4 inline" }} 50 + subscribe 51 + </button> 52 + </span> 53 + </form> 54 + {{ end }} 55 + {{ end }}
+16
appview/pages/templates/timeline/fragments/newsletterResponse.html
··· 1 + {{/* 2 + Response fragment swapped into place by htmx after a /newsletter/signup 3 + submission. Returns a <span> whose id matches the form's hx-target so the 4 + swap is clean and no hard-coded id leaks into Go. 5 + 6 + Params: 7 + Id string - matches the Id used in newsletterForm (e.g. "widget", "home") 8 + Error string - if non-empty, renders the error variant 9 + */}} 10 + {{ define "timeline/fragments/newsletterResponse" }} 11 + {{ if .Error }} 12 + <span id="newsletter-msg-{{ .Id }}" class="text-red-500 text-sm whitespace-nowrap">{{ .Error }}</span> 13 + {{ else }} 14 + <span id="newsletter-msg-{{ .Id }}" class="text-sm text-green-700 dark:text-green-400 whitespace-nowrap">You&#39;re signed up!</span> 15 + {{ end }} 16 + {{ end }}
+3 -26
appview/pages/templates/timeline/fragments/newsletterWidget.html
··· 1 1 {{ define "timeline/fragments/newsletterWidget" }} 2 2 <div 3 3 id="newsletter-widget" 4 - class="relative border border-green-800/15 dark:border-green-700 rounded bg-green-50 dark:bg-green-950 p-4 flex-1"> 4 + class="relative border border-green-800/15 dark:border-green-700 rounded bg-green-50 dark:bg-green-950 p-4"> 5 5 <button 6 6 type="button" 7 - onclick=" 8 - document.getElementById('newsletter-widget').remove(); 9 - localStorage.setItem('newsletter-dismissed', '1'); 10 - " 7 + data-newsletter-dismiss 11 8 class="absolute top-4 right-4 text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 leading-none text-sm" 12 9 aria-label="Dismiss"> 13 10 ··· 17 14 <br /> 18 15 Punch in your email to subscribe. 19 16 </p> 20 - <form 21 - hx-post="/newsletter/signup" 22 - hx-target="#newsletter-widget-msg" 23 - hx-swap="outerHTML"> 24 - <span id="newsletter-widget-msg" class="flex items-center gap-2"> 25 - <input 26 - type="email" 27 - name="email" 28 - placeholder="your@email.com" 29 - required 30 - class="flex-1 min-w-0 text-sm" /> 31 - <button type="submit" class="btn whitespace-nowrap h-12 px-4 pb-0 gap-2"> 32 - {{ i "mail-plus" "size-4 inline" }} 33 - subscribe 34 - </button> 35 - </span> 36 - </form> 17 + {{ template "timeline/fragments/newsletterForm" (dict "Id" "widget" "Variant" "card") }} 37 18 </div> 38 - <script> 39 - if (localStorage.getItem("newsletter-dismissed")) 40 - document.getElementById("newsletter-widget").remove(); 41 - </script> 42 19 {{ end }}
+6 -20
appview/pages/templates/timeline/home.html
··· 507 507 {{ define "newsletter" }} 508 508 <div class="w-full px-2 py-16 md:py-24"> 509 509 <div class="max-w-2xl mx-auto text-center space-y-6"> 510 - <h2 class="text-3xl md:text-6xl font-bold text-gray-900 dark:text-gray-100">Stay in the loop</h2> 511 - <p class="text-xl text-gray-600 dark:text-gray-300"> 512 - We've got a newsletter! Punch in your email to stay updated on what we're shipping at Tangled. 513 - </p> 514 - <form class="flex gap-2 items-stretch w-full md:max-w-md mx-auto p-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded shadow-sm" hx-post="/newsletter/signup" hx-target="#newsletter-msg" hx-swap="outerHTML"> 515 - <span id="newsletter-msg" class="flex gap-2 items-stretch flex-1 min-w-0"> 516 - <input 517 - type="email" 518 - name="email" 519 - tabindex="4" 520 - required 521 - placeholder="Enter your email" 522 - class="py-2 w-full" 523 - /> 524 - <button class="btn-create flex items-center gap-2 text-base whitespace-nowrap" type="submit"> 525 - subscribe 526 - {{ i "arrow-right" "size-4" }} 527 - </button> 528 - </span> 529 - </form> 510 + <h2 class="text-3xl md:text-6xl font-bold text-gray-900 dark:text-gray-100">Stay in the loop</h2> 511 + <p class="text-xl text-gray-600 dark:text-gray-300"> 512 + We've got a newsletter! Punch in your email to stay updated on what we're shipping at Tangled. 513 + </p> 514 + {{ template "timeline/fragments/newsletterForm" (dict "Id" "home" "Variant" "hero") }} 515 + </div> 530 516 </div> 531 517 {{ end }}
+48 -36
appview/pages/templates/timeline/timeline.html
··· 16 16 {{ end }} 17 17 18 18 {{ define "content" }} 19 - <div class="lg:hidden"> 20 - {{ template "timeline/fragments/newsletter" . }} 21 - {{ template "timeline/fragments/goodfirstissues" . }} 22 - {{ template "timeline/fragments/trending" . }} 23 - {{ template "timeline/fragments/timeline" . }} 24 - </div> 19 + {{/* 20 + Single responsive grid. Every fragment is rendered once, with CSS alone 21 + handling the mobile → desktop reflow. Only trending has two variants 22 + (horizontal scroll for mobile, vertical sidebar for desktop); both are 23 + present in the DOM and toggled via utility classes. 24 + 25 + Desktop grid (lg+): 26 + row 1: [ gfi-banner (col-span-2) ] [ newsletter (col 3) ] 27 + row 2: [ timeline (col-span-2) ] [ trendingSidebar (col 3) ] 25 28 26 - <div class="hidden lg:grid grid-cols-3 gap-x-2 gap-y-6"> 27 - <div id="gfi-banner" class="col-span-3 max-w-4xl w-full mx-auto"> 28 - {{ template "timeline/fragments/goodfirstissues" . }} 29 - </div> 30 - <div id="newsletter-widget-col" class="pl-8 flex-col hidden"> 29 + If the newsletter is dismissed, the gfi-banner widens to col-span-3 30 + (see the small init script at the bottom of this block). 31 + */}} 32 + <div id="timeline-grid" 33 + class="flex flex-col gap-4 lg:grid lg:grid-cols-3 lg:gap-x-2 lg:gap-y-6"> 34 + 35 + <div id="newsletter-col" 36 + class="order-1 lg:order-none lg:col-start-3 lg:row-start-1 lg:pl-8"> 31 37 {{ template "timeline/fragments/newsletterWidget" . }} 32 38 </div> 33 39 34 - <div class="col-span-2 flex flex-col"> 40 + <div id="gfi-banner" 41 + class="order-2 lg:order-none lg:col-span-2 lg:row-start-1"> 42 + {{ template "timeline/fragments/goodfirstissues" . }} 43 + </div> 44 + 45 + <div class="order-4 lg:order-none lg:col-span-2 lg:row-start-2"> 35 46 {{ template "timeline/fragments/timeline" . }} 36 47 </div> 37 - <div class="flex flex-col gap-6 pl-8"> 48 + 49 + <div class="order-3 lg:hidden"> 50 + {{ template "timeline/fragments/trending" . }} 51 + </div> 52 + 53 + <div class="hidden lg:flex lg:flex-col gap-6 lg:pl-8 lg:col-start-3 lg:row-start-2"> 38 54 {{ template "timeline/fragments/trendingSidebar" . }} 39 55 </div> 40 56 </div> 41 57 42 58 <script> 43 59 (function() { 60 + var DISMISS_KEY = 'newsletter-dismissed'; 61 + var newsletterCol = document.getElementById('newsletter-col'); 44 62 var gfi = document.getElementById('gfi-banner'); 45 - var col = document.getElementById('newsletter-widget-col'); 46 - if (!gfi || !col) return; 63 + if (!newsletterCol) return; 47 64 48 - function showNewsletter() { 49 - col.classList.remove('hidden'); 50 - col.classList.add('flex'); 51 - gfi.classList.remove('col-span-3', 'max-w-4xl', 'mx-auto'); 52 - gfi.classList.add('col-span-2'); 53 - } 54 - 55 - function hideNewsletter() { 56 - col.classList.add('hidden'); 57 - col.classList.remove('flex'); 58 - gfi.classList.remove('col-span-2'); 59 - gfi.classList.add('col-span-3', 'max-w-4xl', 'mx-auto'); 65 + function widenGfi() { 66 + if (!gfi) return; 67 + gfi.classList.remove('lg:col-span-2'); 68 + gfi.classList.add('lg:col-span-3', 'lg:max-w-4xl', 'lg:mx-auto'); 60 69 } 61 70 62 - // If the newsletter widget is still in the DOM, switch to two-column 63 - if (document.getElementById('newsletter-widget')) { 64 - showNewsletter(); 71 + function dismiss() { 72 + newsletterCol.remove(); 73 + widenGfi(); 74 + try { localStorage.setItem(DISMISS_KEY, '1'); } catch (e) {} 65 75 } 66 76 67 - // Watch for mid-session dismissal 68 - var obs = new MutationObserver(function() { 69 - if (!document.getElementById('newsletter-widget')) { 70 - hideNewsletter(); 71 - obs.disconnect(); 77 + try { 78 + if (localStorage.getItem(DISMISS_KEY) === '1') { 79 + dismiss(); 80 + return; 72 81 } 82 + } catch (e) { /* storage disabled; keep widget visible */ } 83 + 84 + newsletterCol.addEventListener('click', function(e) { 85 + if (e.target.closest('[data-newsletter-dismiss]')) dismiss(); 73 86 }); 74 - obs.observe(col, { childList: true, subtree: true }); 75 87 })(); 76 88 </script> 77 89 {{ end }}
+15 -4
appview/state/state.go
··· 328 328 } 329 329 330 330 func (s *State) NewsletterSignup(w http.ResponseWriter, r *http.Request) { 331 + // target is echoed back from the form via hx-vals so the response span's 332 + // id matches the form's hx-target. Fallback keeps the handler useful if 333 + // a caller forgets to send it. 334 + target := strings.TrimSpace(r.FormValue("target")) 335 + if target == "" { 336 + target = "home" 337 + } 338 + 339 + w.Header().Set("Content-Type", "text/html") 340 + 331 341 emailAddr := strings.TrimSpace(r.FormValue("email")) 332 342 if !email.IsValidEmail(emailAddr) { 333 - w.Header().Set("Content-Type", "text/html") 334 - fmt.Fprintf(w, `<span id="newsletter-msg" class="text-red-500 text-sm whitespace-nowrap">Invalid email address.</span>`) 343 + s.pages.NewsletterResponse(w, pages.NewsletterResponseParams{ 344 + Id: target, 345 + Error: "Invalid email address.", 346 + }) 335 347 return 336 348 } 337 349 ··· 343 355 }() 344 356 } 345 357 346 - w.Header().Set("Content-Type", "text/html") 347 - fmt.Fprintf(w, `<span id="newsletter-msg" class="text-sm text-green-700 dark:text-green-400 whitespace-nowrap">You&#39;re signed up!</span>`) 358 + s.pages.NewsletterResponse(w, pages.NewsletterResponseParams{Id: target}) 348 359 } 349 360 350 361 func (s *State) Keys(w http.ResponseWriter, r *http.Request) {