mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
97cd03325f
Scanned books are commonly distributed as DjVu, which the scanner only stored as a supplement. Decode pages on the server with DjVuLibre so DjVu imports with a cover and page count, opens in the image reader with its embedded OCR text, and can be the preferred format of an ordinary email recipient. Migration 0113 widens the recipient format check to allow it. Decoding runs on validated private snapshots behind a bounded, reader-first queue, and revalidation and cached pages are answered without a decoder. Released iOS builds cannot open DjVu, so series continuation offers it only to callers sending includeDjvu=true, and the dashboard and notebook read file never name it. The first full scan after upgrading promotes stored .djvu supplements to content.
123 lines
5.0 KiB
Vue
123 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { ChevronRight, LibraryBig } from '@lucide/vue'
|
|
import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types'
|
|
import BookCoverSurface from '../BookCoverSurface.vue'
|
|
import BookTableChipsCell from './BookTableChipsCell.vue'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
|
|
|
const props = defineProps<{
|
|
book: BookCard
|
|
colId: string
|
|
linkFn?: (chip: string) => string | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'open-series': []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const collapsed = computed(() => props.book.collapsedSeries!)
|
|
const seriesName = computed(() => props.book.seriesName ?? '')
|
|
const readCount = computed(() => collapsed.value.readCount)
|
|
const bookCount = computed(() => collapsed.value.bookCount)
|
|
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
|
|
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
|
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
|
const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
|
const { coverUrl } = useCoverVersions()
|
|
|
|
function thumbnailUrl(bookId: number): string {
|
|
const version = bookId === props.book.id ? props.book.coverVersion : collapsed.value.coverUpdatedAtByBookId?.[bookId]
|
|
return coverUrl(bookId, 'thumbnail', version)
|
|
}
|
|
|
|
function handleOpenSeries() {
|
|
emit('open-series')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="colId === 'lockRow'" class="flex h-6 w-6 items-center justify-center">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<span role="img" tabindex="0" :aria-label="t('book.table.series.seriesRow')" class="rounded-sm focus-visible:ring-2 focus-visible:ring-ring">
|
|
<LibraryBig :size="13" class="text-muted-foreground" aria-hidden="true" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.table.series.seriesRow') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div v-else-if="colId === 'readStatus'" class="flex w-full items-center px-1 py-0.5">
|
|
<div class="flex w-3 items-center justify-center">
|
|
<span class="text-[10px] text-muted-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="colId === 'read'" class="flex w-full items-center justify-start">
|
|
<div class="mr-auto flex h-7 w-7 items-center justify-center">
|
|
<span class="text-[10px] text-muted-foreground">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="colId === 'cover'" class="flex h-9 items-center">
|
|
<!-- Cover row: up to 4 thumbnails -->
|
|
<div class="flex shrink-0 gap-0.5 min-w-9" :class="coverIds.length <= 1 ? 'justify-center' : 'justify-start'">
|
|
<BookCoverSurface
|
|
v-for="coverId in coverIds"
|
|
:key="coverId"
|
|
size="mini"
|
|
:disable-spine="isAudiobook"
|
|
:is-comic="isComic"
|
|
class="h-8 w-6 rounded-sm overflow-hidden"
|
|
>
|
|
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
|
|
</BookCoverSurface>
|
|
<BookCoverSurface
|
|
v-if="coverIds.length === 0"
|
|
size="mini"
|
|
:disable-spine="isAudiobook"
|
|
:is-comic="isComic"
|
|
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
|
|
>
|
|
<span class="text-[8px] text-muted-foreground">?</span>
|
|
</BookCoverSurface>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="colId === 'title'" class="flex items-center gap-2 min-w-0 h-full px-1">
|
|
<span class="font-medium text-sm truncate block">{{ seriesName }}</span>
|
|
<span class="text-xs text-muted-foreground shrink-0 mt-0.5">{{ t('book.table.series.bookCount', { count: bookCount }) }}</span>
|
|
<span v-if="readCount > 0" class="text-xs text-muted-foreground shrink-0 mt-0.5"
|
|
>· {{ t('book.table.series.readOfCount', { read: readCount, total: bookCount }) }}</span
|
|
>
|
|
</div>
|
|
|
|
<BookTableChipsCell v-else-if="colId === 'authors'" :value="book.authors" :is-active="false" is-read-only :link-fn="linkFn" />
|
|
|
|
<div v-else-if="colId === 'actions'" class="flex h-full w-6 items-center justify-center">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
type="button"
|
|
:aria-label="t('book.tableView.openSeries')"
|
|
class="flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
@click.stop="handleOpenSeries"
|
|
>
|
|
<ChevronRight :size="16" aria-hidden="true" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.tableView.openSeries') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<!-- Dash for all other columns -->
|
|
<div v-else class="flex h-full items-center">
|
|
<span class="text-[10px] text-muted-foreground">-</span>
|
|
</div>
|
|
</template>
|