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:
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
if user.Role == "admin" {
|
||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||
@@ -370,12 +372,18 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
|
||||
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
||||
</div>
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
</div>
|
||||
}
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
</div>
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
|
||||
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</div>
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(user, book)
|
||||
@MetadataEditorModal(book)
|
||||
if user.Role == "admin" {
|
||||
@MetadataEditorModal(book)
|
||||
}
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+391
-360
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user