0f09fdd413ace3721fca7d8c92c7fec57ba83f88
- Author
- Pavle <29902467+TheEdgeOfRage@users.noreply.github.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
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 }