mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(books): rename tags to genres and add user tags
This commit is contained in:
@@ -101,7 +101,7 @@ export function useOidc() {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as Record<string, unknown>).message as string ?? 'OIDC login failed')
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'OIDC login failed')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
|
||||
@@ -42,7 +42,7 @@ const metaLine = computed(() => {
|
||||
return parts.length > 0 ? parts.join(' · ') : null
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.book.tags.slice(0, 2))
|
||||
const visibleTags = computed(() => props.book.genres.slice(0, 2))
|
||||
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
|
||||
@@ -155,10 +155,10 @@ function editMetadata() {
|
||||
{{ detail.publisher }}
|
||||
</p>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="detail.tags.length" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="tag in detail.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{{ tag }}
|
||||
<!-- Genres -->
|
||||
<div v-if="detail.genres.length" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="genre in detail.genres" :key="genre" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
...overrides,
|
||||
|
||||
@@ -70,20 +70,14 @@ onUnmounted(() => clearTimeout(debounceTimer))
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="lightboxOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
@click="lightboxOpen = false"
|
||||
>
|
||||
<button class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors" @click="lightboxOpen = false">
|
||||
<div v-if="lightboxOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="lightboxOpen = false">
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
@click="lightboxOpen = false"
|
||||
>
|
||||
<X class="size-5" />
|
||||
</button>
|
||||
<img
|
||||
:src="activeSrc"
|
||||
:alt="book.title ?? ''"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl object-contain"
|
||||
@click.stop
|
||||
/>
|
||||
<img :src="activeSrc" :alt="book.title ?? ''" class="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl object-contain" @click.stop />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
|
||||
@@ -188,10 +188,10 @@ function downloadFile() {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-5">
|
||||
<span v-for="tag in book.tags" :key="tag" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
|
||||
{{ tag }}
|
||||
<!-- Genres -->
|
||||
<div v-if="book.genres.length" class="flex flex-wrap gap-1.5 mt-5">
|
||||
<span v-for="genre in book.genres" :key="genre" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
|
||||
{{ genre }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
|
||||
import { useAuthorSearch } from '../../../composables/useAuthorSearch'
|
||||
import { useTagSearch } from '../../../composables/useTagSearch'
|
||||
import { useGenreSearch } from '../../../composables/useTagSearch'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const emit = defineEmits<{ saved: [BookDetail]; coverChanged: ['extracted' | 'custom' | null] }>()
|
||||
|
||||
const { form, saving, error, isDirty, load, reset, save } = useMetadataEditor()
|
||||
const { search: searchAuthors } = useAuthorSearch()
|
||||
const { search: searchTags } = useTagSearch()
|
||||
const { search: searchGenres } = useGenreSearch()
|
||||
|
||||
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
|
||||
const searchOpen = ref(false)
|
||||
@@ -30,20 +30,29 @@ const providerIdFields = [
|
||||
|
||||
function setIntField(field: 'publishedYear' | 'pageCount', e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val === '') { form[field] = null; return }
|
||||
if (val === '') {
|
||||
form[field] = null
|
||||
return
|
||||
}
|
||||
const n = parseInt(val, 10)
|
||||
form[field] = isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
function setFloatField(field: 'seriesIndex', e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (val === '') { form[field] = null; return }
|
||||
if (val === '') {
|
||||
form[field] = null
|
||||
return
|
||||
}
|
||||
const n = parseFloat(val)
|
||||
form[field] = isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
onMounted(() => load(props.book))
|
||||
watch(() => props.book.id, () => load(props.book))
|
||||
watch(
|
||||
() => props.book.id,
|
||||
() => load(props.book),
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (coverPanel.value?.hasPending) {
|
||||
@@ -125,15 +134,15 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Authors + Tags -->
|
||||
<!-- Authors + Genres -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Authors</label>
|
||||
<ChipInput v-model="form.authors" placeholder="Add author..." :search-fn="searchAuthors" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<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" />
|
||||
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Genres</label>
|
||||
<ChipInput v-model="form.genres" placeholder="Add genre..." :search-fn="searchGenres" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,10 +290,5 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetadataSearchDrawer
|
||||
v-if="searchOpen"
|
||||
:book="props.book"
|
||||
@close="searchOpen = false"
|
||||
@apply="handleApply"
|
||||
/>
|
||||
<MetadataSearchDrawer v-if="searchOpen" :book="props.book" @close="searchOpen = false" @apply="handleApply" />
|
||||
</template>
|
||||
|
||||
@@ -19,10 +19,7 @@ const emit = defineEmits<{
|
||||
|
||||
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
|
||||
|
||||
const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff(
|
||||
props.book,
|
||||
props.candidate,
|
||||
)
|
||||
const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff(props.book, props.candidate)
|
||||
|
||||
function apply() {
|
||||
emit('apply', buildPatch())
|
||||
@@ -43,10 +40,7 @@ function apply() {
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold truncate leading-snug">{{ candidate.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
class="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
:style="providerBadgeStyle(candidate.provider)"
|
||||
>
|
||||
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :style="providerBadgeStyle(candidate.provider)">
|
||||
{{ providerLabel }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,15 +78,8 @@ function apply() {
|
||||
|
||||
<!-- Diff rows -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<MetadataDiffRow
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
@toggle="toggleField"
|
||||
/>
|
||||
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
No metadata available from this source.
|
||||
</p>
|
||||
<MetadataDiffRow v-for="field in fields" :key="field.key" :field="field" @toggle="toggleField" />
|
||||
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">No metadata available from this source.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -15,17 +15,19 @@ const lightboxSrc = ref<string | null>(null)
|
||||
<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="grid grid-cols-[1fr_44px_1fr] gap-2 items-center">
|
||||
|
||||
<!-- Current cover -->
|
||||
<div
|
||||
class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group"
|
||||
:class="field.isCopied ? 'ring-primary ring-2 cursor-zoom-in' : 'ring-border opacity-50'"
|
||||
style="aspect-ratio: 2/3"
|
||||
@click="field.isCopied ? lightboxSrc = field.candidateDisplay : null"
|
||||
@click="field.isCopied ? (lightboxSrc = field.candidateDisplay) : null"
|
||||
>
|
||||
<img v-if="field.isCopied" :src="field.candidateDisplay" alt="Preview" class="w-full h-full object-cover" @error="hideOnError" />
|
||||
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
|
||||
<div v-if="field.isCopied" class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div
|
||||
v-if="field.isCopied"
|
||||
class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<ZoomIn class="size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +36,11 @@ const lightboxSrc = ref<string | null>(null)
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm"
|
||||
:class="field.isCopied ? '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'"
|
||||
:class="
|
||||
field.isCopied
|
||||
? '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'
|
||||
"
|
||||
@click="$emit('toggle', field.key)"
|
||||
>
|
||||
<RotateCcw v-if="field.isCopied" class="size-3.5" />
|
||||
@@ -62,23 +68,13 @@ const lightboxSrc = ref<string | null>(null)
|
||||
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_44px_1fr] sm:gap-1.5 sm:items-stretch">
|
||||
|
||||
<!-- Current value: framed card to signal it's the active record -->
|
||||
<div
|
||||
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
:class="
|
||||
!field.hasDiff
|
||||
? 'bg-muted/30 opacity-50'
|
||||
: field.isCopied
|
||||
? 'bg-muted/30 opacity-40'
|
||||
: 'bg-background ring-1 ring-border'
|
||||
"
|
||||
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-muted/30 opacity-40' : 'bg-background ring-1 ring-border'"
|
||||
>
|
||||
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">Current</p>
|
||||
<p
|
||||
class="wrap-break-word leading-snug text-sm w-full"
|
||||
:class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : 'text-foreground'"
|
||||
>
|
||||
<p class="wrap-break-word leading-snug text-sm w-full" :class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : 'text-foreground'">
|
||||
{{ field.currentDisplay || 'empty' }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -88,7 +84,11 @@ const lightboxSrc = ref<string | null>(null)
|
||||
<button
|
||||
v-if="field.hasDiff"
|
||||
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm shrink-0"
|
||||
:class="field.isCopied ? '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'"
|
||||
:class="
|
||||
field.isCopied
|
||||
? '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'
|
||||
"
|
||||
@click="$emit('toggle', field.key)"
|
||||
>
|
||||
<RotateCcw v-if="field.isCopied" class="size-3.5" />
|
||||
@@ -102,33 +102,19 @@ const lightboxSrc = ref<string | null>(null)
|
||||
<!-- New value: muted by default, highlights when selected -->
|
||||
<div
|
||||
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
:class="
|
||||
!field.hasDiff
|
||||
? 'bg-muted/30 opacity-50'
|
||||
: field.isCopied
|
||||
? 'bg-primary/8 ring-1 ring-primary/20'
|
||||
: 'bg-muted/40'
|
||||
"
|
||||
:class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-primary/8 ring-1 ring-primary/20' : 'bg-muted/40'"
|
||||
>
|
||||
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">New</p>
|
||||
<p
|
||||
class="wrap-break-word leading-snug text-sm w-full"
|
||||
:class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'"
|
||||
>
|
||||
<p class="wrap-break-word leading-snug text-sm w-full" :class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'">
|
||||
{{ field.candidateDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="lightboxSrc"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
@click="lightboxSrc = null"
|
||||
>
|
||||
<div v-if="lightboxSrc" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="lightboxSrc = null">
|
||||
<button
|
||||
class="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors"
|
||||
@click="lightboxSrc = null"
|
||||
|
||||
@@ -40,10 +40,7 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
|
||||
{{ candidate.authors.join(', ') }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md"
|
||||
:style="providerBadgeStyle(candidate.provider)"
|
||||
>
|
||||
<span class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md" :style="providerBadgeStyle(candidate.provider)">
|
||||
{{ providerLabel }}
|
||||
</span>
|
||||
<span v-if="candidate.publishedYear" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.publishedYear }}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, reactive } from 'vue'
|
||||
import { Search, BookOpen, Loader2 } from 'lucide-vue-next'
|
||||
import type { BookDetail, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
|
||||
import MetadataResultCard from './MetadataResultCard.vue'
|
||||
import { providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
import { providerActivePillStyle } from '../../../lib/metadata-fetch'
|
||||
|
||||
const props = defineProps<{
|
||||
book: BookDetail
|
||||
@@ -95,8 +95,12 @@ function runSearch() {
|
||||
v-for="p in providers"
|
||||
:key="p.key"
|
||||
class="h-6 px-2.5 rounded-full text-xs font-medium transition-all flex items-center gap-1 active:scale-95"
|
||||
:class="(selectedProviders.includes(p.key) || !selectedProviders.length) ? '' : 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'"
|
||||
:style="(selectedProviders.includes(p.key) || !selectedProviders.length) ? providerActivePillStyle(p.key) : {}"
|
||||
:class="
|
||||
selectedProviders.includes(p.key) || !selectedProviders.length
|
||||
? ''
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
|
||||
"
|
||||
:style="selectedProviders.includes(p.key) || !selectedProviders.length ? providerActivePillStyle(p.key) : {}"
|
||||
@click="$emit('toggleProvider', p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
@@ -114,11 +118,7 @@ function runSearch() {
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||
<!-- Skeleton grid while loading with no results yet -->
|
||||
<div v-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="rounded-xl border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5"
|
||||
>
|
||||
<div v-for="n in 8" :key="n" class="rounded-xl border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5">
|
||||
<div class="rounded-lg bg-muted shrink-0" style="width: 64px; aspect-ratio: 2/3" />
|
||||
<div class="flex-1 flex flex-col justify-center gap-2 py-1">
|
||||
<div class="h-3 bg-muted rounded-md w-full" />
|
||||
|
||||
@@ -93,4 +93,3 @@ describe('onBookMissing', () => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export type DiffFieldKey =
|
||||
| 'seriesIndex'
|
||||
| 'isbn13'
|
||||
| 'isbn10'
|
||||
| 'tags'
|
||||
| 'genres'
|
||||
| 'coverUrl'
|
||||
|
||||
export interface DiffField {
|
||||
@@ -41,7 +41,7 @@ export interface MetadataPatch {
|
||||
isbn10?: string | null
|
||||
isbn13?: string | null
|
||||
authors?: string[]
|
||||
tags?: string[]
|
||||
genres?: string[]
|
||||
googleBooksId?: string | null
|
||||
goodreadsId?: string | null
|
||||
amazonId?: string | null
|
||||
@@ -63,7 +63,7 @@ const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
|
||||
{ key: 'seriesIndex', label: 'Series Index' },
|
||||
{ key: 'isbn13', label: 'ISBN-13' },
|
||||
{ key: 'isbn10', label: 'ISBN-10' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'genres', label: 'Genres' },
|
||||
]
|
||||
|
||||
const PROVIDER_ID_FIELD: Record<MetadataProviderKey, keyof MetadataPatch | undefined> = {
|
||||
@@ -79,7 +79,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
|
||||
|
||||
function getBookValue(key: DiffFieldKey): string {
|
||||
if (key === 'authors') return book.authors.map((a) => a.name).join(', ')
|
||||
if (key === 'tags') return book.tags.join(', ')
|
||||
if (key === 'genres') return book.genres.join(', ')
|
||||
if (key === 'coverUrl') return ''
|
||||
const val = book[key as keyof BookDetail]
|
||||
return val != null ? String(val) : ''
|
||||
@@ -88,7 +88,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
|
||||
function getCandidateValue(key: DiffFieldKey): string {
|
||||
if (key === 'coverUrl') return candidate.coverUrl ?? ''
|
||||
if (key === 'authors') return (candidate.authors ?? []).join(', ')
|
||||
if (key === 'tags') return (candidate.tags ?? []).join(', ')
|
||||
if (key === 'genres') return (candidate.genres ?? []).join(', ')
|
||||
const val = candidate[key as keyof MetadataCandidate]
|
||||
return val != null ? String(val) : ''
|
||||
}
|
||||
@@ -99,16 +99,18 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
|
||||
if (!candidateVal) return []
|
||||
const bookVal = getBookValue(def.key)
|
||||
const isCopied = copiedFields.has(def.key)
|
||||
return [{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
bookValue: bookVal,
|
||||
currentDisplay: isCopied ? candidateVal : bookVal,
|
||||
candidateDisplay: candidateVal,
|
||||
hasDiff: bookVal !== candidateVal,
|
||||
isCopied,
|
||||
isCover: def.key === 'coverUrl',
|
||||
}]
|
||||
return [
|
||||
{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
bookValue: bookVal,
|
||||
currentDisplay: isCopied ? candidateVal : bookVal,
|
||||
candidateDisplay: candidateVal,
|
||||
hasDiff: bookVal !== candidateVal,
|
||||
isCopied,
|
||||
isCover: def.key === 'coverUrl',
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -132,12 +134,30 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
|
||||
let coverUrl: string | undefined
|
||||
|
||||
for (const key of copiedFields) {
|
||||
if (key === 'coverUrl') { coverUrl = candidate.coverUrl; continue }
|
||||
if (key === 'authors') { formPatch.authors = candidate.authors ?? []; continue }
|
||||
if (key === 'tags') { formPatch.tags = candidate.tags ?? []; continue }
|
||||
if (key === 'publishedYear') { formPatch.publishedYear = candidate.publishedYear ?? null; continue }
|
||||
if (key === 'pageCount') { formPatch.pageCount = candidate.pageCount ?? null; continue }
|
||||
if (key === 'seriesIndex') { formPatch.seriesIndex = candidate.seriesIndex ?? null; continue }
|
||||
if (key === 'coverUrl') {
|
||||
coverUrl = candidate.coverUrl
|
||||
continue
|
||||
}
|
||||
if (key === 'authors') {
|
||||
formPatch.authors = candidate.authors ?? []
|
||||
continue
|
||||
}
|
||||
if (key === 'genres') {
|
||||
formPatch.genres = candidate.genres ?? []
|
||||
continue
|
||||
}
|
||||
if (key === 'publishedYear') {
|
||||
formPatch.publishedYear = candidate.publishedYear ?? null
|
||||
continue
|
||||
}
|
||||
if (key === 'pageCount') {
|
||||
formPatch.pageCount = candidate.pageCount ?? null
|
||||
continue
|
||||
}
|
||||
if (key === 'seriesIndex') {
|
||||
formPatch.seriesIndex = candidate.seriesIndex ?? null
|
||||
continue
|
||||
}
|
||||
const val = candidate[key as keyof MetadataCandidate]
|
||||
;(formPatch as Record<string, unknown>)[key] = val != null ? String(val) : null
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function useMetadataEditor() {
|
||||
isbn13: null as string | null,
|
||||
rating: null as number | null,
|
||||
authors: [] as string[],
|
||||
genres: [] as string[],
|
||||
tags: [] as string[],
|
||||
googleBooksId: null as string | null,
|
||||
goodreadsId: null as string | null,
|
||||
@@ -46,6 +47,7 @@ export function useMetadataEditor() {
|
||||
form.isbn13 = book.isbn13
|
||||
form.rating = book.rating ?? null
|
||||
form.authors = book.authors.map((a) => a.name)
|
||||
form.genres = [...book.genres]
|
||||
form.tags = [...book.tags]
|
||||
form.googleBooksId = book.providerIds.google ?? null
|
||||
form.goodreadsId = book.providerIds.goodreads ?? null
|
||||
|
||||
@@ -83,31 +83,29 @@ export function useMetadataSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
|
||||
const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
|
||||
|
||||
function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
|
||||
const byProvider = new Map<string, MetadataCandidate[]>()
|
||||
for (const r of list) {
|
||||
const bucket = byProvider.get(r.provider) ?? []
|
||||
bucket.push(r)
|
||||
byProvider.set(r.provider, bucket)
|
||||
function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
|
||||
const byProvider = new Map<string, MetadataCandidate[]>()
|
||||
for (const r of list) {
|
||||
const bucket = byProvider.get(r.provider) ?? []
|
||||
bucket.push(r)
|
||||
byProvider.set(r.provider, bucket)
|
||||
}
|
||||
|
||||
const orderedKeys = [
|
||||
...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
|
||||
...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
|
||||
]
|
||||
|
||||
const out: MetadataCandidate[] = []
|
||||
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
|
||||
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
|
||||
return out
|
||||
}
|
||||
|
||||
const orderedKeys = [
|
||||
...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
|
||||
...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
|
||||
]
|
||||
|
||||
const out: MetadataCandidate[] = []
|
||||
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
|
||||
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
|
||||
return out
|
||||
}
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
const filtered = selectedProviders.value.length
|
||||
? results.value.filter((r) => selectedProviders.value.includes(r.provider))
|
||||
: results.value
|
||||
const filtered = selectedProviders.value.length ? results.value.filter((r) => selectedProviders.value.includes(r.provider)) : results.value
|
||||
return sortResults(filtered)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useTagSearch() {
|
||||
function useMetadataSearch(endpoint: string) {
|
||||
async function search(q: string): Promise<string[]> {
|
||||
if (!q.trim()) return []
|
||||
const res = await api(`/api/metadata/tags?q=${encodeURIComponent(q)}`)
|
||||
const res = await api(`/api/metadata/${endpoint}?q=${encodeURIComponent(q)}`)
|
||||
if (!res.ok) return []
|
||||
const data: { name: string }[] = await res.json()
|
||||
return data.map((t) => t.name)
|
||||
return data.map((item) => item.name)
|
||||
}
|
||||
|
||||
return { search }
|
||||
}
|
||||
|
||||
export const useGenreSearch = () => useMetadataSearch('genres')
|
||||
export const useTagSearch = () => useMetadataSearch('tags')
|
||||
|
||||
@@ -19,7 +19,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
|
||||
publishedYear: 'Published Year',
|
||||
pageCount: 'Page Count',
|
||||
author: 'Author',
|
||||
tag: 'Tag',
|
||||
genre: 'Genre',
|
||||
format: 'Format',
|
||||
addedAt: 'Added Date',
|
||||
fileAvailability: 'File Availability',
|
||||
|
||||
@@ -5,7 +5,7 @@ export function getProviderLabel(provider: MetadataProviderKey, providers: Metad
|
||||
}
|
||||
|
||||
export function hideOnError(e: Event): void {
|
||||
(e.target as HTMLImageElement).style.visibility = 'hidden'
|
||||
;(e.target as HTMLImageElement).style.visibility = 'hidden'
|
||||
}
|
||||
|
||||
/** Brand colors for each provider - used for pill/badge tinting. */
|
||||
|
||||
@@ -23,8 +23,8 @@ const TEMPLATES: { label: string; build: () => GroupRule }[] = [
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'No Tags',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'tag', operator: 'isEmpty' } as Rule] }),
|
||||
label: 'No Genres',
|
||||
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'genre', operator: 'isEmpty' } as Rule] }),
|
||||
},
|
||||
{
|
||||
label: 'EPUB Only',
|
||||
|
||||
@@ -63,7 +63,6 @@ async function revoke(userId: number) {
|
||||
await loadAccess()
|
||||
}
|
||||
|
||||
|
||||
onMounted(loadAccess)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -345,13 +345,7 @@ function navigateSearch(cfiTarget: string) {
|
||||
@search="onSearchQuery"
|
||||
@clear="onSearchClear"
|
||||
@navigate="navigateSearch($event)"
|
||||
@close="
|
||||
() => {
|
||||
onSearchClear()
|
||||
searchInitialQuery = ''
|
||||
showSearch = false
|
||||
}
|
||||
"
|
||||
@close="onSearchClear(); searchInitialQuery = ''; showSearch = false"
|
||||
/>
|
||||
|
||||
<ReaderSettingsPanel v-if="showSettings" :state="state" @update="applyUpdate" @close="showSettings = false" />
|
||||
|
||||
@@ -59,7 +59,7 @@ async function save() {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as Record<string, unknown>).message as string ?? 'Failed to save')
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
|
||||
}
|
||||
saveSuccess.value = true
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -36,7 +36,11 @@ const { items: books, total, loading, hasMore, load } = useCollectionBooks(colle
|
||||
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
|
||||
|
||||
function handleSelect(id: number, event: MouseEvent) {
|
||||
if (event.shiftKey) rangeSelectTo(id, books.value.map((b) => b.id))
|
||||
if (event.shiftKey)
|
||||
rangeSelectTo(
|
||||
id,
|
||||
books.value.map((b) => b.id),
|
||||
)
|
||||
else toggleBook(id)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user