179ec8e108e39a3f6706caf0b22573625222c291

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

Message

Add auth page

Diff

This diff is truncated to protect this page.

  1diff --git a/.air.toml b/.air.toml
  2index 97c50c5d88668fe6ee05d242de2076d62233f38b..9f8c40e45aa3722347d1504ffe7101de03c32764 100644
  3--- a/.air.toml
  4+++ b/.air.toml
  5@@ -5,6 +5,7 @@ tmp_dir = "/tmp"
  6   args_bin = [
  7     "--db-uri=postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable",
  8     "--auth-token=foo",
  9+    "--dev",
 10   ]
 11   entrypoint = "./dist/ytrssil"
 12   cmd = "make build"
 13diff --git a/cmd/main.go b/cmd/main.go
 14index c45e085be7f2abdb79de5275ac85108f81db4675..72c0896cd168778afbcc4c4d250d5bfb9ae9673a 100644
 15--- a/cmd/main.go
 16+++ b/cmd/main.go
 17@@ -17,7 +17,6 @@ import (
 18 	"github.com/TheEdgeOfRage/ytrssil-api/db"
 19 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 20 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
 21-	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
 22 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
 23 )
 24 
 25@@ -61,13 +60,12 @@ func main() {
 26 	}
 27 	parser := feedparser.NewParser(logger)
 28 	handler := handler.New(logger, cfg, db, parser)
 29-	gin.SetMode(gin.ReleaseMode)
 30-	router, err := ytrssil.SetupGinRouter(
 31-		logger,
 32-		handler,
 33-		auth.APIAuthMiddleware(cfg.AuthToken),
 34-		auth.PageAuthMiddleware(cfg.AuthToken),
 35-	)
 36+	if cfg.Dev {
 37+		gin.SetMode(gin.DebugMode)
 38+	} else {
 39+		gin.SetMode(gin.ReleaseMode)
 40+	}
 41+	router, err := ytrssil.SetupGinRouter(logger, cfg, handler)
 42 	if err != nil {
 43 		logger.Error(
 44 			"Failed to set up gin server",
 45diff --git a/config/config.go b/config/config.go
 46index 25c2dc4bab48f28e252921c2c8f39e652c1119e6..762ebf76bae42339e8767eabc123d620127be740 100644
 47--- a/config/config.go
 48+++ b/config/config.go
 49@@ -9,6 +9,7 @@ import (
 50 )
 51 
 52 type Config struct {
 53+	Dev           bool   `long:"dev" env:"DEV"`
 54 	Port          int    `long:"port" env:"PORT" default:"8080"`
 55 	DBURI         string `long:"db-uri" env:"DB_URI"`
 56 	AuthToken     string `long:"auth-token" env:"AUTH_TOKEN"`
 57diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go
 58index 47b21aa8bcf60a70c67bcab6a82f6327b67797aa..aae426d79d6a5e982b7bfd9326e070a5dc7d585e 100644
 59--- a/httpserver/auth/auth.go
 60+++ b/httpserver/auth/auth.go
 61@@ -11,15 +11,14 @@ func PageAuthMiddleware(authToken string) gin.HandlerFunc {
 62 	return func(c *gin.Context) {
 63 		tokenCookie, err := c.Request.Cookie("token")
 64 		if err != nil {
 65-			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing auth token cookie"})
 66+			c.Redirect(http.StatusFound, "/auth")
 67 			return
 68 		}
 69 		if tokenCookie.Value != authToken {
 70-			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid auth token"})
 71+			c.Redirect(http.StatusFound, "/auth")
 72 			return
 73 		}
 74 
 75-		// handle request
 76 		c.Next()
 77 	}
 78 }
 79@@ -37,7 +36,6 @@ func APIAuthMiddleware(authToken string) gin.HandlerFunc {
 80 			return
 81 		}
 82 
 83-		// handle request
 84 		c.Next()
 85 	}
 86 }
 87diff --git a/httpserver/ytrssil/auth_pages.go b/httpserver/ytrssil/auth_pages.go
 88new file mode 100644
 89index 0000000000000000000000000000000000000000..e3d2a07070ba070bdc1abded1a703f98f90fa856
 90--- /dev/null
 91+++ b/httpserver/ytrssil/auth_pages.go
 92@@ -0,0 +1,41 @@
 93+package ytrssil
 94+
 95+import (
 96+	"net/http"
 97+
 98+	"github.com/gin-gonic/gin"
 99+
100+	"github.com/TheEdgeOfRage/ytrssil-api/pages"
101+)
102+
103+func (srv server) AuthPage(c *gin.Context) {
104+	r := pages.TemplRenderer{
105+		Ctx:       c.Request.Context(),
106+		Component: pages.AuthPage(""),
107+	}
108+	c.Render(http.StatusOK, r)
109+}
110+
111+func (srv server) HandleAuth(c *gin.Context) {
112+	token := c.PostForm("token")
113+
114+	if token != srv.cfg.AuthToken {
115+		r := pages.TemplRenderer{
116+			Ctx:       c.Request.Context(),
117+			Component: pages.AuthPage("Invalid token"),
118+		}
119+		c.Render(http.StatusBadRequest, r)
120+		return
121+	}
122+
123+	c.SetCookie(
124+		"token",
125+		token,
126+		0,            // maxAge (0 = session cookie)
127+		"/",          // path
128+		"",           // domain (empty = current domain)
129+		!srv.cfg.Dev, // secure (false = works on HTTP)
130+		true,         // httpOnly (true = not accessible via JavaScript)
131+	)
132+	c.Redirect(http.StatusFound, "/")
133+}
134diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
135index 17882aadef829c1d30e09689136edd49171d8b67..37afb434831bf74872ada122d5082d2a24bbf8f9 100644
136--- a/httpserver/ytrssil/server.go
137+++ b/httpserver/ytrssil/server.go
138@@ -8,16 +8,19 @@ import (
139 
140 	"github.com/gin-gonic/gin"
141 
142+	"github.com/TheEdgeOfRage/ytrssil-api/config"
143 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
144+	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
145 )
146 
147 type server struct {
148 	log     *slog.Logger
149+	cfg     config.Config
150 	handler handler.Handler
151 }
152 
153-func NewServer(log *slog.Logger, handler handler.Handler) (*server, error) {
154-	return &server{log: log, handler: handler}, nil
155+func NewServer(log *slog.Logger, cfg config.Config, handler handler.Handler) (*server, error) {
156+	return &server{log: log, cfg: cfg, handler: handler}, nil
157 }
158 
159 func ginLogFormatter(param gin.LogFormatterParams) string {
160@@ -39,9 +42,8 @@ func (srv *server) Healthz(c *gin.Context) {
161 // SetupGinRouter sets up routes for all APIs on a Gin server (aka router)
162 func SetupGinRouter(
163 	l *slog.Logger,
164+	cfg config.Config,
165 	handler handler.Handler,
166-	apiAuthMiddleware func(c *gin.Context),
167-	pageAuthMiddleware func(c *gin.Context),
168 ) (*gin.Engine, error) {
169 	engine := gin.New()
170 	// Middlewares are executed top to bottom in a stack-like manner
171@@ -57,24 +59,26 @@ func SetupGinRouter(
172 		c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"error": "HTTP method not allowed"})
173 	})
174 
175-	srv, err := NewServer(l, handler)
176+	srv, err := NewServer(l, cfg, handler)
177 	if err != nil {
178 		return nil, err
179 	}
180 	engine.GET("/healthz", srv.Healthz)
181 	engine.POST("/fetch", srv.FetchVideosJSON)
182 
183+	engine.GET("/auth", srv.AuthPage)
184+	engine.POST("/auth", srv.HandleAuth)
185+
186 	pages := engine.Group("")
187-	pages.Use(pageAuthMiddleware)
188+	pages.Use(auth.PageAuthMiddleware(cfg.AuthToken))
189 	{
190 		pages.GET("/", srv.NewVideosPage)
191 		pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
192 		pages.PATCH("/videos/:video_id/progress", srv.SetVideoProgressPage)
193 	}
194 
195-	// all APIs go in this routing group and require authentication
196 	api := engine.Group("/api")
197-	api.Use(apiAuthMiddleware)
198+	api.Use(auth.APIAuthMiddleware(cfg.AuthToken))
199 	{
200 		api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
201 		api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
202diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
203index 053fd6a0bfb3f4fc18c2643a4a4fd1504e702364..663abadd7e853c07f1aca97911382cb4a610766b 100644
204--- a/httpserver/ytrssil/videos.go
205+++ b/httpserver/ytrssil/videos.go
206@@ -4,8 +4,6 @@ import (
207 	"net/http"
208 
209 	"github.com/gin-gonic/gin"
210-
211-	"github.com/TheEdgeOfRage/ytrssil-api/models"
212 )
213 
214 func (srv *server) GetNewVideosJSON(c *gin.Context) {
215@@ -15,9 +13,7 @@ func (srv *server) GetNewVideosJSON(c *gin.Context) {
216 		return
217 	}
218 
219-	c.JSON(http.StatusOK, models.VideosResponse{
220-		Videos: videos,
221-	})
222+	c.JSON(http.StatusOK, gin.H{"videos": videos})
223 }
224 
225 func (srv *server) GetWatchedVideosJSON(c *gin.Context) {
226@@ -27,9 +23,7 @@ func (srv *server) GetWatchedVideosJSON(c *gin.Context) {
227 		return
228 	}
229 
230-	c.JSON(http.StatusOK, models.VideosResponse{
231-		Videos: videos,
232-	})
233+	c.JSON(http.StatusOK, gin.H{"videos": videos})
234 }
235 
236 func (srv *server) FetchVideosJSON(c *gin.Context) {
237@@ -43,14 +37,7 @@ func (srv *server) FetchVideosJSON(c *gin.Context) {
238 }
239 
240 func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
241-	var req models.VideoURIRequest
242-	err := c.ShouldBindUri(&req)
243-	if err != nil {
244-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
245-		return
246-	}
247-
248-	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), req.VideoID)
249+	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), c.Param("video_id"))
250 	if err != nil {
251 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
252 		return
253@@ -60,14 +47,7 @@ func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
254 }
255 
256 func (srv *server) MarkVideoAsUnwatchedJSON(c *gin.Context) {
257-	var req models.VideoURIRequest
258-	err := c.ShouldBindUri(&req)
259-	if err != nil {
260-		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
261-		return
262-	}
263-
264-	err = srv.handler.MarkVideoAsUnwatched(c.Request.Context(), req.VideoID)
265+	err := srv.handler.MarkVideoAsUnwatched(c.Request.Context(), c.Param("video_id"))
266 	if err != nil {
267 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
268 		return
269diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
270index 0351c41b6d41cf33a8b7a0b65a6b46d3355e14bf..7e21e7a1645cdf0795300514f403c7d525af92ec 100644
271--- a/httpserver/ytrssil/videos_pages.go
272+++ b/httpserver/ytrssil/videos_pages.go
273@@ -5,7 +5,6 @@ import (
274 
275 	"github.com/gin-gonic/gin"
276 
277-	"github.com/TheEdgeOfRage/ytrssil-api/models"
278 	"github.com/TheEdgeOfRage/ytrssil-api/pages"
279 )
280 
281@@ -28,17 +27,9 @@ func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
282 		Ctx: c.Request.Context(),
283 	}
284 
285-	var req models.VideoURIRequest
286-	err := c.ShouldBindUri(&req)
287+	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), c.Param("video_id"))
288 	if err != nil {
289-		r.Component = pages.ErrorPage(err)
290-		c.Render(http.StatusBadRequest, r)
291-		return
292-	}
293-
294-	err = srv.handler.MarkVideoAsWatched(c.Request.Context(), req.VideoID)
295-	if err != nil {
296-		r.Component = pages.ErrorPage(err)
297+		r.Component = pages.ErrorPage(err.Error())
298 		c.Render(http.StatusInternalServerError, r)
299 		return
300 	}
301@@ -50,26 +41,16 @@ func (srv server) SetVideoProgressPage(c *gin.Context) {
302 	r := pages.TemplRenderer{
303 		Ctx: c.Request.Context(),
304 	}
305-	var req struct {
306-		models.VideoURIRequest
307-		models.SetVideoProgressRequest
308-	}
309-	err := c.ShouldBindUri(&req.VideoURIRequest)
310-	if err != nil {
311-		r.Component = pages.ErrorPage(err)
312-		c.Render(http.StatusBadRequest, r)
313-		return
314-	}
315-	err = c.ShouldBind(&req.SetVideoProgressRequest)
316-	if err != nil {
317-		r.Component = pages.ErrorPage(err)
318+	progress := c.PostForm("progress")
319+	if progress == "" {
320+		r.Component = pages.ErrorPage("missing progress")
321 		c.Render(http.StatusBadRequest, r)
322 		return
323 	}
324 
325-	video, err := srv.handler.SetVideoProgress(c.Request.Context(), req.VideoID, req.Progress)
326+	video, err := srv.handler.SetVideoProgress(c.Request.Context(), c.Param("video_id"), progress)
327 	if err != nil {
328-		r.Component = pages.ErrorPage(err)
329+		r.Component = pages.ErrorPage(err.Error())
330 		c.Render(http.StatusInternalServerError, r)
331 		return
332 	}
333diff --git a/models/http.go b/models/http.go
334deleted file mode 100644
335index f65eb00e5c22fa1b049e1dd4f4c416e2fd40e48b..0000000000000000000000000000000000000000
336--- a/models/http.go
337+++ /dev/null
338@@ -1,13 +0,0 @@
339-package models
340-
341-type VideosResponse struct {
342-	Videos []Video `json:"videos"`
343-}
344-
345-type VideoURIRequest struct {
346-	VideoID string `uri:"video_id" binding:"required"`
347-}
348-
349-type SetVideoProgressRequest struct {
350-	Progress string `form:"progress" binding:"required"`
351-}
352diff --git a/pages/auth.templ b/pages/auth.templ
353new file mode 100644
354index 0000000000000000000000000000000000000000..7f9d27fb9ececa6cda0bc7824d5c7fdb51a41d65
355--- /dev/null
356+++ b/pages/auth.templ
357@@ -0,0 +1,35 @@
358+package pages
359+
360+templ AuthPage(errorMsg string) {
361+	@BaseLayout("ytrssil - Authentication") {
362+		<div class="row justify-content-center mt-5">
363+			<div class="col-md-6 col-lg-4">
364+				<div class="card">
365+					<div class="card-body">
366+						<h5 class="card-title mb-4">Authentication Required</h5>
367+						if errorMsg != "" {
368+							<div class="alert alert-danger" role="alert">
369+								{ errorMsg }
370+							</div>
371+						}
372+						<form method="POST" action="/auth">
373+							<div class="mb-3">
374+								<label for="token" class="form-label">Access Token</label>
375+								<input
376+									type="password"
377+									class="form-control"
378+									id="token"
379+									name="token"
380+									placeholder="Enter your token"
381+									required
382+									autofocus
383+								/>
384+							</div>
385+							<button type="submit" class="btn btn-primary w-100">Submit</button>
386+						</form>
387+					</div>
388+				</div>
389+			</div>
390+		</div>
391+	}
392+}
393diff --git a/pages/auth_templ.go b/pages/auth_templ.go
394new file mode 100644
395index 0000000000000000000000000000000000000000..c0ad6766c0943f40c920a670a2d416c007df1b92
396--- /dev/null
397+++ b/pages/auth_templ.go
398@@ -0,0 +1,81 @@
399+// Code generated by templ - DO NOT EDIT.
400+
401+// templ: version: v0.3.960
402+package pages
403+
404+//lint:file-ignore SA4006 This context is only used if a nested component is present.
405+
406+import "github.com/a-h/templ"
407+import templruntime "github.com/a-h/templ/runtime"
408+
409+func AuthPage(errorMsg string) templ.Component {
410+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
411+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
412+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
413+			return templ_7745c5c3_CtxErr
414+		}
415+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
416+		if !templ_7745c5c3_IsBuffer {
417+			defer func() {
418+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
419+				if templ_7745c5c3_Err == nil {
420+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
421+				}
422+			}()
423+		}
424+		ctx = templ.InitializeContext(ctx)
425+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
426+		if templ_7745c5c3_Var1 == nil {
427+			templ_7745c5c3_Var1 = templ.NopComponent
428+		}
429+		ctx = templ.ClearChildren(ctx)
430+		templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
431+			templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
432+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
433+			if !templ_7745c5c3_IsBuffer {
434+				defer func() {
435+					templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
436+					if templ_7745c5c3_Err == nil {
437+						templ_7745c5c3_Err = templ_7745c5c3_BufErr
438+					}
439+				}()
440+			}
441+			ctx = templ.InitializeContext(ctx)
442diff --git a/pages/error.templ b/pages/error.templ
443index 35b8b02c43f9362612896555244b3003fa9c6dad..23e489136b9f01f343307bc91b2b85a54b8c1515 100644
444--- a/pages/error.templ
445+++ b/pages/error.templ
446@@ -2,11 +2,11 @@ package pages
447 
448 import "fmt"
449 
450-templ ErrorPage(err error) {
451+templ ErrorPage(err string) {
452 	<div class="toast align-items-center text-bg-danger border-0" role="alert">
453 		<div class="d-flex">
454 			<div class="toast-body">
455-				{ fmt.Sprintf("Error: %s", err.Error()) }
456+				{ fmt.Sprintf("Error: %s", err) }
457 			</div>
458 			<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
459 		</div>
460diff --git a/pages/error_templ.go b/pages/error_templ.go
461index 04e3ca881c58552b3b83f4e37bd3bd8e31719608..8661470749078b3e55083c47e1ef873071e81d90 100644
462--- a/pages/error_templ.go
463+++ b/pages/error_templ.go
464@@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime"
465 
466 import "fmt"
467 
468-func ErrorPage(err error) templ.Component {
469+func ErrorPage(err string) templ.Component {
470 	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
471 		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
472 		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
473@@ -36,9 +36,9 @@ func ErrorPage(err error) templ.Component {
474 			return templ_7745c5c3_Err
475 		}
476 		var templ_7745c5c3_Var2 string
477-		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Error: %s", err.Error()))
478+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Error: %s", err))
479 		if templ_7745c5c3_Err != nil {
480-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/error.templ`, Line: 9, Col: 43}
481+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/error.templ`, Line: 9, Col: 35}
482 		}
483 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
484 		if templ_7745c5c3_Err != nil {
485diff --git a/pages/main.templ b/pages/main.templ
486new file mode 100644
487index 0000000000000000000000000000000000000000..b23de3f4cf10d30565c6ccbd75c5b229255d63f3
488--- /dev/null
489+++ b/pages/main.templ
490@@ -0,0 +1,26 @@
491+package pages
492+
493+templ BaseLayout(title string) {
494+	<!DOCTYPE html>
495+	<html lang="en" data-bs-theme="dark">
496+		<head>
497+			<title>{ title }</title>
498+			<meta charset="utf-8"/>
499+			<meta name="viewport" content="width=device-width, initial-scale=1"/>
500+			<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"/>
501+			<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"/>
502+			<style>
503+				.pillarbox { position: relative; }
504+				.pillarbox::after { right: 0; }
505+				.pillarbox::before,
506+				.pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
507+			</style>
508+			<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
509+		</head>
510+		<body>
511+			<div class="container-fluid">
512+				{ children... }
513+			</div>
514+		</body>
515+	</html>
516+}
517diff --git a/pages/main_templ.go b/pages/main_templ.go
518new file mode 100644
519index 0000000000000000000000000000000000000000..280534d91bbb5bdd2f9aee6dd0b0f6f0bec857ba
520--- /dev/null
521+++ b/pages/main_templ.go
522@@ -0,0 +1,61 @@
523+// Code generated by templ - DO NOT EDIT.
524+
525+// templ: version: v0.3.960
526+package pages
527+
528+//lint:file-ignore SA4006 This context is only used if a nested component is present.
529+
530+import "github.com/a-h/templ"
531+import templruntime "github.com/a-h/templ/runtime"
532+
533+func BaseLayout(title string) templ.Component {
534+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
535+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
536+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
537+			return templ_7745c5c3_CtxErr
538+		}
539+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
540+		if !templ_7745c5c3_IsBuffer {
541+			defer func() {
542+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
543+				if templ_7745c5c3_Err == nil {
544+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
545+				}
546+			}()
547+		}
548+		ctx = templ.InitializeContext(ctx)
549+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
550+		if templ_7745c5c3_Var1 == nil {
551+			templ_7745c5c3_Var1 = templ.NopComponent
552+		}
553+		ctx = templ.ClearChildren(ctx)
554+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" data-bs-theme=\"dark\"><head><title>")
555+		if templ_7745c5c3_Err != nil {
556+			return templ_7745c5c3_Err
557+		}
558+		var templ_7745c5c3_Var2 string
559+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
560+		if templ_7745c5c3_Err != nil {
561+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/main.templ`, Line: 7, Col: 17}
562+		}
563+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
564+		if templ_7745c5c3_Err != nil {
565+			return templ_7745c5c3_Err
566+		}
567diff --git a/pages/videos.templ b/pages/new_videos.templ
568rename from pages/videos.templ
569rename to pages/new_videos.templ
570index 81aa9d0c1c04fcc21db6a4993f84af6c859cb244..1947ba9c165a9e77ffe3e749269687a518852ab6 100644
571--- a/pages/videos.templ
572+++ b/pages/new_videos.templ
573@@ -80,8 +80,8 @@ templ videoCard(video models.Video) {
574 					hx-swap="outerHTML"
575 					class="d-flex"
576 				>
577-					<input type="text" name="progress" placeholder="hh:mm:ss" class="form-control "/>
578-					<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i> </button>
579+					<input type="text" name="progress" placeholder="1h2m3s" class="form-control" style="width: 50%"/>
580+					<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i></button>
581 				</form>
582 				<button
583 					hx-patch={ fmt.Sprintf("/videos/%s/watch", video.ID) }
584@@ -97,32 +97,13 @@ templ videoCard(video models.Video) {
585 }
586 
587 templ NewVideosPage(videos []models.Video) {
588-	<!DOCTYPE html>
589-	<html lang="en" data-bs-theme="dark">
590-		<head>
591-			<title>ytrssil</title>
592-			<meta charset="utf-8"/>
593-			<meta name="viewport" content="width=device-width, initial-scale=1"/>
594-			<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"/>
595-			<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"/>
596-			<style>
597-				.pillarbox { position: relative; }
598-				.pillarbox::after { right: 0; }
599-				.pillarbox::before,
600-				.pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
601-			</style>
602-			<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
603-		</head>
604-		<body>
605-			<div class="container-fluid">
606-				<div class="row">
607-					for _, video := range videos {
608-						<div class="video-card col-md-3 p-2">
609-							@videoCard(video)
610-						</div>
611-					}
612+	@BaseLayout("ytrssil - New Videos") {
613+		<div class="row">
614+			for _, video := range videos {
615+				<div class="video-card col-md-3 p-2">
616+					@videoCard(video)
617 				</div>
618-			</div>
619-		</body>
620-	</html>
621+			}
622+		</div>
623+	}
624 }
625diff --git a/pages/videos_templ.go b/pages/new_videos_templ.go
626rename from pages/videos_templ.go
627rename to pages/new_videos_templ.go
628index aabcfef72e4874ab0c22b7b8699fd608801a6eba..baf4eabd53deacdbbf77f7e239e74d1a1c8579f7 100644
629--- a/pages/videos_templ.go
630+++ b/pages/new_videos_templ.go
631@@ -43,7 +43,7 @@ func ProgressBar(video models.Video) templ.Component {
632 			var templ_7745c5c3_Var2 string
633 			templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("progress-%s", video.ID))
634 			if templ_7745c5c3_Err != nil {
635-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 11, Col: 48}
636+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 11, Col: 48}
637 			}
638 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
639 			if templ_7745c5c3_Err != nil {
640@@ -56,7 +56,7 @@ func ProgressBar(video models.Video) templ.Component {
641 			var templ_7745c5c3_Var3 string
642 			templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background: #b11; width: %d%%", video.ProgressPercentage()))
643 			if templ_7745c5c3_Err != nil {
644-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 12, Col: 109}
645+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 12, Col: 109}
646 			}
647 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
648 			if templ_7745c5c3_Err != nil {
649@@ -74,7 +74,7 @@ func ProgressBar(video models.Video) templ.Component {
650 			var templ_7745c5c3_Var4 string
651 			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("progress-%s", video.ID))
652 			if templ_7745c5c3_Err != nil {
653-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 15, Col: 48}
654+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 15, Col: 48}
655 			}
656 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
657 			if templ_7745c5c3_Err != nil {
658@@ -117,7 +117,7 @@ func thumbnail(video models.Video) templ.Component {
659 		var templ_7745c5c3_Var6 templ.SafeURL
660 		templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)))
661 		if templ_7745c5c3_Err != nil {
662-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 22, Col: 79}
663+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 22, Col: 79}
664 		}
665 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
666 		if templ_7745c5c3_Err != nil {
667@@ -145,7 +145,7 @@ func thumbnail(video models.Video) templ.Component {
668 			var templ_7745c5c3_Var7 string
669 			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID))
670 			if templ_7745c5c3_Err != nil {
671-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 30, Col: 70}
672+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 30, Col: 70}
673 			}
674 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
675 			if templ_7745c5c3_Err != nil {
676@@ -158,7 +158,7 @@ func thumbnail(video models.Video) templ.Component {
677 			var templ_7745c5c3_Var8 string
678 			templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
679 			if templ_7745c5c3_Err != nil {
680-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 32, Col: 21}
681+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 32, Col: 21}
682 			}
683 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
684 			if templ_7745c5c3_Err != nil {
685@@ -176,7 +176,7 @@ func thumbnail(video models.Video) templ.Component {
686 			var templ_7745c5c3_Var9 string
687 			templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID))
688 			if templ_7745c5c3_Err != nil {
689-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 37, Col: 70}
690+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 37, Col: 70}
691 			}
692 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
693 			if templ_7745c5c3_Err != nil {
694@@ -189,7 +189,7 @@ func thumbnail(video models.Video) templ.Component {
695 			var templ_7745c5c3_Var10 string
696 			templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
697 			if templ_7745c5c3_Err != nil {
698-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 39, Col: 21}
699+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 39, Col: 21}
700 			}
701 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
702 			if templ_7745c5c3_Err != nil {
703@@ -253,7 +253,7 @@ func videoCard(video models.Video) templ.Component {
704 			var templ_7745c5c3_Var12 string
705 			templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
706 			if templ_7745c5c3_Err != nil {
707-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 64, Col: 18}
708+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 64, Col: 18}
709 			}
710 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
711 			if templ_7745c5c3_Err != nil {
712@@ -271,7 +271,7 @@ func videoCard(video models.Video) templ.Component {
713 			var templ_7745c5c3_Var13 string
714 			templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
715 			if templ_7745c5c3_Err != nil {
716-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 67, Col: 53}
717+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 67, Col: 53}
718 			}
719 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
720 			if templ_7745c5c3_Err != nil {
721@@ -289,7 +289,7 @@ func videoCard(video models.Video) templ.Component {
722 		var templ_7745c5c3_Var14 templ.SafeURL
723 		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID))
724 		if templ_7745c5c3_Err != nil {
725-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 72, Col: 78}
726+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 72, Col: 78}
727 		}
728 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
729 		if templ_7745c5c3_Err != nil {
730@@ -302,7 +302,7 @@ func videoCard(video models.Video) templ.Component {