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 {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -823,8 +824,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -848,11 +850,14 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
libData := make([]templates.LibraryData, len(libraries))
|
libData := make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||||
libData[i] = templates.LibraryData{
|
libData[i] = templates.LibraryData{
|
||||||
ID: libUUID.String(),
|
ID: libUUID.String(),
|
||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
FolderCount: len(folders),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,6 +1051,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin library HTMX endpoints
|
||||||
|
registerAdminLibraryRoutes(cfg, frontendProtected)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LEGACY API ROUTES (for backward compatibility)
|
// LEGACY API ROUTES (for backward compatibility)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+111
-116
@@ -1,140 +1,135 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ Admin(user User) {
|
templ Admin(user User, stats AdminStats) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>Admin Dashboard - Bookhoard</title>
|
<title>Admin Dashboard - Bookhoard</title>
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</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")
|
@Header(user, "/admin")
|
||||||
<div class="flex">
|
<main class="p-8">
|
||||||
@AdminSidebar(user, "/admin")
|
<div class="max-w-4xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="max-w-4xl">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<div class="mb-8">
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<div class="flex items-center gap-3 mb-1">
|
@Icon("grid", "h-5 w-5")
|
||||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
</span>
|
||||||
@Icon("grid", "h-5 w-5")
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||||
</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>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard instance</p>
|
||||||
<div class="stat-card">
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<!-- Stats Grid -->
|
||||||
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
@Icon("library", "h-5 w-5")
|
<div class="stat-card">
|
||||||
</span>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<div>
|
@Icon("library", "h-4 w-4")
|
||||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
<span class="text-xs font-semibold uppercase tracking-wide">Libraries</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.LibraryCount }</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-6">
|
<div class="stat-card">
|
||||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
@Icon("book", "h-4 w-4")
|
||||||
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
|
<span class="text-xs font-semibold uppercase tracking-wide">Books</span>
|
||||||
<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>
|
</div>
|
||||||
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.MediaCount }</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Scan Progress Section -->
|
<div class="stat-card">
|
||||||
<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 items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<div class="flex justify-between items-center mb-4">
|
@Icon("users", "h-4 w-4")
|
||||||
<h3 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
<span class="text-xs font-semibold uppercase tracking-wide">Users</span>
|
||||||
@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>
|
||||||
<!-- Overall Progress -->
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.UserCount }</p>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<div class="flex justify-between text-sm mb-2">
|
<div class="stat-card">
|
||||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
|
||||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
@Icon("device", "h-4 w-4")
|
||||||
</div>
|
<span class="text-xs font-semibold uppercase tracking-wide">Devices</span>
|
||||||
<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>
|
||||||
<!-- Per-Library Progress -->
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.DeviceCount }</p>
|
||||||
<div id="library-progress-list" class="space-y-3">
|
</div>
|
||||||
<!-- Dynamically populated -->
|
</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>
|
</div>
|
||||||
<!-- Results Summary -->
|
<div class="text-right text-sm" style="color: var(--text-secondary)">
|
||||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span>
|
||||||
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);">
|
Watching <span id="watch-count">0</span> libraries
|
||||||
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<!-- Quick Actions -->
|
||||||
</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")
|
||||||
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
+339
-125
@@ -6,126 +6,53 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>Library Management - Bookhoard</title>
|
<title>Library Management - Bookhoard</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
<body class="theme-{ user.Theme }">
|
||||||
@Header(user, "/admin/library")
|
@Header(user, "/admin/library")
|
||||||
<div class="flex">
|
<main class="p-8">
|
||||||
@AdminSidebar(user, "/admin/library")
|
<div class="max-w-4xl">
|
||||||
<main class="flex-1 p-8">
|
<div class="mb-8">
|
||||||
<div class="max-w-5xl">
|
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||||
<div class="mb-8">
|
<div>
|
||||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
<div class="flex items-center gap-3 mb-1">
|
||||||
<a href="/admin" class="btn btn-secondary">
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
@Icon("arrow-left", "h-4 w-4")
|
@Icon("library", "h-5 w-5")
|
||||||
Back to Dashboard
|
</span>
|
||||||
</a>
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Libraries</h1>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- User Library Visibility Section -->
|
<button
|
||||||
<div class="card p-6">
|
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
|
||||||
<div class="flex items-center gap-2 mb-1">
|
class="btn btn-primary"
|
||||||
@Icon("check-circle", "h-5 w-5 shrink-0")
|
>
|
||||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Library Visibility</h3>
|
@Icon("plus", "h-4 w-4")
|
||||||
</div>
|
Create Library
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Control which libraries are visible to users</p>
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div id="libraries-container">
|
||||||
</div>
|
@LibraryList(user, libraries, users)
|
||||||
<!-- Create Library Modal -->
|
</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 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">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
|
<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")
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="create-library-form">
|
<form
|
||||||
<input type="hidden" id="library-id" name="id"/>
|
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">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
<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/>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<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">
|
<button type="submit" class="btn btn-primary">
|
||||||
@Icon("plus", "h-4 w-4")
|
@Icon("plus", "h-4 w-4")
|
||||||
Create
|
Create
|
||||||
@@ -152,42 +79,329 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Folder Browser Modal -->
|
<!-- Edit Library 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 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 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-4">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Library</h2>
|
||||||
<button type="button" data-action="browse-cancel" class="icon-btn" aria-label="Close">
|
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||||
@Icon("close", "h-5 w-5")
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="folder-browser-content">
|
<form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" onsubmit="document.getElementById('edit-library-modal').classList.add('hidden')">
|
||||||
<!-- Directory listings will be rendered here -->
|
<div class="mb-4">
|
||||||
</div>
|
<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>
|
||||||
</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 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="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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
<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")
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
|
<p class="mb-6" style="color: var(--text-primary)">
|
||||||
<!-- Dynamic content will be injected here -->
|
Are you sure you want to delete <strong id="delete-library-name"></strong>?
|
||||||
</div>
|
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">
|
<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" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="button" data-action="confirm-delete" class="btn btn-danger">
|
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||||
@Icon("trash", "h-4 w-4")
|
@Icon("trash", "h-4 w-4")
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</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 "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
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
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -37,15 +37,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex\">")
|
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
|
|
||||||
}
|
|
||||||
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);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -53,23 +45,91 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -77,7 +137,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -85,7 +145,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -93,7 +153,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -101,7 +161,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -109,7 +169,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -117,7 +177,7 @@ func Admin(user User) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,32 @@ type LibraryData struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
TypeName string
|
TypeName string
|
||||||
|
TypeValue string
|
||||||
MediaCount int64
|
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 {
|
type SeriesCardData struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user