fix(metadata): show ComicVine links on book details

Build provider links from one shared registry so the detail page and quick view stay aligned.\nRender ComicVine with its CV fallback when no icon is available.

Fixes #1103
This commit is contained in:
neon
2026-08-24 20:12:03 +00:00
parent a6599ebbb5
commit 2a280ad106
6 changed files with 315 additions and 235 deletions
@@ -8,8 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
import { formatBytes } from '@/lib/formatting'
import { getProviderColor } from '@/lib/provider-colors'
import { providerIconPath } from '@/features/book/lib/provider-icons'
import { libroFmAudiobookUrl, lubimyczytacBookUrl } from '@/features/book/lib/provider-links'
import { createBookProviderLinks } from '@/features/book/lib/provider-links'
import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import { getFormatColor } from '../lib/format-colors'
@@ -32,14 +31,6 @@ const emit = defineEmits<{
action: [type: 'add-to-collection' | 'delete']
}>()
type ProviderLink = {
key: string
label: string
url: string
iconUrl: string
fallback: string
}
const router = useRouter()
const { detail, loading, fetch } = useBookDetail()
@@ -81,103 +72,7 @@ const seriesLine = computed(() => {
const authorLine = computed(() => detail.value?.authors.map((a) => a.name).join(', ') ?? null)
const ratingStars = [1, 2, 3, 4, 5]
const providerLinks = computed<ProviderLink[]>(() => {
const out: ProviderLink[] = []
const ids = detail.value?.providerIds
if (!ids) return out
if (ids.google) {
out.push({
key: 'google',
label: 'Google Books',
url: `https://books.google.com/books?id=${ids.google}`,
iconUrl: providerIconPath('google'),
fallback: 'G',
})
}
if (ids.goodreads) {
out.push({
key: 'goodreads',
label: 'Goodreads',
url: `https://www.goodreads.com/book/show/${ids.goodreads}`,
iconUrl: providerIconPath('goodreads'),
fallback: 'GR',
})
}
if (ids.amazon) {
out.push({
key: 'amazon',
label: 'Amazon',
url: `https://www.amazon.com/dp/${ids.amazon}`,
iconUrl: providerIconPath('amazon'),
fallback: 'A',
})
}
if (ids.hardcover) {
out.push({
key: 'hardcover',
label: 'Hardcover',
url: `https://hardcover.app/books/${ids.hardcover}`,
iconUrl: providerIconPath('hardcover'),
fallback: 'H',
})
}
if (ids.openLibrary) {
const path = String(ids.openLibrary).startsWith('/works/') ? String(ids.openLibrary) : `/works/${ids.openLibrary}`
out.push({
key: 'openLibrary',
label: 'Open Library',
url: `https://openlibrary.org${path}`,
iconUrl: providerIconPath('openLibrary'),
fallback: 'OL',
})
}
if (ids.itunes) {
out.push({
key: 'itunes',
label: 'Apple Books',
url: `https://books.apple.com/book/id${ids.itunes}`,
iconUrl: providerIconPath('itunes'),
fallback: '',
})
}
if (ids.librofm) {
out.push({
key: 'librofm',
label: 'Libro.fm',
url: libroFmAudiobookUrl(ids.librofm),
iconUrl: providerIconPath('librofm'),
fallback: 'Lf',
})
}
if (ids.ranobedb) {
out.push({
key: 'ranobedb',
label: 'RanobeDB',
url: `https://ranobedb.org/book/${ids.ranobedb}`,
iconUrl: providerIconPath('ranobedb'),
fallback: 'RN',
})
}
if (ids.lubimyczytac) {
out.push({
key: 'lubimyczytac',
label: 'LubimyCzytac',
url: lubimyczytacBookUrl(ids.lubimyczytac),
iconUrl: providerIconPath('lubimyczytac'),
fallback: 'LC',
})
}
if (ids.aladin) {
out.push({
key: 'aladin',
label: 'Aladin',
url: `https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=${ids.aladin}`,
iconUrl: providerIconPath('aladin'),
fallback: '알',
})
}
return out
})
const providerLinks = computed(() => (detail.value ? createBookProviderLinks(detail.value.providerIds) : []))
const safeDescription = useSafeHtml(() => detail.value?.description)
@@ -351,7 +246,7 @@ function handleDelete() {
:style="providerLinkStyle(link.key)"
>
<img
v-if="!providerIconErrors[link.key]"
v-if="link.iconUrl && !providerIconErrors[link.key]"
:src="link.iconUrl"
:alt="link.label"
class="size-3.5 rounded-[2px] object-contain"
@@ -227,6 +227,25 @@ describe('BookQuickView', () => {
expect(ranobedbLink.find('img[alt="RanobeDB"][src="/assets/provider-icons/ranobedb.svg"]').exists()).toBe(true)
})
it('renders a direct ComicVine issue link with the monogram fallback', () => {
detailRef.value = makeDetail({ providerIds: { comicvine: '1126983' } })
const wrapper = mount(BookQuickView, {
props: { open: true, bookId: 42 },
global: globalStubs,
})
const comicVineLink = wrapper.find('a[title="Open in ComicVine"]')
expect(comicVineLink.exists()).toBe(true)
expect(comicVineLink.attributes()).toMatchObject({
href: 'https://comicvine.gamespot.com/issue/4000-1126983/',
target: '_blank',
rel: 'noopener noreferrer',
})
expect(comicVineLink.find('img').exists()).toBe(false)
expect(comicVineLink.text()).toBe('CV')
})
it('renders Libro.fm provider icon link', () => {
detailRef.value = makeDetail({ providerIds: { librofm: '9781234567890' } })
@@ -198,6 +198,29 @@ describe('DetailsTab - present state', () => {
expect(wrapper.text()).not.toContain('Info Links')
})
it('renders a direct ComicVine issue link with the monogram fallback', () => {
const wrapper = mount(DetailsTab, {
props: {
book: makeBook({
providerIds: {
comicvine: '1126983',
},
}),
},
global: globalStubs,
})
const comicVineLink = wrapper.find('a[title="Open in ComicVine"]')
expect(comicVineLink.exists()).toBe(true)
expect(comicVineLink.attributes()).toMatchObject({
href: 'https://comicvine.gamespot.com/issue/4000-1126983/',
target: '_blank',
rel: 'noopener noreferrer',
})
expect(comicVineLink.find('img').exists()).toBe(false)
expect(comicVineLink.text()).toBe('CV')
})
it('fetches collections data for Kobo sync but does not display them', async () => {
vi.mocked(api).mockImplementation(async (input) => {
if (input === '/api/v1/collections/membership') {
@@ -25,8 +25,8 @@ import {
} from '@lucide/vue'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { providerIconPath, providerIconPathSafe } from '@/features/book/lib/provider-icons'
import { libroFmAudiobookUrl, lubimyczytacBookUrl } from '@/features/book/lib/provider-links'
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
import { createBookProviderLinks } from '@/features/book/lib/provider-links'
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
@@ -81,14 +81,6 @@ type CollectionMembership = {
memberCount?: number
}
type ProviderLink = {
key: string
label: string
url: string
iconUrl: string
fallback: string
}
type SeriesDisplayLink = {
key: string
seriesId: number | null
@@ -655,120 +647,7 @@ const supplementalLoading = ref(false)
const resettingFileIds = ref<number[]>([])
const providerIconErrors = ref<Record<string, boolean>>({})
const providerLinks = computed<ProviderLink[]>(() => {
const out: ProviderLink[] = []
const ids = props.book.providerIds
if (ids.google) {
out.push({
key: 'google',
label: 'Google Books',
url: `https://books.google.com/books?id=${ids.google}`,
iconUrl: providerIconPath('google'),
fallback: 'G',
})
}
if (ids.goodreads) {
out.push({
key: 'goodreads',
label: 'Goodreads',
url: `https://www.goodreads.com/book/show/${ids.goodreads}`,
iconUrl: providerIconPath('goodreads'),
fallback: 'GR',
})
}
if (ids.amazon) {
out.push({
key: 'amazon',
label: 'Amazon',
url: `https://www.amazon.com/dp/${ids.amazon}`,
iconUrl: providerIconPath('amazon'),
fallback: 'A',
})
}
if (ids.hardcover) {
out.push({
key: 'hardcover',
label: 'Hardcover',
url: `https://hardcover.app/books/${ids.hardcover}`,
iconUrl: providerIconPath('hardcover'),
fallback: 'H',
})
}
if (ids.openLibrary) {
const path = String(ids.openLibrary).startsWith('/works/') ? String(ids.openLibrary) : `/works/${ids.openLibrary}`
out.push({
key: 'openLibrary',
label: 'Open Library',
url: `https://openlibrary.org${path}`,
iconUrl: providerIconPath('openLibrary'),
fallback: 'OL',
})
}
if (ids.itunes) {
out.push({
key: 'itunes',
label: 'Apple Books',
url: `https://books.apple.com/book/id${ids.itunes}`,
iconUrl: providerIconPath('itunes'),
fallback: '',
})
}
if (ids.audible) {
out.push({
key: 'audible',
label: 'Audible',
url: `https://www.audible.com/pd/${ids.audible}`,
iconUrl: providerIconPath('audible'),
fallback: 'Au',
})
}
if (ids.librofm) {
out.push({
key: 'librofm',
label: 'Libro.fm',
url: libroFmAudiobookUrl(ids.librofm),
iconUrl: providerIconPath('librofm'),
fallback: 'Lf',
})
}
if (ids.kobo) {
out.push({
key: 'kobo',
label: 'Kobo',
url: `https://www.kobo.com/us/en/ebook/${encodeURIComponent(ids.kobo)}`,
iconUrl: providerIconPath('kobo'),
fallback: 'K',
})
}
if (ids.ranobedb) {
out.push({
key: 'ranobedb',
label: 'RanobeDB',
url: `https://ranobedb.org/book/${ids.ranobedb}`,
iconUrl: providerIconPath('ranobedb'),
fallback: 'RN',
})
}
if (ids.lubimyczytac) {
out.push({
key: 'lubimyczytac',
label: 'LubimyCzytac',
url: lubimyczytacBookUrl(ids.lubimyczytac),
iconUrl: providerIconPath('lubimyczytac'),
fallback: 'LC',
})
}
if (ids.aladin) {
out.push({
key: 'aladin',
label: 'Aladin',
url: `https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=${ids.aladin}`,
iconUrl: providerIconPath('aladin'),
fallback: '알',
})
}
return out
})
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds))
const communityRatingBadges = computed(() => {
const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link]))
@@ -2023,7 +1902,7 @@ watch(
>
<span class="flex size-7 items-center justify-center">
<img
v-if="!providerIconErrors[link.key]"
v-if="link.iconUrl && !providerIconErrors[link.key]"
:src="link.iconUrl"
:alt="link.label"
class="size-4 rounded-[2px] object-contain"
@@ -1,7 +1,144 @@
import { describe, expect, it } from 'vitest'
import { libroFmAudiobookUrl } from '../provider-links'
import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
import { comicVineIssueUrl, createBookProviderLinks, libroFmAudiobookUrl } from '../provider-links'
describe('book provider links', () => {
it('builds every supported book provider link from one ordered registry', () => {
const providerIds: ProviderIds = {
google: 'google-id',
goodreads: 'goodreads-id',
amazon: 'amazon-id',
hardcover: 'hardcover-slug',
openLibrary: '/works/OL123W',
itunes: '123456',
audible: 'B012345',
librofm: '9781234567890',
kobo: 'kobo-slug',
comicvine: '1126983',
ranobedb: '1287',
lubimyczytac: '123/title name',
aladin: '456789',
}
expect(createBookProviderLinks(providerIds)).toEqual([
{
key: MetadataProviderKey.GOOGLE,
label: 'Google Books',
url: 'https://books.google.com/books?id=google-id',
iconUrl: '/assets/provider-icons/google.svg',
fallback: 'G',
},
{
key: MetadataProviderKey.GOODREADS,
label: 'Goodreads',
url: 'https://www.goodreads.com/book/show/goodreads-id',
iconUrl: '/assets/provider-icons/goodreads.svg',
fallback: 'GR',
},
{
key: MetadataProviderKey.AMAZON,
label: 'Amazon',
url: 'https://www.amazon.com/dp/amazon-id',
iconUrl: '/assets/provider-icons/amazon.svg',
fallback: 'A',
},
{
key: MetadataProviderKey.HARDCOVER,
label: 'Hardcover',
url: 'https://hardcover.app/books/hardcover-slug',
iconUrl: '/assets/provider-icons/hardcover.svg',
fallback: 'H',
},
{
key: MetadataProviderKey.OPEN_LIBRARY,
label: 'Open Library',
url: 'https://openlibrary.org/works/OL123W',
iconUrl: '/assets/provider-icons/openlibrary.svg',
fallback: 'OL',
},
{
key: MetadataProviderKey.ITUNES,
label: 'Apple Books',
url: 'https://books.apple.com/book/id123456',
iconUrl: '/assets/provider-icons/apple-books.svg',
fallback: '',
},
{
key: MetadataProviderKey.AUDIBLE,
label: 'Audible',
url: 'https://www.audible.com/pd/B012345',
iconUrl: '/assets/provider-icons/audible.svg',
fallback: 'Au',
},
{
key: MetadataProviderKey.LIBROFM,
label: 'Libro.fm',
url: 'https://libro.fm/audiobooks/9781234567890',
iconUrl: '/assets/provider-icons/librofm.svg',
fallback: 'Lf',
},
{
key: MetadataProviderKey.KOBO,
label: 'Kobo',
url: 'https://www.kobo.com/us/en/ebook/kobo-slug',
iconUrl: '/assets/provider-icons/kobo.svg',
fallback: 'K',
},
{
key: MetadataProviderKey.COMICVINE,
label: 'ComicVine',
url: 'https://comicvine.gamespot.com/issue/4000-1126983/',
iconUrl: null,
fallback: 'CV',
},
{
key: MetadataProviderKey.RANOBEDB,
label: 'RanobeDB',
url: 'https://ranobedb.org/book/1287',
iconUrl: '/assets/provider-icons/ranobedb.svg',
fallback: 'RN',
},
{
key: MetadataProviderKey.LUBIMYCZYTAC,
label: 'LubimyCzytac',
url: 'https://lubimyczytac.pl/ksiazka/123/title%20name',
iconUrl: '/assets/provider-icons/lubimyczytac.svg',
fallback: 'LC',
},
{
key: MetadataProviderKey.ALADIN,
label: 'Aladin',
url: 'https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=456789',
iconUrl: '/assets/provider-icons/aladin.svg',
fallback: '알',
},
])
})
it('trims provider IDs, safely encodes URL values, and ignores empty IDs', () => {
const links = createBookProviderLinks({
google: ' id with spaces&extra=true ',
comicvine: ' 1126983 ',
goodreads: ' ',
amazon: null,
})
expect(links).toEqual([
expect.objectContaining({
key: MetadataProviderKey.GOOGLE,
url: 'https://books.google.com/books?id=id%20with%20spaces%26extra%3Dtrue',
}),
expect.objectContaining({
key: MetadataProviderKey.COMICVINE,
url: 'https://comicvine.gamespot.com/issue/4000-1126983/',
}),
])
})
it('constructs the generic ComicVine issue URL from a bare issue ID', () => {
expect(comicVineIssueUrl(' 1126983 ')).toBe('https://comicvine.gamespot.com/issue/4000-1126983/')
})
describe('provider links', () => {
it('builds a trimmed and encoded Libro.fm audiobook URL', () => {
expect(libroFmAudiobookUrl(' 978 123 ')).toBe('https://libro.fm/audiobooks/978%20123')
})
@@ -1,3 +1,22 @@
import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
import { providerIconPathSafe } from './provider-icons'
export type BookProviderLink = {
key: MetadataProviderKey
label: string
url: string
iconUrl: string | null
fallback: string
}
type ProviderLinkDefinition = Omit<BookProviderLink, 'url' | 'iconUrl'> & {
url: (id: string) => string
}
function encoded(value: string): string {
return encodeURIComponent(value)
}
export function lubimyczytacBookUrl(id: string): string {
const value = String(id).trim()
// New data stores the canonical "id/slug" path. lubimyczytac.pl 404s on a slug-less id,
@@ -9,3 +28,111 @@ export function lubimyczytacBookUrl(id: string): string {
export function libroFmAudiobookUrl(id: string): string {
return `https://libro.fm/audiobooks/${encodeURIComponent(String(id).trim())}`
}
export function comicVineIssueUrl(id: string): string {
return `https://comicvine.gamespot.com/issue/4000-${encoded(String(id).trim())}/`
}
function openLibraryBookUrl(id: string): string {
const value = String(id).trim()
const workId = value.startsWith('/works/') ? value.slice('/works/'.length) : value
return `https://openlibrary.org/works/${encoded(workId)}`
}
const PROVIDER_LINK_DEFINITIONS: readonly ProviderLinkDefinition[] = [
{
key: MetadataProviderKey.GOOGLE,
label: 'Google Books',
url: (id) => `https://books.google.com/books?id=${encoded(id)}`,
fallback: 'G',
},
{
key: MetadataProviderKey.GOODREADS,
label: 'Goodreads',
url: (id) => `https://www.goodreads.com/book/show/${encoded(id)}`,
fallback: 'GR',
},
{
key: MetadataProviderKey.AMAZON,
label: 'Amazon',
url: (id) => `https://www.amazon.com/dp/${encoded(id)}`,
fallback: 'A',
},
{
key: MetadataProviderKey.HARDCOVER,
label: 'Hardcover',
url: (id) => `https://hardcover.app/books/${encoded(id)}`,
fallback: 'H',
},
{
key: MetadataProviderKey.OPEN_LIBRARY,
label: 'Open Library',
url: openLibraryBookUrl,
fallback: 'OL',
},
{
key: MetadataProviderKey.ITUNES,
label: 'Apple Books',
url: (id) => `https://books.apple.com/book/id${encoded(id)}`,
fallback: '',
},
{
key: MetadataProviderKey.AUDIBLE,
label: 'Audible',
url: (id) => `https://www.audible.com/pd/${encoded(id)}`,
fallback: 'Au',
},
{
key: MetadataProviderKey.LIBROFM,
label: 'Libro.fm',
url: libroFmAudiobookUrl,
fallback: 'Lf',
},
{
key: MetadataProviderKey.KOBO,
label: 'Kobo',
url: (id) => `https://www.kobo.com/us/en/ebook/${encoded(id)}`,
fallback: 'K',
},
{
key: MetadataProviderKey.COMICVINE,
label: 'ComicVine',
url: comicVineIssueUrl,
fallback: 'CV',
},
{
key: MetadataProviderKey.RANOBEDB,
label: 'RanobeDB',
url: (id) => `https://ranobedb.org/book/${encoded(id)}`,
fallback: 'RN',
},
{
key: MetadataProviderKey.LUBIMYCZYTAC,
label: 'LubimyCzytac',
url: lubimyczytacBookUrl,
fallback: 'LC',
},
{
key: MetadataProviderKey.ALADIN,
label: 'Aladin',
url: (id) => `https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=${encoded(id)}`,
fallback: '알',
},
]
export function createBookProviderLinks(providerIds: ProviderIds): BookProviderLink[] {
return PROVIDER_LINK_DEFINITIONS.flatMap((definition) => {
const id = providerIds[definition.key]?.trim()
if (!id) return []
return [
{
key: definition.key,
label: definition.label,
url: definition.url(id),
iconUrl: providerIconPathSafe(definition.key),
fallback: definition.fallback,
},
]
})
}