feat: enhance book metadata editing and details view

- add tags input to book edit page
- rearrange provider IDs in edit page to a single row
- make rating stars clickable in book details for direct updates
- always display rating in book details even if not set
- improve alignment of title, score badge, and provider icons
This commit is contained in:
neon
2026-03-13 03:31:18 -06:00
parent c9221f92be
commit c5fcabded7
2 changed files with 63 additions and 26 deletions
@@ -79,6 +79,7 @@ const descriptionExpanded = ref(false)
const { hasPermission } = usePermissions()
const canViewKobo = computed(() => hasPermission('kobo_sync'))
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const { coverUrl } = useCoverVersions()
@@ -87,6 +88,35 @@ const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
const formats = computed(() => [...new Set(props.book.files.map((f) => f.format ?? '?'))])
const localRating = ref<number | null>(null)
const hoverRating = ref<number | null>(null)
const displayRating = computed(() => hoverRating.value ?? localRating.value)
watch(
() => props.book.rating,
(val) => {
localRating.value = val ?? null
},
{ immediate: true },
)
async function setRating(star: number) {
if (!canEditMetadata.value) return
const newRating = localRating.value === star ? null : star
localRating.value = newRating
try {
const res = await api(`/api/v1/books/${props.book.id}/metadata`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
})
if (!res.ok) throw new Error()
} catch {
localRating.value = props.book.rating ?? null
}
}
const ratingStars = [1, 2, 3, 4, 5]
const fileProgressById = ref<Record<number, FileProgress>>({})
@@ -469,7 +499,7 @@ watch(
<!-- Right column -->
<div class="flex-1 min-w-0">
<!-- Identity block -->
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1">
<div class="flex items-center flex-wrap gap-x-3 gap-y-2">
<h1 class="text-2xl font-bold leading-tight">{{ book.title ?? 'Untitled' }}</h1>
<Popover :open="scoreBreakdownOpen" @update:open="(v) => (scoreBreakdownOpen = v)">
<PopoverTrigger as-child>
@@ -499,7 +529,7 @@ watch(
loading="lazy"
@error="providerIconErrors[link.key] = true"
/>
<span v-else class="text-[9px] font-bold leading-none text-foreground/90">{{ link.fallback }}</span>
<span v-else class="text-[8px] font-bold leading-none text-foreground/90">{{ link.fallback }}</span>
</a>
</div>
</div>
@@ -543,19 +573,7 @@ watch(
</div>
<!-- Metadata grid -->
<dl
v-if="
book.publisher ||
book.publishedYear ||
book.language ||
book.pageCount ||
book.isbn13 ||
book.isbn10 ||
book.rating != null ||
book.lastWrittenAt
"
class="mt-5 pt-5 border-t border-border grid grid-cols-2 xl:grid-cols-4 gap-x-8 gap-y-4"
>
<dl class="mt-5 pt-5 border-t border-border grid grid-cols-2 xl:grid-cols-4 gap-x-8 gap-y-4">
<div v-if="book.publisher" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Publisher</dt>
<dd class="text-sm text-foreground mt-0.5 leading-snug">{{ book.publisher }}</dd>
@@ -580,16 +598,28 @@ watch(
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-10</dt>
<dd class="text-sm text-foreground mt-0.5 font-mono">{{ book.isbn10 }}</dd>
</div>
<div v-if="book.rating != null" class="min-w-0">
<div class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Rating</dt>
<dd class="mt-0.5 flex items-center gap-1">
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="book.rating >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'"
/>
<span class="text-xs text-muted-foreground ml-1">{{ book.rating }}/5</span>
<dd class="mt-0.5 flex items-center gap-1" @mouseleave="hoverRating = null">
<template v-if="canEditMetadata">
<Tooltip v-for="star in ratingStars" :key="star">
<TooltipTrigger as-child>
<button type="button" class="p-0.5 transition-colors" @mouseenter="hoverRating = star" @click="setRating(star)">
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'" />
</button>
</TooltipTrigger>
<TooltipContent>Rate {{ star }}</TooltipContent>
</Tooltip>
</template>
<template v-else>
<Star
v-for="star in ratingStars"
:key="star"
class="size-3.5"
:class="(localRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/30'"
/>
</template>
<span class="text-xs text-muted-foreground ml-1">{{ localRating ?? 0 }}/5</span>
</dd>
</div>
<div v-if="book.lastWrittenAt" class="min-w-0">
@@ -9,7 +9,7 @@ import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
import { useGenreSearch } from '../../../composables/useTagSearch'
import { useGenreSearch, useTagSearch } from '../../../composables/useTagSearch'
import { useRefreshMetadata } from '../../../composables/useRefreshMetadata'
const props = defineProps<{ book: BookDetail }>()
@@ -18,6 +18,7 @@ const emit = defineEmits<{ saved: [BookDetail]; coverChanged: ['extracted' | 'cu
const { form, saving, error, isDirty, load, reset, save } = useMetadataEditor()
const { search: searchAuthors } = useAuthorSearch()
const { search: searchGenres } = useGenreSearch()
const { search: searchTags } = useTagSearch()
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false)
@@ -188,6 +189,12 @@ async function autoFill() {
<ChipInput v-model="form.genres" placeholder="Add genre..." :search-fn="searchGenres" />
</div>
<!-- Tags -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tags</label>
<ChipInput v-model="form.tags" placeholder="Add tag..." :search-fn="searchTags" />
</div>
<!-- Series + Index + Rating -->
<div class="grid grid-cols-6 gap-3">
<div class="col-span-4 space-y-1">
@@ -302,7 +309,7 @@ async function autoFill() {
<!-- Provider IDs -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider IDs</label>
<div class="rounded-lg border border-border bg-muted/30 p-3 grid grid-cols-5 gap-3">
<div class="rounded-lg border border-border bg-muted/30 p-3 grid grid-cols-6 gap-3">
<div v-for="{ field, label } in providerIdFields" :key="field" class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ label }}</label>
<input