0f09fdd413ace3721fca7d8c92c7fec57ba83f88

Author
Pavle <29902467+TheEdgeOfRage@users.noreply.github.com>
Committer
GitHub <noreply@github.com>
Date

Message

Replace HTMX with Datastar (#8)

* Add datastar-go dependency and replace htmx with datastar

* Convert templ templates from htmx to datastar attributes

* Convert handlers to SSE responses and update tests for datastar

* Fix progress form: use evt.target and declare signal for data-on expression

* Fix service worker intercepting non-GET HTTP requests

* Fix datastar JS/SDK version mismatch and update attribute syntax

Replace beta.11 JS bundle (npm) with v1.0.0-RC.8 (GitHub CDN).
The beta used different SSE event types (datastar-merge-fragments)
than the Go SDK v1.1.0 (datastar-patch-elements), causing all SSE
events to be silently ignored by the client.

Also update attribute key delimiter from hyphen to colon per RC.6
breaking change: data-on-click -> data-on:click.

* Format js

* Update AGENTS.md

* Add smooth transition animations

Diff

This diff is truncated to protect this page.

   1diff --git a/AGENTS.md b/AGENTS.md
   2index 6b4c4e9fedb38104a6d307aa8a086831e5d6542e..b024fb202974a0ca0898165a20f284cedb780fc7 100644
   3--- a/AGENTS.md
   4+++ b/AGENTS.md
   5@@ -7,11 +7,11 @@
   6 
   7 **Ytrssil** is a YouTube RSS feed aggregator with video download capabilities built with Go, Gin, PostgreSQL, and Templ for server-side rendering.
   8 
   9-**Tech Stack**: Go 1.25, Gin (HTTP), Templ (SSR), PostgreSQL (pgx), yt-dlp (downloads), HTMX (frontend interactivity), Bootstrap 5 (UI)
  10+**Tech Stack**: Go 1.25, Gin (HTTP), Templ (SSR), PostgreSQL (pgx), yt-dlp (downloads), Datastar (frontend interactivity via SSE), Bootstrap 5 (UI)
  11 
  12 **Layers**:
  13 
  14-- **HTTP**: Gin router with dual interfaces (HTML pages + JSON API), authentication middleware, HTMX-enabled templates
  15+- **HTTP**: Gin router with dual interfaces (HTML pages + JSON API), authentication middleware, Datastar SSE-driven templates
  16 - **Handler**: Business logic layer orchestrating channels, videos, downloads, and cleanup routines
  17 - **Database**: PostgreSQL with pgx connection pooling, 7 migrations tracking schema evolution
  18 - **External**: YouTube API client, RSS feed parser, yt-dlp downloader wrapper
  19diff --git a/assets/index.js b/assets/index.js
  20index 14af5720636f5544486bad8b3be5dc7f384c2270..8f05be9338901a0a79940cce406fa2f36946d3bd 100644
  21--- a/assets/index.js
  22+++ b/assets/index.js
  23@@ -1,8 +1,8 @@
  24-document.addEventListener("DOMContentLoaded", function() {
  25+document.addEventListener("DOMContentLoaded", function () {
  26 	const addVideoModal = document.getElementById("add-video-modal");
  27 	if (addVideoModal) {
  28 		addVideoModal.addEventListener("shown.bs.modal", function () {
  29-			const input = addVideoModal.querySelector("input[name='video_id']");
  30+			const input = addVideoModal.querySelector("input");
  31 			if (input) input.focus();
  32 		});
  33 	}
  34@@ -46,64 +46,78 @@ document.addEventListener("DOMContentLoaded", function() {
  35 	}
  36 });
  37 
  38-var currentVideoID = '';
  39+var currentVideoID = "";
  40 
  41 function openResolutionModal(button) {
  42 	currentVideoID = button.dataset.videoId;
  43-	document.getElementById('resolution-modal-title').textContent = button.dataset.videoTitle;
  44-	new bootstrap.Modal(document.getElementById('resolution-modal')).show();
  45+	document.getElementById("resolution-modal-title").textContent =
  46+		button.dataset.videoTitle;
  47+	new bootstrap.Modal(document.getElementById("resolution-modal")).show();
  48 }
  49 
  50-document.addEventListener('click', function(e) {
  51-	const btn = e.target.closest('.resolution-btn');
  52+document.addEventListener("click", function (e) {
  53+	const btn = e.target.closest(".resolution-btn");
  54 	if (!btn || !currentVideoID) return;
  55 	const formData = new FormData();
  56-	formData.append('format', btn.dataset.height);
  57-	fetch('/videos/' + currentVideoID + '/download', {
  58-		method: 'POST',
  59+	formData.append("format", btn.dataset.height);
  60+	fetch("/videos/" + currentVideoID + "/download", {
  61+		method: "POST",
  62 		body: formData,
  63-	}).then(function(resp) {
  64+	}).then(function (resp) {
  65 		if (resp.ok) {
  66-			bootstrap.Modal.getInstance(document.getElementById('resolution-modal')).hide();
  67+			bootstrap.Modal.getInstance(
  68+				document.getElementById("resolution-modal"),
  69+			).hide();
  70 			location.reload();
  71 		}
  72 	});
  73 });
  74 
  75-function addVideoHandler(event) {
  76-	if (event.detail.successful) {
  77-		bootstrap.Modal.getInstance(
  78-			document.getElementById("add-video-modal"),
  79-		).hide();
  80-	} else {
  81-		const field = event.detail.elt.querySelector(`[name="video_id"]`);
  82-		field.setCustomValidity(event.detail.xhr.responseText);
  83-		field.onfocus = () => field.reportValidity();
  84-		field.onchange = () => field.setCustomValidity("");
  85-		field.reportValidity();
  86-	}
  87+function animateRemove(selector) {
  88+	const el = document.querySelector(selector);
  89+	if (!el) return;
  90+	const siblings = [...el.parentElement.children].filter(c => c !== el);
  91+	const oldRects = siblings.map(s => s.getBoundingClientRect());
  92+
  93+	el.style.transition = "opacity 0.15s ease-out";
  94+	el.style.opacity = "0";
  95+	setTimeout(() => {
  96+		el.remove();
  97+		siblings.forEach((s, i) => {
  98+			const newRect = s.getBoundingClientRect();
  99+			const dx = oldRects[i].left - newRect.left;
 100+			const dy = oldRects[i].top - newRect.top;
 101+			if (!dx && !dy) return;
 102+			s.style.transition = "none";
 103+			s.style.transform = `translate(${dx}px, ${dy}px)`;
 104+			s.offsetHeight; // force reflow
 105+			s.style.transition = "transform 0.2s ease-out";
 106+			s.style.transform = "";
 107+		});
 108+	}, 150);
 109 }
 110 
 111-function subscribeHandler(event) {
 112-	if (event.detail.successful) {
 113-		bootstrap.Modal.getInstance(
 114-			document.getElementById("subscription-modal"),
 115-		).hide();
 116-	} else {
 117-		const field = event.detail.elt.querySelector(`[name="channel_id"]`);
 118-		field.setCustomValidity(event.detail.xhr.responseText);
 119-		field.onfocus = field.reportValidity;
 120-		field.onchange = () => field.setCustomValidity("");
 121-		field.reportValidity();
 122-	}
 123diff --git a/assets/load.sh b/assets/load.sh
 124index b37970dd461f69b685424318197421d440ed4cdc..62f2a9dc5da1f4fadf0bcbd881172743bdaccc6e 100755
 125--- a/assets/load.sh
 126+++ b/assets/load.sh
 127@@ -7,4 +7,4 @@ curl -o "assets/vendor/bootstrap.bundle.min.js" https://cdn.jsdelivr.net/npm/boo
 128 curl -o "assets/vendor/bootstrap-icons.min.css" https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css
 129 curl -o "assets/vendor/fonts/bootstrap-icons.woff2" "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a="
 130 curl -o "assets/vendor/fuse.js" https://cdn.jsdelivr.net/npm/fuse.js@7.0.0
 131-curl -o "assets/vendor/htmx.min.js" https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js
 132+curl -o "assets/vendor/datastar.js" https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js
 133diff --git a/assets/sw.js b/assets/sw.js
 134index 5ba34581f849b5d13ac331df61c347e25c2b1593..004271e27a60a82f13cf227131b76fdb7e40aa00 100644
 135--- a/assets/sw.js
 136+++ b/assets/sw.js
 137@@ -1,26 +1,26 @@
 138-const CACHE_NAME = 'ytrssil-v2';
 139+const CACHE_NAME = "ytrssil-v3";
 140 const ASSETS_TO_CACHE = [
 141-	'/',
 142-	'/assets/vendor/bootstrap.min.css',
 143-	'/assets/vendor/bootstrap-icons.min.css',
 144-	'/assets/vendor/bootstrap.bundle.min.js',
 145-	'/assets/vendor/htmx.min.js',
 146-	'/assets/vendor/fuse.js',
 147-	'/assets/index.js',
 148-	'/assets/ytrssil.png',
 149-	'/assets/manifest.json'
 150+	"/",
 151+	"/assets/vendor/bootstrap.min.css",
 152+	"/assets/vendor/bootstrap-icons.min.css",
 153+	"/assets/vendor/bootstrap.bundle.min.js",
 154+	"/assets/vendor/datastar.js",
 155+	"/assets/vendor/fuse.js",
 156+	"/assets/index.js",
 157+	"/assets/ytrssil.png",
 158+	"/assets/manifest.json",
 159 ];
 160 
 161-self.addEventListener('install', (event) => {
 162+self.addEventListener("install", (event) => {
 163 	event.waitUntil(
 164 		caches.open(CACHE_NAME).then((cache) => {
 165 			return cache.addAll(ASSETS_TO_CACHE);
 166-		})
 167+		}),
 168 	);
 169 	self.skipWaiting();
 170 });
 171 
 172-self.addEventListener('activate', (event) => {
 173+self.addEventListener("activate", (event) => {
 174 	event.waitUntil(
 175 		caches.keys().then((cacheNames) => {
 176 			return Promise.all(
 177@@ -28,14 +28,16 @@ self.addEventListener('activate', (event) => {
 178 					if (cacheName !== CACHE_NAME) {
 179 						return caches.delete(cacheName);
 180 					}
 181-				})
 182+				}),
 183 			);
 184-		})
 185+		}),
 186 	);
 187 	self.clients.claim();
 188 });
 189 
 190-self.addEventListener('fetch', (event) => {
 191+self.addEventListener("fetch", (event) => {
 192+	if (event.request.method !== "GET") return;
 193+
 194 	const url = new URL(event.request.url);
 195 	if (url.pathname.match(/^\/videos\/[^/]+\/file$/)) {
 196 		return;
 197@@ -44,6 +46,6 @@ self.addEventListener('fetch', (event) => {
 198 	event.respondWith(
 199 		caches.match(event.request).then((response) => {
 200 			return response || fetch(event.request);
 201-		})
 202+		}),
 203 	);
 204 });
 205diff --git a/go.mod b/go.mod
 206index c31dd57bdce235b2e3cfce9c752a744fd5c3cff7..92e6b390416b0644de5ceda90c2fcc774f445448 100644
 207--- a/go.mod
 208+++ b/go.mod
 209@@ -10,10 +10,13 @@ require (
 210 	github.com/jessevdk/go-flags v1.6.1
 211 	github.com/oklog/ulid/v2 v2.1.1
 212 	github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c
 213+	github.com/starfederation/datastar-go v1.1.0
 214 	github.com/stretchr/testify v1.11.1
 215 )
 216 
 217 require (
 218+	github.com/CAFxX/httpcompression v0.0.9 // indirect
 219+	github.com/andybalholm/brotli v1.2.0 // indirect
 220 	github.com/bytedance/sonic v1.14.0 // indirect
 221 	github.com/bytedance/sonic/loader v0.3.0 // indirect
 222 	github.com/cloudwego/base64x v0.1.6 // indirect
 223@@ -29,6 +32,7 @@ require (
 224 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
 225 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 226 	github.com/json-iterator/go v1.1.12 // indirect
 227+	github.com/klauspost/compress v1.18.0 // indirect
 228 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 229 	github.com/kr/text v0.2.0 // indirect
 230 	github.com/leodido/go-urn v1.4.0 // indirect
 231@@ -42,6 +46,7 @@ require (
 232 	github.com/rogpeppe/go-internal v1.14.1 // indirect
 233 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 234 	github.com/ugorji/go/codec v1.3.0 // indirect
 235+	github.com/valyala/bytebufferpool v1.0.0 // indirect
 236 	go.uber.org/mock v0.5.0 // indirect
 237 	golang.org/x/arch v0.20.0 // indirect
 238 	golang.org/x/crypto v0.41.0 // indirect
 239diff --git a/go.sum b/go.sum
 240index 963daa9a46793316c2d32d7a6fb090a5d31f0e50..59babae028cf07a2f65a296b57686a29b41559f9 100644
 241--- a/go.sum
 242+++ b/go.sum
 243@@ -1,5 +1,10 @@
 244+github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
 245+github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
 246 github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
 247 github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
 248+github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 249+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
 250+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
 251 github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
 252 github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
 253 github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
 254@@ -30,6 +35,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 255 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 256 github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
 257 github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 258+github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
 259+github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
 260 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 261 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 262 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 263@@ -45,8 +52,12 @@ github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bB
 264 github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
 265 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 266 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 267+github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 268+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 269+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 270 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 271 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 272+github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
 273 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
 274 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
 275 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 276@@ -67,6 +78,8 @@ github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:Y
 277 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
 278 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
 279 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 280+github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
 281+github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 282 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 283 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 284 github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 285@@ -75,6 +88,8 @@ github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQB
 286 github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
 287 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 288 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 289+github.com/starfederation/datastar-go v1.1.0 h1:UVOYpbNfKPfrEq3MBOa1FRPO/YsxxcIduUxUTJiEQbQ=
 290+github.com/starfederation/datastar-go v1.1.0/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
 291 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 292 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 293 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 294@@ -83,12 +98,20 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 295 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 296 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 297 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 298+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 299 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 300 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 301 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 302 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 303 github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
 304 github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 305+github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 306+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 307+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 308+github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
 309+github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
 310+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 311+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 312 go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
 313 go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
 314 golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
 315diff --git a/httpserver/ytrssil/channels_pages.go b/httpserver/ytrssil/channels_pages.go
 316index 93360f574c6f3b05935759207cd015f9fbf03c6d..3dc051dbf37d8476432365ce756e5b49e3349b76 100644
 317--- a/httpserver/ytrssil/channels_pages.go
 318+++ b/httpserver/ytrssil/channels_pages.go
 319@@ -2,12 +2,13 @@ package ytrssil
 320 
 321 import (
 322 	"errors"
 323+	"fmt"
 324 	"net/http"
 325 
 326 	"github.com/gin-gonic/gin"
 327+	datastar "github.com/starfederation/datastar-go/datastar"
 328 
 329 	"github.com/TheEdgeOfRage/ytrssil-api/db"
 330-	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 331 	"github.com/TheEdgeOfRage/ytrssil-api/pages"
 332 )
 333 
 334@@ -26,30 +27,32 @@ func (srv *server) ChannelsPage(c *gin.Context) {
 335 }
 336 
 337 func (srv *server) SubscribeToChannelPage(c *gin.Context) {
 338-	channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), c.PostForm("channel_id"))
 339-	if err != nil {
 340-		if errors.Is(err, db.ErrAlreadySubscribed) {
 341-			returnErr(c, http.StatusConflict, err)
 342-			return
 343-		}
 344-		if errors.Is(err, feedparser.ErrInvalidChannelID) {
 345-			returnErr(c, http.StatusBadRequest, err)
 346-			return
 347-		}
 348-
 349-		returnErr(c, http.StatusInternalServerError, err)
 350+	var signals struct {
 351+		ChannelID string `json:"channelID"`
 352+	}
 353+	if err := datastar.ReadSignals(c.Request, &signals); err != nil {
 354+		sse := newSSE(c)
 355+		sse.ExecuteScript(fmt.Sprintf(`showFormError("subscription-modal", %q)`, err.Error()))
 356 		return
 357 	}
 358 
 359-	r := pages.TemplRenderer{
 360-		Ctx:       c.Request.Context(),
 361-		Component: pages.ChannelCard(*channel),
 362+	_, err := srv.handler.SubscribeToChannel(c.Request.Context(), signals.ChannelID)
 363+	if err != nil {
 364+		sse := newSSE(c)
 365+		sse.ExecuteScript(fmt.Sprintf(`showFormError("subscription-modal", %q)`, err.Error()))
 366+		return
 367 	}
 368-	c.Render(http.StatusOK, r)
 369+
 370+	sse := newSSE(c)
 371+	sse.ExecuteScript(`
 372+		bootstrap.Modal.getInstance(document.getElementById("subscription-modal")).hide();
 373+		location.reload()
 374+	`)
 375 }
 376 
 377 func (srv *server) UnsubscribeFromChannelPage(c *gin.Context) {
 378-	err := srv.handler.UnsubscribeFromChannel(c.Request.Context(), c.Param("channel_id"))
 379+	channelID := c.Param("channel_id")
 380+	err := srv.handler.UnsubscribeFromChannel(c.Request.Context(), channelID)
 381 	if err != nil {
 382 		if errors.Is(err, db.ErrChannelNotFound) {
 383 			returnErr(c, http.StatusNotFound, err)
 384@@ -60,14 +63,21 @@ func (srv *server) UnsubscribeFromChannelPage(c *gin.Context) {
 385 		return
 386 	}
 387 
 388-	returnMsg(c, http.StatusOK, "")
 389+	sse := newSSE(c)
 390+	sse.ExecuteScript(fmt.Sprintf(`animateRemove("#channel-card-%s")`, channelID))
 391 }
 392 
 393 func (srv *server) ToggleChannelShortsPage(c *gin.Context) {
 394-	channelID := c.Param("channel_id")
 395-	enable := c.PostForm("enable") == "true"
 396+	var signals struct {
 397+		Enable bool `json:"enable"`
 398+	}
 399+	if err := datastar.ReadSignals(c.Request, &signals); err != nil {
 400+		returnErr(c, http.StatusBadRequest, err)
 401+		return
 402+	}
 403 
 404-	err := srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, enable)
 405+	channelID := c.Param("channel_id")
 406+	err := srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, signals.Enable)
 407 	if err != nil {
 408 		if errors.Is(err, db.ErrChannelNotFound) {
 409 			returnErr(c, http.StatusNotFound, err)
 410@@ -84,9 +94,6 @@ func (srv *server) ToggleChannelShortsPage(c *gin.Context) {
 411 		return
 412 	}
 413 
 414-	r := pages.TemplRenderer{
 415-		Ctx:       c.Request.Context(),
 416-		Component: pages.ChannelCard(*channel),
 417-	}
 418-	c.Render(http.StatusOK, r)
 419diff --git a/httpserver/ytrssil/channels_test.go b/httpserver/ytrssil/channels_test.go
 420index 3659255a1e69ac824d2cbc27b44343f15a4dda10..fd2e37f61c624d4471cc6bd4af3050eafc0ff157 100644
 421--- a/httpserver/ytrssil/channels_test.go
 422+++ b/httpserver/ytrssil/channels_test.go
 423@@ -6,7 +6,6 @@ import (
 424 	"fmt"
 425 	"net/http"
 426 	"net/http/httptest"
 427-	"net/url"
 428 	"strings"
 429 	"testing"
 430 
 431@@ -86,17 +85,14 @@ func (s *ChannelsTestSuite) TestChannelsPage() {
 432 }
 433 
 434 func (s *ChannelsTestSuite) TestSubscribeToChannelPage() {
 435-	form := url.Values{}
 436-	form.Add("channel_id", "channel-707")
 437-
 438 	w := httptest.NewRecorder()
 439-	req, _ := http.NewRequest("POST", "/subscribe", strings.NewReader(form.Encode()))
 440-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 441+	req, _ := http.NewRequest("POST", "/subscribe", strings.NewReader(`{"channelID":"channel-707"}`))
 442+	req.Header.Set("Content-Type", "application/json")
 443 	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
 444 	s.server.Handler.ServeHTTP(w, req)
 445 
 446 	s.Equal(http.StatusOK, w.Code)
 447-	s.Contains(w.Body.String(), "channel-707")
 448+	s.Contains(w.Header().Get("Content-Type"), "text/event-stream")
 449 }
 450 
 451 func (s *ChannelsTestSuite) TestUnsubscribeFromChannelPage() {
 452diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
 453index b10bca04f900a1dee3dccdc7bd1e1ff8981ef7cb..8c4b08395b55401db3f5a4ef136a953b6ea9ab67 100644
 454--- a/httpserver/ytrssil/server.go
 455+++ b/httpserver/ytrssil/server.go
 456@@ -64,7 +64,6 @@ func SetupGinRouter(
 457 		return nil, err
 458 	}
 459 	engine.GET("/healthz", srv.Healthz)
 460-	engine.POST("/fetch", srv.FetchVideosJSON)
 461 	engine.Static("/assets", "./assets")
 462 
 463 	engine.GET("/auth", srv.AuthPage)
 464@@ -73,6 +72,7 @@ func SetupGinRouter(
 465 	pages := engine.Group("")
 466 	pages.Use(auth.PageAuthMiddleware(cfg.AuthToken))
 467 	{
 468+		pages.POST("/fetch", srv.FetchVideosPage)
 469 		pages.GET("/", srv.NewVideosPage)
 470 		pages.GET("/watched", srv.WatchedVideosPage)
 471 		pages.GET("/channels", srv.ChannelsPage)
 472@@ -91,6 +91,7 @@ func SetupGinRouter(
 473 	api := engine.Group("/api")
 474 	api.Use(auth.APIAuthMiddleware(cfg.AuthToken))
 475 	{
 476+		api.POST("/fetch", srv.FetchVideosJSON)
 477 		api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
 478 		api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
 479 		api.GET("videos/new", srv.GetNewVideosJSON)
 480diff --git a/httpserver/ytrssil/sse_helper.go b/httpserver/ytrssil/sse_helper.go
 481new file mode 100644
 482index 0000000000000000000000000000000000000000..0f6fd5bdfc46a7cd469d86e96685c379a03f1ada
 483--- /dev/null
 484+++ b/httpserver/ytrssil/sse_helper.go
 485@@ -0,0 +1,10 @@
 486+package ytrssil
 487+
 488+import (
 489+	"github.com/gin-gonic/gin"
 490+	datastar "github.com/starfederation/datastar-go/datastar"
 491+)
 492+
 493+func newSSE(c *gin.Context) *datastar.ServerSentEventGenerator {
 494+	return datastar.NewSSE(c.Writer, c.Request)
 495+}
 496diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
 497index 4926aa32540247bbc9fef93ef310480f782afe82..16086604c0cfd21f6d542b0d81e82cd550e3d37c 100644
 498--- a/httpserver/ytrssil/videos_pages.go
 499+++ b/httpserver/ytrssil/videos_pages.go
 500@@ -6,6 +6,7 @@ import (
 501 	"strconv"
 502 
 503 	"github.com/gin-gonic/gin"
 504+	datastar "github.com/starfederation/datastar-go/datastar"
 505 
 506 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 507 	"github.com/TheEdgeOfRage/ytrssil-api/pages"
 508@@ -44,59 +45,88 @@ func (srv server) WatchedVideosPage(c *gin.Context) {
 509 	})
 510 }
 511 
 512+func (srv server) FetchVideosPage(c *gin.Context) {
 513+	err := srv.handler.FetchVideos(c.Request.Context())
 514+	if err != nil {
 515+		returnErr(c, http.StatusInternalServerError, err)
 516+		return
 517+	}
 518+
 519+	sse := newSSE(c)
 520+	sse.ExecuteScript(`window.location.reload()`)
 521+}
 522+
 523 func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
 524-	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), c.Param("video_id"))
 525+	videoID := c.Param("video_id")
 526+	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), videoID)
 527 	if err != nil {
 528 		returnErr(c, http.StatusInternalServerError, err)
 529 		return
 530 	}
 531 
 532-	returnMsg(c, http.StatusOK, "")
 533+	sse := newSSE(c)
 534+	sse.ExecuteScript(fmt.Sprintf(`animateRemove("#video-card-%s")`, videoID))
 535 }
 536 
 537 func (srv server) MarkVideoAsUnwatchedPage(c *gin.Context) {
 538-	err := srv.handler.MarkVideoAsUnwatched(c.Request.Context(), c.Param("video_id"))
 539+	videoID := c.Param("video_id")
 540+	err := srv.handler.MarkVideoAsUnwatched(c.Request.Context(), videoID)
 541 	if err != nil {
 542 		returnErr(c, http.StatusInternalServerError, err)
 543 		return
 544 	}
 545 
 546-	returnMsg(c, http.StatusOK, "")
 547+	sse := newSSE(c)
 548+	sse.ExecuteScript(fmt.Sprintf(`animateRemove("#video-card-%s")`, videoID))
 549 }
 550 
 551 func (srv server) SetVideoProgressPage(c *gin.Context) {
 552-	progress := c.PostForm("progress")
 553-	if progress == "" {
 554+	var signals struct {
 555+		Progress string `json:"progress"`
 556+	}
 557+	if err := datastar.ReadSignals(c.Request, &signals); err != nil {
 558+		returnErr(c, http.StatusBadRequest, err)
 559+		return
 560+	}
 561+	if signals.Progress == "" {
 562 		returnMsg(c, http.StatusBadRequest, "missing progress")
 563 		return
 564 	}
 565 
 566-	video, err := srv.handler.SetVideoProgress(c.Request.Context(), c.Param("video_id"), progress)
 567+	video, err := srv.handler.SetVideoProgress(c.Request.Context(), c.Param("video_id"), signals.Progress)
 568 	if err != nil {
 569 		returnErr(c, http.StatusInternalServerError, err)
 570 		return
 571 	}
 572 
 573-	c.Render(http.StatusOK, pages.TemplRenderer{
 574-		Ctx:       c.Request.Context(),
 575-		Component: pages.ProgressBar(*video),
 576-	})
 577+	sse := newSSE(c)
 578+	sse.PatchElementTempl(pages.ProgressBar(*video))
 579 }
 580 
 581 func (srv server) AddVideoPage(c *gin.Context) {
 582-	videoID := c.PostForm("video_id")
 583-	if videoID == "" {
 584-		returnMsg(c, http.StatusBadRequest, "missing video ID")
 585+	var signals struct {
 586+		VideoID string `json:"videoID"`
 587+	}
 588+	if err := datastar.ReadSignals(c.Request, &signals); err != nil {
 589+		sse := newSSE(c)
 590+		sse.ExecuteScript(fmt.Sprintf(`showFormError("add-video-modal", %q)`, err.Error()))
 591+		return
 592+	}
 593+	if signals.VideoID == "" {
 594+		sse := newSSE(c)
 595+		sse.ExecuteScript(`showFormError("add-video-modal", "missing video ID")`)
 596 		return
 597 	}
 598 
 599-	err := srv.handler.AddCustomVideo(c.Request.Context(), videoID)
 600diff --git a/httpserver/ytrssil/videos_test.go b/httpserver/ytrssil/videos_test.go
 601index 55b946296c08f67b811823180822fde575cdd6ae..643952c3aa34e3f48d2f3442b58b9f1f2ae33762 100644
 602--- a/httpserver/ytrssil/videos_test.go
 603+++ b/httpserver/ytrssil/videos_test.go
 604@@ -5,7 +5,6 @@ import (
 605 	"encoding/json"
 606 	"net/http"
 607 	"net/http/httptest"
 608-	"net/url"
 609 	"strings"
 610 	"testing"
 611 	"time"
 612@@ -232,16 +231,14 @@ func (s *VideosTestSuite) TestWatchedVideosPage() {
 613 }
 614 
 615 func (s *VideosTestSuite) TestAddVideoPage() {
 616-	form := url.Values{}
 617-	form.Add("video_id", "custom-video-123")
 618-
 619 	w := httptest.NewRecorder()
 620-	req, _ := http.NewRequest("POST", "/videos", strings.NewReader(form.Encode()))
 621-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 622+	req, _ := http.NewRequest("POST", "/videos", strings.NewReader(`{"videoID":"custom-video-123"}`))
 623+	req.Header.Set("Content-Type", "application/json")
 624 	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
 625 	s.server.Handler.ServeHTTP(w, req)
 626 
 627-	s.Equal(http.StatusAccepted, w.Code)
 628+	s.Equal(http.StatusOK, w.Code)
 629+	s.Contains(w.Header().Get("Content-Type"), "text/event-stream")
 630 }
 631 
 632 func (s *VideosTestSuite) TestMarkVideoAsWatchedPage() {
 633@@ -324,16 +321,14 @@ func (s *VideosTestSuite) TestSetVideoProgressPage() {
 634 	}, channelID, false)
 635 	s.Require().NoError(err)
 636 
 637-	form := url.Values{}
 638-	form.Add("progress", "2:30")
 639-
 640 	w := httptest.NewRecorder()
 641-	req, _ := http.NewRequest("PATCH", "/videos/video606/progress", strings.NewReader(form.Encode()))
 642-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 643+	req, _ := http.NewRequest("PATCH", "/videos/video606/progress", strings.NewReader(`{"progress":"2:30"}`))
 644+	req.Header.Set("Content-Type", "application/json")
 645 	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
 646 	s.server.Handler.ServeHTTP(w, req)
 647 
 648 	s.Equal(http.StatusOK, w.Code)
 649+	s.Contains(w.Header().Get("Content-Type"), "text/event-stream")
 650 }
 651 
 652 func (s *VideosTestSuite) TestFetchVideosJSON() {
 653@@ -347,7 +342,7 @@ func (s *VideosTestSuite) TestFetchVideosJSON() {
 654 	s.Require().NoError(err)
 655 
 656 	w := httptest.NewRecorder()
 657-	req, _ := http.NewRequest("POST", "/fetch", nil)
 658+	req, _ := http.NewRequest("POST", "/api/fetch", nil)
 659 	req.Header.Set("Authorization", s.cfg.AuthToken)
 660 	s.server.Handler.ServeHTTP(w, req)
 661 
 662@@ -360,12 +355,14 @@ func (s *VideosTestSuite) TestFetchVideosJSON() {
 663 	s.Equal("videos fetched successfully", response["msg"])
 664 }
 665 
 666-func (s *VideosTestSuite) TestFetchEndpointNoAuthRequired() {
 667+func (s *VideosTestSuite) TestFetchVideosPage() {
 668 	w := httptest.NewRecorder()
 669 	req, _ := http.NewRequest("POST", "/fetch", nil)
 670+	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
 671 	s.server.Handler.ServeHTTP(w, req)
 672 
 673 	s.Equal(http.StatusOK, w.Code)
 674+	s.Contains(w.Header().Get("Content-Type"), "text/event-stream")
 675 }
 676 
 677 func (s *VideosTestSuite) TestVideosRequireAuth() {
 678diff --git a/pages/add_video_modal.templ b/pages/add_video_modal.templ
 679index 50af9365cb9286e47ee4775fe38b514fe60a01e7..fe066ce9b3ea9449c2530dedb933de5c756e0a1d 100644
 680--- a/pages/add_video_modal.templ
 681+++ b/pages/add_video_modal.templ
 682@@ -13,16 +13,13 @@ templ addVideoModal() {
 683 					<form
 684 						id="add-video-form"
 685 						class="d-flex"
 686-						hx-post="/videos"
 687-						hx-swap="none"
 688-						hx-on::after-request="addVideoHandler(event)"
 689+						data-on:submit__prevent="$videoID = el.querySelector('input').value; @post('/videos')"
 690 					>
 691 						<input
 692 							type="text"
 693-							name="video_id"
 694 							placeholder="Video ID"
 695 							class="form-control"
 696-						autocomplete="off"
 697+							autocomplete="off"
 698 						/>
 699 						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-camera-video"></i></button>
 700 					</form>
 701diff --git a/pages/add_video_modal_templ.go b/pages/add_video_modal_templ.go
 702index 0195ddf46b74cbf91f03f36010f80766b63000d0..12cc8a0f10dce1bb4f9282d1b2d5332ea5b54b00 100644
 703--- a/pages/add_video_modal_templ.go
 704+++ b/pages/add_video_modal_templ.go
 705@@ -30,7 +30,7 @@ func addVideoModal() templ.Component {
 706 			templ_7745c5c3_Var1 = templ.NopComponent
 707 		}
 708 		ctx = templ.ClearChildren(ctx)
 709diff --git a/pages/channels.templ b/pages/channels.templ
 710index 0b22722f3af7acd02098acbadbbb6940a6a53a9f..b1e47ec8efae72a02ce22efe5b0216f45e63f034 100644
 711--- a/pages/channels.templ
 712+++ b/pages/channels.templ
 713@@ -23,9 +23,7 @@ templ unsubscribeModal(channelID string) {
 714 						type="button"
 715 						class="btn btn-danger"
 716 						data-bs-dismiss="modal"
 717-						hx-post={ fmt.Sprintf("/channels/%s/unsubscribe", channelID) }
 718-						hx-target={ fmt.Sprintf("#channel-card-%s", channelID) }
 719-						hx-swap="outerHTML"
 720+						data-on:click={ fmt.Sprintf("@post('/channels/%s/unsubscribe')", channelID) }
 721 					>
 722 						Unsubscribe
 723 					</button>
 724@@ -36,13 +34,10 @@ templ unsubscribeModal(channelID string) {
 725 }
 726 
 727 templ shortsToggle(channel models.Channel) {
 728-	<a
 729-		href={ fmt.Sprintf("/channels/%s/toggle-shorts?enable=%t", channel.ID, !channel.EnableShorts) }
 730+	<button
 731+		type="button"
 732 		class={ fmt.Sprintf("btn btn-sm %s", ifShortsEnabled(channel.EnableShorts)) }
 733-		hx-post={ fmt.Sprintf("/channels/%s/toggle-shorts", channel.ID) }
 734-		hx-vals={ fmt.Sprintf(`{"enable": %t}`, !channel.EnableShorts) }
 735-		hx-target={ fmt.Sprintf("#channel-card-%s", channel.ID) }
 736-		hx-swap="outerHTML"
 737+		data-on:click={ fmt.Sprintf("$enable = %t; @post('/channels/%s/toggle-shorts')", !channel.EnableShorts, channel.ID) }
 738 		title={ toggleLabel(channel.EnableShorts) }
 739 	>
 740 		<svg
 741@@ -53,7 +48,7 @@ templ shortsToggle(channel models.Channel) {
 742 			focusable="false"
 743 			fill="currentColor"
 744 		><path d="m13.974 2.052-8 4.7a4 4 0 00.385 7.097l.942.423-1.327.78a4 4 0 004.052 6.897l8-4.7a4.001 4.001 0 00-.384-7.096L16.7 9.73l1.326-.78a4 4 0 10-4.052-6.897ZM10 15V9l5 3-5 3Z"></path></svg>
 745-	</a>
 746+	</button>
 747 }
 748 
 749 func ifShortsEnabled(enabled bool) string {
 750diff --git a/pages/channels_templ.go b/pages/channels_templ.go
 751index e70c2dcb433001e9a960c0d018f27568c7a85fe0..669da7c83ff798f20f9b8121b3396c3b103e46af 100644
 752--- a/pages/channels_templ.go
 753+++ b/pages/channels_templ.go
 754@@ -48,33 +48,20 @@ func unsubscribeModal(channelID string) templ.Component {
 755 		if templ_7745c5c3_Err != nil {
 756 			return templ_7745c5c3_Err
 757 		}
 758diff --git a/pages/main.templ b/pages/main.templ
 759index 2d676d1aed1c08ae2266073438c917d7bf7c3e89..50f47b90b14bff3bbf2bd2114c5738530bcb3eaa 100644
 760--- a/pages/main.templ
 761+++ b/pages/main.templ
 762@@ -21,7 +21,7 @@ templ BaseLayout(title string, route string) {
 763 				.pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
 764 			</style>
 765 			<script src="/assets/vendor/bootstrap.bundle.min.js"></script>
 766-			<script src="/assets/vendor/htmx.min.js"></script>
 767+			<script type="module" src="/assets/vendor/datastar.js"></script>
 768 			<script src="/assets/vendor/fuse.js"></script>
 769 			<script src="/assets/index.js"></script>
 770 		</head>
 771diff --git a/pages/main_templ.go b/pages/main_templ.go
 772index 2443fcbb99560ed893f80092bbbbd9a2df64581b..f131e7ec632a6cd7541749c428618301642e1e96 100644
 773--- a/pages/main_templ.go
 774+++ b/pages/main_templ.go
 775@@ -42,7 +42,7 @@ func BaseLayout(title string, route string) templ.Component {
 776 		if templ_7745c5c3_Err != nil {
 777 			return templ_7745c5c3_Err
 778 		}
 779diff --git a/pages/navbar.templ b/pages/navbar.templ
 780index f63543008c49627010004954dc9a126f3b10aa81..6b590a3ccf8651a8bce5e5616da5ffa178b89109 100644
 781--- a/pages/navbar.templ
 782+++ b/pages/navbar.templ
 783@@ -55,9 +55,7 @@ templ navbar(route string) {
 784 							type="button"
 785 							class="btn btn-outline-secondary"
 786 							style="--bs-btn-color: var(--bs-body-color);"
 787-							hx-post="/fetch"
 788-							hx-swap="none"
 789-							hx-on::after-settle="window.location.reload()"
 790+							data-on:click="@post('/fetch')"
 791 						>
 792 							<span class="d-none d-lg-inline">Fetch </span><i class="bi bi-cloud-download"></i>
 793 						</button>
 794diff --git a/pages/navbar_templ.go b/pages/navbar_templ.go
 795index f2cc7c24b33e02e695702c9799eadc2af64ac3cd..60e329fa0659fcdf7e7de3e59036884bf3cca722 100644
 796--- a/pages/navbar_templ.go
 797+++ b/pages/navbar_templ.go
 798@@ -58,7 +58,7 @@ func navbar(route string) templ.Component {
 799 			return templ_7745c5c3_Err
 800 		}
 801 		if route != "auth" {
 802diff --git a/pages/new_videos.templ b/pages/new_videos.templ
 803index 817410f5fc131a93a1dbe149dc91e495fe6bd291..6b2f3fe61d8b95479f3c20b136e63c86afe407ca 100644
 804--- a/pages/new_videos.templ
 805+++ b/pages/new_videos.templ
 806@@ -62,7 +62,7 @@ templ thumbnail(video models.Video) {
 807 }
 808 
 809 templ VideoCard(video models.Video) {
 810-	<div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
 811+	<div id={ fmt.Sprintf("video-card-%s", video.ID) } class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
 812 		<div class="card">
 813 			@thumbnail(video)
 814 			<div class="card-body">
 815@@ -79,12 +79,11 @@ templ VideoCard(video models.Video) {
 816 				</p>
 817 				<div class="d-flex justify-content-between">
 818 					<form
 819-						hx-patch={ fmt.Sprintf("/videos/%s/progress", video.ID) }
 820-						hx-target={ fmt.Sprintf("#progress-%s", video.ID) }
 821-						hx-swap="outerHTML"
 822+						data-signals="{progress: ''}"
 823+						data-on:submit__prevent={ fmt.Sprintf("$progress = evt.target.querySelector('input').value; @patch('/videos/%s/progress')", video.ID) }
 824 						class="d-flex"
 825 					>
 826-						<input type="text" name="progress" placeholder="mm:ss/11m22s" class="form-control" style="width: 50%"/>
 827+						<input type="text" placeholder="mm:ss/11m22s" class="form-control" style="width: 50%"/>
 828 						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i></button>
 829 					</form>
 830 					<div class="d-flex gap-2">
 831@@ -99,10 +98,7 @@ templ VideoCard(video models.Video) {
 832 							<button
 833 								class="btn btn-primary"
 834 								disabled
 835-								hx-get={ fmt.Sprintf("/videos/%s/card", video.ID) }
 836-								hx-trigger="every 2s"
 837-								hx-target="closest .video-card"
 838-								hx-swap="outerHTML"
 839+								data-on-interval__duration.2s={ fmt.Sprintf("@get('/videos/%s/card')", video.ID) }
 840 							>
 841 								<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
 842 							</button>
 843@@ -129,9 +125,7 @@ templ VideoCard(video models.Video) {
 844 							</button>
 845 						}
 846 						<button
 847-							hx-patch={ fmt.Sprintf("/videos/%s/watch", video.ID) }
 848-							hx-target="closest .video-card"
 849-							hx-swap="outerHTML"
 850+							data-on:click={ fmt.Sprintf("@patch('/videos/%s/watch')", video.ID) }
 851 							class="btn btn-danger"
 852 						>
 853 							<i class="bi bi-check-circle"></i>
 854diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
 855index 32b2b7ccf056dc709dc04014c98326d4dbd3ed42..239551f3a1ff51b441a79dd6a495099ea9a887ee 100644
 856--- a/pages/new_videos_templ.go
 857+++ b/pages/new_videos_templ.go
 858@@ -246,119 +246,119 @@ func VideoCard(video models.Video) templ.Component {
 859 			templ_7745c5c3_Var12 = templ.NopComponent
 860 		}
 861 		ctx = templ.ClearChildren(ctx)
 862-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"video-card col-md-3 p-2\" data-title=\"")
 863+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div id=\"")
 864 		if templ_7745c5c3_Err != nil {
 865 			return templ_7745c5c3_Err
 866 		}
 867 		var templ_7745c5c3_Var13 string
 868-		templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
 869+		templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("video-card-%s", video.ID))
 870 		if templ_7745c5c3_Err != nil {
 871-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 62}
 872+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 49}
 873 		}
 874 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
 875 		if templ_7745c5c3_Err != nil {
 876 			return templ_7745c5c3_Err
 877 		}
 878-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-channel-name=\"")
 879+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"video-card col-md-3 p-2\" data-title=\"")
 880 		if templ_7745c5c3_Err != nil {
 881 			return templ_7745c5c3_Err
 882 		}
 883 		var templ_7745c5c3_Var14 string
 884-		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
 885+		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
 886 		if templ_7745c5c3_Err != nil {
 887-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 102}
 888+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 108}
 889 		}
 890 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
 891 		if templ_7745c5c3_Err != nil {
 892 			return templ_7745c5c3_Err
 893 		}
 894-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><div class=\"card\">")
 895+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-channel-name=\"")
 896 		if templ_7745c5c3_Err != nil {
 897 			return templ_7745c5c3_Err
 898 		}
 899-		templ_7745c5c3_Err = thumbnail(video).Render(ctx, templ_7745c5c3_Buffer)
 900+		var templ_7745c5c3_Var15 string
 901+		templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
 902 		if templ_7745c5c3_Err != nil {
 903-			return templ_7745c5c3_Err
 904+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 148}
 905 		}
 906-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card-body\"><p class=\"card-title text-truncate mb-0\">")
 907+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
 908 		if templ_7745c5c3_Err != nil {
 909 			return templ_7745c5c3_Err
 910 		}
 911-		var templ_7745c5c3_Var15 string
 912-		templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
 913+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"><div class=\"card\">")
 914 		if templ_7745c5c3_Err != nil {
 915-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 69, Col: 58}
 916+			return templ_7745c5c3_Err
 917 		}
 918-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
 919+		templ_7745c5c3_Err = thumbnail(video).Render(ctx, templ_7745c5c3_Buffer)
 920 		if templ_7745c5c3_Err != nil {
 921 			return templ_7745c5c3_Err
 922 		}
 923-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p class=\"d-flex justify-content-between\"><a target=\"blank\" href=\"")
 924+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"card-body\"><p class=\"card-title text-truncate mb-0\">")
 925 		if templ_7745c5c3_Err != nil {
 926 			return templ_7745c5c3_Err
 927 		}
 928-		var templ_7745c5c3_Var16 templ.SafeURL
 929-		templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID))
 930+		var templ_7745c5c3_Var16 string
 931+		templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
 932 		if templ_7745c5c3_Err != nil {
 933-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 73, Col: 79}
 934+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 69, Col: 58}
 935 		}
 936 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
 937 		if templ_7745c5c3_Err != nil {
 938 			return templ_7745c5c3_Err
 939 		}
 940-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"card-text fw-light link-light link-underline-opacity-0\">")
 941+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p class=\"d-flex justify-content-between\"><a target=\"blank\" href=\"")
 942 		if templ_7745c5c3_Err != nil {
 943 			return templ_7745c5c3_Err
 944 		}
 945-		var templ_7745c5c3_Var17 string
 946-		templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
 947+		var templ_7745c5c3_Var17 templ.SafeURL
 948+		templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID))
 949 		if templ_7745c5c3_Err != nil {
 950-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 75, Col: 25}
 951+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 73, Col: 79}
 952 		}
 953 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
 954 		if templ_7745c5c3_Err != nil {
 955 			return templ_7745c5c3_Err
 956 		}
 957-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a> <span>")
 958diff --git a/pages/subscription.templ b/pages/subscription.templ
 959index fc7c2b305902f71a23814ee3295dfcdcf61d778a..0ae4e08b6be1e1fbde449e11715057e683142b7e 100644
 960--- a/pages/subscription.templ
 961+++ b/pages/subscription.templ
 962@@ -12,13 +12,10 @@ templ subscriptionModal() {
 963 					<form
 964 						id="subscription-form"
 965 						class="d-flex"
 966-						hx-post="/subscribe"
 967-						hx-swap="none"
 968-						hx-on::after-request="subscribeHandler(event)"
 969+						data-on:submit__prevent="$channelID = el.querySelector('input').value; @post('/subscribe')"
 970 					>
 971 						<input
 972 							type="text"
 973-							name="channel_id"
 974 							placeholder="Channel ID or username"
 975 							class="form-control"
 976 							autocomplete="off"
 977diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
 978index 159c663781a01c7504febb91a4207e0416fc9dea..02178f6cd2e57f0f07a05697f79a609b7d1c11d0 100644
 979--- a/pages/subscription_templ.go
 980+++ b/pages/subscription_templ.go
 981@@ -29,7 +29,7 @@ func subscriptionModal() templ.Component {
 982 			templ_7745c5c3_Var1 = templ.NopComponent
 983 		}
 984 		ctx = templ.ClearChildren(ctx)
 985diff --git a/pages/watched_videos.templ b/pages/watched_videos.templ
 986index 963c4b29f8107b0785e7087b5d2383d6bf53ecc4..96897fbd1426980d3de5532e42506c0d73b34f90 100644
 987--- a/pages/watched_videos.templ
 988+++ b/pages/watched_videos.templ
 989@@ -23,9 +23,7 @@ templ watchedVideoCard(video models.Video) {
 990 			</p>
 991 			<div class="d-flex justify-content-end">
 992 				<button
 993-					hx-patch={ fmt.Sprintf("/videos/%s/unwatch", video.ID) }
 994-					hx-target="closest .video-card"
 995-					hx-swap="outerHTML"
 996+					data-on:click={ fmt.Sprintf("@patch('/videos/%s/unwatch')", video.ID) }
 997 					class="btn btn-secondary"
 998 				>
 999 					<i class="bi bi-arrow-counterclockwise"></i>
1000@@ -39,7 +37,7 @@ templ WatchedVideosPage(videos []models.Video, currentPage int) {
1001 	@BaseLayout("ytrssil - Watched Videos", "watched") {
1002 		<div class="row">
1003 			for _, video := range videos {
1004-				<div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
1005+				<div id={ fmt.Sprintf("video-card-%s", video.ID) } class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
1006 					@watchedVideoCard(video)
1007 				</div>
1008 			}
1009diff --git a/pages/watched_videos_templ.go b/pages/watched_videos_templ.go
1010index ad99a7ca9636848649fc0ab284111bc80e955bb2..a2b5e1af706cc4fa9272e7116e14aad26c8a7c49 100644
1011--- a/pages/watched_videos_templ.go
1012+++ b/pages/watched_videos_templ.go
1013@@ -95,20 +95,20 @@ func watchedVideoCard(video models.Video) templ.Component {
1014 		if templ_7745c5c3_Err != nil {
1015 			return templ_7745c5c3_Err
1016 		}
1017-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></p><div class=\"d-flex justify-content-end\"><button hx-patch=\"")
1018+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></p><div class=\"d-flex justify-content-end\"><button data-on:click=\"")
1019 		if templ_7745c5c3_Err != nil {
1020 			return templ_7745c5c3_Err
1021 		}
1022 		var templ_7745c5c3_Var6 string
1023-		templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/videos/%s/unwatch", video.ID))
1024+		templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("@patch('/videos/%s/unwatch')", video.ID))
1025 		if templ_7745c5c3_Err != nil {
1026-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 26, Col: 59}
1027+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 26, Col: 74}
1028 		}
1029 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
1030 		if templ_7745c5c3_Err != nil {
1031 			return templ_7745c5c3_Err
1032 		}
1033-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-target=\"closest .video-card\" hx-swap=\"outerHTML\" class=\"btn btn-secondary\"><i class=\"bi bi-arrow-counterclockwise\"></i></button></div></div></div>")
1034+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"btn btn-secondary\"><i class=\"bi bi-arrow-counterclockwise\"></i></button></div></div></div>")
1035 		if templ_7745c5c3_Err != nil {
1036 			return templ_7745c5c3_Err
1037 		}
1038@@ -154,33 +154,46 @@ func WatchedVideosPage(videos []models.Video, currentPage int) templ.Component {
1039 				return templ_7745c5c3_Err
1040 			}
1041 			for _, video := range videos {
1042-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"video-card col-md-3 p-2\" data-title=\"")
1043+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div id=\"")
1044 				if templ_7745c5c3_Err != nil {
1045 					return templ_7745c5c3_Err
1046 				}
1047 				var templ_7745c5c3_Var9 string
1048-				templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
1049+				templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("video-card-%s", video.ID))
1050 				if templ_7745c5c3_Err != nil {
1051-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 42, Col: 65}
1052+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 40, Col: 52}
1053 				}
1054 				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
1055 				if templ_7745c5c3_Err != nil {
1056 					return templ_7745c5c3_Err
1057 				}
1058-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-channel-name=\"")
1059+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"video-card col-md-3 p-2\" data-title=\"")
1060 				if templ_7745c5c3_Err != nil {
1061 					return templ_7745c5c3_Err
1062 				}
1063 				var templ_7745c5c3_Var10 string
1064-				templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
1065+				templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
1066 				if templ_7745c5c3_Err != nil {
1067-					return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 42, Col: 105}
1068+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 40, Col: 111}
1069 				}
1070 				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
1071 				if templ_7745c5c3_Err != nil {
1072 					return templ_7745c5c3_Err
1073 				}
1074-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
1075+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-channel-name=\"")
1076+				if templ_7745c5c3_Err != nil {
1077+					return templ_7745c5c3_Err
1078+				}
1079+				var templ_7745c5c3_Var11 string
1080+				templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
1081+				if templ_7745c5c3_Err != nil {
1082+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/watched_videos.templ`, Line: 40, Col: 151}
1083+				}
1084+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
1085+				if templ_7745c5c3_Err != nil {
1086+					return templ_7745c5c3_Err
1087+				}
1088+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
1089 				if templ_7745c5c3_Err != nil {
1090 					return templ_7745c5c3_Err
1091 				}
1092@@ -188,81 +201,81 @@ func WatchedVideosPage(videos []models.Video, currentPage int) templ.Component {
1093 				if templ_7745c5c3_Err != nil {
1094 					return templ_7745c5c3_Err
1095 				}
1096-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
1097+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
1098 				if templ_7745c5c3_Err != nil {
1099 					return templ_7745c5c3_Err
1100 				}
1101 			}
1102-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"row mt-4 mb-4\"><div class=\"col-12 d-flex justify-content-center align-items-center gap-3\">")
1103+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"row mt-4 mb-4\"><div class=\"col-12 d-flex justify-content-center align-items-center gap-3\">")
1104 			if templ_7745c5c3_Err != nil {
1105 				return templ_7745c5c3_Err
1106 			}
1107 			if currentPage > 1 {
1108-				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
1109+				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
1110 				if templ_7745c5c3_Err != nil {
1111 					return templ_7745c5c3_Err
1112 				}