51658595f61f648600967cb1f2ae40c083e9e5d8
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/assets/ytrssil.png b/assets/ytrssil.png
2new file mode 100644
3index 0000000000000000000000000000000000000000..335bf88afd70537e1c5d29ce1fe58f0022fde862
4Binary files /dev/null and b/assets/ytrssil.png differ
5diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
6index f4101d5bac44ec10f73b198fee1ed75a784210f7..9fedac0051644660d8b51f4388eee4c9034c9ebd 100644
7--- a/httpserver/ytrssil/channels.go
8+++ b/httpserver/ytrssil/channels.go
9@@ -8,18 +8,10 @@ import (
10
11 "github.com/TheEdgeOfRage/ytrssil-api/db"
12 "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
13- "github.com/TheEdgeOfRage/ytrssil-api/models"
14 )
15
16 func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
17- var channel models.Channel
18- err := c.ShouldBindUri(&channel)
19- if err != nil {
20- c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
21- return
22- }
23-
24- err = srv.handler.SubscribeToChannel(c.Request.Context(), channel.ID)
25+ err := srv.handler.SubscribeToChannel(c.Request.Context(), c.Param("channel_id"))
26 if err != nil {
27 if errors.Is(err, db.ErrAlreadySubscribed) {
28 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
29@@ -38,14 +30,7 @@ func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
30 }
31
32 func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
33- var channel models.Channel
34- err := c.ShouldBindUri(&channel)
35- if err != nil {
36- c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
37- return
38- }
39-
40- err = srv.handler.UnsubscribeFromChannel(c.Request.Context(), channel.ID)
41+ err := srv.handler.UnsubscribeFromChannel(c.Request.Context(), c.Param("channel_id"))
42 if err != nil {
43 if errors.Is(err, db.ErrChannelNotFound) {
44 c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
45diff --git a/httpserver/ytrssil/channels_pages.go b/httpserver/ytrssil/channels_pages.go
46new file mode 100644
47index 0000000000000000000000000000000000000000..846e9f44918619ec648ad05275fc1eb4b0fcf69f
48--- /dev/null
49+++ b/httpserver/ytrssil/channels_pages.go
50@@ -0,0 +1,30 @@
51+package ytrssil
52+
53+import (
54+ "errors"
55+ "net/http"
56+
57+ "github.com/gin-gonic/gin"
58+
59+ "github.com/TheEdgeOfRage/ytrssil-api/db"
60+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
61+)
62+
63+func (srv *server) SubscribeToChannelPage(c *gin.Context) {
64+ err := srv.handler.SubscribeToChannel(c.Request.Context(), c.PostForm("channel_id"))
65+ if err != nil {
66+ if errors.Is(err, db.ErrAlreadySubscribed) {
67+ c.String(http.StatusConflict, err.Error())
68+ return
69+ }
70+ if errors.Is(err, feedparser.ErrInvalidChannelID) {
71+ c.Data(http.StatusNotFound, "text/html", []byte(err.Error()))
72+ return
73+ }
74+
75+ c.String(http.StatusInternalServerError, err.Error())
76+ return
77+ }
78+
79+ c.String(http.StatusOK, "")
80+}
81diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
82index 37afb434831bf74872ada122d5082d2a24bbf8f9..741f40512373002009a10d95d9978420c8f34587 100644
83--- a/httpserver/ytrssil/server.go
84+++ b/httpserver/ytrssil/server.go
85@@ -65,6 +65,7 @@ func SetupGinRouter(
86 }
87 engine.GET("/healthz", srv.Healthz)
88 engine.POST("/fetch", srv.FetchVideosJSON)
89+ engine.Static("/assets", "./assets")
90
91 engine.GET("/auth", srv.AuthPage)
92 engine.POST("/auth", srv.HandleAuth)
93@@ -73,6 +74,7 @@ func SetupGinRouter(
94 pages.Use(auth.PageAuthMiddleware(cfg.AuthToken))
95 {
96 pages.GET("/", srv.NewVideosPage)
97+ pages.POST("/subscribe", srv.SubscribeToChannelPage)
98 pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
99 pages.PATCH("/videos/:video_id/progress", srv.SetVideoProgressPage)
100 }
101diff --git a/models/channel.go b/models/channel.go
102index 095ae8b725b11213ef5af87d10496633c3c70245..f5e91df34c2ce4e04366733a7c223cab3076606c 100644
103--- a/models/channel.go
104+++ b/models/channel.go
105@@ -2,7 +2,7 @@ package models
106
107 type Channel struct {
108 // YouTube ID of the channel
109- ID string `json:"channel_id" uri:"channel_id" binding:"required"`
110+ ID string `json:"channel_id"`
111 // Name of the channel
112 Name string `json:"name"`
113 }
114diff --git a/pages/main.templ b/pages/main.templ
115index b23de3f4cf10d30565c6ccbd75c5b229255d63f3..26e6416b6f737ade10b8fe2fc9d353057c2ae58b 100644
116--- a/pages/main.templ
117+++ b/pages/main.templ
118@@ -15,9 +15,20 @@ templ BaseLayout(title string) {
119 .pillarbox::before,
120 .pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
121 </style>
122+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
123 <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
124+ <script>
125+ document.addEventListener('htmx:responseError', event => {
126+ const field = event.detail.elt.querySelector(`[name="channel_id"]`);
127+ field.setCustomValidity(event.detail.xhr.responseText);
128+ field.onchange = () => field.setCustomValidity('');
129+ field.reportValidity();
130+ });
131+ </script>
132 </head>
133 <body>
134+ @subscriptionModal()
135+ @navbar()
136 <div class="container-fluid">
137 { children... }
138 </div>
139diff --git a/pages/main_templ.go b/pages/main_templ.go
140index 280534d91bbb5bdd2f9aee6dd0b0f6f0bec857ba..08d84d4529f145f2f4fffa86d39bfd8c95e19509 100644
141--- a/pages/main_templ.go
142+++ b/pages/main_templ.go
143@@ -42,7 +42,19 @@ func BaseLayout(title string) templ.Component {
144 if templ_7745c5c3_Err != nil {
145 return templ_7745c5c3_Err
146 }
147diff --git a/pages/navbar.templ b/pages/navbar.templ
148new file mode 100644
149index 0000000000000000000000000000000000000000..ea51178e20a4b15cc803d155e0081efbe1f25e56
150--- /dev/null
151+++ b/pages/navbar.templ
152@@ -0,0 +1,35 @@
153+package pages
154+
155+templ navbar() {
156+ <nav class="navbar navbar-expand-lg bg-body-tertiary">
157+ <div class="container-fluid">
158+ <a class="navbar-brand" href="/">
159+ <img src="/assets/ytrssil.png" alt="ytrssil" width="32" height="32"/>
160+ </a>
161+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent">
162+ <span class="navbar-toggler-icon"></span>
163+ </button>
164+ <div class="collapse navbar-collapse" id="navbarSupportedContent">
165+ <ul class="navbar-nav me-auto mb-2 mb-lg-0">
166+ <li class="nav-item me-md-2 mt-2 mt-md-0">
167+ <button type="button" data-bs-toggle="modal" data-bs-target="#subscription-modal" class="btn btn-success">
168+ Subscribe <i class="bi bi-bookmark-plus"></i>
169+ </button>
170+ </li>
171+ <li class="nav-item me-md-2 mt-2 mt-md-0">
172+ <button
173+ type="button"
174+ class="btn btn-primary"
175+ hx-post="/fetch"
176+ hx-swap="none"
177+ >Fetch <i class="bi bi-cloud-download"></i></button>
178+ </li>
179+ </ul>
180+ <form class="d-flex" role="search">
181+ <input class="form-control me-2" type="search" placeholder="Search"/>
182+ <button class="btn btn-outline-success" type="submit">Search</button>
183+ </form>
184+ </div>
185+ </div>
186+ </nav>
187+}
188diff --git a/pages/navbar_templ.go b/pages/navbar_templ.go
189new file mode 100644
190index 0000000000000000000000000000000000000000..f5a6e31c1b686999c0cf5b577f01df4984222530
191--- /dev/null
192+++ b/pages/navbar_templ.go
193@@ -0,0 +1,40 @@
194+// Code generated by templ - DO NOT EDIT.
195+
196+// templ: version: v0.3.960
197+package pages
198+
199+//lint:file-ignore SA4006 This context is only used if a nested component is present.
200+
201+import "github.com/a-h/templ"
202+import templruntime "github.com/a-h/templ/runtime"
203+
204+func navbar() templ.Component {
205+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
206+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
207+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
208+ return templ_7745c5c3_CtxErr
209+ }
210+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
211+ if !templ_7745c5c3_IsBuffer {
212+ defer func() {
213+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
214+ if templ_7745c5c3_Err == nil {
215+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
216+ }
217+ }()
218+ }
219+ ctx = templ.InitializeContext(ctx)
220+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
221+ if templ_7745c5c3_Var1 == nil {
222+ templ_7745c5c3_Var1 = templ.NopComponent
223+ }
224+ ctx = templ.ClearChildren(ctx)
225diff --git a/pages/new_videos.templ b/pages/new_videos.templ
226index 1947ba9c165a9e77ffe3e749269687a518852ab6..2986d63f05193530a896d2d51a2fb2dda000f3a1 100644
227--- a/pages/new_videos.templ
228+++ b/pages/new_videos.templ
229@@ -32,6 +32,15 @@ templ thumbnail(video models.Video) {
230 alt={ video.Title }
231 style="width: 100%; aspect-ratio: 16/9; object-fit: contain;"
232 />
233+ <svg
234+ xmlns="http://www.w3.org/2000/svg"
235+ height="24"
236+ viewBox="0 0 24 24"
237+ width="24"
238+ focusable="false"
239+ style="pointer-events: none; width: 2rem; height: 2rem; z-index: 2; position: absolute; bottom: 0.5rem; right: 0.5rem;"
240+ fill="white"
241+ ><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>
242 } else {
243 <img
244 src={ fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID) }
245@@ -48,24 +57,7 @@ templ videoCard(video models.Video) {
246 <div class="card">
247 @thumbnail(video)
248 <div class="card-body">
249- if video.IsShort {
250- <p class="card-title text-truncate mb-0">
251- <svg
252- xmlns="http://www.w3.org/2000/svg"
253- height="24"
254- viewBox="0 0 24 24"
255- width="24"
256- focusable="false"
257- aria-hidden="true"
258- style="pointer-events: none; display: inherit; width: 2rem; height: 2rem;"
259- class="float-end"
260- fill="white"
261- ><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>
262- { video.Title }
263- </p>
264- } else {
265- <p class="card-title text-truncate">{ video.Title }</p>
266- }
267+ <p class="card-title text-truncate mb-0">{ video.Title }</p>
268 <p>
269 <a
270 target="blank"
271diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
272index baf4eabd53deacdbbf77f7e239e74d1a1c8579f7..b7847bb26332426799c1cc044035cfad4a90b401 100644
273--- a/pages/new_videos_templ.go
274+++ b/pages/new_videos_templ.go
275@@ -164,7 +164,7 @@ func thumbnail(video models.Video) templ.Component {
276 if templ_7745c5c3_Err != nil {
277 return templ_7745c5c3_Err
278 }
279- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" style=\"width: 100%; aspect-ratio: 16/9; object-fit: contain;\">")
280diff --git a/pages/subscription.templ b/pages/subscription.templ
281new file mode 100644
282index 0000000000000000000000000000000000000000..22cff9553caf6e46e025b72f65810ecde3a606de
283--- /dev/null
284+++ b/pages/subscription.templ
285@@ -0,0 +1,27 @@
286+package pages
287+
288+templ subscriptionModal() {
289+ <div class="modal fade" id="subscription-modal" tabindex="-1">
290+ <div class="modal-dialog">
291+ <div class="modal-content">
292+ <div class="modal-header">
293+ <h1 class="modal-title fs-5">Subscribe to channel</h1>
294+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
295+ </div>
296+ <div class="modal-body">
297+ <form
298+ hx-post="/subscribe"
299+ hx-target="#subscription-error"
300+ hx-swap="innerHTML"
301+ hx-on::after-request="if(event.detail.successful) bootstrap.Modal.getInstance(document.getElementById('subscription-modal')).hide()"
302+ class="d-flex"
303+ >
304+ <input type="text" name="channel_id" placeholder="Channel username or ID" class="form-control"/>
305+ <button type="submit" class="btn btn-primary ms-3"><i class="bi bi-plus"></i></button>
306+ </form>
307+ <div id="subscription-error" class="text-danger mt-2 ms-1"></div>
308+ </div>
309+ </div>
310+ </div>
311+ </div>
312+}
313diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
314new file mode 100644
315index 0000000000000000000000000000000000000000..8c805fd26fe4809a0d5ae5fddcc0951face4f8f7
316--- /dev/null
317+++ b/pages/subscription_templ.go
318@@ -0,0 +1,40 @@
319+// Code generated by templ - DO NOT EDIT.
320+
321+// templ: version: v0.3.960
322+package pages
323+
324+//lint:file-ignore SA4006 This context is only used if a nested component is present.
325+
326+import "github.com/a-h/templ"
327+import templruntime "github.com/a-h/templ/runtime"
328+
329+func subscriptionModal() templ.Component {
330+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
331+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
332+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
333+ return templ_7745c5c3_CtxErr
334+ }
335+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
336+ if !templ_7745c5c3_IsBuffer {
337+ defer func() {
338+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
339+ if templ_7745c5c3_Err == nil {
340+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
341+ }
342+ }()
343+ }
344+ ctx = templ.InitializeContext(ctx)
345+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
346+ if templ_7745c5c3_Var1 == nil {
347+ templ_7745c5c3_Var1 = templ.NopComponent
348+ }
349+ ctx = templ.ClearChildren(ctx)