feat(ui): admin-only file location and metadata editing on book detail

The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.

- The page handler resolves library folder + relative path (verified
  with os.Stat; falls back to the relative path when the file is not
  found on disk) into the new MediaDetail.FileLocation field - only for
  admins, so the absolute path never leaves the server for regular
  users. This also makes it possible to locate sparse entries whose
  metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
  Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
  admins. The modal drives admin-only endpoints (metadata PUT, rescan,
  reset), so non-admins previously saw a button and a form that could
  only ever fail with 403s.
This commit is contained in:
John O'Keefe
2026-09-12 14:21:42 -04:00
parent 7f64b92b9d
commit 9da193e718
4 changed files with 440 additions and 375 deletions
+4
View File
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
type MediaDetail struct {
database.MediaItems // Embedded - ALL book fields available
// Absolute on-disk location (library folder + relative path), resolved by
// the page handler so the UI can show where the file lives.
FileLocation string `json:"file_location"`
// User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"`
Collections []database.Collections `json:"collections"`
+28 -8
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
@@ -984,12 +986,12 @@ func registerFrontendRoutes(cfg *Config) {
}
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0,
})
@@ -1090,9 +1092,9 @@ func registerFrontendRoutes(cfg *Config) {
// already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice.
dedicatedUI := map[string]bool{
"default_timezone": true,
"default_timezone": true,
"scan_poll_interval_seconds": true,
"auto_scan_enabled": true,
"auto_scan_enabled": true,
}
var tunableSettings []templates.SettingEntry
if cfg.Settings != nil {
@@ -1267,6 +1269,23 @@ func registerFrontendRoutes(cfg *Config) {
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
}
// Resolve the on-disk location (library folder + relative path) so the
// detail page can show where the file lives, even for sparse metadata.
// Admin-only: everyday users never receive the absolute path.
fileLocation := ""
if user.Role == "admin" {
fileLocation = mediaItem.FilePath
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
if _, serr := os.Stat(candidate); serr == nil {
fileLocation = candidate
break
}
}
}
}
// Fetch rating
var rating *database.MediaRatings
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
@@ -1330,6 +1349,7 @@ func registerFrontendRoutes(cfg *Config) {
// Assemble response (no field duplication!)
detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating,
Collections: collections,
ReadingProgress: progress,