mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add per-field locking to protect fields from overwrites
This commit is contained in:
@@ -6,6 +6,8 @@ const props = defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
searchFn: (q: string) => Promise<string[]>
|
||||
disabled?: boolean
|
||||
controlClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
|
||||
@@ -16,6 +18,7 @@ const showDropdown = ref(false)
|
||||
let debounceTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
async function onInput() {
|
||||
if (props.disabled) return
|
||||
clearTimeout(debounceTimer)
|
||||
if (!query.value.trim()) {
|
||||
results.value = []
|
||||
@@ -30,6 +33,7 @@ async function onInput() {
|
||||
}
|
||||
|
||||
function addItem(item: string) {
|
||||
if (props.disabled) return
|
||||
const trimmed = item.trim()
|
||||
if (trimmed && !props.modelValue.includes(trimmed)) {
|
||||
emit('update:modelValue', [...props.modelValue, trimmed])
|
||||
@@ -40,6 +44,7 @@ function addItem(item: string) {
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled) return
|
||||
if (e.key === 'Enter' && query.value.trim()) {
|
||||
e.preventDefault()
|
||||
addItem(query.value)
|
||||
@@ -49,6 +54,7 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function removeItem(item: string) {
|
||||
if (props.disabled) return
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((v) => v !== item),
|
||||
@@ -68,17 +74,24 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<div class="relative">
|
||||
<div
|
||||
class="min-h-10 flex flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm focus-within:ring-1 focus-within:ring-ring transition-shadow"
|
||||
:class="[props.disabled ? 'cursor-not-allowed opacity-60' : '', props.controlClass]"
|
||||
>
|
||||
<span v-for="item in modelValue" :key="item" class="flex items-center gap-1 bg-muted px-2 py-0.5 rounded-full text-xs">
|
||||
{{ item }}
|
||||
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors" @click="removeItem(item)">
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors disabled:pointer-events-none"
|
||||
:disabled="props.disabled"
|
||||
@click="removeItem(item)"
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="query"
|
||||
class="flex-1 min-w-24 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
class="flex-1 min-w-24 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="props.disabled"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
|
||||
@@ -88,6 +88,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
lockedFields: [],
|
||||
collections: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, onUnmounted } from 'vue'
|
||||
import { Image, ImagePlus, Link, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
|
||||
import { Image, ImagePlus, Link, Lock, LockOpen, Loader2, RotateCcw, Search, Upload, X } from 'lucide-vue-next'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { FORMAT_TO_GROUP } from '@projectx/types'
|
||||
import { hideOnError } from '../../../lib/metadata-fetch'
|
||||
@@ -10,8 +10,8 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
|
||||
import CoverSearchDrawer from './CoverSearchDrawer.vue'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null] }>()
|
||||
const props = defineProps<{ book: BookDetail; locked?: boolean }>()
|
||||
const emit = defineEmits<{ coverChanged: ['extracted' | 'custom' | null]; toggleLock: [] }>()
|
||||
|
||||
const bookIdRef = computed(() => props.book.id)
|
||||
const { uploading, error, previewSrc, pendingFile, pendingUrl, selectFile, setUrl, clearPending, confirm, revert } = useCoverEditor(bookIdRef)
|
||||
@@ -22,7 +22,7 @@ const { hasPermission } = usePermissions()
|
||||
const reExtractingCover = ref(false)
|
||||
|
||||
async function reExtractCover() {
|
||||
if (reExtractingCover.value) return
|
||||
if (props.locked || reExtractingCover.value) return
|
||||
reExtractingCover.value = true
|
||||
try {
|
||||
await fetch(`/api/v1/books/${props.book.id}/re-extract-cover`, { method: 'POST' })
|
||||
@@ -51,11 +51,13 @@ function cancelPending() {
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
if (props.locked) return
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function onUrlInput() {
|
||||
if (props.locked) return
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => setUrl(urlInput.value.trim()), 400)
|
||||
}
|
||||
@@ -73,11 +75,13 @@ function handleSearchSelect(url: string) {
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (props.locked) return
|
||||
const ok = await confirm()
|
||||
if (ok) emit('coverChanged', 'custom')
|
||||
}
|
||||
|
||||
async function handleRevert() {
|
||||
if (props.locked) return
|
||||
const result = await revert()
|
||||
if (result !== false) emit('coverChanged', result)
|
||||
}
|
||||
@@ -98,6 +102,15 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
@click="lightboxOpen = true"
|
||||
>
|
||||
<img :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" />
|
||||
<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"
|
||||
:title="props.locked ? 'Unlock cover' : 'Lock cover'"
|
||||
@click.stop="emit('toggleLock')"
|
||||
>
|
||||
<Lock v-if="props.locked" class="size-3.5 text-primary/70" />
|
||||
<LockOpen v-else class="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
@@ -116,16 +129,18 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<!-- Mode toggle -->
|
||||
<div class="flex gap-1 p-0.5 rounded-lg bg-muted">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="mode === 'file' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="props.locked"
|
||||
@click="switchMode('file')"
|
||||
>
|
||||
<ImagePlus class="size-3.5" />
|
||||
File
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="mode === 'url' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="props.locked"
|
||||
@click="switchMode('url')"
|
||||
>
|
||||
<Link class="size-3.5" />
|
||||
@@ -136,11 +151,12 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<!-- File input -->
|
||||
<div v-if="mode === 'file'">
|
||||
<label
|
||||
class="flex items-center gap-2 h-9 px-3 rounded-lg border border-dashed border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors cursor-pointer"
|
||||
class="flex items-center gap-2 h-9 px-3 rounded-lg border border-dashed border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors"
|
||||
:class="props.locked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
>
|
||||
<Upload class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ pendingFile ? pendingFile.name : 'Choose image...' }}</span>
|
||||
<input type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||
<input type="file" accept="image/*" class="hidden" :disabled="props.locked" @change="onFileChange" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -149,13 +165,15 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<input
|
||||
v-model="urlInput"
|
||||
class="w-full h-9 rounded-lg border border-input bg-background px-3 text-xs outline-none focus:ring-1 focus:ring-ring transition-shadow"
|
||||
:disabled="props.locked"
|
||||
@input="onUrlInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search Button -->
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
|
||||
class="flex items-center justify-center gap-2 w-full h-9 rounded-lg border border-input bg-background text-xs font-medium hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="props.locked"
|
||||
@click="isSearchOpen = true"
|
||||
>
|
||||
<Search class="size-3.5" />
|
||||
@@ -182,7 +200,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<button
|
||||
v-if="hasPending"
|
||||
class="w-full h-8 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
:disabled="uploading || props.locked"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ uploading ? 'Saving...' : 'Save cover' }}
|
||||
@@ -190,7 +208,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<button
|
||||
v-if="hasPending"
|
||||
class="w-full h-8 rounded-lg border border-input bg-background text-xs hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
:disabled="uploading || props.locked"
|
||||
@click="cancelPending"
|
||||
>
|
||||
Cancel
|
||||
@@ -198,7 +216,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<button
|
||||
v-if="book.coverSource === 'custom'"
|
||||
class="flex items-center justify-center gap-1.5 w-full h-8 rounded-lg border border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
:disabled="uploading || props.locked"
|
||||
@click="handleRevert"
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
@@ -207,7 +225,7 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
<button
|
||||
v-if="hasPermission('library_edit_metadata')"
|
||||
class="flex items-center justify-center gap-1.5 w-full h-8 rounded-lg border border-input bg-background text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
:disabled="reExtractingCover"
|
||||
:disabled="reExtractingCover || props.locked"
|
||||
@click="reExtractCover"
|
||||
>
|
||||
<Loader2 v-if="reExtractingCover" class="size-3 animate-spin" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Check, ChevronDown, FolderPlus, Headphones, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { BookOpen, Check, ChevronDown, FolderPlus, Headphones, Lock, MoreHorizontal, Pencil, Star, Trash2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
|
||||
import { bookCoverStyle } from '@/features/book/lib/book-cover'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
@@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { api } from '@/lib/api'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
|
||||
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
|
||||
@@ -219,6 +220,14 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
const { load: loadLocks, isLocked } = useMetadataLocks()
|
||||
watch(
|
||||
() => props.book,
|
||||
(b) => loadLocks(b),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isRatingLocked = computed(() => isLocked('rating'))
|
||||
const canViewKobo = computed(() => hasPermission('kobo_sync'))
|
||||
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
|
||||
|
||||
@@ -873,24 +882,44 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-1" @mouseleave="hoverRating = null">
|
||||
<template v-if="canEditMetadata">
|
||||
<Tooltip v-for="star in ratingStars" :key="star">
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="canEditMetadata">
|
||||
<Tooltip v-for="star in ratingStars" :key="star">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 transition-colors"
|
||||
:class="isRatingLocked ? 'pointer-events-none' : 'disabled:opacity-50'"
|
||||
:disabled="isRatingLocked"
|
||||
@mouseenter="hoverRating = star"
|
||||
@click="setRating(star)"
|
||||
>
|
||||
<Star class="size-3.5" :class="(displayRating ?? 0) >= star ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/60'" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isRatingLocked ? 'Rating is locked' : `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/60'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="isRatingLocked">
|
||||
<Tooltip>
|
||||
<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/60'" />
|
||||
</button>
|
||||
<div class="ml-1 p-1 rounded-full bg-primary/10 text-primary">
|
||||
<Lock class="size-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rate {{ star }}</TooltipContent>
|
||||
<TooltipContent>Rating is locked</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/60'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="w-px h-3.5 bg-border mx-1.5" />
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ArrowLeft, CopyCheck, Copy, CheckCheck, Eye, EyeOff, Info } from 'lucide-vue-next'
|
||||
import type { MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
|
||||
import type {
|
||||
BookMetadataLockField,
|
||||
MetadataCandidate,
|
||||
MetadataProviderInfo,
|
||||
MetadataProviderKey,
|
||||
MetadataSource,
|
||||
ProviderIds,
|
||||
} from '@projectx/types'
|
||||
import { useMetadataDiff, type DiffFieldKey, type MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { getProviderColor, getProviderLabel, hideOnError, providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import MetadataDiffRow from './MetadataDiffRow.vue'
|
||||
@@ -16,6 +23,7 @@ const props = defineProps<{
|
||||
backLabel?: string
|
||||
currentCoverUrl?: string
|
||||
providerIds?: ProviderIds
|
||||
lockedFields?: BookMetadataLockField[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -85,6 +93,7 @@ const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicks
|
||||
computed(() => props.providers),
|
||||
props.currentCoverUrl,
|
||||
computed(() => props.providerIds),
|
||||
computed(() => props.lockedFields ?? []),
|
||||
)
|
||||
|
||||
const activeProviderLabel = computed(() => getProviderLabel(activeProvider.value, props.providers))
|
||||
|
||||
@@ -84,7 +84,12 @@ watch(
|
||||
<template>
|
||||
<!-- Cover row -->
|
||||
<div v-if="field.isCover" class="py-3.5 border-b border-border/40">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p>
|
||||
<div class="mb-2.5 flex items-center gap-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ field.label }}</p>
|
||||
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-2 items-center">
|
||||
<!-- Current cover -->
|
||||
<div
|
||||
@@ -136,6 +141,7 @@ watch(
|
||||
? 'bg-primary text-primary-foreground shadow-primary/30'
|
||||
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
|
||||
"
|
||||
:disabled="field.isLocked"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
|
||||
@@ -143,7 +149,7 @@ watch(
|
||||
<ArrowLeft v-else class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
|
||||
<Popover v-if="field.isCopyable && field.providerValues.length >= 2 && !field.isLocked">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
@@ -212,7 +218,12 @@ watch(
|
||||
|
||||
<!-- Text row -->
|
||||
<div v-else class="py-2.5 border-b border-border/40">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">{{ field.label }}</p>
|
||||
<span v-if="field.isLocked" class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-medium text-primary">
|
||||
Locked
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-1.5 sm:items-stretch">
|
||||
<!-- Current value -->
|
||||
@@ -250,6 +261,7 @@ watch(
|
||||
? 'bg-primary text-primary-foreground shadow-primary/30'
|
||||
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
|
||||
"
|
||||
:disabled="field.isLocked"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<RotateCcw v-if="field.isPicked && field.pickedFromActive" class="size-3.5" />
|
||||
@@ -261,7 +273,7 @@ watch(
|
||||
</div>
|
||||
<div v-else class="size-8 shrink-0" />
|
||||
|
||||
<Popover v-if="field.isCopyable && field.providerValues.length >= 2">
|
||||
<Popover v-if="field.isCopyable && field.providerValues.length >= 2 && !field.isLocked">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="size-5 rounded-full flex items-center justify-center transition-all hover:bg-muted text-muted-foreground hover:text-foreground shrink-0"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2, Lock, LockOpen } from 'lucide-vue-next'
|
||||
import type { BookMetadataLockField } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
field: BookMetadataLockField
|
||||
locked: boolean
|
||||
isUpdating?: (field: BookMetadataLockField) => boolean
|
||||
multiline?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [field: BookMetadataLockField] }>()
|
||||
|
||||
const loading = computed(() => props.isUpdating?.(props.field) ?? false)
|
||||
|
||||
function handleToggle() {
|
||||
emit('toggle', props.field)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{{ label }}</label>
|
||||
<div class="relative">
|
||||
<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-2.5' : 'top-1/2 -translate-y-1/2', loading ? 'opacity-60' : 'opacity-100']"
|
||||
:aria-label="locked ? `Unlock ${label}` : `Lock ${label}`"
|
||||
:title="locked ? `Unlock ${label}` : `Lock ${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" />
|
||||
</Transition>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-enter-active,
|
||||
.icon-leave-active {
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
.icon-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
.icon-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -2,14 +2,14 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { X, Sparkles } from 'lucide-vue-next'
|
||||
import { isAudioFormat } from '@projectx/types'
|
||||
import type { BookDetail, MetadataCandidate, MetadataSource } from '@projectx/types'
|
||||
import type { BookDetail, BookMetadataLockField, MetadataCandidate, MetadataSource } from '@projectx/types'
|
||||
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
|
||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import MetadataSearchPanel from './MetadataSearchPanel.vue'
|
||||
import MetadataDiffPanel from './MetadataDiffPanel.vue'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const props = defineProps<{ book: BookDetail; lockedFields: BookMetadataLockField[] }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
apply: [{ formPatch: MetadataPatch; coverUrl?: string }]
|
||||
@@ -156,6 +156,7 @@ function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) {
|
||||
:providers="providers"
|
||||
:current-cover-url="bookCoverUrl"
|
||||
:provider-ids="book.providerIds"
|
||||
:locked-fields="props.lockedFields"
|
||||
:filtered-results="filteredResults"
|
||||
back-label="Results"
|
||||
@back="backToSearch"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import MetadataFieldLabel from '../MetadataFieldLabel.vue'
|
||||
|
||||
describe('MetadataFieldLabel', () => {
|
||||
it('renders the lock button inside the control wrapper', () => {
|
||||
const wrapper = mount(MetadataFieldLabel, {
|
||||
props: {
|
||||
label: 'Title',
|
||||
field: 'title',
|
||||
locked: false,
|
||||
},
|
||||
slots: {
|
||||
default: '<input data-test="control" class="w-full pr-10" />',
|
||||
},
|
||||
})
|
||||
|
||||
const button = wrapper.get('button[aria-label="Lock Title"]')
|
||||
const control = wrapper.get('[data-test="control"]')
|
||||
|
||||
expect(button.classes()).toContain('absolute')
|
||||
expect(button.element.parentElement).toBe(control.element.parentElement)
|
||||
})
|
||||
|
||||
it('emits the target field when toggled', async () => {
|
||||
const wrapper = mount(MetadataFieldLabel, {
|
||||
props: {
|
||||
label: 'Title',
|
||||
field: 'title',
|
||||
locked: true,
|
||||
},
|
||||
slots: {
|
||||
default: '<input class="w-full pr-10" />',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button[aria-label="Unlock Title"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('toggle')).toEqual([['title']])
|
||||
})
|
||||
})
|
||||
@@ -173,4 +173,25 @@ describe('useMetadataDiff', () => {
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('marks locked fields and prevents them from being picked', () => {
|
||||
const candidates = ref([mockCandidate1])
|
||||
const activeProvider = ref<MetadataProviderKey>('google')
|
||||
const lockedFields = ref(['title'] as const)
|
||||
const { fields, toggleField, buildPatch } = useMetadataDiff(
|
||||
mockCurrent,
|
||||
candidates,
|
||||
activeProvider,
|
||||
providers,
|
||||
undefined,
|
||||
undefined,
|
||||
lockedFields,
|
||||
)
|
||||
|
||||
toggleField('title')
|
||||
|
||||
expect(fields.value.find((f) => f.key === 'title')?.isLocked).toBe(true)
|
||||
expect(fields.value.find((f) => f.key === 'title')?.isPicked).toBe(false)
|
||||
expect(buildPatch().formPatch.title).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
lockedFields: [],
|
||||
collections: [],
|
||||
...overrides,
|
||||
}
|
||||
@@ -97,8 +98,9 @@ describe('useMetadataEditor', () => {
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => book })
|
||||
|
||||
const { load, save } = useMetadataEditor()
|
||||
const { form, load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
form.narrators = ['Narrator Two']
|
||||
await save(book.id)
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
@@ -106,9 +108,23 @@ describe('useMetadataEditor', () => {
|
||||
audioMetadata?: { narrators?: string[]; durationSeconds?: number | null; abridged?: boolean }
|
||||
}
|
||||
expect(payload.audioMetadata).toEqual({
|
||||
narrators: ['Narrator One'],
|
||||
durationSeconds: 3600,
|
||||
abridged: false,
|
||||
narrators: ['Narrator Two'],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only changed fields in the metadata payload', async () => {
|
||||
const book = makeBook({ title: 'Original Title', publisher: 'Original Publisher' })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => book })
|
||||
|
||||
const { form, load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
form.publisher = 'Updated Publisher'
|
||||
await save(book.id)
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
const payload = JSON.parse(String(req.body)) as Record<string, unknown>
|
||||
expect(payload).toEqual({
|
||||
publisher: 'Updated Publisher',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
|
||||
import { useMetadataLocks } from '../useMetadataLocks'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>>())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}))
|
||||
|
||||
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
return {
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
libraryName: 'Test Library',
|
||||
status: 'present',
|
||||
folderPath: '/books',
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
title: 'Test Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
lockedFields: [],
|
||||
collections: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMetadataLocks', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
it('loads current lock state from the book detail', () => {
|
||||
const { load, isLocked } = useMetadataLocks()
|
||||
|
||||
load(makeBook({ lockedFields: ['authors', 'cover'] }))
|
||||
|
||||
expect(isLocked('authors')).toBe(true)
|
||||
expect(isLocked('cover')).toBe(true)
|
||||
expect(isLocked('title')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles a field by replacing the full lock set', async () => {
|
||||
const updated = makeBook({ lockedFields: ['title'] })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => updated })
|
||||
|
||||
const { load, toggle } = useMetadataLocks()
|
||||
load(makeBook())
|
||||
const result = await toggle(1, 'title')
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(JSON.parse(String(req.body))).toEqual({ lockedFields: ['title'] })
|
||||
expect(result?.lockedFields).toEqual(['title'])
|
||||
})
|
||||
|
||||
it('lockAll sends every lockable field', async () => {
|
||||
const { BOOK_METADATA_LOCK_FIELDS } = await import('@projectx/types')
|
||||
const allLocked = makeBook({ lockedFields: [...BOOK_METADATA_LOCK_FIELDS] })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => allLocked })
|
||||
|
||||
const { lockAll, areAllLocked } = useMetadataLocks()
|
||||
const result = await lockAll(1)
|
||||
|
||||
expect(result?.lockedFields).toHaveLength(BOOK_METADATA_LOCK_FIELDS.length)
|
||||
expect(areAllLocked.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unlockAll sends an empty lock set', async () => {
|
||||
const unlocked = makeBook({ lockedFields: [] })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => unlocked })
|
||||
|
||||
const { load, unlockAll, lockedFields } = useMetadataLocks()
|
||||
load(makeBook({ lockedFields: ['title', 'cover'] }))
|
||||
const result = await unlockAll(1)
|
||||
|
||||
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(JSON.parse(String(req.body))).toEqual({ lockedFields: [] })
|
||||
expect(result?.lockedFields).toEqual([])
|
||||
expect(lockedFields.value).toEqual([])
|
||||
})
|
||||
|
||||
it('sets error and returns null on network failure', async () => {
|
||||
apiMock.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { toggle, error } = useMetadataLocks()
|
||||
const result = await toggle(1, 'title')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(error.value).toBe('network error')
|
||||
})
|
||||
|
||||
it('sets error and returns null on non-ok response', async () => {
|
||||
apiMock.mockResolvedValue({ ok: false, status: 409 })
|
||||
|
||||
const { toggle, error } = useMetadataLocks()
|
||||
const result = await toggle(1, 'title')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(error.value).toBe('HTTP 409')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import { computed, reactive, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import type { ComicMetadataFields, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey, MetadataSource, ProviderIds } from '@projectx/types'
|
||||
import type {
|
||||
BookMetadataLockField,
|
||||
ComicMetadataFields,
|
||||
MetadataCandidate,
|
||||
MetadataProviderInfo,
|
||||
MetadataProviderKey,
|
||||
MetadataSource,
|
||||
ProviderIds,
|
||||
} from '@projectx/types'
|
||||
import { getProviderLabel } from '../lib/metadata-fetch'
|
||||
|
||||
type ComicDiffFieldKey =
|
||||
@@ -56,6 +64,7 @@ export interface DiffField {
|
||||
pickedProvider: MetadataProviderKey | null
|
||||
pickedDisplay: string
|
||||
isCover: boolean
|
||||
isLocked: boolean
|
||||
isCopyable: boolean
|
||||
providerValues: ProviderFieldValue[]
|
||||
}
|
||||
@@ -198,8 +207,16 @@ export function useMetadataDiff(
|
||||
providerInfos: MaybeRefOrGetter<MetadataProviderInfo[]>,
|
||||
currentCoverUrl?: string,
|
||||
providerIds?: MaybeRefOrGetter<ProviderIds | undefined>,
|
||||
lockedFields?: MaybeRefOrGetter<readonly BookMetadataLockField[] | undefined>,
|
||||
) {
|
||||
const pickedSources = reactive(new Map<DiffFieldKey, MetadataProviderKey>())
|
||||
const lockedFieldSet = computed(() => new Set(toValue(lockedFields) ?? []))
|
||||
|
||||
function resolveLockField(key: DiffFieldKey): BookMetadataLockField | null {
|
||||
if (key === 'coverUrl') return 'cover'
|
||||
if (key === 'sourceUrl') return null
|
||||
return key
|
||||
}
|
||||
|
||||
function getBookValue(key: DiffFieldKey): string {
|
||||
if (isComicDiffFieldKey(key)) return ''
|
||||
@@ -251,6 +268,7 @@ export function useMetadataDiff(
|
||||
const pickedFromActive = isPicked && pickedProvider === ap
|
||||
const pickedCandidate = isPicked ? allCandidates.find((c) => c.provider === pickedProvider) : null
|
||||
const pickedDisplay = pickedCandidate ? getCandidateValueFrom(pickedCandidate, key) : ''
|
||||
const lockField = resolveLockField(key)
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -264,6 +282,7 @@ export function useMetadataDiff(
|
||||
pickedProvider,
|
||||
pickedDisplay,
|
||||
isCover: key === 'coverUrl',
|
||||
isLocked: lockField ? lockedFieldSet.value.has(lockField) : false,
|
||||
isCopyable: true,
|
||||
providerValues: buildProviderValues(key),
|
||||
}
|
||||
@@ -310,6 +329,7 @@ export function useMetadataDiff(
|
||||
pickedProvider: pickedFromActive ? pickedProvider : null,
|
||||
pickedDisplay: pickedFromActive ? pickedDisplay : '',
|
||||
isCover: false,
|
||||
isLocked: lockedFieldSet.value.has(providerIdKey),
|
||||
isCopyable: true,
|
||||
providerValues: [],
|
||||
})
|
||||
@@ -328,6 +348,7 @@ export function useMetadataDiff(
|
||||
pickedProvider: null,
|
||||
pickedDisplay: '',
|
||||
isCover: false,
|
||||
isLocked: false,
|
||||
isCopyable: false,
|
||||
providerValues: [],
|
||||
})
|
||||
@@ -337,6 +358,8 @@ export function useMetadataDiff(
|
||||
})
|
||||
|
||||
function toggleField(key: DiffFieldKey) {
|
||||
const lockField = resolveLockField(key)
|
||||
if (lockField && lockedFieldSet.value.has(lockField)) return
|
||||
const ap = toValue(activeProvider)
|
||||
if (pickedSources.get(key) === ap) {
|
||||
pickedSources.delete(key)
|
||||
@@ -346,6 +369,8 @@ export function useMetadataDiff(
|
||||
}
|
||||
|
||||
function pickFieldFromProvider(key: DiffFieldKey, provider: MetadataProviderKey) {
|
||||
const lockField = resolveLockField(key)
|
||||
if (lockField && lockedFieldSet.value.has(lockField)) return
|
||||
if (pickedSources.get(key) === provider) {
|
||||
pickedSources.delete(key)
|
||||
} else {
|
||||
@@ -356,14 +381,14 @@ export function useMetadataDiff(
|
||||
function copyAll() {
|
||||
const ap = toValue(activeProvider)
|
||||
for (const f of fields.value) {
|
||||
if (f.isCopyable) pickedSources.set(f.key, ap)
|
||||
if (f.isCopyable && !f.isLocked) pickedSources.set(f.key, ap)
|
||||
}
|
||||
}
|
||||
|
||||
function copyMissing() {
|
||||
const ap = toValue(activeProvider)
|
||||
for (const f of fields.value) {
|
||||
if (f.isCopyable && !f.bookValue && !f.isPicked) pickedSources.set(f.key, ap)
|
||||
if (f.isCopyable && !f.isLocked && !f.bookValue && !f.isPicked) pickedSources.set(f.key, ap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +399,8 @@ export function useMetadataDiff(
|
||||
const allCandidates = toValue(candidates)
|
||||
|
||||
for (const [key, providerKey] of pickedSources) {
|
||||
const lockField = resolveLockField(key)
|
||||
if (lockField && lockedFieldSet.value.has(lockField)) continue
|
||||
const candidate = allCandidates.find((c) => c.provider === providerKey)
|
||||
if (!candidate) continue
|
||||
|
||||
|
||||
@@ -2,6 +2,52 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { FORMAT_TO_GROUP, type BookDetail } from '@projectx/types'
|
||||
|
||||
const ROOT_FIELDS = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'description',
|
||||
'publisher',
|
||||
'publishedYear',
|
||||
'language',
|
||||
'pageCount',
|
||||
'seriesName',
|
||||
'seriesIndex',
|
||||
'isbn10',
|
||||
'isbn13',
|
||||
'rating',
|
||||
'authors',
|
||||
'genres',
|
||||
'tags',
|
||||
'googleBooksId',
|
||||
'goodreadsId',
|
||||
'amazonId',
|
||||
'hardcoverId',
|
||||
'openLibraryId',
|
||||
'itunesId',
|
||||
'audibleId',
|
||||
'comicvineId',
|
||||
] as const
|
||||
|
||||
const COMIC_FIELDS = {
|
||||
issueNumber: 'comicIssueNumber',
|
||||
volumeName: 'comicVolumeName',
|
||||
storyArcs: 'comicStoryArcs',
|
||||
pencillers: 'comicPencillers',
|
||||
inkers: 'comicInkers',
|
||||
colorists: 'comicColorists',
|
||||
letterers: 'comicLetterers',
|
||||
coverArtists: 'comicCoverArtists',
|
||||
characters: 'comicCharacters',
|
||||
teams: 'comicTeams',
|
||||
locations: 'comicLocations',
|
||||
} as const
|
||||
|
||||
const AUDIO_FIELDS = {
|
||||
narrators: 'narrators',
|
||||
durationSeconds: 'durationSeconds',
|
||||
abridged: 'abridged',
|
||||
} as const
|
||||
|
||||
export function useMetadataEditor() {
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -102,47 +148,34 @@ export function useMetadataEditor() {
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const {
|
||||
comicIssueNumber,
|
||||
comicVolumeName,
|
||||
comicStoryArcs,
|
||||
comicPencillers,
|
||||
comicInkers,
|
||||
comicColorists,
|
||||
comicLetterers,
|
||||
comicCoverArtists,
|
||||
comicCharacters,
|
||||
comicTeams,
|
||||
comicLocations,
|
||||
narrators,
|
||||
durationSeconds,
|
||||
abridged,
|
||||
...rest
|
||||
} = form
|
||||
const payload = {
|
||||
...rest,
|
||||
comicMetadata: {
|
||||
issueNumber: comicIssueNumber ?? undefined,
|
||||
volumeName: comicVolumeName ?? undefined,
|
||||
storyArcs: comicStoryArcs,
|
||||
pencillers: comicPencillers,
|
||||
inkers: comicInkers,
|
||||
colorists: comicColorists,
|
||||
letterers: comicLetterers,
|
||||
coverArtists: comicCoverArtists,
|
||||
characters: comicCharacters,
|
||||
teams: comicTeams,
|
||||
locations: comicLocations,
|
||||
},
|
||||
const previous = JSON.parse(snapshot.value) as typeof form
|
||||
const payload: Record<string, unknown> = {}
|
||||
|
||||
for (const field of ROOT_FIELDS) {
|
||||
if (JSON.stringify(form[field]) !== JSON.stringify(previous[field])) {
|
||||
payload[field] = form[field]
|
||||
}
|
||||
}
|
||||
|
||||
const comicMetadata: Record<string, unknown> = {}
|
||||
for (const [payloadKey, formKey] of Object.entries(COMIC_FIELDS)) {
|
||||
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
|
||||
comicMetadata[payloadKey] = form[formKey]
|
||||
}
|
||||
}
|
||||
if (Object.keys(comicMetadata).length > 0) {
|
||||
payload.comicMetadata = comicMetadata
|
||||
}
|
||||
|
||||
if (includeAudioMetadata.value) {
|
||||
return {
|
||||
...payload,
|
||||
audioMetadata: {
|
||||
narrators,
|
||||
durationSeconds,
|
||||
abridged,
|
||||
},
|
||||
const audioMetadata: Record<string, unknown> = {}
|
||||
for (const [payloadKey, formKey] of Object.entries(AUDIO_FIELDS)) {
|
||||
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
|
||||
audioMetadata[payloadKey] = form[formKey]
|
||||
}
|
||||
}
|
||||
if (Object.keys(audioMetadata).length > 0) {
|
||||
payload.audioMetadata = audioMetadata
|
||||
}
|
||||
}
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@projectx/types'
|
||||
|
||||
function normalizeLockedFields(fields: readonly BookMetadataLockField[]): BookMetadataLockField[] {
|
||||
const unique = new Set(fields)
|
||||
return BOOK_METADATA_LOCK_FIELDS.filter((field) => unique.has(field))
|
||||
}
|
||||
|
||||
export function useMetadataLocks() {
|
||||
const lockedFields = ref<BookMetadataLockField[]>([])
|
||||
const updatingField = ref<BookMetadataLockField | 'all' | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const updating = computed(() => updatingField.value !== null)
|
||||
const lockedFieldSet = computed(() => new Set(lockedFields.value))
|
||||
const areAllLocked = computed(() => BOOK_METADATA_LOCK_FIELDS.every((field) => lockedFieldSet.value.has(field)))
|
||||
|
||||
function load(book: BookDetail) {
|
||||
lockedFields.value = normalizeLockedFields(book.lockedFields)
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function isLocked(field: BookMetadataLockField): boolean {
|
||||
return lockedFieldSet.value.has(field)
|
||||
}
|
||||
|
||||
async function replace(
|
||||
bookId: number,
|
||||
nextLockedFields: readonly BookMetadataLockField[],
|
||||
scope: BookMetadataLockField | 'all',
|
||||
): Promise<BookDetail | null> {
|
||||
updatingField.value = scope
|
||||
error.value = null
|
||||
try {
|
||||
const normalized = normalizeLockedFields(nextLockedFields)
|
||||
const res = await api(`/api/v1/books/${bookId}/metadata-locks`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lockedFields: normalized }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated: BookDetail = await res.json()
|
||||
lockedFields.value = normalizeLockedFields(updated.lockedFields)
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update locks'
|
||||
return null
|
||||
} finally {
|
||||
updatingField.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isUpdating(field: BookMetadataLockField): boolean {
|
||||
return updatingField.value === field || updatingField.value === 'all'
|
||||
}
|
||||
|
||||
async function toggle(bookId: number, field: BookMetadataLockField): Promise<BookDetail | null> {
|
||||
const nextLockedFields = isLocked(field) ? lockedFields.value.filter((lockedField) => lockedField !== field) : [...lockedFields.value, field]
|
||||
return replace(bookId, nextLockedFields, field)
|
||||
}
|
||||
|
||||
async function lockAll(bookId: number): Promise<BookDetail | null> {
|
||||
return replace(bookId, BOOK_METADATA_LOCK_FIELDS, 'all')
|
||||
}
|
||||
|
||||
async function unlockAll(bookId: number): Promise<BookDetail | null> {
|
||||
return replace(bookId, [], 'all')
|
||||
}
|
||||
|
||||
return {
|
||||
lockedFields,
|
||||
updating,
|
||||
error,
|
||||
areAllLocked,
|
||||
load,
|
||||
isLocked,
|
||||
isUpdating,
|
||||
replace,
|
||||
toggle,
|
||||
lockAll,
|
||||
unlockAll,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import type { BookDetail, BookMetadataLockField } from '@projectx/types'
|
||||
import BookDetailLayout from '@/features/book/components/detail/BookDetailLayout.vue'
|
||||
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
|
||||
import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
|
||||
@@ -69,6 +69,10 @@ function onMetadataSaved(updated: BookDetail) {
|
||||
detail.value = updated
|
||||
}
|
||||
|
||||
function onLocksChanged(lockedFields: BookMetadataLockField[]) {
|
||||
if (detail.value) detail.value.lockedFields = lockedFields
|
||||
}
|
||||
|
||||
function onCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
if (detail.value) detail.value = { ...detail.value, coverSource: source }
|
||||
}
|
||||
@@ -82,6 +86,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"
|
||||
:book="detail"
|
||||
@saved="onMetadataSaved"
|
||||
@locks-changed="onLocksChanged"
|
||||
@cover-changed="onCoverChanged"
|
||||
/>
|
||||
<FilesTab v-else-if="tab === 'files'" :book="detail" />
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
lockedFields: [],
|
||||
collections: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user