feat(books): rename tags to genres and add user tags

This commit is contained in:
neon
2026-02-19 05:08:21 -06:00
parent 1c97403379
commit 3eff3616fc
49 changed files with 583 additions and 792 deletions
@@ -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>
+1 -7
View File
@@ -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(() => {
+5 -1
View File
@@ -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)
}