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 {
|
type MediaDetail struct {
|
||||||
database.MediaItems // Embedded - ALL book fields available
|
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
|
// User-specific data
|
||||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||||
Collections []database.Collections `json:"collections"`
|
Collections []database.Collections `json:"collections"`
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1267,6 +1269,23 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
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
|
// Fetch rating
|
||||||
var rating *database.MediaRatings
|
var rating *database.MediaRatings
|
||||||
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||||
@@ -1330,6 +1349,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Assemble response (no field duplication!)
|
// Assemble response (no field duplication!)
|
||||||
detail := handlers.MediaDetail{
|
detail := handlers.MediaDetail{
|
||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
|
FileLocation: fileLocation,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
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>
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
if user.Role == "admin" {
|
||||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Rating -->
|
<!-- Rating -->
|
||||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||||
@@ -376,6 +378,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
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);">
|
<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>
|
</div>
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(user, book)
|
@NotesHighlightsModal(user, book)
|
||||||
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
}
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+381
-350
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user