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"
|
||||||
|
|
||||||
@@ -984,12 +986,12 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
|
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
|
||||||
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
||||||
ID: uuid.UUID(mi.ID.Bytes).String(),
|
ID: uuid.UUID(mi.ID.Bytes).String(),
|
||||||
Title: mi.Title,
|
Title: mi.Title,
|
||||||
Author: mi.Author.String,
|
Author: mi.Author.String,
|
||||||
FilePath: mi.FilePath,
|
FilePath: mi.FilePath,
|
||||||
FileSize: mi.FileSize.Int64,
|
FileSize: mi.FileSize.Int64,
|
||||||
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
||||||
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
||||||
HasReadingData: totalData > 0,
|
HasReadingData: totalData > 0,
|
||||||
})
|
})
|
||||||
@@ -1090,9 +1092,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// already have their own dedicated UI cards (timezone dropdown, scan
|
// already have their own dedicated UI cards (timezone dropdown, scan
|
||||||
// settings) so they aren't listed twice.
|
// settings) so they aren't listed twice.
|
||||||
dedicatedUI := map[string]bool{
|
dedicatedUI := map[string]bool{
|
||||||
"default_timezone": true,
|
"default_timezone": true,
|
||||||
"scan_poll_interval_seconds": true,
|
"scan_poll_interval_seconds": true,
|
||||||
"auto_scan_enabled": true,
|
"auto_scan_enabled": true,
|
||||||
}
|
}
|
||||||
var tunableSettings []templates.SettingEntry
|
var tunableSettings []templates.SettingEntry
|
||||||
if cfg.Settings != nil {
|
if cfg.Settings != nil {
|
||||||
@@ -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">
|
||||||
@@ -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 class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
|
||||||
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
||||||
</div>
|
</div>
|
||||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
<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>
|
<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)
|
||||||
@MetadataEditorModal(book)
|
if user.Role == "admin" {
|
||||||
|
@MetadataEditorModal(book)
|
||||||
|
}
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+391
-360
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user