feat(book-detail): rebuild the edit metadata tab as a four-column ledger

Artwork and source, identity, catalog and description sit side by side, sized
by container queries on the pane rather than the viewport. Provider ids lose
their accordion for compact rows above the community ratings, and a locked
field now reads as a filled chip instead of a near-identical open padlock.
This commit is contained in:
neon
2026-08-26 06:42:49 +00:00
parent 2861aff338
commit 3f9d21defa
7 changed files with 919 additions and 811 deletions
@@ -165,13 +165,18 @@ onUnmounted(() => clearTimeout(debounceTimer))
/>
<button
type="button"
class="absolute bottom-2 right-2 flex items-center justify-center size-6 rounded-md bg-background/90 shadow-sm border border-input hover:bg-muted transition-colors"
class="absolute right-2 bottom-2 flex size-7 items-center justify-center rounded-md border shadow-sm backdrop-blur-sm transition-colors"
:class="
props.locked
? 'border-primary/40 bg-primary/25 text-primary hover:bg-primary/35'
: 'border-input bg-background/90 text-muted-foreground hover:bg-muted hover:text-foreground'
"
:title="props.locked ? t('book.detail.coverEditor.unlockCover') : t('book.detail.coverEditor.lockCover')"
:disabled="props.disabled"
@click.stop="handleToggleLock"
>
<Lock v-if="props.locked" class="size-3.5 text-primary" />
<LockOpen v-else class="size-3.5 text-muted-foreground" />
<Lock v-if="props.locked" class="size-4" />
<LockOpen v-else class="size-4" />
</button>
</div>
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ const props = defineProps<{
locked: boolean
isUpdating?: (field: BookMetadataLockField) => boolean
multiline?: boolean
grow?: boolean
}>()
const emit = defineEmits<{ toggle: [field: BookMetadataLockField] }>()
@@ -23,23 +24,29 @@ function handleToggle() {
</script>
<template>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ label }}</label>
<div class="relative">
<div class="group/field space-y-1" :class="grow ? 'flex min-h-0 flex-col' : ''">
<label class="text-[10px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{ label }}</label>
<div class="relative" :class="grow ? 'min-h-0 flex-1' : ''">
<slot />
<button
type="button"
class="absolute right-2 z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border border-input bg-background/95 text-muted-foreground shadow-sm transition-[colors,opacity] hover:text-foreground hover:bg-muted disabled:cursor-not-allowed"
:class="[multiline ? 'top-1' : 'top-1/2 -translate-y-1/2', loading ? 'opacity-60' : 'opacity-100']"
class="absolute right-1.5 z-10 inline-flex size-7 cursor-pointer items-center justify-center rounded-md border transition-[color,background-color,border-color,opacity] disabled:cursor-not-allowed sm:size-6"
:class="[
multiline ? 'top-1.5 sm:top-1' : 'top-1/2 -translate-y-1/2',
locked
? 'border-primary/30 bg-primary/15 text-primary hover:bg-primary/25'
: 'border-transparent text-muted-foreground hover:border-input hover:bg-muted hover:text-foreground focus-visible:border-input focus-visible:bg-muted',
loading ? 'opacity-60' : '',
]"
:aria-label="locked ? t('book.detail.editMetadata.unlockField', { field: label }) : t('book.detail.editMetadata.lockField', { field: label })"
:title="locked ? t('book.detail.editMetadata.unlockField', { field: label }) : t('book.detail.editMetadata.lockField', { field: label })"
:disabled="loading"
@click="handleToggle"
>
<Transition name="icon" mode="out-in">
<Loader2 v-if="loading" key="loading" class="size-3.5 animate-spin" />
<Lock v-else-if="locked" key="locked" class="size-3.5 text-primary" />
<LockOpen v-else key="unlocked" class="size-3.5" />
<Loader2 v-if="loading" key="loading" class="size-3.5 animate-spin" aria-hidden="true" />
<Lock v-else-if="locked" key="locked" class="size-4 sm:size-3.5" aria-hidden="true" />
<LockOpen v-else key="unlocked" class="size-4 sm:size-3.5" aria-hidden="true" />
</Transition>
</button>
</div>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileCheck, FileX } from '@lucide/vue'
import type { BookDetail } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { formatRelativeFromNow } from '@/i18n/formatters'
const props = defineProps<{ book: BookDetail }>()
const { t } = useI18n()
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const otherFiles = computed(() => props.book.files.filter((file) => file !== primaryFile.value))
const writeStatus = computed(() => props.book.fileWriteStatus ?? null)
const writeEnabled = computed(() => writeStatus.value?.enabled === true)
const writableFieldCount = computed(() => writeStatus.value?.writableFields?.length ?? 0)
const lastWritten = computed(() => (props.book.lastWrittenAt ? formatRelativeFromNow(new Date(props.book.lastWrittenAt)) : null))
function formatLabel(format: string | null): string {
return format ? format.toUpperCase() : t('book.detail.editMetadata.unknownFormat')
}
</script>
<template>
<section class="flex flex-col overflow-hidden rounded-xl border border-border bg-card" :aria-label="t('book.detail.editMetadata.sourceCard')">
<header class="flex h-7 flex-none items-center gap-2 border-b border-border px-2.5">
<h3 class="text-[10px] font-bold tracking-[0.12em] text-muted-foreground uppercase">{{ t('book.detail.editMetadata.sourceCard') }}</h3>
<span class="ml-auto text-[10px] font-semibold text-muted-foreground">
{{ t('book.detail.editMetadata.fileCount', { count: book.files.length }) }}
</span>
</header>
<!-- A two-column grid rather than per-row flex, so every value lines up on the same
edge no matter how long the label beside it is. -->
<dl class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-3 px-2.5 py-3 text-xs">
<dt class="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">{{ t('book.detail.editMetadata.libraryRow') }}</dt>
<dd class="truncate text-right font-medium" :title="book.libraryName">{{ book.libraryName }}</dd>
<template v-if="primaryFile">
<dt class="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">{{ t('book.detail.editMetadata.primaryRow') }}</dt>
<dd class="flex min-w-0 items-center justify-end gap-1.5">
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-bold tracking-wide text-primary">
{{ formatLabel(primaryFile.format) }}
</span>
<span class="truncate text-muted-foreground">{{ formatBytes(primaryFile.sizeBytes) }}</span>
</dd>
</template>
<dt class="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">{{ t('book.detail.editMetadata.writeBackRow') }}</dt>
<dd class="flex min-w-0 items-center justify-end gap-1.5">
<FileCheck v-if="writeEnabled" class="size-3 shrink-0 text-primary" aria-hidden="true" />
<FileX v-else class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate font-medium">
{{ writeEnabled ? t('book.detail.editMetadata.fieldCount', { count: writableFieldCount }) : t('book.detail.editMetadata.writeBackOff') }}
</span>
</dd>
<dt class="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">{{ t('book.detail.editMetadata.lastWrittenRow') }}</dt>
<dd class="truncate text-right" :class="lastWritten ? 'font-medium' : 'text-muted-foreground'">
{{ lastWritten ?? t('book.detail.editMetadata.neverWritten') }}
</dd>
<template v-for="file in otherFiles" :key="file.id">
<dt class="truncate text-[10px] font-semibold tracking-wide text-muted-foreground uppercase" :title="file.filename ?? undefined">
{{ formatLabel(file.format) }}
</dt>
<dd class="truncate text-right text-muted-foreground">{{ formatBytes(file.sizeBytes) }}</dd>
</template>
</dl>
</section>
</template>
@@ -127,7 +127,7 @@ function moveMembership(index: number, offset: -1 | 1) {
:search-fn="searchFn"
:disabled="disabled"
:placeholder="t('book.detail.seriesMembership.seriesPlaceholder')"
:class="'h-8 min-w-40 flex-1 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'"
:class="'h-8 min-w-24 flex-[3_1_12rem] rounded-lg border border-input bg-background px-3 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'"
@update:model-value="updateSeriesName(index, $event)"
/>
<div class="flex flex-wrap items-center gap-2">
@@ -136,7 +136,7 @@ function moveMembership(index: number, offset: -1 | 1) {
type="text"
inputmode="decimal"
:maxlength="SERIES_INDEX_MAX_LENGTH"
class="h-8 min-w-16 max-w-22 flex-1 rounded-lg border border-input bg-background px-2 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="h-8 min-w-14 max-w-16 flex-1 rounded-lg border border-input bg-background px-2 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:class="isSeriesIndexInvalid(membership.seriesIndex) ? 'border-destructive' : ''"
:disabled="disabled"
placeholder="#"
@@ -149,7 +149,7 @@ function moveMembership(index: number, offset: -1 | 1) {
type="number"
step="1"
min="1"
class="h-8 min-w-16 max-w-22 flex-1 rounded-lg border border-input bg-background px-2 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="h-8 min-w-14 max-w-16 flex-1 rounded-lg border border-input bg-background px-2 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
:placeholder="t('book.detail.seriesMembership.totalPlaceholder')"
:aria-label="t('book.detail.seriesMembership.totalLabel')"
@@ -158,8 +158,9 @@ function moveMembership(index: number, offset: -1 | 1) {
/>
<div class="flex items-center gap-1">
<button
v-if="visibleMemberships.length > 1"
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
class="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || index === 0"
:title="t('book.detail.seriesMembership.moveUp')"
@click="moveMembership(index, -1)"
@@ -167,8 +168,9 @@ function moveMembership(index: number, offset: -1 | 1) {
<ArrowUp class="size-3.5" />
</button>
<button
v-if="visibleMemberships.length > 1"
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
class="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || modelValue.length < 2 || index === visibleMemberships.length - 1"
:title="t('book.detail.seriesMembership.moveDown')"
@click="moveMembership(index, 1)"
@@ -177,7 +179,7 @@ function moveMembership(index: number, offset: -1 | 1) {
</button>
<button
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-40"
class="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || modelValue.length === 0"
:title="t('book.detail.seriesMembership.removeSeries')"
@click="removeMembership(index)"
+16 -1
View File
@@ -4562,7 +4562,22 @@
"searchPrompt": "Search above, then click a result to start comparing metadata."
},
"writeAndRename": "Write to File & Rename",
"writeAndRenameShort": "Write & Rename"
"writeAndRenameShort": "Write & Rename",
"identityCard": "Identity",
"catalogCard": "Catalog",
"sourceCard": "Source",
"score": "Score",
"scoreTooltip": "Metadata score {score} out of 100",
"emptyFields": "{count, plural, one {empty field} other {empty fields}}",
"emptyFieldsTooltip": "Not filled in yet: {fields}. Search online to look them up.",
"libraryRow": "Library",
"primaryRow": "Primary",
"writeBackRow": "Write-back",
"writeBackOff": "Off",
"lastWrittenRow": "Last written",
"neverWritten": "Never",
"fileCount": "{count, plural, =0 {no files} one {one file} other {# files}}",
"unknownFormat": "Unknown"
},
"files": {
"title": "Book files",
+3 -1
View File
@@ -38,7 +38,9 @@ function onFileRenamed() {
<template>
<BookDetailLayout :book-id="bookId">
<EditMetadataTab v-if="detail" :book="detail" @saved="onMetadataSaved" @cover-changed="onCoverChanged" @file-renamed="onFileRenamed" />
<div v-if="detail" class="h-full">
<EditMetadataTab :book="detail" @saved="onMetadataSaved" @cover-changed="onCoverChanged" @file-renamed="onFileRenamed" />
</div>
<div v-else-if="loading" class="max-w-2xl space-y-4">
<div class="h-9 rounded-md bg-muted animate-pulse" />
<div class="h-9 rounded-md bg-muted animate-pulse" />