From a87c8afc2282429b47c78f9993a50f42f80d4b1a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 7 Aug 2026 09:35:30 -0400 Subject: [PATCH 01/16] refactor(admin): remove separate sidebar, integrate admin nav into main sidebar - Delete AdminSidebar component entirely - Add Administration section (Dashboard, Libraries, Users, Settings) to header sidebar, visible only for admin users - Remove Admin Panel link from user dropdown - Strip admin chrome (sidebar wrapper, back buttons) from all 5 admin page templates - Fix activeClass to handle trailing-slash routes correctly - Add isUserVisible helper for library visibility toggles - Fix processing issues page: remove dead Alpine JS, wire HTMX dismiss with proper mediaItemId, add issue ID swap targets - Add processing issue resolve/delete routes to library router - Add GetProcessingIssueStatsData context-based method - Fix users page: remove broken hx-headers auth, simplify role select - Fix settings page: remove dead adminSettings Alpine ref --- internal/handlers/processing_issues.go | 6 + internal/router/library.go | 2 + templates/admin_processing_issues.templ | 59 +++--- templates/admin_processing_issues_templ.go | 171 ++++++++++-------- templates/admin_settings.templ | 41 ++--- templates/admin_settings_templ.go | 168 ++++++++--------- templates/admin_sidebar.templ | 32 ---- templates/admin_sidebar_templ.go | 168 ----------------- templates/admin_users.templ | 25 +-- templates/admin_users_templ.go | 88 +++++---- templates/header.templ | 29 ++- templates/header_templ.go | 200 ++++++++++++++++----- templates/utils.go | 11 +- 13 files changed, 467 insertions(+), 533 deletions(-) delete mode 100644 templates/admin_sidebar.templ delete mode 100644 templates/admin_sidebar_templ.go diff --git a/internal/handlers/processing_issues.go b/internal/handlers/processing_issues.go index cb5b03e..7d34c8f 100644 --- a/internal/handlers/processing_issues.go +++ b/internal/handlers/processing_issues.go @@ -2,6 +2,7 @@ package handlers import ( "bookhoard/internal/database" + "context" "net/http" "time" @@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error { "message": "Issue deleted", }) } + +// GetProcessingIssueStatsData returns stats for SSR (not JSON response) +func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) { + return h.db.GetProcessingIssueStats(ctx, libraryID) +} diff --git a/internal/router/library.go b/internal/router/library.go index f2582c6..b86cb4a 100644 --- a/internal/router/library.go +++ b/internal/router/library.go @@ -38,6 +38,8 @@ func registerLibraryRoutes(cfg *Config) { adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats) adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues) adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats) + adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue) + adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue) adminLibrary.POST("/:id/scan", func(c *echo.Context) error { libraryID := c.Param("id") scanReq := map[string]interface{}{ diff --git a/templates/admin_processing_issues.templ b/templates/admin_processing_issues.templ index 9c58178..82d5bf7 100644 --- a/templates/admin_processing_issues.templ +++ b/templates/admin_processing_issues.templ @@ -8,27 +8,21 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu Processing Issues - Bookhoard - - @Header(user, "/admin/libraries/"+libraryID) -
-
-
-
-
-
- - @Icon("alert", "h-5 w-5") - -

Processing Issues

-
-

Items that couldn't be processed in this library

-
- - @Icon("arrow-left", "h-4 w-4") - Back to Library - + + @Header(user, "/admin/library") +
+
+
+
+
+ + @Icon("alert", "h-5 w-5") + +

Processing Issues

+

Items that couldn't be processed in this library

+
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
@@ -72,7 +66,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
for _, issue := range issues { -
+

{ issue.Title }

@@ -94,17 +88,20 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu }
-
- if issue.Severity == "warning" || issue.Severity == "info" { - - } -
+
+ if issue.Severity == "warning" || issue.Severity == "info" { + + } +
}
diff --git a/templates/admin_processing_issues_templ.go b/templates/admin_processing_issues_templ.go index 0b56c9a..0242e7e 100644 --- a/templates/admin_processing_issues_templ.go +++ b/templates/admin_processing_issues_templ.go @@ -29,15 +29,15 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Processing Issues - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Processing Issues - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = Header(user, "/admin/libraries/"+libraryID).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Header(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -45,25 +45,17 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Processing Issues

Items that couldn't be processed in this library

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Library
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Processing Issues

Items that couldn't be processed in this library

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -71,26 +63,26 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Errors

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Errors

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 41, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 35, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.WarningCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -98,26 +90,26 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Warnings

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Warnings

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 50, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -125,31 +117,31 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Info

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 59, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 53, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -157,26 +149,39 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

No processing issues found for this library.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

No processing issues found for this library.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 72, Col: 98} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -184,12 +189,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 96} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -197,12 +202,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 81, Col: 140} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 140} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -210,12 +215,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 82, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 144} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -223,12 +228,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 139} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 139} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -236,12 +241,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 149} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 149} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -254,12 +259,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -272,12 +277,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 91, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 63} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -290,12 +295,12 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 93, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -309,7 +314,33 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue return templ_7745c5c3_Err } if issue.Severity == "warning" || issue.Severity == "info" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Dismiss") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index d0e2a1e..4490eea 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -9,27 +9,19 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri - - @Header(user, "/admin/settings") -
- @AdminSidebar(user, "/admin/settings") -
-
-
- -
- - @Icon("settings", "h-5 w-5") - -

System Settings

-
-

Configure your Bookhoard instance

-
+ + @Header(user, "/admin/settings") +
+
+
+
+ + @Icon("gear", "h-5 w-5") + +

System Settings

+
+

Configure your Bookhoard instance

+
if errorMessage != "" {
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5") @@ -113,9 +105,8 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri

Device Sync: { systemConfig["base_url"] }/api/sync

-
-
-
- +
+
+ } diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index ec93dcc..20e51e8 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -29,7 +29,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "System Settings - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "System Settings - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -37,36 +37,20 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = AdminSidebar(user, "/admin/settings").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Icon("gear", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

System Settings

Configure your Bookhoard instance

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

System Settings

Configure your Bookhoard instance

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -74,25 +58,25 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 36, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 28, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -100,20 +84,20 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Base URL

Base URL

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Save Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -129,247 +113,247 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

System Defaults

Default timezone for users who haven't set their own.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">New Zealand (UTC+12/+13)

Default timezone for users who haven't set their own.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -377,46 +361,46 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

URL Paths

OPDS: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

URL Paths

OPDS: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 103, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/opds

API: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "/opds

API: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 104, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "/api

Device Sync: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "/api

Device Sync: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 113, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 105, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "/api/sync

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_sidebar.templ b/templates/admin_sidebar.templ deleted file mode 100644 index f213594..0000000 --- a/templates/admin_sidebar.templ +++ /dev/null @@ -1,32 +0,0 @@ -package templates - -templ AdminSidebar(user User, currentPath string) { - -} diff --git a/templates/admin_sidebar_templ.go b/templates/admin_sidebar_templ.go deleted file mode 100644 index dfa9407..0000000 --- a/templates/admin_sidebar_templ.go +++ /dev/null @@ -1,168 +0,0 @@ -// Code generated by templ - DO NOT EDIT. - -// templ: version: v0.3.1020 -package templates - -//lint:file-ignore SA4006 This context is only used if a nested component is present. - -import "github.com/a-h/templ" -import templruntime "github.com/a-h/templ/runtime" - -func AdminSidebar(user User, currentPath string) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var1 := templ.GetChildren(ctx) - if templ_7745c5c3_Var1 == nil { - templ_7745c5c3_Var1 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -var _ = templruntime.GeneratedTemplate diff --git a/templates/admin_users.templ b/templates/admin_users.templ index 878cb1f..0525d94 100644 --- a/templates/admin_users.templ +++ b/templates/admin_users.templ @@ -10,14 +10,12 @@ templ AdminUsers(currentUser User, users []User, adminCount int) { - @Header(currentUser, "/admin/users") - - -
- @AdminSidebar(currentUser, "/admin/users") -
-
-
+ @Header(currentUser, "/admin/users") + + +
+
+
@Icon("users", "h-5 w-5") @@ -76,14 +74,11 @@ templ AdminUsers(currentUser User, users []User, adminCount int) { } else {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Admin
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 96, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 91, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Edit ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if user.Role == "admin" && adminCount == 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/header.templ b/templates/header.templ index 92802b4..711d065 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -122,6 +122,29 @@ templ Header(user User, currentPath string) { } + + if user.Role == "admin" { +
+

Administration

+
+ + @Icon("grid", "h-5 w-5 shrink-0") + Dashboard + + + @Icon("library", "h-5 w-5 shrink-0") + Libraries + + + @Icon("users", "h-5 w-5 shrink-0") + Users + + + @Icon("gear", "h-5 w-5 shrink-0") + Settings + +
+ } @@ -173,12 +196,6 @@ templ SidebarUserMenu(user User) { @Icon("user", "h-4 w-4") Profile - if user.Role == "admin" { - - @Icon("settings", "h-4 w-4") - Admin Panel - - }
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -364,7 +494,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -389,12 +519,12 @@ func SidebarUserMenu(user User) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -431,25 +561,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Profile ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("settings", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Admin Panel ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -483,12 +595,12 @@ func SidebarSignIn(currentPath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = templ.NopComponent + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
Sign in
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">
Create an account
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/utils.go b/templates/utils.go index 917f973..a836c22 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -16,7 +16,7 @@ import ( func activeClass(current, target string) string { base := "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors " - if current == target { + if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") { return base + "bg-brand/15 text-brand" } return base + "text-content-muted hover:bg-surface-hover hover:text-content" @@ -40,6 +40,15 @@ func ContainsString(slice []string, item string) bool { return false } +func isUserVisible(userID string, visibility []UserVisibilityData) bool { + for _, v := range visibility { + if v.UserID == userID { + return v.IsVisible + } + } + return false +} + // TotalMediaCount sums the MediaCount across the given libraries, // used to display the total next to the "All Libraries" option. func TotalMediaCount(libs []LibraryData) int64 { From 706be09dec7d12ee5f4ea465c3f5387c32d0c401 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 7 Aug 2026 09:35:39 -0400 Subject: [PATCH 02/16] feat(admin): redesign library management with HTMX expandable rows + stats dashboard Library management: - Redesign admin_library page with expandable rows that load detail panels via HTMX (LibraryList, LibraryPanel, FolderBrowserContent partials) - Add create/edit/delete library modals using data-* attributes - Add folder browser modal with inline add/remove via HTMX - Add user visibility checkboxes toggled via HTMX - New endpoints in admin_library.go: create, update, delete, panel, folders add/remove, browse, visibility - New template types: FolderData, DirEntry, UserVisibilityData, AdminStats; extend LibraryData with TypeValue and FolderCount Admin dashboard: - Rewrite admin.templ to show 4-stat grid (libraries, media, users, devices) - Add getAdminStats helper querying library/user/media/device counts - Pass AdminStats to template from both /admin and /admin/ handlers --- internal/router/admin_library.go | 380 ++++++++++ internal/router/frontend.go | 12 +- templates/admin.templ | 227 +++--- templates/admin_library.templ | 464 +++++++++---- templates/admin_library_templ.go | 1111 +++++++++++++++++++++++------- templates/admin_templ.go | 104 ++- templates/types.go | 25 + 7 files changed, 1813 insertions(+), 510 deletions(-) create mode 100644 internal/router/admin_library.go diff --git a/internal/router/admin_library.go b/internal/router/admin_library.go new file mode 100644 index 0000000..e1059ef --- /dev/null +++ b/internal/router/admin_library.go @@ -0,0 +1,380 @@ +package router + +import ( + "bytes" + "bookhoard/internal/database" + "bookhoard/internal/handlers" + "bookhoard/templates" + "context" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v5" +) + +func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) { + g := frontendProtected.Group("", handlers.AdminMiddleware) + + // HTMX: Create library + g.POST("/admin/library/create", func(c *echo.Context) error { + user := c.Get("user").(database.Users) + + name := c.FormValue("name") + desc := c.FormValue("description") + libType := c.FormValue("type") + if name == "" || libType == "" { + return c.HTML(http.StatusBadRequest, `
Name and type are required
`) + } + + _, err := cfg.LibraryService.CreateLibrary( + c.Request().Context(), + name, + desc, + libType, + user.ID, + ) + if err != nil { + return c.HTML(http.StatusInternalServerError, `
Failed to create library
`) + } + + return renderLibraryList(c, cfg) + }) + + // HTMX: Update library + g.PUT("/admin/library/:id", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + name := c.FormValue("name") + desc := c.FormValue("description") + if name == "" { + return c.HTML(http.StatusBadRequest, `
Name is required
`) + } + + _, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc) + if err != nil { + return c.HTML(http.StatusInternalServerError, `
Failed to update library
`) + } + + return renderLibraryList(c, cfg) + }) + + // HTMX: Delete library + g.DELETE("/admin/library/:id", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID) + if err != nil { + return c.HTML(http.StatusInternalServerError, `
Failed to delete library
`) + } + + return renderLibraryList(c, cfg) + }) + + // HTMX: Library expanded panel + g.GET("/admin/library/:id/panel", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + return renderLibraryPanel(c, cfg, libraryID) + }) + + // HTMX: Add folder + g.POST("/admin/library/:id/folders", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + folderPath := c.FormValue("folder_path") + if folderPath == "" { + return renderLibraryPanel(c, cfg, libraryID) + } + + if strings.Contains(folderPath, "..") { + return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed") + } + + cleanPath := filepath.Clean(folderPath) + fileInfo, err := os.Stat(cleanPath) + if err != nil { + return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist") + } + if !fileInfo.IsDir() { + return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory") + } + + _, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath) + if err != nil { + return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error()) + } + + return renderLibraryPanel(c, cfg, libraryID) + }) + + // HTMX: Remove folder + g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + folderPath := c.FormValue("folder_path") + if folderPath == "" { + return renderLibraryPanel(c, cfg, libraryID) + } + + err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath) + if err != nil { + return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder") + } + + return renderLibraryPanel(c, cfg, libraryID) + }) + + // HTMX: Folder browser + g.GET("/admin/library/browse", func(c *echo.Context) error { + path := c.QueryParam("path") + if path == "" { + path = "/" + } + targetInput := c.QueryParam("target_input") + libraryID := c.QueryParam("library_id") + + dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Cannot browse: `+err.Error()+`
`) + } + + entries := make([]templates.DirEntry, len(dirs)) + for i, d := range dirs { + fullPath := filepath.Join(currentPath, d) + entries[i] = templates.DirEntry{Name: d, Path: fullPath} + } + + var buf bytes.Buffer + err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // HTMX: Set user visibility for library + g.POST("/admin/library/:id/visibility", func(c *echo.Context) error { + libraryID, err := parseAdminUUID(c.Param("id")) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) + } + + userIDStr := c.FormValue("user_id") + isVisible := c.FormValue("is_visible") == "true" + + userID, err := parseAdminUUID(userIDStr) + if err != nil { + return c.HTML(http.StatusBadRequest, `
Invalid user ID
`) + } + + _, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible) + if err != nil { + return c.HTML(http.StatusInternalServerError, `
Failed to update visibility
`) + } + + return renderLibraryPanel(c, cfg, libraryID) + }) +} + +// renderLibraryList fetches all libraries + users and renders the LibraryList partial. +func renderLibraryList(c *echo.Context, cfg *Config) error { + libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context()) + if err != nil { + return c.HTML(http.StatusInternalServerError, `
Failed to load libraries
`) + } + + libData := make([]templates.LibraryData, len(libraries)) + for i, lib := range libraries { + libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID) + libData[i] = templates.LibraryData{ + ID: libUUID.String(), + Name: lib.Name, + Description: getText(lib.Description), + TypeName: lib.TypeName, + TypeValue: lib.TypeName, + FolderCount: folderCount, + } + } + + users, err := cfg.Queries.ListUsers(c.Request().Context()) + if err != nil { + log.Printf("ListUsers failed: %v", err) + users = []database.ListUsersRow{} + } + userData := make([]templates.User, len(users)) + for i, u := range users { + userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) + userData[i] = templates.User{ + ID: userUUID.String(), + Username: u.Username, + Email: u.Email, + Role: u.Role, + } + } + + var buf bytes.Buffer + err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +} + +// renderLibraryPanel fetches library details and renders the LibraryPanel partial. +func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error { + return renderLibraryPanelWithError(c, cfg, libraryID, "") +} + +func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error { + ctx := c.Request().Context() + libraryIDStr := uuid.UUID(libraryID.Bytes).String() + + // Get library details + lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID) + if err != nil { + return c.HTML(http.StatusNotFound, `
Library not found
`) + } + + libData := templates.LibraryData{ + ID: libraryIDStr, + Name: lib.Name, + Description: getText(lib.Description), + TypeName: lib.TypeName, + TypeValue: lib.TypeName, + } + + // Get folders + dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID) + if err != nil { + log.Printf("GetLibraryFolders failed: %v", err) + } + folders := make([]templates.FolderData, len(dbFolders)) + for i, f := range dbFolders { + folders[i] = templates.FolderData{FolderPath: f.FolderPath} + } + libData.FolderCount = len(folders) + + // Get users + dbUsers, err := cfg.Queries.ListUsers(ctx) + if err != nil { + log.Printf("ListUsers failed: %v", err) + dbUsers = []database.ListUsersRow{} + } + userData := make([]templates.User, len(dbUsers)) + for i, u := range dbUsers { + userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) + userData[i] = templates.User{ + ID: userUUID.String(), + Username: u.Username, + Email: u.Email, + } + } + + // Get visibility for all users + visibility := make([]templates.UserVisibilityData, len(userData)) + for i, u := range userData { + userUUID, _ := parseAdminUUID(u.ID) + visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID) + if err != nil { + log.Printf("GetUserVisibleLibraries failed: %v", err) + } + isVisible := false + for _, vl := range visibleLibs { + if vl.ID.Bytes == libraryID.Bytes { + isVisible = true + break + } + } + visibility[i] = templates.UserVisibilityData{ + UserID: u.ID, + Username: u.Username, + Email: u.Email, + IsVisible: isVisible, + } + } + + // Get issue count + issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID) + if err != nil { + log.Printf("GetProcessingIssueStats failed: %v", err) + } + issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount + + // Get current user for template + tmplUser := templates.User{} + if u, ok := c.Get("user").(database.Users); ok { + userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) + tmplUser = templates.User{ + ID: userUUID.String(), + Username: u.Username, + Role: u.Role, + } + } + + var buf bytes.Buffer + err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf) + if err != nil { + return err + } + + html := buf.String() + if errMsg != "" { + html = `
` + errMsg + `
` + html + } + return c.HTML(http.StatusOK, html) +} + +func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int { + folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID) + if err != nil { + return 0 + } + return len(folders) +} + +func parseAdminUUID(s string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(s) + if err != nil { + return pgtype.UUID{}, err + } + return pgtype.UUID{Bytes: parsed, Valid: true}, nil +} + +func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats { + stats := templates.AdminStats{} + + libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx) + stats.LibraryCount = len(libs) + + users, _ := cfg.Queries.ListUsers(ctx) + stats.UserCount = len(users) + + if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok { + _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount) + _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount) + } + + return stats +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 1ee0774..39724d1 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -810,8 +810,9 @@ func registerFrontendRoutes(cfg *Config) { if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } + stats := getAdminStats(c.Request().Context(), cfg) var buf bytes.Buffer - err = templates.Admin(user).Render(c.Request().Context(), &buf) + err = templates.Admin(user, stats).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -823,8 +824,9 @@ func registerFrontendRoutes(cfg *Config) { if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } + stats := getAdminStats(c.Request().Context(), cfg) var buf bytes.Buffer - err = templates.Admin(user).Render(c.Request().Context(), &buf) + err = templates.Admin(user, stats).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -848,11 +850,14 @@ func registerFrontendRoutes(cfg *Config) { libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, + TypeValue: lib.TypeName, + FolderCount: len(folders), } } @@ -1046,6 +1051,9 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) })) + // Admin library HTMX endpoints + registerAdminLibraryRoutes(cfg, frontendProtected) + // ============================================================================ // LEGACY API ROUTES (for backward compatibility) // ============================================================================ diff --git a/templates/admin.templ b/templates/admin.templ index fb1d8a7..bb7bbe9 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -1,140 +1,135 @@ package templates -templ Admin(user User) { +templ Admin(user User, stats AdminStats) { Admin Dashboard - Bookhoard - - + @Header(user, "/admin") -
- @AdminSidebar(user, "/admin") -
-
-
-
- - @Icon("grid", "h-5 w-5") - -

Dashboard

-
-

Overview of your Bookhoard library and settings

+
+
+
+
+ + @Icon("grid", "h-5 w-5") + +

Dashboard

-
-
-
- - @Icon("library", "h-5 w-5") - -
-

Library

-

Manage your ebook collection

-
-
- - @Icon("arrow-right", "h-4 w-4") - View Library - -
-
-
- - @Icon("sync", "h-5 w-5") - -
-

Scan Watch Status

-

Auto-detecting new files

-
-
-
- - Watching 0 libraries -
+

Overview of your Bookhoard instance

+
+ +
+
+
+ @Icon("library", "h-4 w-4") + Libraries
+

{ stats.LibraryCount }

-
-

Quick Actions

-
- - - - @Icon("library", "h-5 w-5") - Manage Libraries - - Add or remove libraries and scan directories - +
+
+ @Icon("book", "h-4 w-4") + Books
+

{ stats.MediaCount }

- -
-
+ +
+

Quick Actions

+
+ + + + @Icon("library", "h-5 w-5") + Manage Libraries + + Add or remove libraries and folders + +
+
+ + +
+
} diff --git a/templates/admin_library.templ b/templates/admin_library.templ index 1872f7f..18bc24f 100644 --- a/templates/admin_library.templ +++ b/templates/admin_library.templ @@ -6,126 +6,53 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) { Library Management - Bookhoard + - + @Header(user, "/admin/library") -
- @AdminSidebar(user, "/admin/library") -
-
-
-
- - @Icon("arrow-left", "h-4 w-4") - Back to Dashboard - - -
-
- - @Icon("library", "h-5 w-5") - -

Library Management

-
-

Manage libraries and configure media scanning

-
-
- -
-
- @Icon("library", "h-5 w-5 shrink-0") -

Libraries

-
-

Manage media libraries and their folders

-
- if len(libraries) == 0 { -

- No libraries yet. Create your first library to get started. -

- } else { - for _, library := range libraries { -
-
-
-

{ library.Name }

- if library.Description != "" { -

{ library.Description }

- } - - @Icon("tag", "h-3 w-3") - { library.TypeName } - -
-
- - - -
-
- -
- } - } +
+
+
+
+
+
+ + @Icon("library", "h-5 w-5") + +

Libraries

+

Manage media libraries, folders, and scanning

- -
-
- @Icon("check-circle", "h-5 w-5 shrink-0") -

Library Visibility

-
-

Control which libraries are visible to users

-
- -
-
-
- -
-
- @Icon("users", "h-5 w-5 shrink-0") -

User Library Access

-
-

Manage individual user access to specific libraries

-
- -
-
- -
+
-
-
- +
+ @LibraryList(user, libraries, users) +
+
+
+ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LibraryList(user User, libraries []LibraryData, users []User) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if len(libraries) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("library", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

No Libraries Yet

Create your first library to get started

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, library := range libraries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 194, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if library.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 196, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 201, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if library.FolderCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("folder", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 206, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " folders") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

Folders

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(folders) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

No folders configured. Add a folder to enable scanning.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, folder := range folders { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 254, Col: 99} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(users) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("users", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

User Access

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, u := range users { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if issueCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if issueCount == 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "1 processing issue") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " processing issues") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if parentPath != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, entry := range entries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_templ.go b/templates/admin_templ.go index 5351e6b..ed83f71 100644 --- a/templates/admin_templ.go +++ b/templates/admin_templ.go @@ -8,7 +8,7 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func Admin(user User) templ.Component { +func Admin(user User, stats AdminStats) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -29,7 +29,7 @@ func Admin(user User) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -37,15 +37,7 @@ func Admin(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = AdminSidebar(user, "/admin").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -53,23 +45,91 @@ func Admin(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Dashboard

Overview of your Bookhoard library and settings

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Dashboard

Overview of your Bookhoard instance

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("book", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Books

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 38, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("users", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Users

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 45, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("device", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Devices

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 52, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -77,7 +137,7 @@ func Admin(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Scan Watch Status

Auto-detecting new files

Watching 0 libraries

Quick Actions

Quick Actions

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Scan All Libraries Re-scan existing files and detect new items ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,7 +153,7 @@ func Admin(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Manage Libraries Add or remove libraries and scan directories
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scan Complete!
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 84268d7..8201f58 100644 --- a/templates/types.go +++ b/templates/types.go @@ -38,7 +38,32 @@ type LibraryData struct { Name string Description string TypeName string + TypeValue string MediaCount int64 + FolderCount int +} + +type FolderData struct { + FolderPath string +} + +type DirEntry struct { + Name string + Path string +} + +type UserVisibilityData struct { + UserID string + Username string + Email string + IsVisible bool +} + +type AdminStats struct { + LibraryCount int + MediaCount int + UserCount int + DeviceCount int } type SeriesCardData struct { From 006cc0c2c953e8389c6c83d8623e77456d3915f8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 7 Aug 2026 09:35:47 -0400 Subject: [PATCH 03/16] chore(frontend): slim admin.ts, remove dead library.ts - Remove dead functions from admin.ts: loadSystemStats, renderSystemStats, triggerLibraryScan, triggerQuickScan, all WebSocket functions - Remove library.ts (695 lines of innerHTML string-building replaced by HTMX server-rendered partials) - Remove library import from main.ts --- web/src/admin.ts | 143 +-------- web/src/library.ts | 695 ------------------------------------------- web/src/main.ts | 1 - web/static/style.css | 2 +- 4 files changed, 4 insertions(+), 837 deletions(-) delete mode 100644 web/src/library.ts diff --git a/web/src/admin.ts b/web/src/admin.ts index e9cd903..7464756 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,134 +1,5 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; -import { createWebSocket } from "./websocket"; - -function initializeScanWebSocket(): void { - createWebSocket({ - onMessage: (message) => { - switch (message.type) { - case "scan_progress": - updateScanProgress(message.data); - break; - case "scan_complete": - showScanComplete(message.data); - break; - case "scan_error": - showScanError(message.data); - break; - } - }, - enableReconnect: true, - reconnectDelay: 5000, - }); -} - -function updateScanProgress(data: { progress: number; files_scanned: number; new_items: number }): void { - const progressBar = document.getElementById("scan-progress-bar"); - if (progressBar) { - progressBar.style.width = (data.progress * 100) + "%"; - } - - const progressText = document.getElementById("scan-progress-text"); - if (progressText) { - progressText.textContent = `${data.files_scanned} files scanned (${data.new_items} new)`; - } -} - -function showScanComplete(_data: unknown): void { - console.log("Scan complete:", _data); -} - -function showScanError(_data: unknown): void { - console.error("Scan error:", _data); -} - -async function triggerLibraryScan(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/libraries/scan", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Library scan started", "success"); - } else { - const error = await response.json(); - showToast(error.error || "Failed to start scan", "error"); - } - } catch (error) { - console.error("Scan error:", error); - showToast("Failed to start library scan", "error"); - } -} - -async function triggerQuickScan(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/libraries/quick-scan", { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Quick scan started", "success"); - } else { - const error = await response.json(); - showToast(error.error || "Failed to start quick scan", "error"); - } - } catch (error) { - console.error("Quick scan error:", error); - showToast("Failed to start quick scan", "error"); - } -} - -async function loadSystemStats(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/admin/stats", { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const stats = await response.json(); - renderSystemStats(stats); - } - } catch (error) { - console.error("Failed to load stats:", error); - } -} - -function renderSystemStats(stats: Record): void { - const container = document.getElementById("system-stats"); - if (!container) return; - - container.innerHTML = ` -
-
-

${stats.total_books || 0}

-

Total Books

-
-
-

${stats.total_users || 0}

-

Users

-
-
-

${stats.total_devices || 0}

-

Devices

-
-
-

${stats.total_libraries || 0}

-

Libraries

-
-
- `; -} async function scanAllLibraries(): Promise { const token = localStorage.getItem("token"); @@ -227,6 +98,8 @@ function showScanProgress( pollScanProgress(jobIds, libraryNames); } +let scanPollInterval: ReturnType | undefined = undefined; + function pollScanProgress( jobIds: string[], _libraryNames: Record, @@ -283,7 +156,7 @@ function pollScanProgress( } if (allComplete) { - clearInterval(scanPollInterval); + clearInterval(scanPollInterval!); showScanResults( jobIds.length, totalFiles, @@ -369,8 +242,6 @@ async function loadWatchStatus(): Promise { } } -let scanPollInterval: ReturnType | undefined = undefined; - function stopScanStatusPolling(): void { if (scanPollInterval !== undefined) { clearInterval(scanPollInterval); @@ -380,22 +251,14 @@ function stopScanStatusPolling(): void { export { hideScanProgress, - initializeScanWebSocket, - loadSystemStats, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, - triggerLibraryScan, - triggerQuickScan, }; Alpine.data("admin", () => ({ hideScanProgress, - initializeScanWebSocket, - loadSystemStats, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, - triggerLibraryScan, - triggerQuickScan, })); diff --git a/web/src/library.ts b/web/src/library.ts deleted file mode 100644 index 608228a..0000000 --- a/web/src/library.ts +++ /dev/null @@ -1,695 +0,0 @@ -// Library management functionality for admin/library page -// Procedural/imperative style t -// nt -// OOP) - -import { Alpine } from "./alpine"; -import { - apiPut, - apiPost, - apiGet, - handleResponse, - handleError, - handleVoidResponse, - apiDelete, -} from "./api"; -import { showToast } from "./toast"; - -interface Library { - id: string; - name: string; - description: string | null; - library_type_id: string; - type_name: string; - created_at: string; - updated_at: string; -} - -interface LibraryFolder { - id: string; - library_id: string; - folder_path: string; - created_at: string; -} - -interface LibrariesResponse { - data: Library[]; -} - -// State (already SSR'd, used for updates) -let libraries: Library[] = []; - -// Reload libraries from API (called after create/delete/update) -async function reloadLibraries(): Promise { - try { - const response = await apiGet("/libraries"); - const result = (await handleResponse( - response, - )) as unknown as LibrariesResponse; - libraries = result.data; - renderLibraries(); - } catch (error) { - handleError(error, "Failed to load libraries"); - } -} - -// Render libraries list (replaces SSR content after updates) -function renderLibraries(): void { - const container = document.getElementById("libraries-list"); - if (!container) return; - if (libraries.length === 0) { - container.innerHTML = - '

No libraries yet. Create your first library to get started.

'; - return; - } - - container.innerHTML = libraries - .map( - (library) => - '
' + - '
' + - "
" + - `

${escapeHtmlLocal(library.name)}

` + - (library.description - ? `

${escapeHtmlLocal(library.description)}

` - : "") + - `${escapeHtmlLocal(library.type_name)}` + - "
" + - '
' + - `` + - `` + - `` + - "
" + - "
" + - `` + - "
", - ) - .join(""); -} - -// Load user's visible libraries for visibility management -async function loadUserVisibility(): Promise { - const select = document.getElementById("user-select") as HTMLSelectElement; - const userId = select?.value; - if (!userId) { - const container = document.getElementById("user-libraries"); - if (container) { - container.innerHTML = - '

Please select a user

'; - } - return; - } - - try { - const response = await apiGet("/libraries/visible"); - const visibleLibraries = (await handleResponse( - response, - )) as unknown as Library[]; - const container = document.getElementById("user-libraries"); - if (!container) return; - - const visibleIds = new Set(visibleLibraries.map((lib: Library) => lib.id)); - - container.innerHTML = libraries - .map((library) => { - const isVisible = visibleIds.has(library.id); - return ( - '" - ); - }) - .join(""); - } catch (error) { - handleError(error, "Failed to load user libraries"); - } -} - -// Set library visibility for a user -async function setLibraryVisibility( - userId: string, - libraryId: string, - isVisible: boolean, -): Promise { - // userId is used in the HTML onchange handler but the API gets user from JWT context - console.debug( - "Setting visibility for user:", - userId, - "library:", - libraryId, - "visible:", - isVisible, - ); - - try { - const response = await apiPost("/libraries/visibility", { - library_id: libraryId, - is_visible: isVisible, - }); - await handleVoidResponse(response); - - showToast("Library visibility updated", "success"); - - // Refresh visibility controls - void loadUserVisibility(); - } catch (error) { - handleError(error, "Failed to update library visibility"); - } -} - -// Create library form handler -async function handleCreateLibrarySubmit(event: Event): Promise { - event.preventDefault(); - - const form = event.target as HTMLFormElement; - const formData = new FormData(form); - - const libraryId = (document.getElementById("library-id") as HTMLInputElement) - ?.value; - const isEdit = !!libraryId; - - const libraryData = { - name: formData.get("name") as string, - description: formData.get("description") as string, - type: formData.get("type") as string, - }; - - try { - const url = isEdit ? `/libraries/${libraryId}` : "/libraries"; - const response = isEdit - ? await apiPut(url, libraryData) - : await apiPost(url, libraryData); - - if (isEdit) { - await handleVoidResponse(response); - } else { - (await handleResponse(response)) as unknown as { data: Library }; - } - - showToast( - isEdit ? "Library updated successfully" : "Library created successfully", - "success", - ); - - hideCreateLibraryModal(); - form.reset(); - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = ""; - } - - void reloadLibraries(); - } catch (error) { - handleError( - error, - isEdit ? "Failed to update library" : "Failed to create library", - ); - } -} - -// Delete library -async function deleteLibrary(libraryId: string): Promise { - const library = libraries.find((l) => l.id === libraryId); - if (!library) return; - - showDeleteModal(library); -} - -// Show library folders -async function showLibraryFolders(libraryId: string): Promise { - const container = document.getElementById(`library-folders-${libraryId}`); - if (!container) return; - - try { - const response = await apiGet(`/libraries/${libraryId}/folders`); - const folders = (await handleResponse( - response, - )) as unknown as LibraryFolder[]; - - container.innerHTML = folders - .map( - (folder: LibraryFolder) => - '
' + - `${escapeHtmlLocal(folder.folder_path)}` + - `' + - "
", - ) - .join(""); - - container.innerHTML += - '
' + - `' + - `' + - `' + - "
"; - - container.classList.remove("hidden"); - } catch (error) { - handleError(error, "Failed to load folders"); - } -} - -// Add library folder -async function addLibraryFolder(libraryId: string): Promise { - const input = document.getElementById( - `folder-path-${libraryId}`, - ) as HTMLInputElement; - const folderPath = input?.value.trim(); - - if (!folderPath) { - return; - } - - try { - const response = await apiPost(`/libraries/${libraryId}/folders`, { - folder_path: folderPath, - }); - await handleVoidResponse(response); - - showToast("Folder added successfully", "success"); - - if (input) { - input.value = ""; - } - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - handleError(error, "Failed to add folder"); - } -} - -// Remove library folder -async function removeLibraryFolder( - libraryId: string, - folderPath: string, -): Promise { - if (!confirm(`Remove folder "${folderPath}" from the library?`)) { - return; - } - - try { - const response = await apiDelete(`/libraries/${libraryId}/folders`, { - folder_path: folderPath, - }); - await handleVoidResponse(response); - - showToast("Folder removed successfully", "success"); - - void showLibraryFolders(libraryId); // Refresh - } catch (error) { - handleError(error, "Failed to remove folder"); - } -} - -// Edit library (placeholder - opens modal or navigates to edit page) -function editLibrary(libraryId: string): void { - const library = libraries.find((l) => l.id === libraryId); - if (!library) { - showToast("Library not found", "error"); - return; - } - - const form = document.getElementById( - "create-library-form", - ) as HTMLFormElement; - if (form) { - const nameInput = form.querySelector('[name="name"]') as HTMLInputElement; - const descInput = form.querySelector( - '[name="description"]', - ) as HTMLTextAreaElement; - const typeInput = form.querySelector('[name="type"]') as HTMLSelectElement; - - if (nameInput) nameInput.value = library.name; - if (descInput) descInput.value = library.description || ""; - if (typeInput) typeInput.value = library.type_name; - } - - const modalTitle = document.querySelector("#create-library-modal h2"); - if (modalTitle) { - modalTitle.textContent = "Edit Library"; - } - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement; - if (libraryIdInput) { - libraryIdInput.value = libraryId; - } - - showCreateLibraryModal(); -} - -// Modal controls -function showCreateLibraryModal(): void { - const modal = document.getElementById("create-library-modal") as HTMLElement; - if (modal) { - modal.classList.remove("hidden"); - - const modalTitle = document.querySelector("#create-library-modal h2"); - if (modalTitle) { - modalTitle.textContent = "Create Library"; - } - } -} - -function hideCreateLibraryModal(): void { - const modal = document.getElementById("create-library-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } -} - -// Delete modal state -let libraryToDelete: Library | null = null; - -function showDeleteModal(library: Library): void { - libraryToDelete = library; - - const modal = document.getElementById("delete-library-modal") as HTMLElement; - const content = document.getElementById( - "delete-modal-content", - ) as HTMLElement; - - if (modal && content) { - const message = `Are you sure you want to delete "${escapeHtmlLocal(library.name)}"? - -This will remove: -β€’ Library metadata from the database -β€’ All folder references -β€’ All book records from the database - -⚠️ Book files on disk will NOT be deleted. - -This action cannot be undone.`; - - content.innerHTML = message.replace(/\n/g, "
"); - modal.classList.remove("hidden"); - } -} - -function hideDeleteModal(): void { - const modal = document.getElementById("delete-library-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } - libraryToDelete = null; -} - -async function confirmDeleteLibrary(): Promise { - if (!libraryToDelete) return; - - const libraryId = libraryToDelete.id; - hideDeleteModal(); - - try { - const response = await apiDelete(`/libraries/${libraryId}`); - await handleVoidResponse(response); - - showToast("Library deleted successfully", "success"); - - await reloadLibraries(); - - const libraryIdInput = document.getElementById( - "library-id", - ) as HTMLInputElement | null; - if (libraryIdInput) { - libraryIdInput.value = ""; - } - } catch (error) { - handleError(error, "Failed to delete library"); - } -} - -// Local escape HTML helper -function escapeHtmlLocal(text: string): string { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; -} - -// Event delegation for handling dynamic button clicks -function handleLibraryListClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - if (!button) return; - - const action = button.dataset.action; - const libraryId = button.dataset.libraryId; - - switch (action) { - case "show-folders": - if (libraryId) showLibraryFolders(libraryId); - break; - case "delete": - if (libraryId) deleteLibrary(libraryId); - break; - case "edit": - if (libraryId) editLibrary(libraryId); - break; - case "add-folder": - if (libraryId) addLibraryFolder(libraryId); - break; - case "remove-folder": - if (libraryId && button.dataset.folderPath) { - removeLibraryFolder(libraryId, button.dataset.folderPath); - } - break; - case "browse-folder": - if (button.dataset.inputId) showFolderBrowser(button.dataset.inputId); - break; - } -} - -function handleFolderBrowserClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - const div = target.closest("div[data-action]") as HTMLElement; - - if (button) { - const action = button.dataset.action; - const path = button.dataset.path; - - switch (action) { - case "browse-parent": - if (path) navigateFolderBrowser(path); - break; - case "browse-cancel": - hideFolderBrowser(); - break; - case "browse-select": - if (path) selectBrowseFolder(path); - break; - } - } - - if (div && div.dataset.action === "browse-navigate") { - const path = div.dataset.path; - if (path) navigateFolderBrowser(path); - } -} - -function handleGlobalClick(event: Event): void { - const target = event.target as HTMLElement; - const button = target.closest("button") as HTMLElement; - if (!button) return; - - const action = button.dataset.action; - - switch (action) { - case "show-create-modal": - showCreateLibraryModal(); - break; - case "hide-create-modal": - hideCreateLibraryModal(); - break; - case "hide-delete-modal": - hideDeleteModal(); - break; - case "confirm-delete": - void confirmDeleteLibrary(); - break; - } -} - -// Folder browser state -let currentBrowsePath = ""; -let currentBrowseInputId = ""; - -// Show folder browser modal -function showFolderBrowser(inputId: string): void { - currentBrowseInputId = inputId; - currentBrowsePath = "/"; - - const modal = document.getElementById("folder-browser-modal") as HTMLElement; - if (modal) { - modal.classList.remove("hidden"); - void loadBrowseDirectories(currentBrowsePath); - } -} - -// Load directories for browsing -async function loadBrowseDirectories(path: string): Promise { - try { - const response = await apiGet( - `/libraries/browse?path=${encodeURIComponent(path)}`, - ); - const data = (await handleResponse(response)) as unknown as { - current_path: string; - parent_path: string; - directories: string[]; - }; - - currentBrowsePath = data.current_path; - renderBrowseDirectories(data); - } catch (error) { - handleError(error, "Failed to load directories"); - } -} - -// Render browse directories (uses event delegation via data-action attributes) -function renderBrowseDirectories(data: { - current_path: string; - parent_path: string; - directories: string[]; -}): void { - const container = document.getElementById("folder-browser-content"); - if (!container) return; - - let html = ` -
- ${ - data.parent_path - ? `` - : "" - } - ${escapeHtmlLocal(data.current_path)} -
-
- `; - - if (data.directories.length === 0) { - html += - '

No subdirectories

'; - } else { - data.directories.forEach((dir) => { - const fullPath = - data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`; - html += ` -
- πŸ“ ${escapeHtmlLocal(dir)} -
- `; - }); - } - - html += ` -
-
- - -
- `; - - container.innerHTML = html; -} - -// Navigate to subdirectory -function navigateFolderBrowser(path: string): void { - void loadBrowseDirectories(path); -} - -// Select folder and close browser -function selectBrowseFolder(path: string): void { - const input = document.getElementById( - currentBrowseInputId, - ) as HTMLInputElement; - if (input) { - input.value = path; - } - hideFolderBrowser(); -} - -// Hide folder browser modal -function hideFolderBrowser(): void { - const modal = document.getElementById("folder-browser-modal") as HTMLElement; - if (modal) { - modal.classList.add("hidden"); - } -} - -// Initialize page -function initializeLibraryAdmin(): void { - // Setup event listeners - const librariesList = document.getElementById("libraries-list"); - if (librariesList) { - librariesList.addEventListener("click", handleLibraryListClick); - } - - const folderBrowserModal = document.getElementById("folder-browser-modal"); - if (folderBrowserModal) { - folderBrowserModal.addEventListener("click", handleFolderBrowserClick); - } - - document.addEventListener("click", handleGlobalClick); - - // Setup form submission - const createLibraryForm = document.getElementById("create-library-form"); - if (createLibraryForm) { - createLibraryForm.addEventListener("submit", handleCreateLibrarySubmit); - } - - // SSR provides initial library list - no need to fetch on page load - // reloadLibraries() is called after create/delete/update operations -} - -// Export functions for global access -export { - addLibraryFolder, - confirmDeleteLibrary, - deleteLibrary, - editLibrary, - handleCreateLibrarySubmit, - hideDeleteModal, - hideFolderBrowser, - initializeLibraryAdmin, - loadUserVisibility, - navigateFolderBrowser, - removeLibraryFolder, - selectBrowseFolder, - setLibraryVisibility, - showDeleteModal, - showFolderBrowser, - showLibraryFolders, -}; - -Alpine.data("library", () => ({ - addLibraryFolder, - confirmDeleteLibrary, - deleteLibrary, - editLibrary, - handleCreateLibrarySubmit, - hideDeleteModal, - hideFolderBrowser, - initializeLibraryAdmin, - loadUserVisibility, - navigateFolderBrowser, - removeLibraryFolder, - selectBrowseFolder, - setLibraryVisibility, - showDeleteModal, - showFolderBrowser, - showLibraryFolders, -})); diff --git a/web/src/main.ts b/web/src/main.ts index 5af70b9..4253739 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -20,7 +20,6 @@ import "./dom"; import "./events"; import "./header"; import "./index"; -import "./library"; import "./library-switcher"; import "./linking"; import "./login"; diff --git a/web/static/style.css b/web/static/style.css index c2ef621..4cf0832 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[calc\(100vh-4rem\)\]{height:calc(100vh - 4rem)}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.scale-100,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.scale-100,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file From a42232e67a114a6cc1473f378b7673819e0907f9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 7 Aug 2026 23:29:01 -0400 Subject: [PATCH 04/16] fix(admin): expandable sidebar section + library manage toggle - Admin section in sidebar now uses same expandable panel pattern as Appearance and User sections (toggle button with chevron, auto-opens when on /admin pages) - Library Manage button toggles open/close instead of only opening (uses htmx.ajax for open, clears panel for close) --- templates/admin_library.templ | 17 +- templates/admin_library_templ.go | 437 +++++++++++++++---------------- templates/header.templ | 27 +- templates/header_templ.go | 62 +++-- web/static/style.css | 2 +- 5 files changed, 283 insertions(+), 262 deletions(-) diff --git a/templates/admin_library.templ b/templates/admin_library.templ index 18bc24f..a6fe460 100644 --- a/templates/admin_library.templ +++ b/templates/admin_library.templ @@ -142,6 +142,18 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -180,7 +180,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 194, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 206, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -198,7 +198,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 196, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 208, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -220,7 +220,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 201, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 213, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -242,7 +242,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 206, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 218, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -260,7 +260,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 212, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 224, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -274,33 +274,20 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Scan
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -355,12 +342,12 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -368,74 +355,74 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

Folders

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

Folders

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(folders) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

No folders configured. Add a folder to enable scanning.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

No folders configured. Add a folder to enable scanning.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, folder := range folders { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 265, Col: 99} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "Add
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(users) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -547,115 +534,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

User Access

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

User Access

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, u := range users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if issueCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -664,25 +651,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo return templ_7745c5c3_Err } if issueCount == 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "1 processing issue") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "1 processing issue") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " processing issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " processing issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -691,51 +678,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "Delete Library") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -801,12 +788,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var33 := templ.GetChildren(ctx) - if templ_7745c5c3_Var33 == nil { - templ_7745c5c3_Var33 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -814,38 +801,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if parentPath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "..") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, entry := range entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "Select This Folder
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/header.templ b/templates/header.templ index 711d065..6699bf5 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -122,11 +122,25 @@ templ Header(user User, currentPath string) { } - - if user.Role == "admin" { -
-

Administration

-
+ + if user.Role == "admin" { + + }
diff --git a/templates/header_templ.go b/templates/header_templ.go index c1796d0..80c4667 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -353,7 +353,15 @@ func Header(user User, currentPath string) templ.Component { return templ_7745c5c3_Err } if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

Administration

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -362,7 +370,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -383,7 +391,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Dashboard ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Dashboard ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -392,7 +400,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -413,7 +421,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Libraries ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Libraries ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -422,7 +430,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -443,7 +451,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "Users ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Users ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -452,7 +460,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -473,12 +481,12 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "Settings
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -494,7 +502,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -524,7 +532,7 @@ func SidebarUserMenu(user User) templ.Component { templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -561,7 +569,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "Profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -600,7 +608,7 @@ func SidebarSignIn(currentPath string) templ.Component { templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
Sign in
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">
Create an account
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/static/style.css b/web/static/style.css index 4cf0832..19e4b24 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.scale-100,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file From 64ad10b3f2bccb6a74dfef461c21d8e5d17a9431 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 7 Aug 2026 23:37:14 -0400 Subject: [PATCH 05/16] feat(admin): add scan settings + system information to settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scanning section: auto-scan toggle (on/off) and poll interval input with HTMX form that updates system_settings table - System Information section: surfaces all hardcoded constants (session duration, password policy, rate limits, worker pool, sync queue, tombstone TTL, OPDS page size, CORS, etc.) - New ScanSettingsData type, ScanSettingsSection partial template - New HTMX endpoint: PUT /admin/settings/scan - registerAdminSettingsRoutes for scan settings HTMX CRUD - Fix bookhoord→bookhoard import typo in admin_library.go --- internal/router/admin_library.go | 37 ++++++++++ internal/router/frontend.go | 24 ++++++- templates/admin_settings.templ | 111 +++++++++++++++++++++++++++++- templates/admin_settings_templ.go | 100 +++++++++++++++++++++++++-- templates/types.go | 5 ++ web/static/style.css | 2 +- 6 files changed, 267 insertions(+), 12 deletions(-) diff --git a/internal/router/admin_library.go b/internal/router/admin_library.go index e1059ef..46ad52c 100644 --- a/internal/router/admin_library.go +++ b/internal/router/admin_library.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "github.com/google/uuid" @@ -378,3 +379,39 @@ func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats { return stats } + +func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { + g := frontendProtected.Group("", handlers.AdminMiddleware) + + g.PUT("/admin/settings/scan", func(c *echo.Context) error { + ctx := c.Request().Context() + + autoScan := c.FormValue("auto_scan_enabled") == "true" + intervalStr := c.FormValue("scan_poll_interval_seconds") + interval, err := strconv.Atoi(intervalStr) + if err != nil || interval < 1 || interval > 3600 { + return c.HTML(http.StatusBadRequest, `
Interval must be between 1 and 3600 seconds
`) + } + + autoScanStr := "false" + if autoScan { + autoScanStr = "true" + } + _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{ + SettingKey: "auto_scan_enabled", + SettingValue: autoScanStr, + }) + _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{ + SettingKey: "scan_poll_interval_seconds", + SettingValue: strconv.Itoa(interval), + }) + + scanSettings := templates.ScanSettingsData{ + AutoScanEnabled: autoScan, + ScanPollIntervalSeconds: interval, + } + var buf bytes.Buffer + _ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf) + return c.HTML(http.StatusOK, buf.String()) + }) +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 39724d1..70f8154 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -995,21 +995,37 @@ func registerFrontendRoutes(cfg *Config) { return renderErrorPage(c, "Error loading user", "user_load_error") } + ctx := c.Request().Context() + // Fetch current system configuration - just base_url - baseURL := cfg.getBaseURL(c.Request().Context()) + baseURL := cfg.getBaseURL(ctx) systemConfig := map[string]string{ "base_url": baseURL, "default_timezone": "UTC", } - defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context()) + defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx) if err == nil && defaultTimezone != "" { systemConfig["default_timezone"] = defaultTimezone } + // Fetch scan settings + scanSettings := templates.ScanSettingsData{ + AutoScanEnabled: true, + ScanPollIntervalSeconds: 60, + } + if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil { + scanSettings.AutoScanEnabled = val == "true" + } + if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil { + if n, err := strconv.Atoi(val); err == nil { + scanSettings.ScanPollIntervalSeconds = n + } + } + var buf bytes.Buffer - err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf) + err = templates.AdminSettings(user, systemConfig, scanSettings, "").Render(ctx, &buf) if err != nil { return err } @@ -1053,6 +1069,8 @@ func registerFrontendRoutes(cfg *Config) { // Admin library HTMX endpoints registerAdminLibraryRoutes(cfg, frontendProtected) + // Admin settings HTMX endpoints + registerAdminSettingsRoutes(cfg, frontendProtected) // ============================================================================ // LEGACY API ROUTES (for backward compatibility) diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 4490eea..3888b30 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -1,6 +1,8 @@ package templates -templ AdminSettings(user User, systemConfig map[string]string, errorMessage string) { +import "fmt" + +templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) { @@ -105,8 +107,115 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri

Device Sync: { systemConfig["base_url"] }/api/sync

+ @ScanSettingsSection(scanSettings) +
+
+ @Icon("info", "h-5 w-5 shrink-0") +

System Information

+
+

These values are hardcoded and require a code change to modify.

+
+
+ Session Duration + 7 days +
+
+ Password Requirements + 8+ chars, upper/lower/number/special +
+
+ Login Lockout + 5 attempts / 15 min +
+
+ Auth Rate Limit + 10 requests/min +
+
+ Device Rate Limits + Sync 60/min, Progress 120/min, Metadata 30/min +
+
+ Sync Queue + Every 5s, batch of 50 +
+
+ Annotation Tombstone TTL + 30 days +
+
+ Worker Pool + 3 workers, queue cap 100 +
+
+ OPDS Page Size + 50 per page (max 200) +
+
+ Conversion Cache TTL + 24 hours +
+
+ CORS Origins + * +
+
+
} + +templ ScanSettingsSection(scanSettings ScanSettingsData) { +
+
+ @Icon("refresh", "h-5 w-5 shrink-0") +

Scanning

+
+
+
+
+
+ +

Watch libraries for file changes on startup

+
+ +
+
+ + +

How often to poll libraries for changes (1–3600 seconds). Default: 60.

+
+
+ +
+
+
+
+} diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index 20e51e8..d3e4667 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -8,7 +8,9 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func AdminSettings(user User, systemConfig map[string]string, errorMessage string) templ.Component { +import "fmt" + +func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -65,7 +67,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 28, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 30, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -91,7 +93,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 42, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 44, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -368,7 +370,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 103, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 105, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -381,7 +383,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 104, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -394,13 +396,97 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 105, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ScanSettingsSection(scanSettings).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

System Information

These values are hardcoded and require a code change to modify.

Session Duration 7 days
Password Requirements 8+ chars, upper/lower/number/special
Login Lockout 5 attempts / 15 min
Auth Rate Limit 10 requests/min
Device Rate Limits Sync 60/min, Progress 120/min, Metadata 30/min
Sync Queue Every 5s, batch of 50
Annotation Tombstone TTL 30 days
Worker Pool 3 workers, queue cap 100
OPDS Page Size 50 per page (max 200)
Conversion Cache TTL 24 hours
CORS Origins *
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("refresh", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

Scanning

Watch libraries for file changes on startup

How often to poll libraries for changes (1–3600 seconds). Default: 60.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 8201f58..12f350f 100644 --- a/templates/types.go +++ b/templates/types.go @@ -66,6 +66,11 @@ type AdminStats struct { DeviceCount int } +type ScanSettingsData struct { + AutoScanEnabled bool + ScanPollIntervalSeconds int +} + type SeriesCardData struct { Name string BookCount int64 diff --git a/web/static/style.css b/web/static/style.css index 19e4b24..f29d824 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-catppuccin-latte{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.btn:active{transform:translateY(1px)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.dockable-panel{background-color:var(--bg-secondary);border-bottom:1px solid var(--border);transition:max-height .2s ease-out;overflow:hidden}.dockable-panel.panel-collapsed .panel-content{display:none}.panel-header{-webkit-user-select:none;-moz-user-select:none;user-select:none}.panel-header:hover{background-color:var(--surface-hover)}.panel-container{background-color:var(--bg-secondary);border-right:1px solid var(--border);width:320px;max-height:calc(100vh - 8rem);overflow-y:auto}.panel-container[data-side=right]{border-right:none;border-left:1px solid var(--border)}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-24{bottom:6rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[200px\]{min-width:200px}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.rotate-180,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-90{--tw-bg-opacity:0.9}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-transform{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:opacity-40:disabled{opacity:.4}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:translate-x-5{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:bg-brand{background-color:var(--accent)}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:max-w-none{max-width:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-3{padding-top:.75rem;padding-bottom:.75rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none} \ No newline at end of file From bc474506538f0b173e7085a8a944b74ce736a719 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:00:52 -0400 Subject: [PATCH 06/16] feat(db): typed tunable system settings + SettingsRegistry Add a typed, cached registry over the system_settings table so that values which used to be hardcoded Go literals can be changed at runtime. Schema (database/schema/schema.sql): - Extend system_settings with setting_type, min_value, max_value, requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS, nullable for backward compat with the original three rows). - Seed rows for every tunable: session duration, password rules, login lockout, auth/device rate limits, OPDS page size, tombstone TTL, conversion cache TTL, sync queue interval/batch, and worker pool size/cap. Seed values equal the previous hardcoded literals, so behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves any admin-modified values. Queries (queries.sql): - Add UpsertSystemSetting (RETURNING *) so new keys without a seed row can still be written through the API. - Add GetSystemSettingFull + GetAllSystemSettingsFull returning the full typed row. - Refactor CleanupExpiredRefreshTokens to take the retention window as a parameter (make_interval(secs => $1)) instead of the INTERVAL '7 days' literal, so it can follow a configurable session duration. Registry (internal/database/settings_registry.go): - SettingsRegistry holds an in-memory cache of all known settings, populated by Load at startup and refreshed by Reload on writes. - Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits, TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig, WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in fallback defaults and min/max clamping, so a corrupt or missing row can never break the app. - SettingDefaults is the single source of truth for keys, types, bounds, and human descriptions; All() exposes metadata + current values for the admin UI/API. The registry lives in the database package (rather than its own internal/settings package) because a quirk in this custom go1.26.5 toolchain prevented the large handlers package from importing any newly-created package; every consumer already imports database. Tests: settings_registry_test.go covers default validity per type, int clamping at both bounds, garbage-value fallback, and unknown-key lookup. --- database/schema/schema.sql | 46 ++- internal/database/db.go | 2 +- internal/database/models.go | 18 +- internal/database/querier.go | 7 +- internal/database/queries.sql.go | 118 ++++++- internal/database/queries/queries.sql | 22 +- internal/database/settings_registry.go | 342 ++++++++++++++++++++ internal/database/settings_registry_test.go | 97 ++++++ 8 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 internal/database/settings_registry.go create mode 100644 internal/database/settings_registry_test.go diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 0629cf6..af2f44f 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); --- Insert default system settings -INSERT INTO system_settings (setting_key, setting_value, description) VALUES -('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), -('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), -('default_timezone', 'UTC', 'System default timezone') +-- Extend system_settings with typed metadata so it can back the admin UI's +-- configurable tunables. All columns are nullable for backward compatibility +-- with the original three rows and any pre-existing data. +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20); +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40); + +-- Insert default system settings (original scan/timezone rows + tunables). +-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade. +-- ON CONFLICT DO NOTHING preserves any admin-modified values. +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES +('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'), +('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'), +('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'), +-- security / auth (live) +('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'), +('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'), +('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'), +('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'), +-- security / auth (restart required) +('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'), +('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'), +('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'), +-- api (live) +('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'), +('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'), +('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'), +('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'), +('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'), +-- sync / performance (live) +('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'), +('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'), +-- sync / performance (restart required) +('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'), +('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'), +('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'), +('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table diff --git a/internal/database/db.go b/internal/database/db.go index bdf4241..486aa36 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database diff --git a/internal/database/models.go b/internal/database/models.go index fa7d6a0..714627a 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database @@ -484,11 +484,16 @@ type SystemConfig struct { } type SystemSettings struct { - ID pgtype.UUID `db:"id" json:"id"` - SettingKey string `db:"setting_key" json:"setting_key"` - SettingValue string `db:"setting_value" json:"setting_value"` - Description pgtype.Text `db:"description" json:"description"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID pgtype.UUID `db:"id" json:"id"` + SettingKey string `db:"setting_key" json:"setting_key"` + SettingValue string `db:"setting_value" json:"setting_value"` + Description pgtype.Text `db:"description" json:"description"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + SettingType pgtype.Text `db:"setting_type" json:"setting_type"` + MinValue pgtype.Text `db:"min_value" json:"min_value"` + MaxValue pgtype.Text `db:"max_value" json:"max_value"` + RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"` + Category pgtype.Text `db:"category" json:"category"` } type UnlinkedBooks struct { @@ -532,3 +537,4 @@ type Users struct { Timezone pgtype.Text `db:"timezone" json:"timezone"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } + diff --git a/internal/database/querier.go b/internal/database/querier.go index 5d8fbc2..28c4b45 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package database @@ -26,7 +26,7 @@ type Querier interface { CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) // Cleanup expired OPDS tokens CleanupExpiredOpdsTokens(ctx context.Context) error - CleanupExpiredRefreshTokens(ctx context.Context) error + CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error @@ -137,6 +137,7 @@ type Querier interface { // Get all system config GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) + GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) // Get collection @@ -278,6 +279,7 @@ type Querier interface { GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) // System Settings queries GetSystemSetting(ctx context.Context, settingKey string) (string, error) + GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) GetSystemTimezone(ctx context.Context) (string, error) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) // Get universal progress for a book @@ -428,6 +430,7 @@ type Querier interface { UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) + UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 91961c5..076afdd 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: queries.sql package database @@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error { } const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec -DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days') +DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)) ` -func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error { - _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens) +func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error { + _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1) return err } @@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti return items, nil } +const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many +SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key +` + +func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) { + rows, err := q.db.Query(ctx, GetAllSystemSettingsFull) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SystemSettings{} + for rows.Next() { + var i SystemSettings + if err := rows.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many SELECT mh.id, @@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri return setting_value, err } +const GetSystemSettingFull = `-- name: GetSystemSettingFull :one +SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1 +` + +func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) { + row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey) + var i SystemSettings + err := row.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ) + return i, err +} + const GetSystemTimezone = `-- name: GetSystemTimezone :one SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone' ` @@ -12280,3 +12337,56 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett ) return i, err } + +const UpsertSystemSetting = `-- name: UpsertSystemSetting :one +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (setting_key) DO UPDATE +SET setting_value = EXCLUDED.setting_value, + description = EXCLUDED.description, + setting_type = EXCLUDED.setting_type, + min_value = EXCLUDED.min_value, + max_value = EXCLUDED.max_value, + requires_restart = EXCLUDED.requires_restart, + category = EXCLUDED.category, + updated_at = NOW() +RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category +` + +type UpsertSystemSettingParams struct { + SettingKey string `db:"setting_key" json:"setting_key"` + SettingValue string `db:"setting_value" json:"setting_value"` + Description pgtype.Text `db:"description" json:"description"` + SettingType pgtype.Text `db:"setting_type" json:"setting_type"` + MinValue pgtype.Text `db:"min_value" json:"min_value"` + MaxValue pgtype.Text `db:"max_value" json:"max_value"` + RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"` + Category pgtype.Text `db:"category" json:"category"` +} + +func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) { + row := q.db.QueryRow(ctx, UpsertSystemSetting, + arg.SettingKey, + arg.SettingValue, + arg.Description, + arg.SettingType, + arg.MinValue, + arg.MaxValue, + arg.RequiresRestart, + arg.Category, + ) + var i SystemSettings + err := row.Scan( + &i.ID, + &i.SettingKey, + &i.SettingValue, + &i.Description, + &i.UpdatedAt, + &i.SettingType, + &i.MinValue, + &i.MaxValue, + &i.RequiresRestart, + &i.Category, + ) + return i, err +} diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index eb1220a..fb8e871 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -373,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1; -- name: UpdateSystemSetting :exec UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1; +-- name: UpsertSystemSetting :one +INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (setting_key) DO UPDATE +SET setting_value = EXCLUDED.setting_value, + description = EXCLUDED.description, + setting_type = EXCLUDED.setting_type, + min_value = EXCLUDED.min_value, + max_value = EXCLUDED.max_value, + requires_restart = EXCLUDED.requires_restart, + category = EXCLUDED.category, + updated_at = NOW() +RETURNING *; + +-- name: GetSystemSettingFull :one +SELECT * FROM system_settings WHERE setting_key = $1; + -- name: GetAllSystemSettings :many SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key; +-- name: GetAllSystemSettingsFull :many +SELECT * FROM system_settings ORDER BY category, setting_key; + -- name: CreateMediaRating :one INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1, $2, $3) @@ -1007,7 +1027,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1; UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL; -- name: CleanupExpiredRefreshTokens :exec -DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days'); +DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)); -- ============================================ -- FORMAT DETECTION & PROGRESS diff --git a/internal/database/settings_registry.go b/internal/database/settings_registry.go new file mode 100644 index 0000000..3f6e905 --- /dev/null +++ b/internal/database/settings_registry.go @@ -0,0 +1,342 @@ +package database + +// SettingsRegistry provides a typed, cached view over the system_settings table. +// It is the single source of truth for tunable runtime values that used to be +// hardcoded as Go literals. +// +// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize, +// etc.) which read from an in-memory cache. The cache is populated by Load at +// startup and refreshed by Reload whenever a setting is written. Getters always +// fall back to a compiled-in default if the DB value is missing or unparsable, +// so a corrupt or deleted row can never break the app. +// +// SettingsRegistry lives in the database package (rather than its own package) +// so that every consumer already imports database and does not need to take on +// a new package import. + +import ( + "context" + "log" + "strconv" + "sync" + "time" +) + +// SettingType enumerates the value types stored in system_settings.setting_type. +const ( + SettingTypeInt = "int" + SettingTypeBool = "bool" + SettingTypeString = "string" + SettingTypeStringList = "string_list" +) + +// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults. +const ( + SecondsPerMinute = 60 + SecondsPerHour = 3600 + SecondsPerDay = 86400 +) + +// SettingDefault holds the fallback value for a key. These mirror the literals that +// were previously hardcoded in the source so an empty/corrupt DB row preserves +// prior behavior exactly. +type SettingDefault struct { + Key string + Value string + Type string + Min string + Max string + RequiresRestart bool + Category string + Group string + Description string +} + +// SettingDefaults is the source of truth for fallback values and metadata. New keys +// must be added here AND seeded in database/schema/schema.sql. Entries are ordered +// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections. +var SettingDefaults = []SettingDefault{ + {Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"}, + {Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"}, + {Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"}, + {Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"}, + {Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"}, + {Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"}, + {Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"}, + {Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"}, + {Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"}, + {Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"}, + {Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"}, + {Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"}, + {Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"}, + {Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"}, + {Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"}, + {Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"}, + {Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"}, + {Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"}, + {Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"}, + {Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"}, + {Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"}, + {Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"}, + {Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"}, +} + +// defaultBy indexes SettingDefaults by key for O(1) lookup. +var defaultBy = func() map[string]SettingDefault { + m := make(map[string]SettingDefault, len(SettingDefaults)) + for _, d := range SettingDefaults { + m[d.Key] = d + } + return m +}() + +// Registry caches system_settings values in memory. The zero value is not +// usable; construct with New. +type SettingsRegistry struct { + q *Queries + + mu sync.RWMutex + values map[string]string + loadedAt time.Time +} + +// New returns a Registry backed by the given queries. The cache is empty +// until Load is called. +func NewSettingsRegistry(q *Queries) *SettingsRegistry { + return &SettingsRegistry{q: q, values: make(map[string]string)} +} + +// Load populates the cache from the database. Missing rows fall back to the +// compiled defaults. Safe to call multiple times. +func (r *SettingsRegistry) Load(ctx context.Context) error { + rows, err := r.q.GetAllSystemSettings(ctx) + if err != nil { + return err + } + fresh := make(map[string]string, len(SettingDefaults)) + for _, d := range SettingDefaults { + fresh[d.Key] = d.Value + } + for _, row := range rows { + if _, ok := fresh[row.SettingKey]; ok { + fresh[row.SettingKey] = row.SettingValue + } + } + r.mu.Lock() + r.values = fresh + r.loadedAt = time.Now() + r.mu.Unlock() + return nil +} + +// Reload refreshes the cache from the database. Should be called after any +// setting write. On error the cache is left untouched and the error is logged. +func (r *SettingsRegistry) Reload(ctx context.Context) { + if err := r.Load(ctx); err != nil { + log.Printf("settings: reload failed: %v", err) + } +} + +// raw returns the cached string value for a key (or the default), clamped to +// [min, max] for int-typed keys. +func (r *SettingsRegistry) raw(key string) string { + r.mu.RLock() + v, ok := r.values[key] + r.mu.RUnlock() + if !ok || v == "" { + v = defaultBy[key].Value + } + return v +} + +func (r *SettingsRegistry) getInt(key string) int { + d := defaultBy[key] + v := r.raw(key) + n, err := strconv.Atoi(v) + if err != nil { + n, _ = strconv.Atoi(d.Value) + } + if d.Min != "" { + if mn, err := strconv.Atoi(d.Min); err == nil && n < mn { + n = mn + } + } + if d.Max != "" { + if mx, err := strconv.Atoi(d.Max); err == nil && n > mx { + n = mx + } + } + return n +} + +func (r *SettingsRegistry) getBool(key string) bool { + v := r.raw(key) + b, err := strconv.ParseBool(v) + if err != nil { + b, _ = strconv.ParseBool(defaultBy[key].Value) + } + return b +} + +// ---- Domain-specific getters (call sites use these) ---- + +// ScanPollInterval is how often the scanner polls, as a duration. +func (r *SettingsRegistry) ScanPollInterval() time.Duration { + return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second +} + +// AutoScanEnabled reports whether auto-scanning is on. +func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") } + +// DefaultTimezone returns the configured default timezone name. +func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") } + +// SessionDuration is how long a login session / refresh token stays valid. +func (r *SettingsRegistry) SessionDuration() time.Duration { + return time.Duration(r.getInt("session_duration_seconds")) * time.Second +} + +// PasswordMinLength is the minimum password length. +func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") } + +// PasswordRules bundles the active complexity requirements. +type PasswordRules struct { + MinLength int + Upper bool + Lower bool + Number bool + Special bool +} + +// PasswordRules returns the active password complexity configuration. +func (r *SettingsRegistry) PasswordRules() PasswordRules { + return PasswordRules{ + MinLength: r.PasswordMinLength(), + Upper: r.getBool("password_require_upper"), + Lower: r.getBool("password_require_lower"), + Number: r.getBool("password_require_number"), + Special: r.getBool("password_require_special"), + } +} + +// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read +// once at startup. +func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") } + +// LoginLockout returns (max attempts, lockout duration). Read once at startup. +func (r *SettingsRegistry) LoginLockout() (int, time.Duration) { + return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute +} + +// OpdsDefaultPageSize is the default OPDS items-per-page. +func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") } + +// OpdsMaxPageSize is the maximum items-per-page a client may request. +func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") } + +// DeviceRateLimits bundles the per-route device rate limits (requests/minute). +type DeviceRateLimits struct { + Sync int + Progress int + Metadata int +} + +// DeviceRateLimits returns the active device rate limits. +func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits { + return DeviceRateLimits{ + Sync: r.getInt("device_rate_sync_per_min"), + Progress: r.getInt("device_rate_progress_per_min"), + Metadata: r.getInt("device_rate_metadata_per_min"), + } +} + +// TombstoneTTL is how long deleted annotations are retained before purge. +func (r *SettingsRegistry) TombstoneTTL() time.Duration { + return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour +} + +// ConversionCacheTTL is how long converted (kepub) files are served from cache. +func (r *SettingsRegistry) ConversionCacheTTL() time.Duration { + return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour +} + +// SyncQueueConfig bundles the sync queue interval and batch size. Read at +// startup; changes require a restart. +type SyncQueueConfig struct { + Interval time.Duration + BatchSize int +} + +// SyncQueueConfig returns the active sync queue configuration. +func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig { + return SyncQueueConfig{ + Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second, + BatchSize: r.getInt("sync_queue_batch_size"), + } +} + +// WorkerPoolConfig bundles worker count and queue capacity. Read at startup; +// changes require a restart. +type WorkerPoolConfig struct { + Size int + QueueCap int +} + +// WorkerPoolConfig returns the active worker pool configuration. +func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig { + return WorkerPoolConfig{ + Size: r.getInt("worker_pool_size"), + QueueCap: r.getInt("worker_queue_cap"), + } +} + +// SettingEntry exposes one setting's metadata + current value, for the admin UI/API. +type SettingEntry struct { + Key string `json:"key"` + Value string `json:"value"` + Type string `json:"type"` + Min string `json:"min,omitempty"` + Max string `json:"max,omitempty"` + RequiresRestart bool `json:"requires_restart"` + Category string `json:"category"` + Group string `json:"group"` + Description string `json:"description"` + IsDefault bool `json:"is_default"` +} + +// All returns metadata + current values for every known setting, grouped by +// the in-memory cache (which reflects the DB after Load/Reload). +func (r *SettingsRegistry) All() []SettingEntry { + r.mu.RLock() + vals := make(map[string]string, len(r.values)) + for k, v := range r.values { + vals[k] = v + } + r.mu.RUnlock() + out := make([]SettingEntry, 0, len(SettingDefaults)) + for _, d := range SettingDefaults { + v, ok := vals[d.Key] + if !ok { + v = d.Value + } + out = append(out, SettingEntry{ + Key: d.Key, + Value: v, + Type: d.Type, + Min: d.Min, + Max: d.Max, + RequiresRestart: d.RequiresRestart, + Category: d.Category, + Group: d.Group, + Description: d.Description, + IsDefault: v == d.Value, + }) + } + return out +} + +// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown). +func LookupDefault(key string) (SettingDefault, bool) { + d, ok := defaultBy[key] + return d, ok +} diff --git a/internal/database/settings_registry_test.go b/internal/database/settings_registry_test.go new file mode 100644 index 0000000..07a455d --- /dev/null +++ b/internal/database/settings_registry_test.go @@ -0,0 +1,97 @@ +package database + +import ( + "strconv" + "testing" +) + +// TestSettingDefaults ensures every seeded setting has a compiled default with +// a valid value for its declared type. This guards against typos that would +// silently fall back at runtime. +func TestSettingDefaults(t *testing.T) { + if len(SettingDefaults) == 0 { + t.Fatal("SettingDefaults is empty") + } + for _, d := range SettingDefaults { + if d.Key == "" { + t.Errorf("default has empty key: %+v", d) + continue + } + switch d.Type { + case SettingTypeInt: + if _, err := strconv.Atoi(d.Value); err != nil { + t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err) + } + if d.Min != "" { + if _, err := strconv.Atoi(d.Min); err != nil { + t.Errorf("int setting %s min %q is not an int", d.Key, d.Min) + } + } + if d.Max != "" { + if _, err := strconv.Atoi(d.Max); err != nil { + t.Errorf("int setting %s max %q is not an int", d.Key, d.Max) + } + } + case SettingTypeBool: + if _, err := strconv.ParseBool(d.Value); err != nil { + t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value) + } + case SettingTypeString: + if d.Value == "" { + t.Errorf("string setting %s has empty default", d.Key) + } + default: + t.Errorf("setting %s has unknown type %q", d.Key, d.Type) + } + } +} + +// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are +// clamped to the declared min/max, and that garbage falls back to the default. +func TestSettingsRegistryGetIntClamping(t *testing.T) { + r := &SettingsRegistry{values: map[string]string{}, q: nil} + + // Seed with an over-max value; expect clamping to the max (3600). + r.values["scan_poll_interval_seconds"] = "999999" + if got := r.ScanPollInterval(); got.Seconds() != 3600 { + t.Errorf("expected clamp to 3600, got %v", got) + } + + // Seed with an under-min value; expect clamp to min (1). + r.values["scan_poll_interval_seconds"] = "0" + if got := r.ScanPollInterval(); got.Seconds() != 1 { + t.Errorf("expected clamp to 1, got %v", got) + } + + // Seed with garbage; expect fallback to default (60). + r.values["scan_poll_interval_seconds"] = "not-a-number" + if got := r.ScanPollInterval(); got.Seconds() != 60 { + t.Errorf("expected fallback default 60, got %v", got) + } +} + +// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback. +func TestSettingsRegistryGetBoolFallback(t *testing.T) { + r := &SettingsRegistry{values: map[string]string{}, q: nil} + + r.values["auto_scan_enabled"] = "true" + if !r.AutoScanEnabled() { + t.Error("expected true") + } + + r.values["auto_scan_enabled"] = "garbage" + // garbage falls back to default ("true") + if !r.AutoScanEnabled() { + t.Error("expected fallback to default true") + } +} + +// TestLookupDefaultUnknownKey verifies unknown keys return ok=false. +func TestLookupDefaultUnknownKey(t *testing.T) { + if _, ok := LookupDefault("does_not_exist"); ok { + t.Error("expected ok=false for unknown key") + } + if _, ok := LookupDefault("session_duration_seconds"); !ok { + t.Error("expected ok=true for known key") + } +} From 457a38306de70d4cc37029ab53b627700f0f2095 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:01:11 -0400 Subject: [PATCH 07/16] feat(auth): make session duration and password rules configurable Replace the hardcoded 7-day session lifetime and fixed password complexity rules with registry-backed accessors so they can be tuned from the admin UI without a code change. auth.go: - Drop the SessionDuration const; keep DefaultSessionDuration (7 days) as the fallback used when no registry is wired (e.g. in tests). - AuthHandler gains an optional *database.SettingsRegistry and a sessionDuration() helper that reads the registry, falling back to DefaultSessionDuration. - Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from sessionDuration() instead of the package-level SessionDurationSec, so a settings change takes effect on the next login. refresh_token.go: - Refresh-token lifetime follows sessionDuration() via a new refreshTokenTTL() helper (was a separate refreshTokenExpiration const that silently had to be kept in sync with the session duration). password_validator.go: - PasswordValidator now reads min length and the upper/lower/number/ special toggles from the registry at validation time, so rule changes apply immediately. The special-character regex is compiled once and reused (sync.Once). - GetPasswordRequirements() and ValidatePassword() reflect the active configured rules instead of a static list. - Add SetDefaultPasswordSettings() so the package-level default validator (used by echo's struct-tag validator) follows live config. All paths degrade gracefully to the historical defaults when no registry is wired. --- internal/handlers/auth.go | 43 ++++-- internal/handlers/refresh_token.go | 8 +- internal/middleware/password_validator.go | 152 ++++++++++++++-------- 3 files changed, 135 insertions(+), 68 deletions(-) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 8710290..8a6471c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -26,14 +26,34 @@ import ( ) const ( - // Session duration constants - // Follows same pattern as refresh_token.go - SessionDuration = 7 * 24 * time.Hour // 7 days + // DefaultSessionDuration is the fallback session duration used when no + // settings registry is wired (matches the historical 7-day value). + DefaultSessionDuration = 7 * 24 * time.Hour ) -// SessionDurationSec is the session duration in seconds for use in cookies and API responses -// Note: This is computed from SessionDuration to avoid magic numbers -var SessionDurationSec = int(SessionDuration.Seconds()) +// SessionDurationSec is retained for backward compatibility; new code uses the +// registry via AuthHandler.sessionDuration(). +var SessionDurationSec = int(DefaultSessionDuration.Seconds()) + +// SetSettings wires the tunable settings registry (optional). +func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + +// sessionDuration returns the active session duration from the registry. +func (h *AuthHandler) sessionDuration() time.Duration { + if h.settings != nil { + return h.settings.SessionDuration() + } + return DefaultSessionDuration +} + +// refreshTokenTTL returns the active refresh-token lifetime (shared with the +// session duration), with a compiled-default fallback. +func (h *AuthHandler) refreshTokenTTL() time.Duration { + if h.settings != nil { + return h.settings.SessionDuration() + } + return DefaultSessionDuration +} var secure = os.Getenv("COOKIE_SECURE") @@ -41,6 +61,7 @@ type AuthHandler struct { db *database.Queries jwtKey []byte loginAttemptTracker *middleware.LoginAttemptTracker + settings *database.SettingsRegistry } func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler { @@ -265,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error { HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, - MaxAge: SessionDurationSec, + MaxAge: int(h.sessionDuration().Seconds()), } c.SetCookie(cookie) @@ -298,7 +319,7 @@ window.location.href = '/dashboard'; Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.sessionDuration().Seconds()), User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, @@ -411,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error { HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, - MaxAge: SessionDurationSec, + MaxAge: int(h.sessionDuration().Seconds()), } c.SetCookie(cookie) @@ -450,7 +471,7 @@ window.location.href = '%s'; Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.sessionDuration().Seconds()), User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, @@ -1014,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user "user_role": userRole, "user_email": userEmail, "user_username": userUsername, - "exp": time.Now().Add(SessionDuration).Unix(), + "exp": time.Now().Add(h.sessionDuration()).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/internal/handlers/refresh_token.go b/internal/handlers/refresh_token.go index 2fbb358..4863926 100644 --- a/internal/handlers/refresh_token.go +++ b/internal/handlers/refresh_token.go @@ -14,10 +14,6 @@ import ( "github.com/labstack/echo/v5" ) -const ( - refreshTokenExpiration = 7 * 24 * time.Hour // 7 days -) - type RefreshTokenRequest struct { RefreshToken string `json:"refresh_token" validate:"required"` } @@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error { return c.JSON(http.StatusOK, RefreshTokenResponse{ AccessToken: accessToken, TokenType: "Bearer", - ExpiresIn: SessionDurationSec, + ExpiresIn: int(h.refreshTokenTTL().Seconds()), }) } @@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro tokenUUID := uuid.New() refreshToken := tokenUUID.String() - expiresAt := time.Now().Add(refreshTokenExpiration) + expiresAt := time.Now().Add(h.refreshTokenTTL()) _, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Token: pgtype.UUID{Bytes: tokenUUID, Valid: true}, diff --git a/internal/middleware/password_validator.go b/internal/middleware/password_validator.go index 159c3aa..e4e18ae 100644 --- a/internal/middleware/password_validator.go +++ b/internal/middleware/password_validator.go @@ -1,96 +1,146 @@ package middleware import ( + "bookhoard/internal/database" "fmt" "regexp" + "sync" "github.com/go-playground/validator/v10" ) -// PasswordValidator validates password complexity requirements -type PasswordValidator struct{} +// specialCharRegex matches the historical "special character" set used by the +// password complexity rules. +const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]` -// Validate checks if a password meets complexity requirements: -// - Minimum 8 characters -// - At least one uppercase letter -// - At least one lowercase letter -// - At least one number -// - At least one special character +// PasswordValidator validates password complexity against the configured rules. +// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the +// regex set is recompiled under a mutex on each validation. Without a registry +// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply. +type PasswordValidator struct { + settings *database.SettingsRegistry +} + +// SetSettings wires the tunable settings registry. +func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s } + +// compileSpecialRegex isolates the regexp compile (which is safe to call +// concurrently, but we keep it behind a cached var for the no-registry path). +var ( + specialOnce sync.Once + specialRe *regexp.Regexp +) + +func specialRegex() *regexp.Regexp { + specialOnce.Do(func() { + specialRe = regexp.MustCompile(specialCharRegex) + }) + return specialRe +} + +func (v *PasswordValidator) rules() database.PasswordRules { + if v.settings != nil { + return v.settings.PasswordRules() + } + return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true} +} + +// Validate checks if a password meets the configured complexity requirements. func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool { - password := fl.Field().String() + return v.CheckPassword(fl.Field().String()) +} - // Check minimum length - if len(password) < 8 { +// CheckPassword applies the active rules to a single password. +func (v *PasswordValidator) CheckPassword(password string) bool { + r := v.rules() + if len(password) < r.MinLength { return false } - - // Check for uppercase - hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) - if !hasUpper { + if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) { return false } - - // Check for lowercase - hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) - if !hasLower { + if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return false } - - // Check for number - hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) - if !hasNumber { + if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return false } - - // Check for special character - hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) - if !hasSpecial { + if r.Special && !specialRegex().MatchString(password) { return false } - return true } -// GetPasswordRequirements returns a human-readable list of password requirements +// GetPasswordRequirements returns a human-readable list of the active password +// requirements, driven by the configured rules when a registry is wired. func GetPasswordRequirements() []string { - return []string{ - "At least 8 characters long", - "At least one uppercase letter (A-Z)", - "At least one lowercase letter (a-z)", - "At least one number (0-9)", - "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)", - } + return defaultPasswordValidator.Requirements() } -// ValidatePassword checks a password and returns an error if it doesn't meet requirements -func ValidatePassword(password string) error { - if len(password) < 8 { - return fmt.Errorf("password must be at least 8 characters long") +// Requirements returns the human-readable list for the receiver's active rules. +func (v *PasswordValidator) Requirements() []string { + r := v.rules() + var out []string + out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength)) + if r.Upper { + out = append(out, "At least one uppercase letter (A-Z)") } + if r.Lower { + out = append(out, "At least one lowercase letter (a-z)") + } + if r.Number { + out = append(out, "At least one number (0-9)") + } + if r.Special { + out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)") + } + return out +} - if !regexp.MustCompile(`[A-Z]`).MatchString(password) { +// ValidatePassword checks a password against the default (hardcoded) rules and +// returns an error describing the first unmet requirement. Retained for callers +// that don't have access to a configured PasswordValidator instance. +func ValidatePassword(password string) error { + v := defaultPasswordValidator + r := v.rules() + if len(password) < r.MinLength { + return fmt.Errorf("password must be at least %d characters long", r.MinLength) + } + if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) { return fmt.Errorf("password must contain at least one uppercase letter") } - - if !regexp.MustCompile(`[a-z]`).MatchString(password) { + if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) { return fmt.Errorf("password must contain at least one lowercase letter") } - - if !regexp.MustCompile(`[0-9]`).MatchString(password) { + if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) { return fmt.Errorf("password must contain at least one number") } - - if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) { + if r.Special && !specialRegex().MatchString(password) { return fmt.Errorf("password must contain at least one special character") } - return nil } -// RegisterPasswordValidation registers the password validator with the validator instance +// defaultPasswordValidator is used by the package-level helpers +// (GetPasswordRequirements, ValidatePassword) and as the fallback inside +// RegisterPasswordValidation when no registry has been wired. Callers that want +// live rule updates should construct their own PasswordValidator and call +// SetSettings. +var defaultPasswordValidator = &PasswordValidator{} + +// RegisterPasswordValidation registers the password validator with the +// validator instance. The registered func re-evaluates rules on every call, so +// changes to the wired registry take effect immediately. func RegisterPasswordValidation(v *validator.Validate) error { return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool { - pv := &PasswordValidator{} - return pv.Validate(fl) + return defaultPasswordValidator.CheckPassword(fl.Field().String()) }) } + +// SetDefaultPasswordSettings wires the settings registry into the package-level +// default validator so that the struct-tag validator (used by echo's +// CustomValidator) and ValidatePassword follow live configuration. Intended to +// be called once at startup. +func SetDefaultPasswordSettings(s *database.SettingsRegistry) { + defaultPasswordValidator.SetSettings(s) +} From d12911d3c8dcbf8868610822155a9e10814860db Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:01:28 -0400 Subject: [PATCH 08/16] feat(api): make device rate limits, OPDS page size, and conversion cache configurable Move three more hardcoded values behind the settings registry. All apply immediately on the next request (no restart needed). device_auth.go: - DeviceAuthMiddleware reads per-route device rate limits (sync / progress / metadata per minute) from the registry on each authenticated request via a rateLimitConfig() helper, falling back to the Default* constants when no registry is wired. - The X-RateLimit-Limit response header previously hardcoded "60" for every request type; it now reflects the actual configured limit for the request type via rateLimitForRequestType(). opds.go: - Default (50) and maximum (200) OPDS page sizes come from the registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline literals, so catalog pagination can be tuned without a redeploy. conversion_service.go: - The 24h kepub cache lifetime is read from the registry via a cacheTTL() helper (was a bare 24 * time.Hour literal in the constructor). The field default is retained for tests that construct the service directly. - conversion_service_test.go updated to assert both the field default and the cacheTTL() accessor return 24h. --- internal/handlers/opds.go | 25 ++++++++++- internal/middleware/device_auth.go | 47 +++++++++++++++++--- internal/services/conversion_service.go | 24 +++++++++- internal/services/conversion_service_test.go | 4 +- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index 69287d6..7b5d286 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -26,6 +26,26 @@ type OPDSHandler struct { conversionService interface { ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error) } + settings *database.SettingsRegistry +} + +// SetSettings wires the tunable settings registry (OPDS page size). +func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + +// opdsDefaultPageSize returns the configured default page size (50 if unset). +func (h *OPDSHandler) opdsDefaultPageSize() int { + if h.settings != nil { + return h.settings.OpdsDefaultPageSize() + } + return 50 +} + +// opdsMaxPageSize returns the configured maximum page size (200 if unset). +func (h *OPDSHandler) opdsMaxPageSize() int { + if h.settings != nil { + return h.settings.OpdsMaxPageSize() + } + return 200 } func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface { @@ -168,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { } } - perPageNum := 50 + perPageNum := h.opdsDefaultPageSize() + maxPerPage := h.opdsMaxPageSize() if perPage != "" { - if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 { + if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage { perPageNum = num } } diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go index d06132c..19893d7 100644 --- a/internal/middleware/device_auth.go +++ b/internal/middleware/device_auth.go @@ -25,6 +25,7 @@ type DeviceContext struct { type DeviceAuthMiddleware struct { db *database.Queries rateLimiter *DeviceRateLimiter + settings *database.SettingsRegistry } func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { @@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { } } +// SetSettings wires the tunable settings registry so device rate limits are +// read live on each authenticated request. +func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s } + +// rateLimitConfig returns the active device rate limits from the registry, or +// the historical defaults when no registry is wired. +func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig { + if m.settings != nil { + dl := m.settings.DeviceRateLimits() + return DeviceRateLimitConfig{ + SyncRequestsPerMinute: dl.Sync, + ProgressUpdatesPerMinute: dl.Progress, + MetadataRequestsPerMinute: dl.Metadata, + } + } + return DeviceRateLimitConfig{ + SyncRequestsPerMinute: DefaultSyncRequestsPerMinute, + ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute, + MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute, + } +} + +// rateLimitForRequestType returns the configured per-minute limit for a given +// request type, for use in X-RateLimit-* headers. +func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int { + switch requestType { + case "progress": + return config.ProgressUpdatesPerMinute + case "metadata": + return config.MetadataRequestsPerMinute + default: // "sync" and any unknown type + return config.SyncRequestsPerMinute + } +} + func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { var device database.Devices @@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF deviceUUID := uuid.UUID(device.ID.Bytes) deviceID := deviceUUID.String() - config := DeviceRateLimitConfig{ - SyncRequestsPerMinute: 60, - ProgressUpdatesPerMinute: 120, - MetadataRequestsPerMinute: 30, - } + config := m.rateLimitConfig() + limitForType := m.rateLimitForRequestType(requestType, config) if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) { remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) - c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Response().Header().Set("X-RateLimit-Reset", "60") return c.JSON(http.StatusTooManyRequests, map[string]string{ @@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF } remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) - c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) ctx := DeviceContext{ diff --git a/internal/services/conversion_service.go b/internal/services/conversion_service.go index 1a9e2ff..e478795 100644 --- a/internal/services/conversion_service.go +++ b/internal/services/conversion_service.go @@ -16,6 +16,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// defaultConversionCacheTTL is the fallback kepub cache lifetime when no +// settings registry is wired. Matches the historical hardcoded 24h. +const defaultConversionCacheTTL = 24 * time.Hour + type ConvertedKEPUB struct { Path string SHA256 string @@ -27,6 +31,7 @@ type ConversionService struct { cacheDir string conversionTool string conversionCacheTTL time.Duration + settings *database.SettingsRegistry } func NewConversionService(db *database.Queries, cacheDir string) *ConversionService { @@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ db: db, cacheDir: cacheDir, conversionTool: "/usr/bin/kepubify", - conversionCacheTTL: 24 * time.Hour, + conversionCacheTTL: defaultConversionCacheTTL, } } +// SetSettings wires the tunable settings registry. When wired, the cache TTL +// is read live on each conversion request. +func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg } + +// cacheTTL returns the active conversion cache TTL. +func (s *ConversionService) cacheTTL() time.Duration { + if s.settings != nil { + return s.settings.ConversionCacheTTL() + } + if s.conversionCacheTTL > 0 { + return s.conversionCacheTTL + } + return defaultConversionCacheTTL +} + func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) { existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ MediaItemID: mediaItemID, FormatType: "kepub", }) if err == nil && existing.FilePath.Valid { - if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL { + if time.Since(existing.CreatedAt.Time) < s.cacheTTL() { return &ConvertedKEPUB{ Path: existing.FilePath.String, SHA256: existing.FileSha256.String, diff --git a/internal/services/conversion_service_test.go b/internal/services/conversion_service_test.go index f974c6d..cbcb625 100644 --- a/internal/services/conversion_service_test.go +++ b/internal/services/conversion_service_test.go @@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) { assert.NotNil(t, service) assert.Equal(t, cacheDir, service.cacheDir) assert.Equal(t, "/usr/bin/kepubify", service.conversionTool) - assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours") + assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours") + // cacheTTL() must reflect the same default when no registry is wired. + assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours") } From 757398bf15b381af16b3dd5f6cfecf548f7dbd15 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:01:45 -0400 Subject: [PATCH 09/16] feat(sync): make annotation tombstone TTL configurable The 30-day retention window for soft-deleted annotations was a package const; move it behind the registry so it can be tuned live. annotations.go: - AnnotationService gains an optional *database.SettingsRegistry and a tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff now call it instead of reading the TombstoneTTL const directly. - Add ActiveTombstoneTTL() so callers outside the sync package can compute cutoffs consistently with the service. - The package-level TombstoneTTL const is retained as the fallback for tests / unwired code paths. kobo.go, koreader.go: - The per-book tombstone sweep cutoff now uses h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL const, so both the service and the handlers honor the configured TTL. --- internal/handlers/kobo.go | 2 +- internal/handlers/koreader.go | 2 +- internal/sync/annotations.go | 45 +++++++++++++++++++++++++---------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index d36efd4..73ab1ee 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -623,7 +623,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error { } if h.annotationSvc != nil && len(processedBooks) > 0 { - cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} for mediaItemID, contentId := range processedBooks { tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ MediaItemID: mediaItemID, diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index a28b1d0..a19775e 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -854,7 +854,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error { annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) } - cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true} + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ MediaItemID: pgBookUUID, UserID: pgUserID, diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index d047479..4689436 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -18,8 +18,38 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +// TombstoneTTL is the fallback retention for soft-deleted annotations when no +// settings registry is wired (e.g. in tests). It matches the historical value. const TombstoneTTL = 30 * 24 * time.Hour +type AnnotationService struct { + db *database.Queries + connMgr *ConnectionManager + settings *database.SettingsRegistry +} + +func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService { + return &AnnotationService{db: db, connMgr: connMgr} +} + +// SetSettings wires the tunable settings registry. When wired, the tombstone +// TTL is read live from the DB; otherwise the package const TombstoneTTL is +// used. +func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg } + +// tombstoneTTL returns the active tombstone retention window. +func (s *AnnotationService) tombstoneTTL() time.Duration { + if s.settings != nil { + return s.settings.TombstoneTTL() + } + return TombstoneTTL +} + +// ActiveTombstoneTTL exposes the configured tombstone retention window for +// callers outside the sync package (e.g. kobo/koreader handlers) that need to +// compute cutoffs consistently with the service. +func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() } + type SaveOutcome string const ( @@ -29,15 +59,6 @@ const ( SaveOutcomeDeleted SaveOutcome = "deleted" ) -type AnnotationService struct { - db *database.Queries - connMgr *ConnectionManager -} - -func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService { - return &AnnotationService{db: db, connMgr: connMgr} -} - type SaveHighlightRequest struct { MediaItemID pgtype.UUID UserID pgtype.UUID @@ -79,7 +100,7 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight } if existing.Deleted.Bool { - if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL { + if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil } return s.createHighlight(ctx, req, dedupKey) @@ -238,7 +259,7 @@ func (s *AnnotationService) TombstoneHighlightByID( } func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { - cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true} + cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true} if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil { return fmt.Errorf("purge highlight tombstones: %w", err) } @@ -457,7 +478,7 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe } if existing.Deleted.Bool { - if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL { + if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil } return s.createBookmark(ctx, req, dedupKey) From 936a48405b3535dcbe2f3e4f7c7463a95a6aebb9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:02:02 -0400 Subject: [PATCH 10/16] refactor(background): parameterize sync queue and worker pool constructors Split each constructor into a default-args wrapper and a config-accepting variant so the sync queue interval/batch size and the worker pool size/ queue cap can be sourced from the settings registry at startup. These values are constructed once at boot, so they are tagged requires_restart in the admin UI. queue.go: - NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the flush interval and batch size as parameters; NewSyncQueueProcessor becomes a thin wrapper with the historical 5s / 50 defaults. worker.go: - NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the queue capacity as a parameter; NewWorker becomes a thin wrapper with the historical cap of 100. No behavior change for existing callers; main.go will switch to the config-accepting variants in a follow-up wiring commit. --- internal/services/worker.go | 10 +++++++++- internal/sync/queue.go | 11 +++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/services/worker.go b/internal/services/worker.go index 8f509d2..59b7b47 100644 --- a/internal/services/worker.go +++ b/internal/services/worker.go @@ -176,10 +176,17 @@ func (w *Worker) GetActiveJobCount() int { return count } func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker { + return NewWorkerWithConfig(numWorkers, 100, connManager) +} + +// NewWorkerWithConfig constructs a worker pool with the given worker count and +// job-queue capacity. Used at startup to source values from the settings +// registry. +func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.ConnectionManager) *Worker { ctx, cancel := context.WithCancel(context.Background()) w := &Worker{ - jobQueue: make(chan *Job, 100), + jobQueue: make(chan *Job, queueCap), results: make(map[string]*JobResult), ctx: ctx, cancel: cancel, @@ -995,3 +1002,4 @@ func (w *Worker) Shutdown() { close(w.jobQueue) w.wg.Wait() } + diff --git a/internal/sync/queue.go b/internal/sync/queue.go index b7822e0..4a579b3 100644 --- a/internal/sync/queue.go +++ b/internal/sync/queue.go @@ -74,11 +74,18 @@ type SyncQueueItem struct { } func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor { + return NewSyncQueueProcessorWithConfig(db, 5*time.Second, 50) +} + +// NewSyncQueueProcessorWithConfig constructs a processor with the given flush +// interval and batch size. Used at startup to source values from the settings +// registry. +func NewSyncQueueProcessorWithConfig(db *database.Queries, interval time.Duration, batchSize int) *SyncQueueProcessor { return &SyncQueueProcessor{ db: db, progressChan: make(chan *ProgressUpdate, 100), - interval: 5 * time.Second, - batchSize: 50, + interval: interval, + batchSize: batchSize, } } From 885f6d8187efa39cd46253b53c88485f20ff5133 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:02:22 -0400 Subject: [PATCH 11/16] feat(api): unified tunable settings endpoints with typed validation Add a single pair of admin-only endpoints that supersede the scattered scan-settings JSON routes as the canonical way to read and write tunable system settings. Existing legacy routes are kept working for backward compatibility and now refresh the registry cache on write. system_settings.go: - GET /api/system/settings returns every known setting with full metadata (value, type, min, max, requires_restart, category, group, description, is_default) via SettingsRegistry.All(). - PUT /api/system/settings accepts {key, value}; ApplySetting() looks up the compiled Default for the key, runs type-aware validation (int range, bool parse, non-empty string, timezone via time.LoadLocation), upserts via UpsertSystemSetting, reloads the registry, and reports whether a restart is needed for the change to take full effect. Shared by the JSON endpoint and the HTMX endpoint. - Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings now reload the registry after writing and prefer the registry when reading, so the cache stays consistent regardless of entry point. sidecar.go: - SidecarHandler gains an optional registry; the timezone branch of UpdateSystemConfiguration (PUT /api/system/config) calls settings.Reload() after the write so the new value is visible immediately. base_url handling is unchanged. system.go: - Register GET/PUT /api/system/settings under the existing admin /api/system group. --- internal/handlers/sidecar.go | 12 +- internal/handlers/system_settings.go | 166 ++++++++++++++++++++++++++- internal/router/system.go | 6 + 3 files changed, 181 insertions(+), 3 deletions(-) diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go index bf36b06..3bd0f77 100644 --- a/internal/handlers/sidecar.go +++ b/internal/handlers/sidecar.go @@ -15,14 +15,19 @@ import ( ) type SidecarHandler struct { - db *database.Queries - cfg *config.Config + db *database.Queries + cfg *config.Config + settings *database.SettingsRegistry } func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler { return &SidecarHandler{db: db, cfg: cfg} } +// SetSettings wires the tunable settings registry so the timezone write path +// keeps the cache consistent. +func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s } + type SidecarConfig struct { Version string `json:"version"` Bookhoard SidecarBookhoardConfig `json:"bookhoard"` @@ -400,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error { "error": "failed to update default timezone", }) } + if h.settings != nil { + h.settings.Reload(ctx) + } continue } _, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{ diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go index 7e3c1eb..762a84d 100644 --- a/internal/handlers/system_settings.go +++ b/internal/handlers/system_settings.go @@ -2,17 +2,21 @@ package handlers import ( "bookhoard/internal/database" + "context" "errors" + "fmt" "net/http" "strconv" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) type SystemSettingsHandler struct { - db *database.Queries + db *database.Queries + settings *database.SettingsRegistry } func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { @@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { } } +// SetSettings wires the tunable settings registry. Required for the unified +// /api/system/settings endpoints and for cache invalidation after writes. +func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) { + h.settings = s +} + +// reload refreshes the in-memory cache after a write. +func (h *SystemSettingsHandler) reload(c *echo.Context) { + if h.settings != nil { + h.settings.Reload(c.Request().Context()) + } +} + +// ---- Unified /api/system/settings endpoints ---- + +// GetSettings handles GET /api/system/settings. +func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error { + if h.settings == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"}) + } + return c.JSON(http.StatusOK, h.settings.All()) +} + +// UpdateSettingRequest is the body for PUT /api/system/settings. +type UpdateSettingRequest struct { + Key string `json:"key" form:"key"` + Value string `json:"value" form:"value"` +} + +// UpdateSettingResponse mirrors a settings entry plus a reload hint. +type UpdateSettingResponse struct { + database.SettingEntry + ReloadRequired bool `json:"reload_required"` + Message string `json:"message,omitempty"` +} + +// UpdateSetting handles PUT /api/system/settings. +func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error { + if h.settings == nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"}) + } + var req UpdateSettingRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, resp) +} + +// ApplySetting validates, persists, and reloads a single setting. Shared by the +// JSON API and the HTMX admin endpoint. +func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) { + if h.settings == nil { + return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized") + } + if key == "" { + return UpdateSettingResponse{}, fmt.Errorf("key is required") + } + def, ok := database.LookupDefault(key) + if !ok { + return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key) + } + if err := validateSettingValue(def, value); err != nil { + return UpdateSettingResponse{}, err + } + + desc := def.Description + rType := pgtype.Text{} + if def.Type != "" { + rType = pgtype.Text{String: def.Type, Valid: true} + } + var minP, maxP pgtype.Text + if def.Min != "" { + minP = pgtype.Text{String: def.Min, Valid: true} + } + if def.Max != "" { + maxP = pgtype.Text{String: def.Max, Valid: true} + } + if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{ + SettingKey: key, + SettingValue: value, + Description: pgtype.Text{String: desc, Valid: desc != ""}, + SettingType: rType, + MinValue: minP, + MaxValue: maxP, + RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true}, + Category: pgtype.Text{String: def.Category, Valid: def.Category != ""}, + }); err != nil { + return UpdateSettingResponse{}, err + } + + h.settings.Reload(ctx) + + resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart} + for _, e := range h.settings.All() { + if e.Key == key { + resp.SettingEntry = e + break + } + } + if def.RequiresRestart { + resp.Message = "Saved. Restart the server for this change to take full effect." + } else { + resp.Message = "Saved." + } + return resp, nil +} + +// validateSettingValue checks a candidate value against the setting's type and bounds. +func validateSettingValue(def database.SettingDefault, value string) error { + switch def.Type { + case database.SettingTypeInt: + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("value must be an integer") + } + if def.Min != "" { + if mn, err := strconv.Atoi(def.Min); err == nil && n < mn { + return fmt.Errorf("value must be >= %s", def.Min) + } + } + if def.Max != "" { + if mx, err := strconv.Atoi(def.Max); err == nil && n > mx { + return fmt.Errorf("value must be <= %s", def.Max) + } + } + case database.SettingTypeBool: + if _, err := strconv.ParseBool(value); err != nil { + return fmt.Errorf("value must be true or false") + } + case database.SettingTypeString: + if value == "" { + return fmt.Errorf("value must not be empty") + } + if def.Key == "default_timezone" { + if _, err := time.LoadLocation(value); err != nil { + return fmt.Errorf("invalid timezone: %v", err) + } + } + } + return nil +} + +// ---- Legacy scan-settings endpoints (retained for backward compatibility) ---- + type UpdateScanSettingsRequest struct { ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"` AutoScanEnabled bool `json:"auto_scan_enabled"` @@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error { if err != nil { return err } + h.reload(c) return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"}) } @@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } + h.reload(c) + return c.JSON(http.StatusOK, ScanSettingsResponse{ ScanPollIntervalSeconds: req.ScanPollIntervalSeconds, AutoScanEnabled: req.AutoScanEnabled, @@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { } func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { + // Prefer the registry (single source of truth after Load). + if h.settings != nil { + interval := int32(h.settings.ScanPollInterval().Seconds()) + return c.JSON(http.StatusOK, ScanSettingsResponse{ + ScanPollIntervalSeconds: interval, + AutoScanEnabled: h.settings.AutoScanEnabled(), + }) + } + scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/router/system.go b/internal/router/system.go index 4c76f8a..aa2c8f7 100644 --- a/internal/router/system.go +++ b/internal/router/system.go @@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) { // System configuration routes (admin-only) system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration) system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration) + + // Unified tunable settings (admin-only). These back the admin UI's + // editable System Settings sections and supersede the legacy + // /api/libraries/scan-settings JSON routes. + system.GET("/settings", cfg.SystemSettingsHandler.GetSettings) + system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting) } From 537330e7e03e0045effabff812da8487a27cbfc0 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:02:41 -0400 Subject: [PATCH 12/16] feat(app): wire settings registry into startup and admin routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct the SettingsRegistry at boot, load it, and thread it through every consumer so the configurable values take effect and stay cached. cmd/server/main.go: - Build the registry from the Queries handle and Load() it right after schema init; a load failure logs and continues (getters fall back to compiled defaults, so startup is never blocked). - Wire the registry into the package-level password validator (SetDefaultPasswordSettings) and call SetSettings on every handler/ service that reads tunables: AuthHandler, DeviceAuthMiddleware, OPDSHandler, SidecarHandler, SystemSettingsHandler, AnnotationService, ConversionService. - Source the restart-time values from the registry: login lockout (max attempts + duration) feeds NewLoginAttemptTracker, and the new NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync queue and worker pool configs. router.go: - Config gains a Settings *database.SettingsRegistry field. - The global auth rate limiter now reads RequestsPerMinute from registry.AuthRateLimit() (env stays as the enabled/disabled switch and as the fallback if the registry is unset). admin_library.go: - The HTMX scan-settings save endpoint reloads the registry after writing so the change is visible without a page reload. - Add PUT /admin/settings/tunable: a small HTMX endpoint that calls SystemSettingsHandler.ApplySetting and returns a colored status snippet ("Saved" or "Saved β€” restart required") for the admin UI's per-row forms. --- cmd/server/main.go | 28 ++++++++++++++++++++++++---- internal/router/admin_library.go | 32 +++++++++++++++++++++++++++++++- internal/router/router.go | 7 +++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index b470e10..cde17fd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,6 +50,16 @@ func main() { } log.Println("βœ… Database schema initialized and verified, starting server...") + // Load tunable settings from the DB into the registry. All values fall back + // to compiled defaults if a row is missing, so this never blocks startup. + registry := database.NewSettingsRegistry(queries) + if err := registry.Load(ctx); err != nil { + log.Printf("⚠️ Could not load system settings (using defaults): %v", err) + } + // Wire the registry into the package-level password validator so live + // rule changes apply to the echo struct-tag validator and ValidatePassword. + middleware.SetDefaultPasswordSettings(registry) + // Seed base_url from env var if not already configured. Uses conditional // UPDATE so admin-set values are never overwritten on restart. if cfg.BaseURL != "" { @@ -79,15 +89,20 @@ func main() { } } - // Create login attempt tracker: 5 failed attempts = 15 minute lockout - loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute) + // Create login attempt tracker from configured (or default) lockout policy. + loginMaxAttempts, loginLockout := registry.LoginLockout() + loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute) authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) + authHandler.SetSettings(registry) systemSettingsHandler := handlers.NewSystemSettingsHandler(queries) + systemSettingsHandler.SetSettings(registry) sidecarHandler := handlers.NewSidecarHandler(queries, cfg) + sidecarHandler.SetSettings(registry) libraryHandler := handlers.NewLibraryHandler(queries) deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) + deviceAuthMiddleware.SetSettings(registry) processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries) // Create WebSocket connection manager @@ -95,10 +110,11 @@ func main() { progressService := sync.NewProgressService(queries, connManager) annotationService := sync.NewAnnotationService(queries, connManager) + annotationService.SetSettings(registry) tombstonePurgerCancel := annotationService.StartTombstonePurger() defer tombstonePurgerCancel() - queueProcessor := sync.NewSyncQueueProcessor(queries) + queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) queueProcessor.SetProgressService(progressService) queueProcessor.SetAnnotationService(annotationService) @@ -109,7 +125,8 @@ func main() { libraryService.SyncAllowedExtensions(context.Background()) // Create worker for background tasks - worker := services.NewWorker(3, connManager) + workerCfg := registry.WorkerPoolConfig() + worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager) services.WorkerInstance = worker koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) @@ -122,7 +139,9 @@ func main() { queueHandler := handlers.NewQueueHandler(queries, queueProcessor) conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") + conversionService.SetSettings(registry) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) + opdsHandler.SetSettings(registry) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) dashboardService := services.NewDashboardService(queries) @@ -175,6 +194,7 @@ func main() { Echo: e, Queries: queries, Cfg: cfg, + Settings: registry, DBPool: dbPool, AuthHandler: authHandler, LibraryHandler: libraryHandler, diff --git a/internal/router/admin_library.go b/internal/router/admin_library.go index 46ad52c..4d4fe9e 100644 --- a/internal/router/admin_library.go +++ b/internal/router/admin_library.go @@ -1,11 +1,12 @@ package router import ( - "bytes" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/templates" + "bytes" "context" + "fmt" "log" "net/http" "os" @@ -406,6 +407,11 @@ func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { SettingValue: strconv.Itoa(interval), }) + // Refresh the registry cache so the change is visible immediately. + if cfg.Settings != nil { + cfg.Settings.Reload(ctx) + } + scanSettings := templates.ScanSettingsData{ AutoScanEnabled: autoScan, ScanPollIntervalSeconds: interval, @@ -414,4 +420,28 @@ func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { _ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf) return c.HTML(http.StatusOK, buf.String()) }) + + // HTMX endpoint for saving a single tunable setting. Returns a small HTML + // status snippet rendered into the row's status span. + g.PUT("/admin/settings/tunable", func(c *echo.Context) error { + ctx := c.Request().Context() + key := c.FormValue("key") + value := c.FormValue("value") + + if cfg.SystemSettingsHandler == nil { + return c.HTML(http.StatusServiceUnavailable, `settings unavailable`) + } + resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value) + if err != nil { + return c.HTML(http.StatusBadRequest, fmt.Sprintf(`%s`, err.Error())) + } + + color := "var(--status-success)" + msg := "Saved" + if resp.ReloadRequired { + color = "var(--status-warning)" + msg = "Saved β€” restart required" + } + return c.HTML(http.StatusOK, fmt.Sprintf(`%s`, color, msg)) + }) } diff --git a/internal/router/router.go b/internal/router/router.go index 4c956ab..318abfa 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -39,6 +39,7 @@ type Config struct { Echo *echo.Echo Queries *database.Queries Cfg *config.Config + Settings *database.SettingsRegistry DBPool interface{} // pgxpool.Pool interface AuthHandler *handlers.AuthHandler LibraryHandler *handlers.LibraryHandler @@ -211,10 +212,12 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { // Setup redirect middleware - must run before all routes e.Pre(setupRedirectMiddleware(cfg)) - // Rate limiter + // Rate limiter. The per-minute value comes from the settings registry (DB); + // the enabled flag stays env-driven since disabling rate limiting is a + // deployment-time decision, not a runtime tunable. rateLimiterConfig := ratelimit.RateLimiterConfig{ Enabled: cfg.Cfg.RateLimitEnabled, - RequestsPerMinute: cfg.Cfg.RequestsPerMinute, + RequestsPerMinute: cfg.Settings.AuthRateLimit(), CleanupInterval: 5 * time.Minute, } rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig) From 598d70f73522c3583bd5f45b0f7347a55991e416 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:03:02 -0400 Subject: [PATCH 13/16] feat(admin): editable tunable settings UI with grouped sub-sections Replace the read-only "System Information" card (which listed hardcoded values) with editable HTMX forms, organized so the live vs restart distinction and related settings are visually clear. admin_settings.templ: - AdminSettings signature now takes liveGroups and restartGroups ([]SettingGroup) instead of a flat entry list. - Remove the static System Information list. Render two cards: "Live" (green, applies immediately) and "Restart Required" (warning header, saved but only takes effect after restart). - Within each card, TunableSettingsSection clusters entries into labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits", "Login Lockout", "Worker Pool") with uppercase tracked sub-headers. - TunableSettingRow renders an inline HTMX form per setting: a Yes/No select for bools, a number input with min/max for ints, text otherwise, posting to /admin/settings/tunable. Rows show "modified from default" when the value differs from the compiled default. types.go: - Add SettingEntry (template-local mirror of database.SettingEntry, keeps templates from importing database) and SettingGroup. utils.go: - Add GroupTunableSettings: splits a flat, group-sorted entry list into live and restart []SettingGroup buckets preserving source order. utils_test.go covers the multi-group + empty cases. frontend.go: - The /admin/settings page handler now loads entries from the registry, drops the three keys that have dedicated UI cards (default_timezone dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are not listed twice, groups the rest, and passes liveGroups/restartGroups into the template. --- internal/router/frontend.go | 32 ++- templates/admin_settings.templ | 147 +++++++----- templates/admin_settings_templ.go | 360 +++++++++++++++++++++++++++++- templates/types.go | 22 ++ templates/utils.go | 19 ++ templates/utils_test.go | 47 ++++ 6 files changed, 559 insertions(+), 68 deletions(-) create mode 100644 templates/utils_test.go diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 70f8154..d8741d6 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -1024,8 +1024,38 @@ func registerFrontendRoutes(cfg *Config) { } } + // Load tunable settings entries from the registry. Exclude keys that + // already have their own dedicated UI cards (timezone dropdown, scan + // settings) so they aren't listed twice. + dedicatedUI := map[string]bool{ + "default_timezone": true, + "scan_poll_interval_seconds": true, + "auto_scan_enabled": true, + } + var tunableSettings []templates.SettingEntry + if cfg.Settings != nil { + for _, e := range cfg.Settings.All() { + if dedicatedUI[e.Key] { + continue + } + tunableSettings = append(tunableSettings, templates.SettingEntry{ + Key: e.Key, + Value: e.Value, + Type: e.Type, + Min: e.Min, + Max: e.Max, + RequiresRestart: e.RequiresRestart, + Category: e.Category, + Group: e.Group, + Description: e.Description, + IsDefault: e.IsDefault, + }) + } + } + liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings) + var buf bytes.Buffer - err = templates.AdminSettings(user, systemConfig, scanSettings, "").Render(ctx, &buf) + err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf) if err != nil { return err } diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 3888b30..2c5240b 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -2,7 +2,7 @@ package templates import "fmt" -templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) { +templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) { @@ -107,61 +107,10 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan

Device Sync: { systemConfig["base_url"] }/api/sync

- @ScanSettingsSection(scanSettings) -
-
- @Icon("info", "h-5 w-5 shrink-0") -

System Information

-
-

These values are hardcoded and require a code change to modify.

-
-
- Session Duration - 7 days -
-
- Password Requirements - 8+ chars, upper/lower/number/special -
-
- Login Lockout - 5 attempts / 15 min -
-
- Auth Rate Limit - 10 requests/min -
-
- Device Rate Limits - Sync 60/min, Progress 120/min, Metadata 30/min -
-
- Sync Queue - Every 5s, batch of 50 -
-
- Annotation Tombstone TTL - 30 days -
-
- Worker Pool - 3 workers, queue cap 100 -
-
- OPDS Page Size - 50 per page (max 200) -
-
- Conversion Cache TTL - 24 hours -
-
- CORS Origins - * -
-
-
- + @ScanSettingsSection(scanSettings) + @TunableSettingsSection(liveGroups, false) + @TunableSettingsSection(restartGroups, true) + @@ -219,3 +168,89 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) { } + +// TunableSettingsSection renders the editable tunables for a given bucket +// (live vs restart-required). Within the card, settings are clustered into +// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits"). +templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) { +
+
+ if restartRequired { + @Icon("alert", "h-5 w-5 shrink-0") +

Tunable Settings β€” Restart Required

+ } else { + @Icon("settings", "h-5 w-5 shrink-0") +

Tunable Settings β€” Live

+ } +
+ if restartRequired { +

Changes are saved immediately but only take effect after the server restarts.

+ } else { +

Changes apply immediately β€” no restart needed.

+ } + for _, g := range groups { +
+

{ g.Name }

+
+ for _, e := range g.Entries { + @TunableSettingRow(e) + } +
+
+ } +
+} + +// TunableSettingRow renders a single editable setting as an inline HTMX form. +templ TunableSettingRow(e SettingEntry) { +
+
+ + if !e.IsDefault { +

{ e.Key } β€” modified from default

+ } else { +

{ e.Key }

+ } +
+
+ + if e.Type == "bool" { + + } else if e.Type == "int" { + + } else { + + } + +
+ +
+} diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index d3e4667..f9ff465 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime" import "fmt" -func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) templ.Component { +func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -410,15 +410,15 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = TunableSettingsSection(restartGroups, true).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

System Information

These values are hardcoded and require a code change to modify.

Session Duration 7 days
Password Requirements 8+ chars, upper/lower/number/special
Login Lockout 5 attempts / 15 min
Auth Rate Limit 10 requests/min
Device Rate Limits Sync 60/min, Progress 120/min, Metadata 30/min
Sync Queue Every 5s, batch of 50
Annotation Tombstone TTL 30 days
Worker Pool 3 workers, queue cap 100
OPDS Page Size 50 per page (max 200)
Conversion Cache TTL 24 hours
CORS Origins *
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -447,7 +447,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -455,30 +455,30 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

Scanning

Watch libraries for file changes on startup

Watch libraries for file changes on startup

How often to poll libraries for changes (1–3600 seconds). Default: 60.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Save Scan Settings
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// TunableSettingsSection renders the editable tunables for a given bucket +// (live vs restart-required). Within the card, settings are clustered into +// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits"). +func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if restartRequired { + templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Tunable Settings β€” Restart Required

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = Icon("settings", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

Tunable Settings β€” Live

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if restartRequired { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

Changes are saved immediately but only take effect after the server restarts.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Changes apply immediately β€” no restart needed.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, g := range groups { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 193, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range g.Entries { + templ_7745c5c3_Err = TunableSettingRow(e).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// TunableSettingRow renders a single editable setting as an inline HTMX form. +func TunableSettingRow(e SettingEntry) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !e.IsDefault { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 210, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " β€” modified from default

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 212, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if e.Type == "bool" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if e.Type == "int" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 12f350f..9bde5a7 100644 --- a/templates/types.go +++ b/templates/types.go @@ -71,6 +71,28 @@ type ScanSettingsData struct { ScanPollIntervalSeconds int } +// SettingEntry mirrors database.SettingEntry for the admin UI. Kept as a +// template-local type so the templates package does not import database. +type SettingEntry struct { + Key string + Value string + Type string + Min string + Max string + RequiresRestart bool + Category string + Group string + Description string + IsDefault bool +} + +// SettingGroup is a labeled cluster of related settings rendered as a +// sub-section within a tunable-settings card. +type SettingGroup struct { + Name string + Entries []SettingEntry +} + type SeriesCardData struct { Name string BookCount int64 diff --git a/templates/utils.go b/templates/utils.go index a836c22..1da27a0 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -303,3 +303,22 @@ func formatDateForInput(d pgtype.Date) string { } return d.Time.Format("2006-01-02") } + +// GroupTunableSettings splits a flat, group-sorted entry list into labeled +// sub-section groups, separated into "live" (applies immediately) and +// "restart required" buckets. Entries keep their original order so groups stay +// coherent. +func GroupTunableSettings(entries []SettingEntry) (live, restart []SettingGroup) { + var liveGroups, restartGroups []SettingGroup + for _, e := range entries { + target := &liveGroups + if e.RequiresRestart { + target = &restartGroups + } + if len(*target) == 0 || (*target)[len(*target)-1].Name != e.Group { + *target = append(*target, SettingGroup{Name: e.Group}) + } + (*target)[len(*target)-1].Entries = append((*target)[len(*target)-1].Entries, e) + } + return liveGroups, restartGroups +} diff --git a/templates/utils_test.go b/templates/utils_test.go new file mode 100644 index 0000000..2b5f50f --- /dev/null +++ b/templates/utils_test.go @@ -0,0 +1,47 @@ +package templates + +import "testing" + +func TestGroupTunableSettings(t *testing.T) { + entries := []SettingEntry{ + {Key: "session_duration_seconds", Group: "Session", RequiresRestart: false}, + {Key: "password_min_length", Group: "Password Quality", RequiresRestart: false}, + {Key: "password_require_upper", Group: "Password Quality", RequiresRestart: false}, + {Key: "opds_default_page_size", Group: "OPDS Catalog", RequiresRestart: false}, + {Key: "auth_rate_limit_per_min", Group: "Auth Rate Limiting", RequiresRestart: true}, + {Key: "login_max_attempts", Group: "Login Lockout", RequiresRestart: true}, + {Key: "login_lockout_minutes", Group: "Login Lockout", RequiresRestart: true}, + } + + live, restart := GroupTunableSettings(entries) + + if len(live) != 3 { + t.Fatalf("expected 3 live groups, got %d", len(live)) + } + if live[0].Name != "Session" || len(live[0].Entries) != 1 { + t.Errorf("live[0] = %+v", live[0]) + } + if live[1].Name != "Password Quality" || len(live[1].Entries) != 2 { + t.Errorf("live[1] = %+v", live[1]) + } + if live[2].Name != "OPDS Catalog" || len(live[2].Entries) != 1 { + t.Errorf("live[2] = %+v", live[2]) + } + + if len(restart) != 2 { + t.Fatalf("expected 2 restart groups, got %d", len(restart)) + } + if restart[0].Name != "Auth Rate Limiting" || len(restart[0].Entries) != 1 { + t.Errorf("restart[0] = %+v", restart[0]) + } + if restart[1].Name != "Login Lockout" || len(restart[1].Entries) != 2 { + t.Errorf("restart[1] = %+v", restart[1]) + } +} + +func TestGroupTunableSettingsEmpty(t *testing.T) { + live, restart := GroupTunableSettings(nil) + if len(live) != 0 || len(restart) != 0 { + t.Errorf("expected empty groups, got live=%d restart=%d", len(live), len(restart)) + } +} From 1461273162d82f0a28853870f7d6b1c706a98e78 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 10:43:05 -0400 Subject: [PATCH 14/16] fix(sync): wire dead token cleanup queries into daily maintenance runner CleanupExpiredRefreshTokens and CleanupExpiredOpdsTokens were generated by sqlc but never invoked anywhere in the codebase, so expired/revoked tokens accumulated in the database indefinitely. The refresh-token query was parameterized in the settings-registry work specifically so its retention window could follow the configurable session duration, but the periodic caller was never wired up. annotations.go: - Rename StartTombstonePurger to StartDailyMaintenance, which now runs all periodic cleanup tasks from a single 24h-tick goroutine. - Add runDailyMaintenance helper: tombstones, then OPDS tokens, then refresh tokens, each logging independently so one failure never skips the others. - Refresh-token retention is read from the registry (SessionDuration) on every tick so live admin edits are honored; guarded on the registry being wired so unwired test paths simply skip cleanup. - All three queries only delete rows that are already expired or revoked, so active sessions are never logged out. main.go: - Update the call site: tombstonePurgerCancel becomes maintenanceCancel and calls StartDailyMaintenance. Net footprint: still one goroutine and one ticker; the cleanup adds one DELETE per table per day. --- cmd/server/main.go | 4 ++-- internal/sync/annotations.go | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index cde17fd..829173b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -111,8 +111,8 @@ func main() { progressService := sync.NewProgressService(queries, connManager) annotationService := sync.NewAnnotationService(queries, connManager) annotationService.SetSettings(registry) - tombstonePurgerCancel := annotationService.StartTombstonePurger() - defer tombstonePurgerCancel() + maintenanceCancel := annotationService.StartDailyMaintenance() + defer maintenanceCancel() queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) queueProcessor.SetProgressService(progressService) diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index 4689436..f30b353 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -272,7 +272,13 @@ func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { return nil } -func (s *AnnotationService) StartTombstonePurger() context.CancelFunc { +// StartDailyMaintenance launches a single background goroutine that runs all +// periodic cleanup tasks once every 24 hours: expired annotation tombstones, +// expired/revoked refresh tokens (retention follows the configured session +// duration), and expired OPDS tokens. Each task is independent; a failure in +// one is logged and does not skip the others. The returned CancelFunc stops the +// goroutine and the underlying ticker; it must be invoked on shutdown. +func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc { ticker := time.NewTicker(24 * time.Hour) ctx, cancel := context.WithCancel(context.Background()) @@ -283,9 +289,7 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc { ticker.Stop() return case <-ticker.C: - if err := s.PurgeExpiredTombstones(ctx); err != nil { - log.Printf("AnnotationService: tombstone purge failed: %v", err) - } + s.runDailyMaintenance(ctx) } } }() @@ -293,6 +297,28 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc { return cancel } +// runDailyMaintenance executes every periodic cleanup task. Tasks run +// sequentially under the single daily-tick goroutine so there is no added +// concurrency. All three queries only delete rows that are already unusable +// (expired or revoked), so this never logs out active sessions. +func (s *AnnotationService) runDailyMaintenance(ctx context.Context) { + if err := s.PurgeExpiredTombstones(ctx); err != nil { + log.Printf("maintenance: tombstone purge failed: %v", err) + } + if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil { + log.Printf("maintenance: OPDS token purge failed: %v", err) + } + // Refresh-token retention follows the configured session duration; re-read + // on every tick so live settings changes are honored. Guarded so unwired + // test paths simply skip cleanup (production always wires the registry). + if s.settings != nil { + retention := s.settings.SessionDuration().Seconds() + if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil { + log.Printf("maintenance: refresh token purge failed: %v", err) + } + } +} + type SaveNoteRequest struct { MediaItemID pgtype.UUID UserID pgtype.UUID From ab465d8e0e5caba01448bc5a08c640a40af397ee Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 13:43:13 -0400 Subject: [PATCH 15/16] feat(ui): adopt book-open brand icon and add SVG favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the tighten-ui brand treatment into new-ui: - Replace the emoji book (πŸ“š) in the sidebar header with the book-open icon rendered in the theme accent color, matching the tighten-ui header brand (templates/header.templ). - Add web/static/favicon.svg (book-open glyph, tokyo-night accent #7aa2f7 stroke) and reference it from the of all 27 page templates, so the favicon is present on login/setup/error pages too. - Regenerate templ output for all affected templates. --- templates/admin.templ | 1 + templates/admin_library.templ | 1 + templates/admin_library_templ.go | 70 +++++----- templates/admin_processing_issues.templ | 1 + templates/admin_processing_issues_templ.go | 32 ++--- templates/admin_settings.templ | 1 + templates/admin_settings_templ.go | 36 +++--- templates/admin_templ.go | 10 +- templates/admin_users.templ | 1 + templates/admin_users_templ.go | 22 ++-- templates/analytics.templ | 1 + templates/analytics_templ.go | 2 +- templates/book_detail.templ | 1 + templates/book_detail_templ.go | 124 +++++++++--------- templates/bookshelf.templ | 1 + templates/bookshelf_templ.go | 30 ++--- templates/browse_detail.templ | 1 + templates/browse_detail_templ.go | 18 +-- templates/collection_rules.templ | 1 + templates/collection_rules_templ.go | 6 +- templates/collections.templ | 2 + templates/collections_templ.go | 62 ++++----- templates/conflicts.templ | 1 + templates/conflicts_templ.go | 16 +-- templates/custom_section.templ | 1 + templates/custom_section_templ.go | 6 +- templates/dashboard.templ | 1 + templates/dashboard_templ.go | 58 ++++----- templates/devices.templ | 1 + templates/devices_templ.go | 34 ++--- templates/docs.templ | 1 + templates/docs_templ.go | 28 ++-- templates/error.templ | 1 + templates/error_templ.go | 6 +- templates/header.templ | 4 +- templates/header_templ.go | 142 +++++++++++---------- templates/index.templ | 1 + templates/index_templ.go | 6 +- templates/login.templ | 1 + templates/login_templ.go | 6 +- templates/profile.templ | 1 + templates/profile_templ.go | 2 +- templates/progress.templ | 1 + templates/progress_templ.go | 32 ++--- templates/queue.templ | 1 + templates/queue_templ.go | 28 ++-- templates/reader.templ | 1 + templates/reader_templ.go | 20 +-- templates/register.templ | 1 + templates/register_templ.go | 2 +- templates/series.templ | 1 + templates/series_templ.go | 22 ++-- templates/setup.templ | 1 + templates/setup_templ.go | 2 +- templates/unlinked_books.templ | 1 + templates/unlinked_books_templ.go | 18 +-- web/static/favicon.svg | 5 + 57 files changed, 460 insertions(+), 417 deletions(-) create mode 100644 web/static/favicon.svg diff --git a/templates/admin.templ b/templates/admin.templ index bb7bbe9..6951568 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -7,6 +7,7 @@ templ Admin(user User, stats AdminStats) { Admin Dashboard - Bookhoard + @Header(user, "/admin") diff --git a/templates/admin_library.templ b/templates/admin_library.templ index a6fe460..a3538f8 100644 --- a/templates/admin_library.templ +++ b/templates/admin_library.templ @@ -8,6 +8,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) { Library Management - Bookhoard + @Header(user, "/admin/library") diff --git a/templates/admin_library_templ.go b/templates/admin_library_templ.go index 1b9c396..bc03b1e 100644 --- a/templates/admin_library_templ.go +++ b/templates/admin_library_templ.go @@ -29,7 +29,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library Management - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library Management - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -180,7 +180,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 206, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 207, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -198,7 +198,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 208, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -220,7 +220,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 213, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 214, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -242,7 +242,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 218, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 219, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -260,7 +260,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 224, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 225, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -281,7 +281,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 234, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 235, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -302,7 +302,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 243, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 244, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -377,7 +377,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 265, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 266, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -390,7 +390,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 268, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { @@ -403,7 +403,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 270, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -416,7 +416,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 270, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -447,7 +447,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 280, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -460,7 +460,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 280, Col: 124} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 124} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -473,7 +473,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 285, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -486,7 +486,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 291, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 292, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { @@ -499,7 +499,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 292, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 293, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -556,7 +556,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 321, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 322, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { @@ -569,7 +569,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 322, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 323, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { @@ -582,7 +582,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 324, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -595,7 +595,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 327, Col: 76} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -608,7 +608,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 329, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -636,7 +636,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var25 templ.SafeURL templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 337, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 338, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -663,7 +663,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -690,7 +690,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 351, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { @@ -703,7 +703,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 353, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) if templ_7745c5c3_Err != nil { @@ -716,7 +716,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 353, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 354, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) if templ_7745c5c3_Err != nil { @@ -737,7 +737,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 361, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -750,7 +750,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 363, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) if templ_7745c5c3_Err != nil { @@ -808,7 +808,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 378, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { @@ -826,7 +826,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 383, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 384, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) if templ_7745c5c3_Err != nil { @@ -857,7 +857,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 396, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 397, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) if templ_7745c5c3_Err != nil { @@ -878,7 +878,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 401, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -896,7 +896,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 408, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 409, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) if templ_7745c5c3_Err != nil { @@ -909,7 +909,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 409, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 410, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) if templ_7745c5c3_Err != nil { diff --git a/templates/admin_processing_issues.templ b/templates/admin_processing_issues.templ index 82d5bf7..ad2cdf2 100644 --- a/templates/admin_processing_issues.templ +++ b/templates/admin_processing_issues.templ @@ -7,6 +7,7 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu Processing Issues - Bookhoard + @Header(user, "/admin/library") diff --git a/templates/admin_processing_issues_templ.go b/templates/admin_processing_issues_templ.go index 0242e7e..3ecfb4c 100644 --- a/templates/admin_processing_issues_templ.go +++ b/templates/admin_processing_issues_templ.go @@ -29,7 +29,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Processing Issues - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Processing Issues - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 35, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 53, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 69, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 72, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 140} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 139} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 149} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 94, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 2c5240b..750da4c 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -10,6 +10,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan System Settings - Bookhoard + @Header(user, "/admin/settings") diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index f9ff465..ffa9b5d 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -31,7 +31,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "System Settings - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "System Settings - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -67,7 +67,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 30, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -93,7 +93,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 44, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -370,7 +370,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 105, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -383,7 +383,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 144} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -396,7 +396,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -472,7 +472,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 153, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -564,7 +564,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 193, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -622,7 +622,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 208, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -640,7 +640,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 210, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -658,7 +658,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 212, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -676,7 +676,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -689,7 +689,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 222, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -732,7 +732,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 232, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -750,7 +750,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 234, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { @@ -769,7 +769,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 237, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -792,7 +792,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 245, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { @@ -818,7 +818,7 @@ func TunableSettingRow(e SettingEntry) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 254, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { diff --git a/templates/admin_templ.go b/templates/admin_templ.go index ed83f71..3d54462 100644 --- a/templates/admin_templ.go +++ b/templates/admin_templ.go @@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Admin Dashboard - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 31, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 38, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 45, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 52, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { diff --git a/templates/admin_users.templ b/templates/admin_users.templ index 0525d94..3cfa182 100644 --- a/templates/admin_users.templ +++ b/templates/admin_users.templ @@ -8,6 +8,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) { Users - Bookhoard Admin + @Header(currentUser, "/admin/users") diff --git a/templates/admin_users_templ.go b/templates/admin_users_templ.go index 2c5cf9b..af59c46 100644 --- a/templates/admin_users_templ.go +++ b/templates/admin_users_templ.go @@ -29,7 +29,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Users - Bookhoard Admin") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Users - Bookhoard Admin") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -57,7 +57,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 41, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -78,7 +78,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 49, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -101,7 +101,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 58, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -124,7 +124,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 76, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -137,7 +137,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -170,7 +170,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 86, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 91, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -201,7 +201,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 97, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -240,7 +240,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 116, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -253,7 +253,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { diff --git a/templates/analytics.templ b/templates/analytics.templ index 105049c..c707669 100644 --- a/templates/analytics.templ +++ b/templates/analytics.templ @@ -9,6 +9,7 @@ templ Analytics(user User) { Reading Analytics - Bookhoard + @Header(user, "/analytics") diff --git a/templates/analytics_templ.go b/templates/analytics_templ.go index 5a44334..198eda9 100644 --- a/templates/analytics_templ.go +++ b/templates/analytics_templ.go @@ -29,7 +29,7 @@ func Analytics(user User) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Analytics - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Analytics - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 27986c0..9989cf3 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -18,6 +18,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { { book.Title } - Bookhoard + Library - Bookhoard + Library - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Library - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -97,7 +97,7 @@ func BookShelf( var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 92, Col: 135} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -110,7 +110,7 @@ func BookShelf( var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 94, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -173,7 +173,7 @@ func BookShelf( var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 146, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -191,7 +191,7 @@ func BookShelf( var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 148, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -211,7 +211,7 @@ func BookShelf( var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -224,7 +224,7 @@ func BookShelf( var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -237,7 +237,7 @@ func BookShelf( var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 152, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -255,7 +255,7 @@ func BookShelf( var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -268,7 +268,7 @@ func BookShelf( var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -281,7 +281,7 @@ func BookShelf( var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -310,7 +310,7 @@ func BookShelf( var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 268, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -333,7 +333,7 @@ func BookShelf( var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 277, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -364,7 +364,7 @@ func BookShelf( var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 286, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -377,7 +377,7 @@ func BookShelf( var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 291, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { diff --git a/templates/browse_detail.templ b/templates/browse_detail.templ index 5b299a3..378c444 100644 --- a/templates/browse_detail.templ +++ b/templates/browse_detail.templ @@ -13,6 +13,7 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, { pageTitle } - Bookhoard + @Header(user, backUrl) diff --git a/templates/browse_detail_templ.go b/templates/browse_detail_templ.go index 47995ba..ee2ca39 100644 --- a/templates/browse_detail_templ.go +++ b/templates/browse_detail_templ.go @@ -47,7 +47,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 21, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 43, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string, var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index 004946d..a78e3fc 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -9,6 +9,7 @@ templ CollectionRules(user User, collection CollectionData) { Collection Rules - { collection.Name } - Bookhoard + @Header(user, "/collections") diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index d50eb76..cfdee6e 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 176} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { diff --git a/templates/collections.templ b/templates/collections.templ index aac3a23..c7382ad 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -11,6 +11,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { Collections - Bookhoard + @Header(user, "/collections") @@ -116,6 +117,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo { collection.Name } - Bookhoard + @Header(user, "/collections") diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 68ef4b3..8eee24f 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -31,7 +31,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collections - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collections - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 70, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 73, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 76, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 79, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 88, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 98, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -246,13 +246,13 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 116, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 130, Col: 166} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 133, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 147} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var17 templ.SafeURL templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var20 templ.SafeURL templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 204, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 207, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) if templ_7745c5c3_Err != nil { @@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 384, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) if templ_7745c5c3_Err != nil { @@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { @@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 395, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { @@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) if templ_7745c5c3_Err != nil { @@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) if templ_7745c5c3_Err != nil { @@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 404, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 409, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) if templ_7745c5c3_Err != nil { @@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 416, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { diff --git a/templates/conflicts.templ b/templates/conflicts.templ index f0e5e5e..8f9af77 100644 --- a/templates/conflicts.templ +++ b/templates/conflicts.templ @@ -11,6 +11,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in Sync Conflicts - Bookhoard + @Header(user, "/conflicts") diff --git a/templates/conflicts_templ.go b/templates/conflicts_templ.go index 539b621..dd1dd9f 100644 --- a/templates/conflicts_templ.go +++ b/templates/conflicts_templ.go @@ -31,7 +31,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Conflicts - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Conflicts - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 44, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 48, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 52, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -136,7 +136,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 124, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -149,7 +149,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 132, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -180,7 +180,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 134, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { diff --git a/templates/custom_section.templ b/templates/custom_section.templ index 341d88c..f8ad18a 100644 --- a/templates/custom_section.templ +++ b/templates/custom_section.templ @@ -9,6 +9,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri Create Custom Section - Bookhoard + @Header(user, "/custom-section") diff --git a/templates/custom_section_templ.go b/templates/custom_section_templ.go index 1c90ad8..e4bfc0a 100644 --- a/templates/custom_section_templ.go +++ b/templates/custom_section_templ.go @@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Create Custom Section - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Create Custom Section - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 90906da..799f17b 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -14,6 +14,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle Dashboard - Bookhoard + @Header(user, "/dashboard") diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index 5948b96..82f7450 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Dashboard - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Dashboard - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -104,7 +104,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 39, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -117,7 +117,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -130,7 +130,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 44, Col: 130} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -143,7 +143,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 46, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -161,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var8 templ.SafeURL templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 67, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -231,7 +231,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -276,7 +276,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 94, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -326,7 +326,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var13 templ.SafeURL templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -344,7 +344,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -357,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -375,7 +375,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -393,7 +393,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 129, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -406,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -424,7 +424,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -460,7 +460,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -473,7 +473,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 144, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -499,7 +499,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var23 templ.SafeURL templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 151, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -512,7 +512,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 153, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) if templ_7745c5c3_Err != nil { @@ -580,7 +580,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -593,7 +593,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) if templ_7745c5c3_Err != nil { @@ -614,7 +614,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 189, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -627,7 +627,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -655,7 +655,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 201, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -688,7 +688,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 227, Col: 128} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -701,7 +701,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [ var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) if templ_7745c5c3_Err != nil { diff --git a/templates/devices.templ b/templates/devices.templ index d6592ac..21d8bce 100644 --- a/templates/devices.templ +++ b/templates/devices.templ @@ -11,6 +11,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P Device Management - Bookhoard + @Header(user, "/devices") diff --git a/templates/devices_templ.go b/templates/devices_templ.go index e6a7e53..2edbb64 100644 --- a/templates/devices_templ.go +++ b/templates/devices_templ.go @@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -111,7 +111,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 63, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -132,7 +132,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 66, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -153,7 +153,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 71, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -166,7 +166,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -199,7 +199,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 85, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -227,7 +227,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 93, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -255,7 +255,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 109, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -282,7 +282,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 131, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -308,7 +308,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 146, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -349,7 +349,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 167, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -362,7 +362,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -375,7 +375,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 169, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -388,7 +388,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 173, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -401,7 +401,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 176, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -440,7 +440,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 241, Col: 148} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -453,7 +453,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 243, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { diff --git a/templates/docs.templ b/templates/docs.templ index 9ef039c..08f3731 100644 --- a/templates/docs.templ +++ b/templates/docs.templ @@ -10,6 +10,7 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) { { doc.Title } - Bookhoard Documentation + diff --git a/templates/docs_templ.go b/templates/docs_templ.go index cc93637..d166186 100644 --- a/templates/docs_templ.go +++ b/templates/docs_templ.go @@ -44,7 +44,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 49, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var6 templ.SafeURL templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var9 templ.SafeURL templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var12 templ.SafeURL templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 85, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var15 templ.SafeURL templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 95, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 97, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 99, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { diff --git a/templates/error.templ b/templates/error.templ index 494851b..e575c56 100644 --- a/templates/error.templ +++ b/templates/error.templ @@ -7,6 +7,7 @@ templ ErrorPage(message string, errorType string) { Error - Bookhoard +
⚠️

Something went wrong

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Error - Bookhoard

⚠️

Something went wrong

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 83, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 89, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/header.templ b/templates/header.templ index 6699bf5..9f8347c 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -20,7 +20,9 @@ templ Header(user User, currentPath string) { class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);" > - πŸ“š + + @Icon("book-open", "h-6 w-6") + Bookhoard

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -502,7 +510,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -532,7 +540,7 @@ func SidebarUserMenu(user User) templ.Component { templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -569,7 +577,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -608,7 +616,7 @@ func SidebarSignIn(currentPath string) templ.Component { templ_7745c5c3_Var30 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
Sign in
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">
Create an account
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/index.templ b/templates/index.templ index 46d4045..bed39b3 100644 --- a/templates/index.templ +++ b/templates/index.templ @@ -10,6 +10,7 @@ templ Index(loggedIn bool) { +
diff --git a/templates/index_templ.go b/templates/index_templ.go index 681bb02..7852fff 100644 --- a/templates/index_templ.go +++ b/templates/index_templ.go @@ -29,7 +29,7 @@ func Index(loggedIn bool) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Bookhoard - Home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -41,7 +41,7 @@ func Index(loggedIn bool) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func Index(loggedIn bool) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/login.templ b/templates/login.templ index 5b64550..3d239ca 100644 --- a/templates/login.templ +++ b/templates/login.templ @@ -10,6 +10,7 @@ templ Login(sessionExpired bool, deleted bool) { +
diff --git a/templates/login_templ.go b/templates/login_templ.go index 6bb4c79..624147e 100644 --- a/templates/login_templ.go +++ b/templates/login_templ.go @@ -29,7 +29,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Login - Bookhoard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -41,7 +41,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func Login(sessionExpired bool, deleted bool) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { diff --git a/templates/profile.templ b/templates/profile.templ index b4adc4d..cdd1fb6 100644 --- a/templates/profile.templ +++ b/templates/profile.templ @@ -8,6 +8,7 @@ templ Profile(user User) { Profile Settings - Bookhoard + @Header(user, "/profile") diff --git a/templates/profile_templ.go b/templates/profile_templ.go index b2b4401..b28e57e 100644 --- a/templates/profile_templ.go +++ b/templates/profile_templ.go @@ -29,7 +29,7 @@ func Profile(user User) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Profile Settings - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Profile Settings - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/progress.templ b/templates/progress.templ index 85b9131..62fa10d 100644 --- a/templates/progress.templ +++ b/templates/progress.templ @@ -12,6 +12,7 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag Reading Progress - Bookhoard + @Header(user, "/progress") diff --git a/templates/progress_templ.go b/templates/progress_templ.go index cc4b862..9d332d2 100644 --- a/templates/progress_templ.go +++ b/templates/progress_templ.go @@ -32,7 +32,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Progress - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Progress - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -74,7 +74,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 43, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 44, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -87,7 +87,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 53, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 54, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -100,7 +100,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 57, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 58, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -118,7 +118,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 60, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 61, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -136,7 +136,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 63, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 64, Col: 127} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -149,7 +149,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 67, Col: 162} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 68, Col: 162} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -164,7 +164,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 76, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 77, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -174,7 +174,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 78, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 79, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -185,7 +185,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 81, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 82, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -199,7 +199,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 87, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 88, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -212,7 +212,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 92, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -225,7 +225,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 94, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -251,7 +251,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 100, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -264,7 +264,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 102, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -291,7 +291,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 107, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 108, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/queue.templ b/templates/queue.templ index 7438190..5f5ca16 100644 --- a/templates/queue.templ +++ b/templates/queue.templ @@ -11,6 +11,7 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q Sync Queue - Bookhoard + @Header(user, "/queue") diff --git a/templates/queue_templ.go b/templates/queue_templ.go index 1172028..e43687d 100644 --- a/templates/queue_templ.go +++ b/templates/queue_templ.go @@ -31,7 +31,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Queue - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Sync Queue - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,7 +62,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.PendingCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 40, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 41, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -75,7 +75,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ProcessingCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 44, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 45, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -88,7 +88,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.CompletedCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 48, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 49, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -101,7 +101,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.FailedCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 52, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 53, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -149,7 +149,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 129, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -162,7 +162,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.SyncType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 131, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -175,7 +175,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Priority) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 132, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 133, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 135, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 136, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -206,7 +206,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(*item.MediaTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 137, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 138, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -220,7 +220,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Attempts) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -233,7 +233,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.MaxAttempts) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -246,7 +246,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 143, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -272,7 +272,7 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(*item.ErrorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 147, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 148, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { diff --git a/templates/reader.templ b/templates/reader.templ index 75c8dc1..2b3dec4 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -43,6 +43,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm + +
diff --git a/templates/register_templ.go b/templates/register_templ.go index 05a9795..14bd4a3 100644 --- a/templates/register_templ.go +++ b/templates/register_templ.go @@ -29,7 +29,7 @@ func Register() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Register - Bookhoard
πŸ“š Bookhoard

Create your account

Password Requirements:

  • β—‹ At least 8 characters
  • β—‹ One uppercase letter
  • β—‹ One lowercase letter
  • β—‹ One number
  • β—‹ One special character
  • β—‹ Passwords match

Already have an account? Login

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Register - Bookhoard
πŸ“š Bookhoard

Create your account

Password Requirements:

  • β—‹ At least 8 characters
  • β—‹ One uppercase letter
  • β—‹ One lowercase letter
  • β—‹ One number
  • β—‹ One special character
  • β—‹ Passwords match

Already have an account? Login

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/series.templ b/templates/series.templ index afcf288..9b0849d 100644 --- a/templates/series.templ +++ b/templates/series.templ @@ -13,6 +13,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr Series - Bookhoard + @Header(user, "/series") diff --git a/templates/series_templ.go b/templates/series_templ.go index 6f1ff39..ba0ea46 100644 --- a/templates/series_templ.go +++ b/templates/series_templ.go @@ -34,7 +34,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Series - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Series - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -99,7 +99,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var2 templ.SafeURL templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -125,7 +125,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -138,7 +138,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -156,7 +156,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre var templ_7745c5c3_Var5 templ.SafeURL templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 61, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 62, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -224,7 +224,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 78, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -269,7 +269,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 83, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 84, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -295,7 +295,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 85, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 86, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -327,7 +327,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 97, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -340,7 +340,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -353,7 +353,7 @@ func SeriesCard(series SeriesCardData) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/templates/setup.templ b/templates/setup.templ index da6a4b3..d137862 100644 --- a/templates/setup.templ +++ b/templates/setup.templ @@ -10,6 +10,7 @@ templ Setup() { +
diff --git a/templates/setup_templ.go b/templates/setup_templ.go index b51d506..3d8d677 100644 --- a/templates/setup_templ.go +++ b/templates/setup_templ.go @@ -29,7 +29,7 @@ func Setup() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Setup - Bookhoard
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Setup - Bookhoard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/unlinked_books.templ b/templates/unlinked_books.templ index 31a5e85..72b858e 100644 --- a/templates/unlinked_books.templ +++ b/templates/unlinked_books.templ @@ -9,6 +9,7 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) { Unlinked Books - Bookhoard + @Header(user, "/unlinked") diff --git a/templates/unlinked_books_templ.go b/templates/unlinked_books_templ.go index b09eab5..8e54636 100644 --- a/templates/unlinked_books_templ.go +++ b/templates/unlinked_books_templ.go @@ -29,7 +29,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Unlinked Books - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Unlinked Books - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -125,7 +125,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 76, Col: 111} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 77, Col: 111} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -138,7 +138,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -151,7 +151,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 79, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -168,7 +168,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 87, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 88, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -186,7 +186,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -199,7 +199,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 94, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -217,7 +217,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 98, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 99, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -235,7 +235,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 104, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 105, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..ed12135 --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + From 8d65e035552b1f40e4e5658992dea8bbd014a118 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 13:43:32 -0400 Subject: [PATCH 16/16] fix(ui): move theme checkmark when theme changes in Appearance menu The checkmark in the Appearance theme menu was rendered server-side (if user.Theme == opt.Name), so it never moved after switching themes in the browser. - Always render the check for every theme option, hidden by default, using a new themeCheckClass(name, current) helper that returns the hidden class unless the option is the active theme. - Give each theme button a data-theme attribute and add updateThemeIndicators() to web/src/theme.ts, which reads the applied theme from the body class (theme-) and toggles the hidden class on each check accordingly. - Call updateThemeIndicators() from changeTheme (before the async save and on failure), initializeTheme, and loadUserTheme so the menu stays in sync with the applied theme. - Add unit test for themeCheckClass (templates/utils_test.go) and regenerate templ output. --- templates/header.templ | 9 +- templates/header_templ.go | 283 +++++++++++++++++++++----------------- templates/utils.go | 11 ++ templates/utils_test.go | 9 ++ web/src/theme.ts | 20 +++ 5 files changed, 203 insertions(+), 129 deletions(-) diff --git a/templates/header.templ b/templates/header.templ index 9f8347c..e0293d8 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -98,14 +98,15 @@ templ Header(user User, currentPath string) { }
diff --git a/templates/header_templ.go b/templates/header_templ.go index d627977..0a4b4b8 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -266,102 +266,135 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" style=\"color: var(--text-secondary);\"> ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if user.Theme == opt.Name { - templ_7745c5c3_Err = Icon("check", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Bookshelf

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, w := range WoodOptions { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

Bookshelf

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, w := range WoodOptions { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Administration
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 = []any{activeClass(currentPath, "/admin")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) + var templ_7745c5c3_Var23 = []any{activeClass(currentPath, "/admin")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Dashboard ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var22 = []any{activeClass(currentPath, "/admin/library")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "Libraries ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Dashboard ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 = []any{activeClass(currentPath, "/admin/users")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) + var templ_7745c5c3_Var25 = []any{activeClass(currentPath, "/admin/library")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "Users ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Libraries ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 = []any{activeClass(currentPath, "/admin/settings")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) + var templ_7745c5c3_Var27 = []any{activeClass(currentPath, "/admin/users")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "Users ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 = []any{activeClass(currentPath, "/admin/settings")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var29...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "Settings
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -510,7 +543,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -535,12 +568,12 @@ func SidebarUserMenu(user User) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -577,7 +610,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "Profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -611,12 +644,12 @@ func SidebarSignIn(currentPath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
Sign in
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">
Create an account
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/utils.go b/templates/utils.go index 1da27a0..88c9add 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -31,6 +31,17 @@ func navItemClass(current, target string) string { return base + "text-content-muted hover:text-content hover:bg-surface-hover" } +// themeCheckClass returns the class suffix for a theme option's checkmark +// wrapper: empty for the active theme (visible), " hidden" otherwise. The +// check element is always rendered so updateThemeIndicators() can move it +// client-side when the user picks a different theme. +func themeCheckClass(name, current string) string { + if name == current { + return "" + } + return " hidden" +} + func ContainsString(slice []string, item string) bool { for _, s := range slice { if s == item { diff --git a/templates/utils_test.go b/templates/utils_test.go index 2b5f50f..474b3bb 100644 --- a/templates/utils_test.go +++ b/templates/utils_test.go @@ -45,3 +45,12 @@ func TestGroupTunableSettingsEmpty(t *testing.T) { t.Errorf("expected empty groups, got live=%d restart=%d", len(live), len(restart)) } } + +func TestThemeCheckClass(t *testing.T) { + if got := themeCheckClass("dracula", "dracula"); got != "" { + t.Errorf("active theme should be visible, got %q", got) + } + if got := themeCheckClass("nord", "dracula"); got != " hidden" { + t.Errorf("inactive theme should be hidden, got %q", got) + } +} diff --git a/web/src/theme.ts b/web/src/theme.ts index 968e637..dc20078 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -41,6 +41,7 @@ const loadTheme = (): void => { // Change: Remove DOM element lookup, accept theme as parameter const changeTheme = async (theme: string): Promise => { applyTheme(theme); + updateThemeIndicators(); // Save to server if logged in const token = localStorage.getItem(TOKEN_STORAGE_KEY); @@ -78,6 +79,7 @@ const loadUserTheme = async (): Promise => { const data = await response.json(); if (data.theme) { applyTheme(data.theme as string); + updateThemeIndicators(); } } } catch { @@ -85,10 +87,26 @@ const loadUserTheme = async (): Promise => { } }; +// Update the active-theme checkmark in the Appearance menu to follow the +// theme currently applied to . Each theme button carries its theme name +// in data-theme and a .theme-check element that is shown for the active theme +// and hidden for the rest. +const updateThemeIndicators = (): void => { + const match = document.body.className.match(/theme-([\w-]+)/); + const current = match ? match[1] : DEFAULT_THEME; + document.querySelectorAll(".theme-btn").forEach((btn) => { + const check = btn.querySelector(".theme-check"); + if (!check) return; + const theme = btn.getAttribute("data-theme"); + check.classList.toggle("hidden", theme !== current); + }); +}; + // Initialize theme system const initializeTheme = (): void => { loadTheme(); loadUserTheme(); + updateThemeIndicators(); // Set theme select value to current theme const themeSelect = document.getElementById( @@ -210,6 +228,7 @@ Alpine.data("theme", () => ({ loadTheme, loadUserTheme, loadWoodPaneling, + updateThemeIndicators, updateWoodPanelingIndicators, })); @@ -221,6 +240,7 @@ export { loadTheme, loadUserTheme, loadWoodPaneling, + updateThemeIndicators, updateWoodPanelingIndicators, }; export type { ThemeType };