af8c180c63f002f9be3943e90fae876e92872c55
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/db/channels.go b/db/channels.go
2index 8a2f20d4e052537992e01b2600938583afc0b6e3..234acf39c42919c0bf2d088f93e344655e4d771a 100644
3--- a/db/channels.go
4+++ b/db/channels.go
5@@ -9,10 +9,10 @@ import (
6
7 func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
8 const query = `
9- INSERT INTO channels (id, name, subscribed) VALUES ($1, $2, $3)
10- ON CONFLICT (id) DO UPDATE SET subscribed = $3
11+ INSERT INTO channels (id, name, subscribed, image_url) VALUES ($1, $2, $3, $4)
12+ ON CONFLICT (id) DO UPDATE SET subscribed = $3, image_url = $4
13 `
14- resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name, channel.Subscribed)
15+ resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name, channel.Subscribed, channel.ImageURL)
16 if err != nil {
17 d.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
18 return err
19@@ -32,7 +32,19 @@ func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Chan
20 }
21
22 func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
23- const query = `SELECT id, name, subscribed FROM channels WHERE subscribed = true`
24+ const query = `
25+ SELECT
26+ channels.id,
27+ channels.name,
28+ channels.subscribed,
29+ COALESCE(channels.image_url, '') as image_url,
30+ COUNT(videos.id) FILTER (WHERE videos.watch_timestamp IS NULL) as unwatched_count
31+ FROM channels
32+ LEFT JOIN videos ON channels.id = videos.channel_id
33+ WHERE channels.subscribed = true
34+ GROUP BY channels.id, channels.name, channels.subscribed, channels.image_url
35+ ORDER BY channels.name
36+ `
37 rows, err := d.db.QueryContext(ctx, query)
38 if err != nil {
39 d.l.Error("Failed to list channels", "call", "sql.QueryContext", "error", err)
40@@ -43,7 +55,7 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
41 channels := make([]models.Channel, 0)
42 for rows.Next() {
43 var channel models.Channel
44- err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed)
45+ err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed, &channel.ImageURL, &channel.UnwatchedCount)
46 if err != nil {
47 d.l.Error("Failed to scan rows to list channels", "call", "sql.Scan", "error", err)
48 return nil, err
49diff --git a/handler/channels.go b/handler/channels.go
50index e513a9362c8e84900cdc4373609509c4945f41d5..8090d476cfa60615eccbe10a63c9ca4ec050d7ef 100644
51--- a/handler/channels.go
52+++ b/handler/channels.go
53@@ -8,26 +8,37 @@ import (
54 "github.com/TheEdgeOfRage/ytrssil-api/models"
55 )
56
57-func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) error {
58+func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) (*models.Channel, error) {
59 parsedChannel, err := h.parser.Parse(channelID)
60 if err != nil {
61- return err
62+ return nil, err
63+ }
64+
65+ imageURL, err := h.youTubeClient.GetChannelImageURL(ctx, channelID)
66+ if err != nil {
67+ h.log.Warn("Failed to fetch channel image URL", "channelID", channelID, "error", err)
68+ imageURL = ""
69 }
70
71 channel := models.Channel{
72 ID: channelID,
73 Name: parsedChannel.Name,
74 Subscribed: true,
75+ ImageURL: imageURL,
76 }
77
78 err = h.db.SubscribeToChannel(ctx, channel)
79 if err != nil && !errors.Is(err, db.ErrChannelExists) {
80- return err
81+ return nil, err
82 }
83
84- return nil
85+ return &channel, nil
86 }
87
88 func (h *handler) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
89 return h.db.UnsubscribeFromChannel(ctx, channelID)
90 }
91+
92+func (h *handler) ListChannels(ctx context.Context) ([]models.Channel, error) {
93+ return h.db.ListChannels(ctx)
94+}
95diff --git a/handler/handler.go b/handler/handler.go
96index 325043341e86d35165864d20094d781b9f6cd73e..ed66479b9fb36156f39c38fb2c1b8deac656fec0 100644
97--- a/handler/handler.go
98+++ b/handler/handler.go
99@@ -11,8 +11,9 @@ import (
100 )
101
102 type Handler interface {
103- SubscribeToChannel(ctx context.Context, channelID string) error
104+ SubscribeToChannel(ctx context.Context, channelID string) (*models.Channel, error)
105 UnsubscribeFromChannel(ctx context.Context, channelID string) error
106+ ListChannels(ctx context.Context) ([]models.Channel, error)
107 GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
108 GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
109 FetchVideos(ctx context.Context) error
110diff --git a/handler/videos.go b/handler/videos.go
111index a6c2837c3b9362ca56bf76a4242872ff4cc6b639..2c4755252e0d599879cbb1759f0523f289cbbe95 100644
112--- a/handler/videos.go
113+++ b/handler/videos.go
114@@ -88,10 +88,11 @@ func (h *handler) FetchVideos(ctx context.Context) error {
115 }
116 wg.Wait()
117
118- for range channels {
119+ for _, c := range channels {
120 parsedChannel := <-parsedChannels
121 err = <-errors
122 if err != nil {
123+ h.log.Error("failed to parse channel feed", "channelID", c.ID, "error", err)
124 continue
125 }
126 h.addVideosForChannel(ctx, parsedChannel)
127diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
128index 3a9ba0e9464603b60e05454c2a248586254958c2..6a0691efe57a415c09e1ea074a283fded0ae021b 100644
129--- a/httpserver/ytrssil/channels.go
130+++ b/httpserver/ytrssil/channels.go
131@@ -11,7 +11,7 @@ import (
132 )
133
134 func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
135- err := srv.handler.SubscribeToChannel(c.Request.Context(), c.Param("channel_id"))
136+ channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), c.Param("channel_id"))
137 if err != nil {
138 if errors.Is(err, db.ErrAlreadySubscribed) {
139 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
140@@ -26,7 +26,7 @@ func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
141 return
142 }
143
144- c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
145+ c.JSON(http.StatusOK, channel)
146 }
147
148 func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
149diff --git a/httpserver/ytrssil/channels_pages.go b/httpserver/ytrssil/channels_pages.go
150index 414f68d788ce133be6ffa150eaf2bdeb53743372..28373caf18c27da3bcfe975118cd9b9889377920 100644
151--- a/httpserver/ytrssil/channels_pages.go
152+++ b/httpserver/ytrssil/channels_pages.go
153@@ -8,10 +8,25 @@ import (
154
155 "github.com/TheEdgeOfRage/ytrssil-api/db"
156 "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
157+ "github.com/TheEdgeOfRage/ytrssil-api/pages"
158 )
159
160+func (srv *server) ChannelsPage(c *gin.Context) {
161+ channels, err := srv.handler.ListChannels(c.Request.Context())
162+ if err != nil {
163+ returnErr(c, http.StatusInternalServerError, err)
164+ return
165+ }
166+
167+ r := pages.TemplRenderer{
168+ Ctx: c.Request.Context(),
169+ Component: pages.ChannelsPage(channels),
170+ }
171+ c.Render(http.StatusOK, r)
172+}
173+
174 func (srv *server) SubscribeToChannelPage(c *gin.Context) {
175- err := srv.handler.SubscribeToChannel(c.Request.Context(), c.PostForm("channel_id"))
176+ channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), c.PostForm("channel_id"))
177 if err != nil {
178 if errors.Is(err, db.ErrAlreadySubscribed) {
179 returnErr(c, http.StatusConflict, err)
180@@ -26,5 +41,24 @@ func (srv *server) SubscribeToChannelPage(c *gin.Context) {
181 return
182 }
183
184- returnMsg(c, http.StatusAccepted, "")
185+ r := pages.TemplRenderer{
186+ Ctx: c.Request.Context(),
187+ Component: pages.ChannelCard(*channel),
188+ }
189+ c.Render(http.StatusOK, r)
190+}
191+
192+func (srv *server) UnsubscribeFromChannelPage(c *gin.Context) {
193+ err := srv.handler.UnsubscribeFromChannel(c.Request.Context(), c.Param("channel_id"))
194+ if err != nil {
195+ if errors.Is(err, db.ErrChannelNotFound) {
196+ returnErr(c, http.StatusNotFound, err)
197+ return
198+ }
199+
200+ returnErr(c, http.StatusInternalServerError, err)
201+ return
202+ }
203+
204+ returnMsg(c, http.StatusOK, "")
205 }
206diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
207index 544c0b89bab920db8085a607e21e37322c3c45d6..ea87f204a7bb3e0c39bb0ec88730334ce2bb0886 100644
208--- a/httpserver/ytrssil/server.go
209+++ b/httpserver/ytrssil/server.go
210@@ -75,7 +75,9 @@ func SetupGinRouter(
211 {
212 pages.GET("/", srv.NewVideosPage)
213 pages.GET("/watched", srv.WatchedVideosPage)
214+ pages.GET("/channels", srv.ChannelsPage)
215 pages.POST("/subscribe", srv.SubscribeToChannelPage)
216+ pages.POST("/channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelPage)
217 pages.POST("/videos", srv.AddVideoPage)
218 pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
219 pages.PATCH("/videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedPage)
220diff --git a/lib/clients/youtube/get_channel_image.go b/lib/clients/youtube/get_channel_image.go
221new file mode 100644
222index 0000000000000000000000000000000000000000..f1c2308dbedfb9af1af6d688a959f309e99417c2
223--- /dev/null
224+++ b/lib/clients/youtube/get_channel_image.go
225@@ -0,0 +1,74 @@
226+package youtube
227+
228+import (
229+ "context"
230+ "encoding/json"
231+ "fmt"
232+ "io"
233+ "net/http"
234+ "net/url"
235+)
236+
237+type APIChannelListResponse struct {
238+ Items []struct {
239+ Snippet struct {
240+ Thumbnails struct {
241+ Medium struct {
242+ URL string `json:"url"`
243+ } `json:"medium"`
244+ } `json:"thumbnails"`
245+ } `json:"snippet"`
246+ } `json:"items"`
247+}
248+
249+func (c *youTubeClient) GetChannelImageURL(ctx context.Context, channelID string) (string, error) {
250+ query := url.Values{}
251+ query.Add("id", channelID)
252+ query.Add("part", "snippet")
253+ query.Add("fields", "items/snippet/thumbnails")
254+ query.Add("key", c.apiKey)
255+
256+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
257+ if err != nil {
258+ return "", fmt.Errorf("failed to set up request: %w", err)
259+ }
260+ req.URL = &url.URL{
261+ Scheme: "https",
262+ Host: "www.googleapis.com",
263+ Path: "/youtube/v3/channels",
264+ RawQuery: query.Encode(),
265+ }
266+
267+ c.log.Info("Making request to YouTube API for channel image", "channelID", channelID)
268+ resp, err := http.DefaultClient.Do(req)
269+ if err != nil {
270+ return "", fmt.Errorf("failed to fetch channel details: %w", err)
271+ }
272+ defer resp.Body.Close()
273+
274+ if resp.StatusCode != http.StatusOK {
275+ body, err := io.ReadAll(resp.Body)
276+ var bodyStr string
277+ if err != nil {
278+ c.log.Error("failed to decode error body", "error", err)
279+ bodyStr = "failed to decode body"
280+ } else {
281+ bodyStr = string(body)
282+ }
283+ return "", fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
284+ }
285+
286+ decoder := json.NewDecoder(resp.Body)
287+ var respData APIChannelListResponse
288+ err = decoder.Decode(&respData)
289+ if err != nil {
290+ return "", fmt.Errorf("failed to decode channel details: %w", err)
291+ }
292+
293+ if len(respData.Items) == 0 {
294+ return "", fmt.Errorf("channel not found [%s]", channelID)
295+ }
296+
297+ channelData := respData.Items[0]
298+ return channelData.Snippet.Thumbnails.Medium.URL, nil
299+}
300diff --git a/lib/clients/youtube/youtube.go b/lib/clients/youtube/youtube.go
301index 703fae7a0e43b8d49b0b3d36569678211a0c5aab..5570129361168b412e577942bcd838658427165b 100644
302--- a/lib/clients/youtube/youtube.go
303+++ b/lib/clients/youtube/youtube.go
304@@ -10,6 +10,7 @@ import (
305 type Client interface {
306 GetVideoDurations(ctx context.Context, videos map[string]*models.Video) error
307 GetVideoMetadata(ctx context.Context, videoID string) (*models.Video, error)
308+ GetChannelImageURL(ctx context.Context, channelID string) (string, error)
309 }
310
311 type youTubeClient struct {
312diff --git a/migrations/000006_add_image_url.down.sql b/migrations/000006_add_image_url.down.sql
313new file mode 100644
314index 0000000000000000000000000000000000000000..e0bb51c8b6a97a8bd16fd160b1c830ab9d2a36c3
315--- /dev/null
316+++ b/migrations/000006_add_image_url.down.sql
317@@ -0,0 +1 @@
318+ALTER TABLE channels DROP COLUMN image_url;
319diff --git a/migrations/000006_add_image_url.up.sql b/migrations/000006_add_image_url.up.sql
320new file mode 100644
321index 0000000000000000000000000000000000000000..7f233832eb36be2fe9a984f869fd11408f521310
322--- /dev/null
323+++ b/migrations/000006_add_image_url.up.sql
324@@ -0,0 +1 @@
325+ALTER TABLE channels ADD COLUMN image_url text;
326diff --git a/mocks/youtube/youtube.go b/mocks/youtube/youtube.go
327index c9e8ee933be56d9f0f648d072f7f975675eb8b39..273d914448bedd9989c8432e0bd0d16123c7ae95 100644
328--- a/mocks/youtube/youtube.go
329+++ b/mocks/youtube/youtube.go
330@@ -20,6 +20,9 @@ var _ youtube.Client = &ClientMock{}
331 //
332 // // make and configure a mocked youtube.Client
333 // mockedClient := &ClientMock{
334+// GetChannelImageURLFunc: func(ctx context.Context, channelID string) (string, error) {
335+// panic("mock out the GetChannelImageURL method")
336+// },
337 // GetVideoDurationsFunc: func(ctx context.Context, videos map[string]*models.Video) error {
338 // panic("mock out the GetVideoDurations method")
339 // },
340@@ -33,6 +36,9 @@ var _ youtube.Client = &ClientMock{}
341 //
342 // }
343 type ClientMock struct {
344+ // GetChannelImageURLFunc mocks the GetChannelImageURL method.
345+ GetChannelImageURLFunc func(ctx context.Context, channelID string) (string, error)
346+
347 // GetVideoDurationsFunc mocks the GetVideoDurations method.
348 GetVideoDurationsFunc func(ctx context.Context, videos map[string]*models.Video) error
349
350@@ -41,6 +47,13 @@ type ClientMock struct {
351
352 // calls tracks calls to the methods.
353 calls struct {
354+ // GetChannelImageURL holds details about calls to the GetChannelImageURL method.
355+ GetChannelImageURL []struct {
356+ // Ctx is the ctx argument value.
357+ Ctx context.Context
358+ // ChannelID is the channelID argument value.
359+ ChannelID string
360+ }
361 // GetVideoDurations holds details about calls to the GetVideoDurations method.
362 GetVideoDurations []struct {
363 // Ctx is the ctx argument value.
364@@ -56,8 +69,45 @@ type ClientMock struct {
365 VideoID string
366 }
367 }
368- lockGetVideoDurations sync.RWMutex
369- lockGetVideoMetadata sync.RWMutex
370+ lockGetChannelImageURL sync.RWMutex
371+ lockGetVideoDurations sync.RWMutex
372+ lockGetVideoMetadata sync.RWMutex
373+}
374+
375+// GetChannelImageURL calls GetChannelImageURLFunc.
376+func (mock *ClientMock) GetChannelImageURL(ctx context.Context, channelID string) (string, error) {
377+ if mock.GetChannelImageURLFunc == nil {
378+ panic("ClientMock.GetChannelImageURLFunc: method is nil but Client.GetChannelImageURL was just called")
379+ }
380+ callInfo := struct {
381+ Ctx context.Context
382+ ChannelID string
383+ }{
384+ Ctx: ctx,
385+ ChannelID: channelID,
386+ }
387+ mock.lockGetChannelImageURL.Lock()
388+ mock.calls.GetChannelImageURL = append(mock.calls.GetChannelImageURL, callInfo)
389+ mock.lockGetChannelImageURL.Unlock()
390+ return mock.GetChannelImageURLFunc(ctx, channelID)
391+}
392+
393+// GetChannelImageURLCalls gets all the calls that were made to GetChannelImageURL.
394+// Check the length with:
395+//
396+// len(mockedClient.GetChannelImageURLCalls())
397+func (mock *ClientMock) GetChannelImageURLCalls() []struct {
398+ Ctx context.Context
399+ ChannelID string
400+} {
401+ var calls []struct {
402+ Ctx context.Context
403+ ChannelID string
404+ }
405+ mock.lockGetChannelImageURL.RLock()
406+ calls = mock.calls.GetChannelImageURL
407+ mock.lockGetChannelImageURL.RUnlock()
408+ return calls
409 }
410
411 // GetVideoDurations calls GetVideoDurationsFunc.
412diff --git a/models/channel.go b/models/channel.go
413index 04a91be2ad8e4ba4f0a3324bdd491e96e7d9e281..a3be9a930cc017c20fdb5ca79e9973ed333e3d51 100644
414--- a/models/channel.go
415+++ b/models/channel.go
416@@ -7,4 +7,8 @@ type Channel struct {
417 Name string `json:"name"`
418 // Subscribed indicates if the user is subscribed to this channel
419 Subscribed bool `json:"subscribed"`
420+ // UnwatchedCount is the number of unwatched videos from this channel
421+ UnwatchedCount int `json:"unwatched_count"`
422+ // ImageURL is the URL of the channel's profile image
423+ ImageURL string `json:"image_url"`
424 }
425diff --git a/pages/auth.templ b/pages/auth.templ
426index daf515468f7b51ddc988c42716cd98af9c0f8de9..d4e92fab7e0862ba7c73a1d78999569de9b324f7 100644
427--- a/pages/auth.templ
428+++ b/pages/auth.templ
429@@ -1,7 +1,7 @@
430 package pages
431
432 templ AuthPage(errorMsg string) {
433- @BaseLayout("ytrssil - Authentication", false) {
434+ @BaseLayout("ytrssil - Authentication", "auth") {
435 <div class="row justify-content-center mt-5">
436 <div class="col-md-6 col-lg-4">
437 <div class="card">
438diff --git a/pages/auth_templ.go b/pages/auth_templ.go
439index c49afe51c2d07b4e3bac09addfbea1d211668f31..bd1f3272bb1fd3e6ac57ed16b95f169a90902770 100644
440--- a/pages/auth_templ.go
441+++ b/pages/auth_templ.go
442@@ -70,7 +70,7 @@ func AuthPage(errorMsg string) templ.Component {
443 }
444 return nil
445 })
446- templ_7745c5c3_Err = BaseLayout("ytrssil - Authentication", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
447+ templ_7745c5c3_Err = BaseLayout("ytrssil - Authentication", "auth").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
448 if templ_7745c5c3_Err != nil {
449 return templ_7745c5c3_Err
450 }
451diff --git a/pages/channels.templ b/pages/channels.templ
452new file mode 100644
453index 0000000000000000000000000000000000000000..640c28beb473c058ec449ede7248ab44d5194a9c
454--- /dev/null
455+++ b/pages/channels.templ
456@@ -0,0 +1,85 @@
457+package pages
458+
459+import (
460+ "fmt"
461+
462+ "github.com/TheEdgeOfRage/ytrssil-api/models"
463+)
464+
465+templ unsubscribeModal(channelID string) {
466+ <div class="modal fade" id={ fmt.Sprintf("unsubscribe-modal-%s", channelID) } tabindex="-1">
467+ <div class="modal-dialog">
468+ <div class="modal-content">
469+ <div class="modal-header">
470+ <h1 class="modal-title fs-5">Confirm Unsubscribe</h1>
471+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
472+ </div>
473+ <div class="modal-body">
474+ <p>Are you sure you want to unsubscribe from this channel?</p>
475+ </div>
476+ <div class="modal-footer">
477+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
478+ <button
479+ type="button"
480+ class="btn btn-danger"
481+ data-bs-dismiss="modal"
482+ hx-post={ fmt.Sprintf("/channels/%s/unsubscribe", channelID) }
483+ hx-target={ fmt.Sprintf("#channel-card-%s", channelID) }
484+ hx-swap="outerHTML"
485+ >
486+ Unsubscribe
487+ </button>
488+ </div>
489+ </div>
490+ </div>
491+ </div>
492+}
493+
494+templ ChannelCard(channel models.Channel) {
495+ <div id={ fmt.Sprintf("channel-card-%s", channel.ID) } class="channel-card col-md-3 p-2">
496+ @unsubscribeModal(channel.ID)
497+ <div class="card">
498+ <div class="card-body d-flex align-items-center">
499+ if channel.ImageURL != "" {
500+ <img
501+ src={ channel.ImageURL }
502+ alt={ channel.Name }
503+ class="rounded-circle me-3"
504+ style="width: 48px; height: 48px; object-fit: cover;"
505+ />
506+ }
507+ <div class="flex-grow-1">
508+ <h5 class="card-title mb-1">
509+ <a
510+ href={ templ.SafeURL(fmt.Sprintf("https://www.youtube.com/channel/%s", channel.ID)) }
511+ target="_blank"
512+ class="link-light link-underline-opacity-0"
513+ >
514+ { channel.Name }
515+ </a>
516+ </h5>
517+ <p class="card-text mb-0 text-muted">
518+ { fmt.Sprintf("%d", channel.UnwatchedCount) } unwatched
519+ </p>
520+ </div>
521+ <button
522+ class="btn btn-danger ms-2"
523+ data-bs-toggle="modal"
524+ data-bs-target={ fmt.Sprintf("#unsubscribe-modal-%s", channel.ID) }
525+ >
526+ <i class="bi bi-bookmark-dash"></i>
527+ </button>
528+ </div>
529+ </div>
530+ </div>
531+}
532+
533+templ ChannelsPage(channels []models.Channel) {
534+ @BaseLayout("ytrssil - Channels", "channels") {
535+ <div id="channels-list" class="row">
536+ for _, channel := range channels {
537+ @ChannelCard(channel)
538+ }
539+ </div>
540+ }
541+}
542diff --git a/pages/channels_templ.go b/pages/channels_templ.go
543new file mode 100644
544index 0000000000000000000000000000000000000000..22c615a6763f4d74a9f57f035aedbec28c292da2
545--- /dev/null
546+++ b/pages/channels_templ.go
547@@ -0,0 +1,280 @@
548+// Code generated by templ - DO NOT EDIT.
549+
550+// templ: version: v0.3.960
551+package pages
552+
553+//lint:file-ignore SA4006 This context is only used if a nested component is present.
554+
555+import "github.com/a-h/templ"
556+import templruntime "github.com/a-h/templ/runtime"
557+
558+import (
559+ "fmt"
560+
561+ "github.com/TheEdgeOfRage/ytrssil-api/models"
562+)
563+
564+func unsubscribeModal(channelID string) templ.Component {
565+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
566+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
567+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
568+ return templ_7745c5c3_CtxErr
569+ }
570+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
571+ if !templ_7745c5c3_IsBuffer {
572+ defer func() {
573+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
574+ if templ_7745c5c3_Err == nil {
575+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
576+ }
577+ }()
578+ }
579+ ctx = templ.InitializeContext(ctx)
580+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
581+ if templ_7745c5c3_Var1 == nil {
582+ templ_7745c5c3_Var1 = templ.NopComponent
583+ }
584+ ctx = templ.ClearChildren(ctx)
585+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"modal fade\" id=\"")
586+ if templ_7745c5c3_Err != nil {
587+ return templ_7745c5c3_Err
588+ }
589+ var templ_7745c5c3_Var2 string
590+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("unsubscribe-modal-%s", channelID))
591+ if templ_7745c5c3_Err != nil {
592+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 10, Col: 76}
593+ }
594+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
595+ if templ_7745c5c3_Err != nil {
596+ return templ_7745c5c3_Err
597+ }
598diff --git a/pages/main.templ b/pages/main.templ
599index 676e1fec14d1b3e37ccba59c3408027f67a24a43..c12172750c4aa4a53e14f35b8bff83262255c80b 100644
600--- a/pages/main.templ
601+++ b/pages/main.templ
602@@ -1,6 +1,6 @@
603 package pages
604
605-templ BaseLayout(title string, isWatchedPage bool) {
606+templ BaseLayout(title string, route string) {
607 <!DOCTYPE html>
608 <html lang="en" data-bs-theme="dark">
609 <head>
610@@ -28,7 +28,7 @@ templ BaseLayout(title string, isWatchedPage bool) {
611 <body>
612 @subscriptionModal()
613 @addVideoModal()
614- @navbar(isWatchedPage)
615+ @navbar(route)
616 <div class="container-fluid">
617 { children... }
618 </div>
619diff --git a/pages/main_templ.go b/pages/main_templ.go
620index b9f17548503ee5fc741cae1cc0901ca5a6c67615..a4935e49c7ca84555318ae7f66fa4d1c783988b0 100644
621--- a/pages/main_templ.go
622+++ b/pages/main_templ.go
623@@ -8,7 +8,7 @@ package pages
624 import "github.com/a-h/templ"
625 import templruntime "github.com/a-h/templ/runtime"
626
627-func BaseLayout(title string, isWatchedPage bool) templ.Component {
628+func BaseLayout(title string, route string) templ.Component {
629 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
630 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
631 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
632@@ -54,7 +54,7 @@ func BaseLayout(title string, isWatchedPage bool) templ.Component {
633 if templ_7745c5c3_Err != nil {
634 return templ_7745c5c3_Err
635 }
636- templ_7745c5c3_Err = navbar(isWatchedPage).Render(ctx, templ_7745c5c3_Buffer)
637+ templ_7745c5c3_Err = navbar(route).Render(ctx, templ_7745c5c3_Buffer)
638 if templ_7745c5c3_Err != nil {
639 return templ_7745c5c3_Err
640 }
641diff --git a/pages/navbar.templ b/pages/navbar.templ
642index 6ae84a5253853af690bf972b962874d3ccd1f226..d75e4ae66d51bdcb030498ac3047c8b5ecec414f 100644
643--- a/pages/navbar.templ
644+++ b/pages/navbar.templ
645@@ -1,6 +1,30 @@
646 package pages
647
648-templ navbar(isWatchedPage bool) {
649+import "fmt"
650+
651+type NavButton struct {
652+ link string
653+ text string
654+ icon string
655+}
656+
657+func navButtons(route string) []*NavButton {
658+ newVideos := &NavButton{"/", "New Videos", "collection-play"}
659+ watched := &NavButton{"/watched", "Watched", "check-circle"}
660+ channels := &NavButton{"/channels", "Channels", "people"}
661+ switch route {
662+ case "channels":
663+ return []*NavButton{newVideos, watched}
664+ case "new":
665+ return []*NavButton{channels, watched}
666+ case "watched":
667+ return []*NavButton{channels, newVideos}
668+ default:
669+ return []*NavButton{channels, newVideos, watched}
670+ }
671+}
672+
673+templ navbar(route string) {
674 <nav class="navbar navbar-expand-lg bg-body-tertiary">
675 <div class="container-fluid">
676 <a class="navbar-brand" href="/">
677@@ -45,17 +69,13 @@ templ navbar(isWatchedPage bool) {
678 Add Video <i class="bi bi-camera-video"></i>
679 </button>
680 </li>
681- <li class="nav-item me-md-2 mt-2 mt-md-0">
682- if isWatchedPage {
683- <a href="/" class="btn btn-outline-secondary" style="--bs-btn-color: var(--bs-body-color);">
684- New Videos <i class="bi bi-collection-play"></i>
685+ for _, button := range navButtons(route) {
686+ <li class="nav-item me-md-2 mt-2 mt-md-0">
687+ <a href={ button.link } class="btn btn-outline-secondary" style="--bs-btn-color: var(--bs-body-color);">
688+ { button.text } <i class={ fmt.Sprintf("bi bi-%s", button.icon) }></i>
689 </a>
690- } else {
691- <a href="/watched" class="btn btn-outline-secondary" style="--bs-btn-color: var(--bs-body-color);">
692- Watched <i class="bi bi-check-circle"></i>
693- </a>
694- }
695- </li>
696+ </li>
697+ }
698 </ul>
699 <div class="d-flex" role="search">
700 <input id="video-search" class="form-control" type="search" placeholder="Search videos..."/>
701diff --git a/pages/navbar_templ.go b/pages/navbar_templ.go
702index 88fab6583053a278f3136324376626f533ff52aa..15ee96aface66cdfdefdd4ed0bec4d99141ca692 100644
703--- a/pages/navbar_templ.go
704+++ b/pages/navbar_templ.go
705@@ -8,7 +8,31 @@ package pages
706 import "github.com/a-h/templ"
707 import templruntime "github.com/a-h/templ/runtime"
708
709-func navbar(isWatchedPage bool) templ.Component {
710+import "fmt"
711+
712+type NavButton struct {
713+ link string
714+ text string
715+ icon string
716+}
717+
718+func navButtons(route string) []*NavButton {
719+ newVideos := &NavButton{"/", "New Videos", "collection-play"}
720+ watched := &NavButton{"/watched", "Watched", "check-circle"}
721+ channels := &NavButton{"/channels", "Channels", "people"}
722+ switch route {
723+ case "channels":
724+ return []*NavButton{newVideos, watched}
725+ case "new":
726+ return []*NavButton{channels, watched}
727+ case "watched":
728+ return []*NavButton{channels, newVideos}
729+ default:
730+ return []*NavButton{channels, newVideos, watched}
731+ }
732+}
733+
734+func navbar(route string) templ.Component {
735 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
736 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
737 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
738@@ -29,22 +53,65 @@ func navbar(isWatchedPage bool) templ.Component {
739 templ_7745c5c3_Var1 = templ.NopComponent
740 }
741 ctx = templ.ClearChildren(ctx)
742diff --git a/pages/new_videos.templ b/pages/new_videos.templ
743index 036c9704137c4308bd2414a41d3372a2b02bce2b..ef5d11a6301500c829a8d81595ef08c2c53ea511 100644
744--- a/pages/new_videos.templ
745+++ b/pages/new_videos.templ
746@@ -100,7 +100,7 @@ templ videoCard(video models.Video) {
747 }
748
749 templ NewVideosPage(videos []models.Video) {
750- @BaseLayout("ytrssil - New Videos", false) {
751+ @BaseLayout("ytrssil - New Videos", "new") {
752 <div class="row">
753 for _, video := range videos {
754 <div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
755diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
756index 4bb5e32fdc39129f11b9ea7a58c616caf6c069e8..6e3c8ad3291fdc0b89a5d53a74f17110ae50751d 100644
757--- a/pages/new_videos_templ.go
758+++ b/pages/new_videos_templ.go
759@@ -436,7 +436,7 @@ func NewVideosPage(videos []models.Video) templ.Component {
760 }
761 return nil
762 })
763- templ_7745c5c3_Err = BaseLayout("ytrssil - New Videos", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
764+ templ_7745c5c3_Err = BaseLayout("ytrssil - New Videos", "new").Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
765 if templ_7745c5c3_Err != nil {
766 return templ_7745c5c3_Err
767 }
768diff --git a/pages/subscription.templ b/pages/subscription.templ
769index 95ec301724102109474e2e0a86922f15f854f250..c4d025294d34a48f9b574add543294e3590059da 100644
770--- a/pages/subscription.templ
771+++ b/pages/subscription.templ
772@@ -13,7 +13,8 @@ templ subscriptionModal() {
773 id="subscription-form"
774 class="d-flex"
775 hx-post="/subscribe"
776- hx-swap="none"
777+ hx-target="#channels-list"
778+ hx-swap="beforeend"
779 hx-on::after-request="subscribeHandler(event)"
780 >
781 <input
782diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
783index dc38f8bc977871275caea2b22425def7186960cb..88012289e41cc5a3dd8258197b04b6ca162c49f7 100644
784--- a/pages/subscription_templ.go
785+++ b/pages/subscription_templ.go
786@@ -29,7 +29,7 @@ func subscriptionModal() templ.Component {
787 templ_7745c5c3_Var1 = templ.NopComponent
788 }
789 ctx = templ.ClearChildren(ctx)
790diff --git a/pages/watched_videos.templ b/pages/watched_videos.templ
791index 27f8f6019febe01437d69e920923f3d59dfc25ba..60e667580608834f8437cc67a3788b5afd255ea9 100644
792--- a/pages/watched_videos.templ
793+++ b/pages/watched_videos.templ
794@@ -36,7 +36,7 @@ templ watchedVideoCard(video models.Video) {
795 }
796
797 templ WatchedVideosPage(videos []models.Video) {
798- @BaseLayout("ytrssil - Watched Videos", true) {
799+ @BaseLayout("ytrssil - Watched Videos", "watched") {
800 <div class="row">
801 for _, video := range videos {
802 <div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
803diff --git a/pages/watched_videos_templ.go b/pages/watched_videos_templ.go
804index 02000188c8e55176cdf4438cbfae65e0ade657eb..a5c5c218b81f9e5a5551d5436778b19ae1a28b24 100644
805--- a/pages/watched_videos_templ.go
806+++ b/pages/watched_videos_templ.go
807@@ -199,7 +199,7 @@ func WatchedVideosPage(videos []models.Video) templ.Component {
808 }
809 return nil
810 })
811- templ_7745c5c3_Err = BaseLayout("ytrssil - Watched Videos", true).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
812+ templ_7745c5c3_Err = BaseLayout("ytrssil - Watched Videos", "watched").Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer)
813 if templ_7745c5c3_Err != nil {
814 return templ_7745c5c3_Err
815 }