af8c180c63f002f9be3943e90fae876e92872c55

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add channels list page

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 		}