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
|
||||
}
|
||||
Reference in New Issue
Block a user