mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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)"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user