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
This commit is contained in:
@@ -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, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
|
||||
}
|
||||
|
||||
_, err := cfg.LibraryService.CreateLibrary(
|
||||
c.Request().Context(),
|
||||
name,
|
||||
desc,
|
||||
libType,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
name := c.FormValue("name")
|
||||
desc := c.FormValue("description")
|
||||
if name == "" {
|
||||
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
|
||||
}
|
||||
|
||||
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||
}
|
||||
|
||||
userIDStr := c.FormValue("user_id")
|
||||
isVisible := c.FormValue("is_visible") == "true"
|
||||
|
||||
userID, err := parseAdminUUID(userIDStr)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
|
||||
}
|
||||
|
||||
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
|
||||
}
|
||||
|
||||
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 = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + 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
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
+111
-116
@@ -1,140 +1,135 @@
|
||||
package templates
|
||||
|
||||
templ Admin(user User) {
|
||||
templ Admin(user User, stats AdminStats) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Admin Dashboard - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
||||
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin")
|
||||
<div class="flex">
|
||||
@AdminSidebar(user, "/admin")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("grid", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("grid", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage your ebook collection</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="btn btn-secondary mt-4 text-sm">
|
||||
@Icon("arrow-right", "h-4 w-4")
|
||||
View Library
|
||||
</a>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("sync", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Auto-detecting new files</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
|
||||
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span>
|
||||
Watching <span id="watch-count">0</span> libraries
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard instance</p>
|
||||
</div>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||
@Icon("library", "h-4 w-4")
|
||||
<span class="text-xs font-semibold uppercase tracking-wide">Libraries</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.LibraryCount }</p>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Rescan Library
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Re-scan existing files and fix metadata</span>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
@Icon("library", "h-5 w-5")
|
||||
Manage Libraries
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Add or remove libraries and scan directories</span>
|
||||
</a>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||
@Icon("book", "h-4 w-4")
|
||||
<span class="text-xs font-semibold uppercase tracking-wide">Books</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.MediaCount }</p>
|
||||
</div>
|
||||
<!-- Scan Progress Section -->
|
||||
<div id="scan-progress-container" class="card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||
@Icon("users", "h-4 w-4")
|
||||
<span class="text-xs font-semibold uppercase tracking-wide">Users</span>
|
||||
</div>
|
||||
<!-- Overall Progress -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||
</div>
|
||||
<div class="w-full rounded-full h-3" style="background-color: var(--surface-hover);">
|
||||
<div
|
||||
id="scan-progress-bar"
|
||||
class="h-3 rounded-full transition-all duration-500"
|
||||
style="width: 0%; background-color: var(--accent);"
|
||||
></div>
|
||||
</div>
|
||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||
Starting scan...
|
||||
</div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.UserCount }</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||
@Icon("device", "h-4 w-4")
|
||||
<span class="text-xs font-semibold uppercase tracking-wide">Devices</span>
|
||||
</div>
|
||||
<!-- Per-Library Progress -->
|
||||
<div id="library-progress-list" class="space-y-3">
|
||||
<!-- Dynamically populated -->
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.DeviceCount }</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Watch Status -->
|
||||
<div class="stat-card mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("sync", "h-5 w-5")
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">File Watcher</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Auto-detects new files in library folders</p>
|
||||
</div>
|
||||
<!-- Results Summary -->
|
||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);">
|
||||
@Icon("check-circle", "h-5 w-5")
|
||||
Scan Complete!
|
||||
</h4>
|
||||
<div id="scan-results-content" style="color: var(--text-secondary)">
|
||||
<!-- Results populated by JS -->
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
@click="window.location.reload()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Refresh to View Books
|
||||
</button>
|
||||
<button
|
||||
@click="hideScanProgress()"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-right text-sm" style="color: var(--text-secondary)">
|
||||
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span>
|
||||
Watching <span id="watch-count">0</span> libraries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- Quick Actions -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scan All Libraries
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Re-scan existing files and detect new items</span>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
@Icon("library", "h-5 w-5")
|
||||
Manage Libraries
|
||||
</span>
|
||||
<span class="text-xs font-normal opacity-80">Add or remove libraries and folders</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scan Progress Section -->
|
||||
<div id="scan-progress-container" class="card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||
</div>
|
||||
<div class="w-full rounded-full h-3" style="background-color: var(--surface-hover);">
|
||||
<div
|
||||
id="scan-progress-bar"
|
||||
class="h-3 rounded-full transition-all duration-500"
|
||||
style="width: 0%; background-color: var(--accent);"
|
||||
></div>
|
||||
</div>
|
||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||
Starting scan...
|
||||
</div>
|
||||
</div>
|
||||
<div id="library-progress-list" class="space-y-3"></div>
|
||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);">
|
||||
@Icon("check-circle", "h-5 w-5")
|
||||
Scan Complete!
|
||||
</h4>
|
||||
<div id="scan-results-content" style="color: var(--text-secondary)"></div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button @click="window.location.reload()" class="btn btn-primary">Refresh to View Books</button>
|
||||
<button @click="hideScanProgress()" class="btn btn-secondary">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+339
-125
@@ -6,126 +6,53 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Library Management - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/library")
|
||||
<div class="flex">
|
||||
@AdminSidebar(user, "/admin/library")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-5xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||
<a href="/admin" class="btn btn-secondary">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back to Dashboard
|
||||
</a>
|
||||
<button data-action="show-create-modal" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Create Library
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Library Management</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Libraries Section -->
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Libraries</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Manage media libraries and their folders</p>
|
||||
<div id="libraries-list" class="space-y-3 mb-2">
|
||||
if len(libraries) == 0 {
|
||||
<p class="text-center py-8 text-sm" style="color: var(--text-secondary)">
|
||||
No libraries yet. Create your first library to get started.
|
||||
</p>
|
||||
} else {
|
||||
for _, library := range libraries {
|
||||
<div class="p-4 rounded-xl border transition-colors hover:bg-surface-hover" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-start gap-3 mb-2">
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-semibold" style="color: var(--text-primary)">{ library.Name }</h4>
|
||||
if library.Description != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||
}
|
||||
<span class="chip mt-1">
|
||||
@Icon("tag", "h-3 w-3")
|
||||
{ library.TypeName }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 shrink-0">
|
||||
<button data-library-id={ library.ID } data-action="show-folders" class="btn btn-secondary text-xs px-2.5 py-1">
|
||||
@Icon("folder", "h-4 w-4")
|
||||
Folders
|
||||
</button>
|
||||
<button data-library-id={ library.ID } data-action="edit" class="btn btn-primary text-xs px-2.5 py-1">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
<button data-library-id={ library.ID } data-action="delete" class="btn btn-danger text-xs px-2.5 py-1">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Libraries</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p>
|
||||
</div>
|
||||
<!-- User Library Visibility Section -->
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("check-circle", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Library Visibility</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Control which libraries are visible to users</p>
|
||||
<div id="visibility-controls" class="space-y-4">
|
||||
<!-- Visibility controls will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Visibility Management -->
|
||||
<div class="mt-6 card p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@Icon("users", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">User Library Access</h3>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Manage individual user access to specific libraries</p>
|
||||
<div class="mb-4">
|
||||
<select id="user-select" onchange="loadUserVisibility()" class="input w-auto">
|
||||
<option value="">Select a user...</option>
|
||||
for _, user := range users {
|
||||
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div id="user-libraries" class="space-y-3">
|
||||
<!-- User library checkboxes will be loaded here -->
|
||||
</div>
|
||||
<button
|
||||
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Create Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- Create Library Modal -->
|
||||
<div id="libraries-container">
|
||||
@LibraryList(user, libraries, users)
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Create / Edit Library Modal -->
|
||||
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
|
||||
<button type="button" data-action="hide-create-modal" class="icon-btn" aria-label="Close">
|
||||
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form id="create-library-form">
|
||||
<input type="hidden" id="library-id" name="id"/>
|
||||
<form
|
||||
hx-post="/admin/library/create"
|
||||
hx-target="#libraries-container"
|
||||
hx-swap="innerHTML"
|
||||
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
||||
<input type="text" name="name" placeholder="My Ebook Library" class="input" required/>
|
||||
@@ -143,7 +70,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" data-action="hide-create-modal" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Create
|
||||
@@ -152,42 +79,329 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Folder Browser Modal -->
|
||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||
<button type="button" data-action="browse-cancel" class="icon-btn" aria-label="Close">
|
||||
<!-- Edit Library Modal -->
|
||||
<div id="edit-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Library</h2>
|
||||
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div id="folder-browser-content">
|
||||
<!-- Directory listings will be rendered here -->
|
||||
</div>
|
||||
<form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" onsubmit="document.getElementById('edit-library-modal').classList.add('hidden')">
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
||||
<input type="text" name="name" id="edit-library-name" class="input" required/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea name="description" id="edit-library-desc" rows="3" class="input"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Library Confirmation Modal -->
|
||||
<!-- Folder Browser Modal -->
|
||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||
<button type="button" onclick="document.getElementById('folder-browser-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div id="folder-browser-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Library Modal -->
|
||||
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
||||
<button type="button" data-action="hide-delete-modal" class="icon-btn" aria-label="Close">
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
|
||||
<!-- Dynamic content will be injected here -->
|
||||
</div>
|
||||
<p class="mb-6" style="color: var(--text-primary)">
|
||||
Are you sure you want to delete <strong id="delete-library-name"></strong>?
|
||||
This will remove the library and all its folder mappings. Media files will not be deleted.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" data-action="hide-delete-modal" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" data-action="confirm-delete" class="btn btn-danger">
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script>
|
||||
function openEditModal(id, name, description) {
|
||||
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
|
||||
document.getElementById('edit-library-name').value = name;
|
||||
document.getElementById('edit-library-desc').value = description;
|
||||
htmx.process(document.getElementById('edit-library-form'));
|
||||
document.getElementById('edit-library-modal').classList.remove('hidden');
|
||||
}
|
||||
function openDeleteModal(id, name) {
|
||||
const btn = document.getElementById('delete-library-confirm');
|
||||
btn.setAttribute('hx-delete', '/admin/library/' + id);
|
||||
btn.setAttribute('hx-target', '#libraries-container');
|
||||
btn.setAttribute('hx-swap', 'innerHTML');
|
||||
document.getElementById('delete-library-name').textContent = name;
|
||||
htmx.process(btn);
|
||||
document.getElementById('delete-library-modal').classList.remove('hidden');
|
||||
}
|
||||
function openFolderBrowser(targetInputId, libraryId) {
|
||||
const content = document.getElementById('folder-browser-content');
|
||||
content.setAttribute('hx-get', '/admin/library/browse');
|
||||
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
|
||||
content.setAttribute('hx-trigger', 'load');
|
||||
htmx.process(content);
|
||||
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
if len(libraries) == 0 {
|
||||
<div class="card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Libraries Yet</h3>
|
||||
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Create your first library to get started</p>
|
||||
<button onclick="document.getElementById('create-library-modal').classList.remove('hidden')" class="btn btn-primary">Create Your First Library</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-4">
|
||||
for _, library := range libraries {
|
||||
<div class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 p-5">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold truncate" style="color: var(--text-primary)">{ library.Name }</h3>
|
||||
if library.Description != "" {
|
||||
<p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||
}
|
||||
</div>
|
||||
<span class="chip shrink-0">
|
||||
@Icon("tag", "h-3 w-3")
|
||||
{ library.TypeName }
|
||||
</span>
|
||||
if library.FolderCount > 0 {
|
||||
<span class="chip shrink-0">
|
||||
@Icon("folder", "h-3 w-3")
|
||||
{ library.FolderCount } folders
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
hx-post={ "/api/libraries/" + library.ID + "/scan" }
|
||||
hx-vals='{"force": "true"}'
|
||||
hx-target="#scan-indicator"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Scan
|
||||
</button>
|
||||
<button
|
||||
hx-get={ "/admin/library/" + library.ID + "/panel" }
|
||||
hx-target={ "#library-panel-" + library.ID }
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
@Icon("chevron-down", "h-4 w-4")
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "library-panel-" + library.ID }></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div id="scan-indicator"></div>
|
||||
}
|
||||
|
||||
templ LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) {
|
||||
<div class="border-t p-5 space-y-6" style="border-color: var(--border);">
|
||||
<!-- Folders -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@Icon("folder", "h-4 w-4 shrink-0")
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Folders</h4>
|
||||
</div>
|
||||
if len(folders) == 0 {
|
||||
<p class="text-sm mb-3" style="color: var(--text-secondary)">No folders configured. Add a folder to enable scanning.</p>
|
||||
} else {
|
||||
<div class="space-y-2 mb-3">
|
||||
for _, folder := range folders {
|
||||
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
|
||||
<button
|
||||
class="icon-btn h-7 w-7 shrink-0"
|
||||
hx-delete={ "/admin/library/" + libraryID + "/folders" }
|
||||
hx-vals={ `{"folder_path": "` + folder.FolderPath + `"}` }
|
||||
hx-target={ "#library-panel-" + libraryID }
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Remove this folder from the library?"
|
||||
>
|
||||
@Icon("trash", "h-3.5 w-3.5")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form class="flex gap-2" hx-post={ "/admin/library/" + libraryID + "/folders" } hx-target={ "#library-panel-" + libraryID } hx-swap="innerHTML">
|
||||
<input
|
||||
type="text"
|
||||
name="folder_path"
|
||||
id={ "folder-input-" + libraryID }
|
||||
placeholder="/path/to/books"
|
||||
class="input flex-1"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-library-id={ libraryID }
|
||||
data-target-input={ "folder-input-" + libraryID }
|
||||
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
|
||||
class="btn btn-secondary text-sm shrink-0"
|
||||
>
|
||||
@Icon("folder", "h-4 w-4")
|
||||
Browse
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary text-sm shrink-0">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- User Visibility -->
|
||||
if len(users) > 0 {
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@Icon("users", "h-4 w-4 shrink-0")
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">User Access</h4>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
for _, u := range users {
|
||||
<label class="flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded"
|
||||
name="is_visible"
|
||||
value="true"
|
||||
checked?={ isUserVisible(u.ID, visibility) }
|
||||
hx-post={ "/admin/library/" + libraryID + "/visibility" }
|
||||
hx-vals={ `{"user_id": "` + u.ID + `"}` }
|
||||
hx-trigger="change"
|
||||
hx-target={ "#library-panel-" + libraryID }
|
||||
hx-swap="innerHTML"
|
||||
/>
|
||||
<span class="text-sm" style="color: var(--text-primary)">{ u.Username }</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary)">{ u.Email }</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Processing Issues -->
|
||||
if issueCount > 0 {
|
||||
<div>
|
||||
<a href={ "/admin/libraries/" + libraryID + "/issues" } class="flex items-center gap-2 text-sm" style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-4 w-4")
|
||||
if issueCount == 1 {
|
||||
<span>1 processing issue</span>
|
||||
} else {
|
||||
<span>{ issueCount } processing issues</span>
|
||||
}
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
data-edit-id={ libraryID }
|
||||
data-edit-name={ library.Name }
|
||||
data-edit-desc={ library.Description }
|
||||
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit Details
|
||||
</button>
|
||||
<button
|
||||
data-delete-id={ libraryID }
|
||||
data-delete-name={ library.Name }
|
||||
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
|
||||
class="btn btn-danger text-sm"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) {
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3 p-2 rounded-lg" style="background-color: var(--bg-primary);">
|
||||
@Icon("folder", "h-4 w-4 shrink-0")
|
||||
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ currentPath }</code>
|
||||
</div>
|
||||
if parentPath != "" {
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
hx-get={ "/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID }
|
||||
hx-target="#folder-browser-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>..</span>
|
||||
</button>
|
||||
}
|
||||
<div class="space-y-1 max-h-64 overflow-y-auto">
|
||||
for _, entry := range entries {
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
hx-get={ "/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID }
|
||||
hx-target="#folder-browser-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@Icon("folder", "h-4 w-4 shrink-0")
|
||||
<span class="truncate">{ entry.Name }</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-target-input={ targetInput }
|
||||
data-current-path={ currentPath }
|
||||
onclick="document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')"
|
||||
class="btn btn-primary text-sm"
|
||||
>
|
||||
@Icon("check", "h-4 w-4")
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+866
-245
File diff suppressed because it is too large
Load Diff
+82
-22
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); initializeScanWebSocket()\" class=\"theme-tokyo-night\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
|
||||
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, "<div class=\"flex\">")
|
||||
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, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
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, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard instance</p></div><!-- Stats Grid --><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("library", "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, 5, "</span><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"btn btn-secondary mt-4 text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
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}
|
||||
}
|
||||
_, 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, 6, "View Library</a></div><div class=\"stat-card\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
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, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Books</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
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, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
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, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Users</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
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, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
|
||||
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, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Devices</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
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, "</p></div></div><!-- Watch Status --><div class=\"stat-card mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
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, "</span><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><div class=\"flex-1\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">File Watcher</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detects new files in library folders</p></div><div class=\"text-right text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><!-- Quick Actions --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -85,7 +145,7 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Rescan Library</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and fix metadata</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
|
||||
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</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and scan directories</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and folders</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -101,7 +161,7 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -109,7 +169,7 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -117,7 +177,7 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user