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
381 lines
12 KiB
Go
381 lines
12 KiB
Go
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
|
|
}
|