mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
3561d5ddd6
Size book detail layouts from usable pane width so sidebar width and browser zoom do not force inappropriate compact layouts. Keep metadata save actions reachable while secondary controls overflow safely. Fixes #1192
2174 lines
93 KiB
Vue
2174 lines
93 KiB
Vue
<script setup lang="ts">
|
|
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
|
|
import { formatBytes as formatFileSize } from '@/lib/formatting'
|
|
import { useRouter } from 'vue-router'
|
|
import {
|
|
BookOpen,
|
|
Check,
|
|
ChevronDown,
|
|
Eye,
|
|
Library,
|
|
Headphones,
|
|
Lock,
|
|
MoreVertical,
|
|
Pencil,
|
|
RotateCcw,
|
|
Send,
|
|
Star,
|
|
StickyNote,
|
|
FolderInput,
|
|
Trash2,
|
|
TriangleAlert,
|
|
X,
|
|
} from '@lucide/vue'
|
|
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
|
|
import { getFormatColor } from '@/features/book/lib/format-colors'
|
|
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'
|
|
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
|
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadStatus, UserBookStatus } from '@bookorbit/types'
|
|
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
|
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
|
|
import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue'
|
|
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
import { api } from '@/lib/api'
|
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
|
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
|
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
|
|
import { usePersonalNote, PERSONAL_NOTE_MAX_LENGTH } from '@/features/book/composables/usePersonalNote'
|
|
import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
|
|
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
|
import MoveToLibrarySheet from '@/features/book/components/MoveToLibrarySheet.vue'
|
|
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
|
|
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
|
|
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
|
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
|
|
import MetadataScoreBreakdown from '@/features/metadata-score/components/MetadataScoreBreakdown.vue'
|
|
import { useMetadataScoreWeights } from '@/features/metadata-score/composables/useMetadataScoreWeights'
|
|
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
|
|
import { useKoreaderBookProgress } from '@/features/koreader/composables/useKoreaderBookProgress'
|
|
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
|
|
import { formatCommunityRatingValue } from '@/features/book/lib/community-rating'
|
|
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
|
|
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
|
import HardcoverBookSyncGridItem from '@/features/hardcover/components/HardcoverBookSyncGridItem.vue'
|
|
import StorygraphBookSyncGridItem from '@/features/storygraph/components/StorygraphBookSyncGridItem.vue'
|
|
import BookEditionsCard from '@/features/book/components/detail/details/BookEditionsCard.vue'
|
|
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.vue'
|
|
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
|
|
|
|
type FileProgress = {
|
|
percentage: number
|
|
cfi: string | null
|
|
pageNumber: number | null
|
|
updatedAt: string | null
|
|
}
|
|
|
|
type FileProgressRow = FileProgress & {
|
|
fileId: number
|
|
}
|
|
|
|
type CollectionMembership = {
|
|
id: number
|
|
name: string
|
|
syncToKobo: boolean
|
|
memberCount?: number
|
|
}
|
|
|
|
type SeriesDisplayLink = {
|
|
key: string
|
|
seriesId: number | null
|
|
label: string
|
|
}
|
|
|
|
const props = defineProps<{ book: BookDetail }>()
|
|
const emit = defineEmits<{ saved: [BookDetail]; moved: [] }>()
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const { user } = useAuth()
|
|
|
|
const addToCollectionOpen = ref(false)
|
|
const moveToLibraryOpen = ref(false)
|
|
const scoreBreakdownOpen = ref(false)
|
|
const mobileScoreBreakdownOpen = ref(false)
|
|
const moreMenuOpen = ref(false)
|
|
const mobileMoreMenuOpen = ref(false)
|
|
const readMenuOpen = ref(false)
|
|
const mobileReadMenuOpen = ref(false)
|
|
const showSendDialog = ref(false)
|
|
const showPersonalReview = ref(false)
|
|
|
|
function togglePersonalReview() {
|
|
showPersonalReview.value = !showPersonalReview.value
|
|
}
|
|
|
|
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
|
|
const {
|
|
bookProgress: koreaderBookProgress,
|
|
fetchBookProgress: fetchKoreaderProgress,
|
|
releaseResetHold: releaseKoreaderResetHold,
|
|
} = useKoreaderBookProgress()
|
|
|
|
onMounted(() => {
|
|
void fetchWeights()
|
|
void reloadReadingLog()
|
|
})
|
|
|
|
const {
|
|
pendingId: deleteBookId,
|
|
deleting: deletingBook,
|
|
promptDelete,
|
|
cancelDelete,
|
|
confirmDelete,
|
|
} = useDeleteBook(() => {
|
|
// Not router.back(): the previous entry may be this same book, or absent
|
|
// entirely when the page was deep-linked or reloaded, which left the user
|
|
// looking at a book that no longer exists.
|
|
void router.push({ name: 'dashboard' })
|
|
})
|
|
|
|
const coverLoaded = ref(false)
|
|
const coverFailed = ref(false)
|
|
const coverImageRatio = ref<number | null>(null)
|
|
const coverLightboxOpen = ref(false)
|
|
const descriptionExpanded = ref(false)
|
|
const genreMeasureContainer = ref<HTMLElement | null>(null)
|
|
const genreHiddenCount = ref(0)
|
|
const visibleGenreCount = ref(0)
|
|
const safeDescription = useSafeHtml(() => props.book.description)
|
|
const filledCustomMetadata = computed(() => (props.book.customMetadata ?? []).filter((field) => field.value !== null && field.value !== ''))
|
|
const displayedGenres = computed(() => props.book.genres.slice(0, visibleGenreCount.value))
|
|
const hiddenGenres = computed(() => props.book.genres.slice(visibleGenreCount.value))
|
|
const GENRE_GAP_PX = 6
|
|
|
|
function resetGenreFoldState() {
|
|
visibleGenreCount.value = props.book.genres.length
|
|
genreHiddenCount.value = 0
|
|
}
|
|
|
|
function measureGenreOverflow() {
|
|
const container = genreMeasureContainer.value
|
|
if (!container) {
|
|
resetGenreFoldState()
|
|
return
|
|
}
|
|
|
|
const pills = Array.from(container.querySelectorAll<HTMLElement>('[data-genre-pill="true"]'))
|
|
const moreButton = container.querySelector<HTMLElement>('[data-genre-more-measure="true"]')
|
|
const containerWidth = container.clientWidth
|
|
if (pills.length === 0 || !moreButton || containerWidth <= 0) {
|
|
resetGenreFoldState()
|
|
return
|
|
}
|
|
|
|
const pillWidths = pills.map((pill) => pill.getBoundingClientRect().width)
|
|
const allPillsWidth = pillWidths.reduce((total, width) => total + width, 0) + GENRE_GAP_PX * Math.max(0, pills.length - 1)
|
|
if (allPillsWidth <= containerWidth) {
|
|
resetGenreFoldState()
|
|
return
|
|
}
|
|
|
|
const moreButtonWidth = moreButton.getBoundingClientRect().width
|
|
let usedWidth = 0
|
|
let visibleCount = 0
|
|
for (const pillWidth of pillWidths) {
|
|
const nextPillWidth = usedWidth + (visibleCount > 0 ? GENRE_GAP_PX : 0) + pillWidth
|
|
if (nextPillWidth + GENRE_GAP_PX + moreButtonWidth > containerWidth) break
|
|
usedWidth = nextPillWidth
|
|
visibleCount += 1
|
|
}
|
|
|
|
visibleGenreCount.value = visibleCount
|
|
genreHiddenCount.value = pills.length - visibleCount
|
|
}
|
|
|
|
let genreResizeObserver: ResizeObserver | null = null
|
|
let genreMeasureFrame: number | null = null
|
|
|
|
function scheduleGenreOverflowMeasure() {
|
|
void nextTick(() => {
|
|
if (genreMeasureFrame != null) cancelAnimationFrame(genreMeasureFrame)
|
|
genreMeasureFrame = requestAnimationFrame(() => {
|
|
genreMeasureFrame = null
|
|
measureGenreOverflow()
|
|
})
|
|
})
|
|
}
|
|
|
|
function formatCustomMetadataValue(field: CustomMetadataBookValue): string {
|
|
if (field.value === null) return ''
|
|
if (field.type === 'boolean') return field.value ? t('common.yes') : t('common.no')
|
|
return String(field.value)
|
|
}
|
|
|
|
watch(
|
|
() => `${props.book.id}:${props.book.genres.join('|')}`,
|
|
() => {
|
|
resetGenreFoldState()
|
|
scheduleGenreOverflowMeasure()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(genreMeasureContainer, (current, previous) => {
|
|
if (genreResizeObserver && previous) genreResizeObserver.unobserve(previous)
|
|
if (genreResizeObserver && current) genreResizeObserver.observe(current)
|
|
scheduleGenreOverflowMeasure()
|
|
})
|
|
|
|
onMounted(() => {
|
|
genreResizeObserver = new ResizeObserver(() => {
|
|
scheduleGenreOverflowMeasure()
|
|
})
|
|
if (genreMeasureContainer.value) genreResizeObserver.observe(genreMeasureContainer.value)
|
|
window.addEventListener('resize', scheduleGenreOverflowMeasure)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (genreMeasureFrame != null) cancelAnimationFrame(genreMeasureFrame)
|
|
if (genreResizeObserver) {
|
|
genreResizeObserver.disconnect()
|
|
genreResizeObserver = null
|
|
}
|
|
window.removeEventListener('resize', scheduleGenreOverflowMeasure)
|
|
})
|
|
|
|
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 canViewKoreader = computed(() => hasPermission('koreader_sync'))
|
|
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
|
|
const resetReadingStateBookId = computed(() => props.book.id)
|
|
const {
|
|
open: resetReadingStateDialogOpen,
|
|
resetting: resettingReadingState,
|
|
error: resetReadingStateError,
|
|
openDialog: openResetReadingStateDialog,
|
|
closeDialog: closeResetReadingStateDialog,
|
|
resetReadingState,
|
|
} = useResetReadingState(resetReadingStateBookId)
|
|
|
|
const coverSeed = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? String(props.book.id))
|
|
const coverPlaceholderTitle = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? null)
|
|
const hasCover = computed(() => props.book.coverSource !== null)
|
|
const { coverUrl } = useCoverVersions()
|
|
const coverSrc = computed(() => coverUrl(props.book.id, 'cover', props.book.updatedAt ?? props.book.addedAt))
|
|
|
|
watch(coverSrc, () => {
|
|
coverLoaded.value = false
|
|
coverFailed.value = false
|
|
coverImageRatio.value = null
|
|
})
|
|
|
|
/**
|
|
* The cover frame follows the library's aspect ratio, so a 1/1 library makes it much shorter than
|
|
* a 2/3 one. The column height is fixed by the grid, so the width is derived from the height that
|
|
* is actually available: that keeps the frame's ratio exact instead of letting it stretch.
|
|
*/
|
|
const coverSlotEl = ref<HTMLElement | null>(null)
|
|
const coverSlotHeight = ref(0)
|
|
let coverSlotObserver: ResizeObserver | null = null
|
|
|
|
const coverMaxWidth = computed(() => {
|
|
if (coverSlotHeight.value <= 0) return undefined
|
|
const parts = detailCoverAspectRatio.value.split('/').map((part) => Number(part.trim()))
|
|
const width = parts[0]
|
|
const height = parts[1]
|
|
if (width === undefined || height === undefined) return undefined
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return undefined
|
|
return `${Math.floor(coverSlotHeight.value * (width / height))}px`
|
|
})
|
|
|
|
function toggleDescription() {
|
|
descriptionExpanded.value = !descriptionExpanded.value
|
|
}
|
|
|
|
function handleMobileScoreOpen(open: boolean) {
|
|
mobileScoreBreakdownOpen.value = open
|
|
}
|
|
|
|
onMounted(() => {
|
|
coverSlotObserver = new ResizeObserver((entries) => {
|
|
coverSlotHeight.value = entries[0]?.contentRect.height ?? 0
|
|
})
|
|
if (coverSlotEl.value) coverSlotObserver.observe(coverSlotEl.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
coverSlotObserver?.disconnect()
|
|
coverSlotObserver = null
|
|
})
|
|
|
|
watch(coverSlotEl, (element) => {
|
|
coverSlotObserver?.disconnect()
|
|
if (element) coverSlotObserver?.observe(element)
|
|
})
|
|
|
|
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
|
const { bookCoverDisplayMode } = useDisplaySettings()
|
|
const detailCoverAspectRatio = computed(() => {
|
|
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
|
|
return coverAspectRatio.value
|
|
}
|
|
|
|
return `${coverImageRatio.value} / 1`
|
|
})
|
|
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
|
|
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
|
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
|
|
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
|
|
|
|
// For multi-file audiobooks, collapse all tracks into one representative entry.
|
|
const isMultiTrackAudio = computed(() => {
|
|
const audioFiles = readableFiles.value.filter((f) => FORMAT_TO_GROUP[f.format!] === 'audio')
|
|
return audioFiles.length > 1
|
|
})
|
|
const openableFiles = computed(() => {
|
|
if (isMultiTrackAudio.value) {
|
|
const first = readableFiles.value.find((f) => FORMAT_TO_GROUP[f.format!] === 'audio')
|
|
const nonAudio = readableFiles.value.filter((f) => FORMAT_TO_GROUP[f.format!] !== 'audio')
|
|
return first ? [first, ...nonAudio] : nonAudio
|
|
}
|
|
return readableFiles.value
|
|
})
|
|
const hasMultipleFiles = computed(() => openableFiles.value.length > 1)
|
|
const authorLinks = computed(() => props.book.authors.filter((author) => author.name.trim().length > 0))
|
|
const narratorLine = computed(() => props.book.audioMetadata?.narrators?.map((n) => n.name).join(', ') || null)
|
|
const formats = computed(() => {
|
|
const all = [...new Set(props.book.files.filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!))]
|
|
const priority = props.book.formatPriority
|
|
const sorted = priority.length
|
|
? all.sort((a, b) => {
|
|
const ai = priority.indexOf(a)
|
|
const bi = priority.indexOf(b)
|
|
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
|
|
})
|
|
: all
|
|
const primary = primaryFile.value?.format
|
|
if (!primary) return sorted
|
|
return [primary, ...sorted.filter((f) => f !== primary)]
|
|
})
|
|
|
|
function formatDuration(seconds: number | null | undefined): string {
|
|
if (seconds == null) return '-'
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0) return `${h}h ${m}m`
|
|
return `${m}m`
|
|
}
|
|
|
|
const localRating = ref<number | null>(null)
|
|
const hoverRating = ref<number | null>(null)
|
|
const displayRating = computed(() => hoverRating.value ?? localRating.value)
|
|
const communityRatingByProvider = computed(() => {
|
|
const map: Record<string, { score: string; tooltip: string }> = {}
|
|
for (const rating of props.book.communityRatings) {
|
|
if (rating.rating == null || !Number.isFinite(rating.rating)) continue
|
|
const score = Number.isInteger(rating.rating) ? rating.rating.toFixed(0) : rating.rating.toFixed(1)
|
|
const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider
|
|
const value = formatCommunityRatingValue(rating.rating, rating.ratingCount)
|
|
map[rating.provider] = { score, tooltip: `${label} ${value}` }
|
|
}
|
|
return map
|
|
})
|
|
|
|
watch(
|
|
() => props.book.rating,
|
|
(val) => {
|
|
localRating.value = val ?? null
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
async function setRating(star: number) {
|
|
if (!canEditMetadata.value) return
|
|
const newRating = localRating.value === star ? null : star
|
|
localRating.value = newRating
|
|
try {
|
|
const res = await api(`/api/v1/books/${props.book.id}/metadata`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rating: newRating }),
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
const updated = (await res.json()) as BookDetail
|
|
localRating.value = updated.rating ?? null
|
|
emit('saved', updated)
|
|
} catch {
|
|
localRating.value = props.book.rating ?? null
|
|
}
|
|
}
|
|
|
|
const ratingStars = RATING_STARS
|
|
|
|
const {
|
|
draft: personalNoteDraft,
|
|
editing: personalNoteEditing,
|
|
saving: personalNoteSaving,
|
|
error: personalNoteError,
|
|
preview: personalNotePreview,
|
|
hasNote: hasPersonalNote,
|
|
canSave: canSavePersonalNote,
|
|
canClearDraft: canClearPersonalNoteDraft,
|
|
charCount: personalNoteCharCount,
|
|
startEdit: startPersonalNoteEdit,
|
|
cancelEdit: cancelPersonalNoteEdit,
|
|
clearDraft: clearPersonalNoteDraft,
|
|
save: savePersonalNoteDraft,
|
|
} = usePersonalNote(computed(() => props.book))
|
|
|
|
const personalNoteUpdatedLabel = computed(() => (props.book.personalNoteUpdatedAt ? formatDateTime(props.book.personalNoteUpdatedAt) : null))
|
|
|
|
watch(
|
|
() => props.book.id,
|
|
() => {
|
|
showPersonalReview.value = false
|
|
},
|
|
)
|
|
|
|
async function savePersonalNote() {
|
|
const updated = await savePersonalNoteDraft()
|
|
if (updated) emit('saved', updated)
|
|
}
|
|
|
|
const bookIdRef = computed(() => props.book.id)
|
|
const { sessions: readingSessions, stats: readingStats, loading: readingLogLoading, reload: reloadReadingLog } = useBookReadingLog(bookIdRef)
|
|
|
|
const { setStatus, updateStatus } = useBookStatus()
|
|
|
|
const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
const userTimeZone = computed(() => {
|
|
const value = user.value?.settings?.timezone?.trim()
|
|
if (!value) return 'UTC'
|
|
try {
|
|
new Intl.DateTimeFormat('en-US', { timeZone: value })
|
|
return value
|
|
} catch {
|
|
return 'UTC'
|
|
}
|
|
})
|
|
|
|
function dateToDateKey(value: Date): string {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: userTimeZone.value,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(value)
|
|
const year = parts.find((part) => part.type === 'year')?.value
|
|
const month = parts.find((part) => part.type === 'month')?.value
|
|
const day = parts.find((part) => part.type === 'day')?.value
|
|
if (!year || !month || !day) return ''
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
function toDateInputValue(value: string | null | undefined): string {
|
|
if (!value) return ''
|
|
if (DATE_KEY_RE.test(value)) return value
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return ''
|
|
return dateToDateKey(parsed)
|
|
}
|
|
|
|
const addedDate = computed(() => toDateInputValue(props.book.addedAt))
|
|
const draftAddedDate = ref('')
|
|
const editingAddedDate = ref(false)
|
|
const savingAddedDate = ref(false)
|
|
const addedDateError = ref<string | null>(null)
|
|
const addedDateInput = ref<HTMLInputElement | null>(null)
|
|
const addedDateEditButton = ref<HTMLButtonElement | null>(null)
|
|
|
|
const localReadStatus = ref<ReadStatus | null>(props.book.readStatus?.status ?? null)
|
|
const savedReadingDates = ref<{ startedAt: string; finishedAt: string }>({
|
|
startedAt: toDateInputValue(props.book.readStatus?.startedAt),
|
|
finishedAt: toDateInputValue(props.book.readStatus?.finishedAt),
|
|
})
|
|
const draftReadingDates = ref<{ startedAt: string; finishedAt: string }>({
|
|
startedAt: savedReadingDates.value.startedAt,
|
|
finishedAt: savedReadingDates.value.finishedAt,
|
|
})
|
|
const savingReadingDates = ref(false)
|
|
const readingDatesError = ref<string | null>(null)
|
|
const activeReadingDateField = ref<'startedAt' | 'finishedAt' | null>(null)
|
|
const todayDateInput = computed(() => dateToDateKey(new Date()))
|
|
|
|
function validateAddedDate(value: string): string | null {
|
|
if (!value) return t('book.detail.details.dateAddedRequiredError')
|
|
if (value > todayDateInput.value) return t('book.detail.details.dateAddedFutureError')
|
|
return null
|
|
}
|
|
|
|
const canSaveAddedDate = computed(() => draftAddedDate.value !== addedDate.value && !savingAddedDate.value)
|
|
|
|
function startEditingAddedDate() {
|
|
if (savingAddedDate.value || isEditingAnyReadingDate.value) return
|
|
draftAddedDate.value = addedDate.value
|
|
addedDateError.value = null
|
|
editingAddedDate.value = true
|
|
void nextTick(() => addedDateInput.value?.focus())
|
|
}
|
|
|
|
function finishAddedDateEdit() {
|
|
editingAddedDate.value = false
|
|
void nextTick(() => addedDateEditButton.value?.focus())
|
|
}
|
|
|
|
async function saveAddedDate() {
|
|
if (!editingAddedDate.value || savingAddedDate.value) return
|
|
const validationError = validateAddedDate(draftAddedDate.value)
|
|
addedDateError.value = validationError
|
|
if (validationError) return
|
|
|
|
savingAddedDate.value = true
|
|
try {
|
|
const res = await api(`/api/v1/books/${props.book.id}/added-at`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ addedAt: draftAddedDate.value }),
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
const updated = (await res.json()) as BookDetail
|
|
addedDateError.value = null
|
|
emit('saved', updated)
|
|
finishAddedDateEdit()
|
|
} catch {
|
|
addedDateError.value = t('book.detail.details.saveAddedDateError')
|
|
} finally {
|
|
savingAddedDate.value = false
|
|
}
|
|
}
|
|
|
|
function cancelAddedDateEdit() {
|
|
if (!editingAddedDate.value || savingAddedDate.value) return
|
|
addedDateError.value = null
|
|
finishAddedDateEdit()
|
|
}
|
|
|
|
function clearAddedDateError() {
|
|
addedDateError.value = null
|
|
}
|
|
|
|
function normalizeReadStatusDates(readStatus: UserBookStatus | null | undefined) {
|
|
return {
|
|
startedAt: toDateInputValue(readStatus?.startedAt),
|
|
finishedAt: toDateInputValue(readStatus?.finishedAt),
|
|
}
|
|
}
|
|
|
|
function validateReadingDates(values: { startedAt: string; finishedAt: string }): string | null {
|
|
const { startedAt, finishedAt } = values
|
|
if (startedAt && startedAt > todayDateInput.value) return t('book.detail.details.dateStartedFutureError')
|
|
if (finishedAt && finishedAt > todayDateInput.value) return t('book.detail.details.dateFinishedFutureError')
|
|
if (startedAt && finishedAt && finishedAt < startedAt)
|
|
return t('book.detail.details.dateFinishedBeforeStartedErrorWithDate', { date: formatDisplayDate(startedAt) })
|
|
return null
|
|
}
|
|
|
|
function readingDateSaveError(error: unknown): string {
|
|
const errorCode = typeof error === 'object' && error !== null && 'errorCode' in error ? (error as { errorCode?: unknown }).errorCode : null
|
|
if (errorCode === 'READING_DATE_STARTED_IN_FUTURE') return t('book.detail.details.dateStartedFutureError')
|
|
if (errorCode === 'READING_DATE_FINISHED_IN_FUTURE') return t('book.detail.details.dateFinishedFutureError')
|
|
if (errorCode === 'READING_DATES_INVALID_ORDER') return t('book.detail.details.dateFinishedBeforeStartedError')
|
|
return t('book.detail.details.saveReadingDatesError')
|
|
}
|
|
|
|
const isEditingAnyReadingDate = computed(() => activeReadingDateField.value !== null)
|
|
const isEditingAnyDate = computed(() => editingAddedDate.value || isEditingAnyReadingDate.value)
|
|
|
|
function formatDisplayDate(dateKey: string): string {
|
|
if (!dateKey) return '-'
|
|
const [year, month, day] = dateKey.split('-').map(Number)
|
|
const d = new Date(year!, month! - 1, day!)
|
|
return formatLocaleDate(d, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
function isEditingReadingDate(field: 'startedAt' | 'finishedAt') {
|
|
return activeReadingDateField.value === field
|
|
}
|
|
|
|
function hasReadingDateFieldChanges(field: 'startedAt' | 'finishedAt') {
|
|
return draftReadingDates.value[field] !== savedReadingDates.value[field]
|
|
}
|
|
|
|
function startEditingReadingDate(field: 'startedAt' | 'finishedAt') {
|
|
if (savingReadingDates.value) return
|
|
activeReadingDateField.value = field
|
|
draftReadingDates.value = { ...savedReadingDates.value }
|
|
readingDatesError.value = null
|
|
}
|
|
|
|
watch(
|
|
draftReadingDates,
|
|
(value) => {
|
|
readingDatesError.value = validateReadingDates(value)
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
function applyReadStatusUpdate(updatedReadStatus: UserBookStatus) {
|
|
localReadStatus.value = updatedReadStatus.status
|
|
const normalizedDates = normalizeReadStatusDates(updatedReadStatus)
|
|
savedReadingDates.value = normalizedDates
|
|
draftReadingDates.value = { ...normalizedDates }
|
|
readingDatesError.value = null
|
|
emit('saved', { ...props.book, readStatus: updatedReadStatus })
|
|
}
|
|
|
|
watch(
|
|
() => props.book.readStatus,
|
|
(value) => {
|
|
activeReadingDateField.value = null
|
|
localReadStatus.value = value?.status ?? null
|
|
const normalizedDates = normalizeReadStatusDates(value)
|
|
savedReadingDates.value = normalizedDates
|
|
draftReadingDates.value = { ...normalizedDates }
|
|
readingDatesError.value = null
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
async function handleSetReadStatus(status: ReadStatus) {
|
|
const prev = localReadStatus.value
|
|
localReadStatus.value = status
|
|
try {
|
|
const updatedReadStatus = await setStatus(props.book.id, status)
|
|
applyReadStatusUpdate(updatedReadStatus)
|
|
} catch {
|
|
localReadStatus.value = prev
|
|
}
|
|
}
|
|
|
|
async function saveReadingDateField(field: 'startedAt' | 'finishedAt') {
|
|
if (!isEditingReadingDate(field)) return
|
|
if (savingReadingDates.value) return
|
|
const validationError = validateReadingDates(draftReadingDates.value)
|
|
readingDatesError.value = validationError
|
|
if (validationError) return
|
|
|
|
if (!hasReadingDateFieldChanges(field)) {
|
|
activeReadingDateField.value = null
|
|
return
|
|
}
|
|
|
|
const patch =
|
|
field === 'startedAt'
|
|
? ({ startedAt: draftReadingDates.value.startedAt || null } as const)
|
|
: ({ finishedAt: draftReadingDates.value.finishedAt || null } as const)
|
|
|
|
savingReadingDates.value = true
|
|
try {
|
|
const updatedReadStatus = await updateStatus(props.book.id, patch)
|
|
applyReadStatusUpdate(updatedReadStatus)
|
|
activeReadingDateField.value = null
|
|
} catch (error) {
|
|
readingDatesError.value = readingDateSaveError(error)
|
|
} finally {
|
|
savingReadingDates.value = false
|
|
}
|
|
}
|
|
|
|
function cancelReadingDateEdit(field: 'startedAt' | 'finishedAt') {
|
|
if (!isEditingReadingDate(field)) return
|
|
activeReadingDateField.value = null
|
|
draftReadingDates.value[field] = savedReadingDates.value[field]
|
|
readingDatesError.value = null
|
|
}
|
|
|
|
const fileProgressById = ref<Record<number, FileProgress>>({})
|
|
const audiobookProgress = ref<{ percentage: number; currentFileId: number; positionSeconds: number; updatedAt: string | null } | null>(null)
|
|
const collections = ref<CollectionMembership[]>([])
|
|
const koboState = ref<BookKoboState | null>(null)
|
|
const supplementalLoading = ref(false)
|
|
const resettingFileIds = ref<number[]>([])
|
|
const providerIconErrors = ref<Record<string, boolean>>({})
|
|
|
|
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds))
|
|
|
|
const communityRatingBadges = computed(() => {
|
|
const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link]))
|
|
return props.book.communityRatings
|
|
.filter((rating) => rating.rating != null && Number.isFinite(rating.rating))
|
|
.map((rating) => {
|
|
const link = linkByKey.get(rating.provider)
|
|
const meta = communityRatingByProvider.value[rating.provider]
|
|
const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider
|
|
return {
|
|
key: rating.provider,
|
|
label,
|
|
fallback: label.slice(0, 2).toUpperCase(),
|
|
iconUrl: link?.iconUrl ?? providerIconPathSafe(rating.provider),
|
|
url: link?.url ?? null,
|
|
score: meta?.score ?? '',
|
|
tooltip: meta?.tooltip ?? '',
|
|
}
|
|
})
|
|
.filter((badge) => badge.iconUrl)
|
|
})
|
|
|
|
const unlinkedCommunityBadges = computed(() => communityRatingBadges.value.filter((badge) => !badge.url))
|
|
|
|
const fileProgressRows = computed(() =>
|
|
props.book.files.map((file) => ({
|
|
file,
|
|
progress: fileProgressById.value[file.id] ?? {
|
|
percentage: 0,
|
|
cfi: null,
|
|
pageNumber: null,
|
|
updatedAt: null,
|
|
},
|
|
})),
|
|
)
|
|
const detailProgressRows = computed(() => fileProgressRows.value.filter(({ progress }) => progress.percentage > 0))
|
|
|
|
type ProgressRow = {
|
|
label: string
|
|
percentage: number
|
|
color: string
|
|
badgeStyle: Record<string, string>
|
|
finished: boolean
|
|
resetFileId: number | null
|
|
}
|
|
|
|
const KOBO_COLOR = '#f59e0b'
|
|
|
|
const leftColumnProgressRows = computed<ProgressRow[]>(() => {
|
|
const rows: ProgressRow[] = []
|
|
|
|
for (const { file, progress } of detailProgressRows.value) {
|
|
const color = getFormatColor(file.format ?? '?')
|
|
rows.push({
|
|
label: (file.format ?? '?').toUpperCase(),
|
|
percentage: progress.percentage,
|
|
color,
|
|
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
|
|
finished: progress.percentage >= 100,
|
|
resetFileId: file.id,
|
|
})
|
|
}
|
|
|
|
if (audiobookProgress.value && audiobookProgress.value.percentage > 0) {
|
|
const audioFile = props.book.files.find((f) => f.id === audiobookProgress.value!.currentFileId)
|
|
const format = audioFile?.format ?? 'audio'
|
|
const color = getFormatColor(format)
|
|
rows.push({
|
|
label: format.toUpperCase(),
|
|
percentage: audiobookProgress.value.percentage,
|
|
color,
|
|
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
|
|
finished: audiobookProgress.value.percentage >= 100,
|
|
resetFileId: audiobookProgress.value.currentFileId,
|
|
})
|
|
}
|
|
const koboPercent = koboState.value?.readingState?.progressPercent
|
|
if (canViewKobo.value && koboPercent != null && koboPercent > 0) {
|
|
rows.push({
|
|
label: 'Kobo',
|
|
percentage: koboPercent,
|
|
color: KOBO_COLOR,
|
|
badgeStyle: { color: KOBO_COLOR, borderColor: `${KOBO_COLOR}66`, backgroundColor: `${KOBO_COLOR}1a` },
|
|
finished: koboPercent >= 100,
|
|
resetFileId: null,
|
|
})
|
|
}
|
|
if (canViewKoreader.value && koreaderBookProgress.value != null && koreaderBookProgress.value.canonicalPercentage > 0) {
|
|
const koreaderColor = '#b3b910'
|
|
rows.push({
|
|
label: 'KO-R',
|
|
percentage: koreaderBookProgress.value.canonicalPercentage,
|
|
color: koreaderColor,
|
|
badgeStyle: { color: koreaderColor, borderColor: `${koreaderColor}66`, backgroundColor: `${koreaderColor}1a` },
|
|
finished: koreaderBookProgress.value.canonicalPercentage >= 100,
|
|
resetFileId: null,
|
|
})
|
|
}
|
|
return rows
|
|
})
|
|
|
|
/** Editions renders one bar per format, so device rows collapse onto their format's row. */
|
|
watch(bookIdRef, () => {
|
|
void reloadReadingLog()
|
|
})
|
|
|
|
const editionProgress = computed(() =>
|
|
leftColumnProgressRows.value.map((row) => ({
|
|
format: row.label.toLowerCase(),
|
|
percentage: row.percentage,
|
|
finished: row.finished,
|
|
resetFileId: row.resetFileId,
|
|
})),
|
|
)
|
|
|
|
function handleEditionReset(format: string) {
|
|
const row = leftColumnProgressRows.value.find((entry) => entry.label.toLowerCase() === format.toLowerCase())
|
|
if (row) void handleResetFileProgress(row)
|
|
}
|
|
|
|
function formatKoboDeviceNames(snapshots: BookKoboState['snapshots']): string {
|
|
const names = snapshots.map((snapshot) => snapshot.deviceName)
|
|
if (names.length === 1) return names.join('')
|
|
if (names.length === 2) return names.join(' and ')
|
|
return `${snapshots.length} devices`
|
|
}
|
|
|
|
const resetHeldDevices = computed(() => (canViewKoreader.value ? (koreaderBookProgress.value?.heldByReset ?? []) : []))
|
|
const releasingDeviceIds = ref<string[]>([])
|
|
const failedReleaseDeviceIds = ref<string[]>([])
|
|
|
|
function isReleasingHold(deviceId: string): boolean {
|
|
return releasingDeviceIds.value.includes(deviceId)
|
|
}
|
|
|
|
function hasReleaseFailed(deviceId: string): boolean {
|
|
return failedReleaseDeviceIds.value.includes(deviceId)
|
|
}
|
|
|
|
async function handleReleaseResetHold(deviceId: string) {
|
|
if (isReleasingHold(deviceId)) return
|
|
releasingDeviceIds.value = [...releasingDeviceIds.value, deviceId]
|
|
failedReleaseDeviceIds.value = failedReleaseDeviceIds.value.filter((id) => id !== deviceId)
|
|
try {
|
|
const released = await releaseKoreaderResetHold(props.book.id, deviceId)
|
|
// A button that re-enables with the hold still showing reads as nothing having happened.
|
|
if (!released) failedReleaseDeviceIds.value = [...failedReleaseDeviceIds.value, deviceId]
|
|
} finally {
|
|
releasingDeviceIds.value = releasingDeviceIds.value.filter((id) => id !== deviceId)
|
|
}
|
|
}
|
|
|
|
const koboAnomaly = computed(() => {
|
|
if (!canViewKobo.value) return null
|
|
const snapshots = koboState.value?.snapshots ?? []
|
|
const pendingDelete = snapshots.filter((snapshot) => snapshot.pendingDelete)
|
|
if (pendingDelete.length > 0) {
|
|
return { label: `Pending delete on ${formatKoboDeviceNames(pendingDelete)}`, tooltip: 'Kobo will remove it on the next sync.' }
|
|
}
|
|
const removedByDevice = snapshots.filter((snapshot) => snapshot.removedByDevice)
|
|
if (removedByDevice.length > 0) {
|
|
return { label: `Removed on ${formatKoboDeviceNames(removedByDevice)}`, tooltip: 'Kobo reported this book removed.' }
|
|
}
|
|
const unsynced = snapshots.filter((snapshot) => snapshot.synced === false)
|
|
if (unsynced.length > 0) {
|
|
return { label: `Not synced on ${formatKoboDeviceNames(unsynced)}`, tooltip: 'Queued for the next Kobo sync.' }
|
|
}
|
|
return null
|
|
})
|
|
|
|
function formatSeriesLabel(seriesName: string, seriesIndex: string | null): string {
|
|
if (seriesIndex == null) return seriesName
|
|
return `${seriesName} #${seriesIndex}`
|
|
}
|
|
|
|
const seriesLinks = computed<SeriesDisplayLink[]>(() => {
|
|
const memberships = props.book.seriesMemberships ?? []
|
|
if (memberships.length > 0) {
|
|
return memberships
|
|
.filter((membership) => membership.seriesName.trim().length > 0)
|
|
.slice()
|
|
.sort((a, b) => a.displayOrder - b.displayOrder || a.seriesId - b.seriesId)
|
|
.map((membership) => ({
|
|
key: `${membership.seriesId}-${membership.displayOrder}`,
|
|
seriesId: membership.seriesId,
|
|
label: formatSeriesLabel(membership.seriesName, membership.seriesIndex),
|
|
}))
|
|
}
|
|
|
|
if (!props.book.seriesName) return []
|
|
return [
|
|
{
|
|
key: `primary-${props.book.seriesId ?? 'unknown'}`,
|
|
seriesId: props.book.seriesId ?? null,
|
|
label: formatSeriesLabel(props.book.seriesName, props.book.seriesIndex),
|
|
},
|
|
]
|
|
})
|
|
|
|
function formatDateTime(iso: string): string {
|
|
return formatLocaleDate(new Date(iso), { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
}
|
|
|
|
function formatAddedDateTime(iso: string): string {
|
|
return formatLocaleDate(new Date(iso), {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZone: userTimeZone.value,
|
|
})
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
const clamped = Math.max(0, Math.min(100, value))
|
|
if (clamped > 0 && clamped < 1) return '<1%'
|
|
if (clamped > 99 && clamped < 100) return '>99%'
|
|
return `${Math.round(clamped)}%`
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return formatLocaleDate(new Date(iso), { year: 'numeric', month: 'short', day: 'numeric', timeZone: userTimeZone.value })
|
|
}
|
|
|
|
function formatBadgeStyle(fmt: string) {
|
|
const color = getFormatColor(fmt)
|
|
return {
|
|
color,
|
|
borderColor: `${color}66`,
|
|
backgroundColor: `${color}1a`,
|
|
}
|
|
}
|
|
|
|
function providerLinkStyle(provider: string) {
|
|
const color = getProviderColor(provider)
|
|
return {
|
|
borderColor: `${color}66`,
|
|
backgroundColor: `${color}12`,
|
|
}
|
|
}
|
|
|
|
function handleEditMetadataFromScore() {
|
|
scoreBreakdownOpen.value = false
|
|
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
|
|
}
|
|
|
|
function handleMoveFromMenu() {
|
|
moreMenuOpen.value = false
|
|
mobileMoreMenuOpen.value = false
|
|
moveToLibraryOpen.value = true
|
|
}
|
|
|
|
function handleMoveOpenChange(open: boolean) {
|
|
moveToLibraryOpen.value = open
|
|
}
|
|
|
|
// The book still exists after a move, it just lives elsewhere, so refresh the
|
|
// detail rather than navigating away.
|
|
function handleBookMoved() {
|
|
emit('moved')
|
|
}
|
|
|
|
function handleDeleteFromMenu() {
|
|
moreMenuOpen.value = false
|
|
mobileMoreMenuOpen.value = false
|
|
promptDelete(props.book.id)
|
|
}
|
|
|
|
function handleSendFromMenu() {
|
|
moreMenuOpen.value = false
|
|
mobileMoreMenuOpen.value = false
|
|
showSendDialog.value = true
|
|
}
|
|
|
|
function handleOpenResetReadingState() {
|
|
moreMenuOpen.value = false
|
|
mobileMoreMenuOpen.value = false
|
|
openResetReadingStateDialog()
|
|
}
|
|
|
|
async function handleResetReadingState() {
|
|
const result = await resetReadingState()
|
|
if (!result) return
|
|
|
|
await loadSupplemental()
|
|
emit('saved', { ...props.book, readStatus: result.readStatus })
|
|
}
|
|
|
|
function handleCoverLoad(ratio: number | null) {
|
|
coverLoaded.value = true
|
|
coverFailed.value = false
|
|
coverImageRatio.value = ratio
|
|
}
|
|
|
|
function handleCoverError() {
|
|
coverLoaded.value = false
|
|
coverFailed.value = true
|
|
coverImageRatio.value = null
|
|
}
|
|
|
|
function handleCoverClick() {
|
|
if (hasCover.value && coverLoaded.value && !coverFailed.value) {
|
|
coverLightboxOpen.value = true
|
|
}
|
|
}
|
|
|
|
function openEditCover() {
|
|
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
|
|
}
|
|
|
|
function openBookWithMode(mode?: 'peek') {
|
|
if (!primaryFile.value) return
|
|
router.push({
|
|
name: 'reader',
|
|
params: { bookId: props.book.id, fileId: primaryFile.value.id },
|
|
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
|
|
})
|
|
}
|
|
|
|
function openBook() {
|
|
openBookWithMode()
|
|
}
|
|
|
|
function peekBook() {
|
|
openBookWithMode('peek')
|
|
}
|
|
|
|
function openBookFile(file: BookDetail['files'][number], mode?: 'peek') {
|
|
readMenuOpen.value = false
|
|
mobileReadMenuOpen.value = false
|
|
router.push({
|
|
name: 'reader',
|
|
params: { bookId: props.book.id, fileId: file.id },
|
|
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
|
|
})
|
|
}
|
|
|
|
function isResettingFile(fileId: number | null): boolean {
|
|
return fileId != null && resettingFileIds.value.includes(fileId)
|
|
}
|
|
|
|
function setFileResetting(fileId: number, resetting: boolean): void {
|
|
if (resetting) {
|
|
if (resettingFileIds.value.includes(fileId)) return
|
|
resettingFileIds.value = [...resettingFileIds.value, fileId]
|
|
return
|
|
}
|
|
resettingFileIds.value = resettingFileIds.value.filter((id) => id !== fileId)
|
|
}
|
|
|
|
async function handleResetFileProgress(row: ProgressRow) {
|
|
const fileId = row.resetFileId
|
|
if (fileId == null || isResettingFile(fileId)) return
|
|
if (!window.confirm(t('book.detail.details.resetProgressConfirm', { label: row.label }))) return
|
|
|
|
setFileResetting(fileId, true)
|
|
try {
|
|
const res = await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error('Failed to reset file progress')
|
|
await loadSupplemental()
|
|
} finally {
|
|
setFileResetting(fileId, false)
|
|
}
|
|
}
|
|
|
|
let supplementalRequestId = 0
|
|
|
|
async function loadSupplemental() {
|
|
const requestId = ++supplementalRequestId
|
|
supplementalLoading.value = true
|
|
const hasAudio = props.book.files.some((f) => f.format && FORMAT_TO_GROUP[f.format] === 'audio')
|
|
try {
|
|
const progressPromise = api(`/api/v1/books/${props.book.id}/progress`).catch(() => null)
|
|
const audioProgressPromise = hasAudio ? api(`/api/v1/books/${props.book.id}/audio-progress`).catch(() => null) : Promise.resolve(null)
|
|
const collectionsPromise = api('/api/v1/collections/membership', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ bookIds: [props.book.id] }),
|
|
})
|
|
const koboPromise = canViewKobo.value ? api(`/api/v1/books/${props.book.id}/kobo-state`) : Promise.resolve(null)
|
|
const koreaderProgressPromise = canViewKoreader.value ? fetchKoreaderProgress(props.book.id) : Promise.resolve()
|
|
|
|
const [progressRes, audioProgressRes, collectionsRes, koboRes] = await Promise.all([
|
|
progressPromise,
|
|
audioProgressPromise,
|
|
collectionsPromise,
|
|
koboPromise,
|
|
])
|
|
await koreaderProgressPromise
|
|
|
|
if (requestId !== supplementalRequestId) return
|
|
|
|
const progressRows: FileProgressRow[] = progressRes && progressRes.ok ? ((await progressRes.json()) as FileProgressRow[]) : []
|
|
const progressMap: Record<number, FileProgress> = {}
|
|
for (const row of progressRows) {
|
|
if (!Number.isFinite(row.fileId)) continue
|
|
progressMap[row.fileId] = {
|
|
percentage: row.percentage,
|
|
cfi: row.cfi,
|
|
pageNumber: row.pageNumber,
|
|
updatedAt: row.updatedAt,
|
|
}
|
|
}
|
|
fileProgressById.value = progressMap
|
|
|
|
if (audioProgressRes && audioProgressRes.ok) {
|
|
const data = await audioProgressRes.json()
|
|
audiobookProgress.value = data
|
|
? {
|
|
percentage: data.percentage,
|
|
currentFileId: data.currentFileId,
|
|
positionSeconds: data.positionSeconds,
|
|
updatedAt: data.updatedAt ?? null,
|
|
}
|
|
: null
|
|
} else {
|
|
audiobookProgress.value = null
|
|
}
|
|
|
|
const fetchedCollections = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : []
|
|
collections.value = fetchedCollections.filter((collection) => (collection.memberCount ?? 0) > 0)
|
|
|
|
if (canViewKobo.value) {
|
|
const fallbackSyncCollections = collections.value.filter((c) => c.syncToKobo && (c.memberCount ?? 0) > 0).map((c) => c.name)
|
|
if (koboRes && koboRes.ok) {
|
|
const data = (await koboRes.json()) as BookKoboState
|
|
koboState.value = {
|
|
...data,
|
|
syncCollections: data.syncCollections.length > 0 ? data.syncCollections : fallbackSyncCollections,
|
|
}
|
|
} else {
|
|
koboState.value = {
|
|
eligibleForKoboSync: fallbackSyncCollections.length > 0,
|
|
syncCollections: fallbackSyncCollections,
|
|
readingState: null,
|
|
snapshots: [],
|
|
}
|
|
}
|
|
} else {
|
|
koboState.value = null
|
|
}
|
|
} catch {
|
|
if (requestId !== supplementalRequestId) return
|
|
fileProgressById.value = {}
|
|
audiobookProgress.value = null
|
|
collections.value = []
|
|
koboState.value = canViewKobo.value
|
|
? {
|
|
eligibleForKoboSync: false,
|
|
syncCollections: [],
|
|
readingState: null,
|
|
snapshots: [],
|
|
}
|
|
: null
|
|
} finally {
|
|
if (requestId === supplementalRequestId) supplementalLoading.value = false
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => `${props.book.id}:${props.book.files.map((f) => f.id).join(',')}:${canViewKobo.value ? 'kobo' : 'nokobo'}`,
|
|
() => {
|
|
providerIconErrors.value = {}
|
|
void loadSupplemental()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="book.status === 'missing'" class="mb-6 flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
|
<TriangleAlert class="size-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p class="text-sm font-medium text-amber-600 dark:text-amber-400">{{ t('book.detail.details.filesNotFound') }}</p>
|
|
<p class="text-xs text-muted-foreground mt-0.5">
|
|
{{ t('book.detail.details.filesNotFoundDescription') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!--
|
|
Direction A. Below 46rem of pane width the page is a single scrolling column. Above it
|
|
the pane owns the height: three columns fill row one, the discovery shelf takes row two,
|
|
and any column that runs long scrolls inside itself so the page itself never does.
|
|
-->
|
|
<div
|
|
class="flex flex-col gap-5 @min-[46rem]/book-detail:grid @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:grid-cols-[clamp(12rem,23cqi,17rem)_minmax(16rem,1fr)_clamp(15rem,26cqi,19.25rem)] @min-[46rem]/book-detail:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)] @min-[46rem]/book-detail:gap-x-6 @min-[46rem]/book-detail:gap-y-5"
|
|
>
|
|
<!-- Cover column -->
|
|
<div
|
|
class="flex min-w-0 flex-col gap-3 @min-[46rem]/book-detail:col-start-1 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0"
|
|
>
|
|
<div class="flex items-start gap-4 sm:gap-5 @min-[46rem]/book-detail:block @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:flex-1">
|
|
<div
|
|
ref="coverSlotEl"
|
|
class="w-28 shrink-0 sm:w-36 @min-[46rem]/book-detail:flex @min-[46rem]/book-detail:h-full @min-[46rem]/book-detail:w-full @min-[46rem]/book-detail:items-center @min-[46rem]/book-detail:justify-center"
|
|
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
|
|
>
|
|
<div class="w-full @min-[46rem]/book-detail:max-h-full" :style="{ maxWidth: coverMaxWidth }">
|
|
<BookCoverSurface
|
|
class="book-cover-surface--spine-fitted group relative w-full overflow-hidden rounded-lg shadow-lg shadow-black/40"
|
|
:disable-spine="isPrimaryAudio"
|
|
:is-comic="isPrimaryComic"
|
|
:class="hasCover && coverLoaded && !coverFailed ? 'cursor-zoom-in' : ''"
|
|
:style="{ aspectRatio: detailCoverAspectRatio }"
|
|
@click="handleCoverClick"
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="absolute top-1.5 right-1.5 z-10 p-1 rounded bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
|
@click.stop="openEditCover"
|
|
>
|
|
<Pencil class="size-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.detail.details.editCover') }}</TooltipContent>
|
|
</Tooltip>
|
|
<BookCoverArtwork
|
|
:src="coverSrc"
|
|
:has-cover="hasCover"
|
|
:title="coverPlaceholderTitle"
|
|
:author-line="book.authors.map((a) => a.name).join(', ') || null"
|
|
:is-audio="isPrimaryAudio"
|
|
:seed="coverSeed"
|
|
:alt="book.title ?? ''"
|
|
:frame-aspect-ratio="detailCoverAspectRatio"
|
|
loading="eager"
|
|
backdrop-class="blur-lg brightness-50"
|
|
:spine="!isPrimaryAudio"
|
|
:is-comic="isPrimaryComic"
|
|
@load="handleCoverLoad"
|
|
@error="handleCoverError"
|
|
/>
|
|
</BookCoverSurface>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compact identity, replaced by the full block in column two from 46rem -->
|
|
<div class="min-w-0 flex-1 @min-[46rem]/book-detail:hidden">
|
|
<h1 class="text-lg font-bold leading-snug break-words sm:text-xl">{{ book.title ?? t('book.detail.details.untitled') }}</h1>
|
|
<p v-if="book.subtitle" class="mt-1 text-sm leading-snug text-muted-foreground break-words">{{ book.subtitle }}</p>
|
|
<p v-if="authorLinks.length" class="mt-2 text-[13px] break-words">
|
|
<span class="text-muted-foreground">{{ t('book.detail.details.by') }}</span>
|
|
<span class="ml-1 font-semibold">
|
|
<template v-for="(author, index) in authorLinks" :key="`m-${author.id}-${index}`">
|
|
<RouterLink
|
|
:to="{ name: 'author-detail', params: { id: author.id } }"
|
|
class="transition-colors hover:text-primary hover:underline underline-offset-2"
|
|
>{{ author.name }}</RouterLink
|
|
><span v-if="index < authorLinks.length - 1">, </span>
|
|
</template>
|
|
</span>
|
|
</p>
|
|
<p v-if="narratorLine" class="mt-1 text-[13px] break-words">
|
|
<span class="text-muted-foreground">{{ t('book.detail.details.narratedBy') }}</span>
|
|
<span class="ml-1 font-semibold">{{ narratorLine }}</span>
|
|
</p>
|
|
<div v-if="seriesLinks.length" class="mt-2 flex flex-wrap gap-1.5">
|
|
<template v-for="series in seriesLinks" :key="`m-${series.key}`">
|
|
<RouterLink
|
|
v-if="series.seriesId != null"
|
|
:to="{ name: 'series-detail', params: { seriesId: series.seriesId } }"
|
|
class="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium transition-colors hover:bg-muted/80"
|
|
>{{ series.label }}</RouterLink
|
|
>
|
|
<span v-else class="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{{ series.label }}</span>
|
|
</template>
|
|
</div>
|
|
<div class="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
<Popover :open="mobileScoreBreakdownOpen" @update:open="handleMobileScoreOpen">
|
|
<PopoverTrigger as-child>
|
|
<MetadataScoreBadge :score="book.metadataScore" />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-72 p-4" align="start">
|
|
<p class="mb-3 text-sm font-semibold">{{ t('book.detail.details.metadataScore') }}</p>
|
|
<MetadataScoreBreakdown :book="book" :weights="scoreWeights" @edit-metadata="handleEditMetadataFromScore" />
|
|
</PopoverContent>
|
|
</Popover>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<button class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground">
|
|
<component :is="STATUS_ICONS[localReadStatus ?? 'unread']" class="size-3.5" :class="STATUS_COLORS[localReadStatus ?? 'unread']" />
|
|
{{ STATUS_OPTIONS.find((o) => o.value === (localReadStatus ?? 'unread'))?.label }}
|
|
<ChevronDown class="size-3 opacity-60" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetReadStatus(opt.value)">
|
|
<component :is="STATUS_ICONS[opt.value]" class="mr-2 size-4" :class="STATUS_COLORS[opt.value]" />
|
|
{{ opt.label }}
|
|
<Check v-if="localReadStatus === opt.value" class="ml-auto size-3 text-primary" />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 space-y-2">
|
|
<div class="flex gap-2">
|
|
<!-- Read/Play button: split when multiple files, plain when single -->
|
|
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
|
|
<button
|
|
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
:disabled="!primaryFile"
|
|
@click="openBook"
|
|
>
|
|
<BookOpen v-if="isPrimaryAudio" class="size-4" />
|
|
<BookOpen v-else class="size-4" />
|
|
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
|
|
</button>
|
|
<div class="w-px bg-primary-foreground/20 shrink-0" />
|
|
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
|
|
<PopoverTrigger as-child>
|
|
<button
|
|
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
:title="t('book.detail.details.chooseFormat')"
|
|
>
|
|
<ChevronDown class="size-3.5" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-52 p-1" align="end">
|
|
<button
|
|
v-for="file in openableFiles"
|
|
:key="file.id"
|
|
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
|
@click="openBookFile(file)"
|
|
>
|
|
<span
|
|
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
|
|
:style="formatBadgeStyle(file.format ?? '?')"
|
|
>{{ file.format ?? '?' }}</span
|
|
>
|
|
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
|
|
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">{{
|
|
t('book.detail.details.audiobook')
|
|
}}</template>
|
|
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
|
|
</span>
|
|
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">{{
|
|
t('book.detail.details.primary')
|
|
}}</span>
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<button
|
|
v-else
|
|
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
|
:disabled="!primaryFile"
|
|
@click="openBook"
|
|
>
|
|
<Headphones v-if="isPrimaryAudio" class="size-4" />
|
|
<BookOpen v-else class="size-4" />
|
|
{{ isPrimaryAudio ? t('book.detail.details.listen') : t('book.detail.details.read') }}
|
|
</button>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
|
:disabled="!primaryFile"
|
|
:aria-label="t('book.detail.details.peek')"
|
|
@click="peekBook"
|
|
>
|
|
<Eye class="size-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.detail.details.peek') }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<div v-if="hasPermission('library_download')" class="flex-1">
|
|
<BookDownloadButton :files="book.files" :book-id="book.id" />
|
|
</div>
|
|
<button
|
|
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
|
@click="addToCollectionOpen = true"
|
|
>
|
|
<Library class="size-3.5" />
|
|
</button>
|
|
<button
|
|
v-if="hasPermission('email_send')"
|
|
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
|
|
:aria-label="t('book.detail.details.sendViaEmail')"
|
|
@click="handleSendFromMenu"
|
|
>
|
|
<Send class="size-3.5" />
|
|
</button>
|
|
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
|
|
<PopoverTrigger as-child>
|
|
<button
|
|
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
|
|
>
|
|
<MoreVertical class="size-3.5" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-44 p-1" align="end">
|
|
<button
|
|
v-if="canEditMetadata"
|
|
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
|
|
@click="handleOpenResetReadingState"
|
|
>
|
|
<RotateCcw class="size-3.5" />
|
|
Reset reading state
|
|
</button>
|
|
<button
|
|
v-if="hasPermission('library_edit_metadata')"
|
|
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
|
@click="handleMoveFromMenu"
|
|
>
|
|
<FolderInput class="size-3.5" />
|
|
{{ t('book.move.action') }}
|
|
</button>
|
|
<button
|
|
v-if="hasPermission('library_delete_books')"
|
|
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
@click="handleDeleteFromMenu"
|
|
>
|
|
<Trash2 class="size-3.5" />
|
|
Delete book
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-for="held in resetHeldDevices" :key="held.deviceId" class="mt-2 flex items-start gap-1.5">
|
|
<TriangleAlert class="size-3 text-amber-500 shrink-0 mt-0.5" aria-hidden="true" />
|
|
<div class="min-w-0">
|
|
<p class="text-[11px] text-amber-500">
|
|
{{ t('book.detail.details.resetHoldNotice', { device: held.device, percent: formatPercent(held.percentage) }) }}
|
|
</p>
|
|
<button
|
|
class="mt-0.5 text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="isReleasingHold(held.deviceId)"
|
|
@click="handleReleaseResetHold(held.deviceId)"
|
|
>
|
|
{{ isReleasingHold(held.deviceId) ? t('book.detail.details.resetHoldReleasing') : t('book.detail.details.resetHoldRelease') }}
|
|
</button>
|
|
<p v-if="hasReleaseFailed(held.deviceId)" role="alert" class="text-[11px] text-destructive">
|
|
{{ t('book.detail.details.resetHoldReleaseFailed') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Tooltip v-if="koboAnomaly">
|
|
<TooltipTrigger as-child>
|
|
<div class="mt-2 flex items-center gap-1.5 cursor-help" tabindex="0">
|
|
<TriangleAlert class="size-3 text-amber-500 shrink-0" />
|
|
<p class="text-[11px] text-amber-500">{{ koboAnomaly.label }}</p>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ koboAnomaly.tooltip }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<!-- Main column -->
|
|
<div
|
|
class="flex min-w-0 flex-col gap-2.5 @min-[46rem]/book-detail:col-start-2 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:overflow-y-auto"
|
|
>
|
|
<div class="hidden @min-[46rem]/book-detail:block">
|
|
<!-- Identity block -->
|
|
<div class="flex items-center flex-wrap gap-x-3 gap-y-2 -mt-1">
|
|
<h1 class="text-2xl font-bold leading-tight">{{ book.title ?? t('book.detail.details.untitled') }}</h1>
|
|
<Popover :open="scoreBreakdownOpen" @update:open="(v) => (scoreBreakdownOpen = v)">
|
|
<PopoverTrigger as-child>
|
|
<MetadataScoreBadge :score="book.metadataScore" />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-72 p-4" align="start">
|
|
<p class="text-sm font-semibold mb-3">{{ t('book.detail.details.metadataScore') }}</p>
|
|
<MetadataScoreBreakdown :book="book" :weights="scoreWeights" @edit-metadata="handleEditMetadataFromScore" />
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1 leading-snug">{{ book.subtitle }}</p>
|
|
|
|
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1 mt-3">
|
|
<p v-if="authorLinks.length" class="text-sm">
|
|
<span class="text-muted-foreground">{{ t('book.detail.details.by') }}</span>
|
|
<span class="ml-1 font-medium text-foreground">
|
|
<template v-for="(author, index) in authorLinks" :key="`${author.id}-${index}`">
|
|
<RouterLink
|
|
:to="{ name: 'author-detail', params: { id: author.id } }"
|
|
class="hover:text-primary hover:underline underline-offset-2 transition-colors"
|
|
>{{ author.name }}</RouterLink
|
|
><span v-if="index < authorLinks.length - 1">, </span>
|
|
</template>
|
|
</span>
|
|
</p>
|
|
<p v-if="narratorLine" class="text-sm">
|
|
<span class="text-muted-foreground">{{ t('book.detail.details.narratedBy') }}</span>
|
|
<span class="ml-1 font-medium text-foreground">{{ narratorLine }}</span>
|
|
</p>
|
|
<template v-if="seriesLinks.length">
|
|
<span class="text-muted-foreground text-xs">·</span>
|
|
<span class="inline-flex flex-wrap items-center gap-1">
|
|
<template v-for="series in seriesLinks" :key="series.key">
|
|
<RouterLink
|
|
v-if="series.seriesId != null"
|
|
:to="{ name: 'series-detail', params: { seriesId: series.seriesId } }"
|
|
class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors"
|
|
>{{ series.label }}</RouterLink
|
|
>
|
|
<span v-else class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ series.label }}</span>
|
|
</template>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="mt-3 flex items-center gap-1" @mouseleave="hoverRating = null">
|
|
<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="getRatingStarClass(star, displayRating)" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{
|
|
isRatingLocked ? t('book.detail.details.ratingLocked') : t('book.detail.details.rateStar', { star })
|
|
}}</TooltipContent>
|
|
</Tooltip>
|
|
</template>
|
|
<template v-else>
|
|
<Star v-for="star in ratingStars" :key="star" class="size-3.5" :class="getRatingStarClass(star, localRating)" />
|
|
</template>
|
|
</div>
|
|
|
|
<template v-if="isRatingLocked">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<div class="ml-1 p-1 rounded-full bg-primary/10 text-primary">
|
|
<Lock class="size-3" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.detail.details.ratingLocked') }}</TooltipContent>
|
|
</Tooltip>
|
|
</template>
|
|
|
|
<div class="w-px h-3.5 bg-border mx-1.5" />
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<button class="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
|
<component :is="STATUS_ICONS[localReadStatus ?? 'unread']" class="size-3.5" :class="STATUS_COLORS[localReadStatus ?? 'unread']" />
|
|
{{ STATUS_OPTIONS.find((o) => o.value === (localReadStatus ?? 'unread'))?.label }}
|
|
<ChevronDown class="size-3 opacity-60" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetReadStatus(opt.value)">
|
|
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
|
{{ opt.label }}
|
|
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div class="w-px h-3.5 bg-border mx-1.5" />
|
|
|
|
<button
|
|
type="button"
|
|
:aria-label="t('book.detail.details.personalReview.toggleAria')"
|
|
class="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
:class="{ 'text-primary hover:text-primary': showPersonalReview }"
|
|
@click="togglePersonalReview"
|
|
>
|
|
<StickyNote class="size-3.5" />
|
|
<span>{{ t('book.detail.details.personalReview.title') }}</span>
|
|
<span v-if="hasPersonalNote" class="size-1.5 rounded-full bg-primary" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Format badges + provider links -->
|
|
<div v-if="formats.length || providerLinks.length || unlinkedCommunityBadges.length" class="flex flex-wrap items-center gap-2">
|
|
<span
|
|
v-for="fmt in formats"
|
|
:key="fmt"
|
|
class="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border"
|
|
:style="formatBadgeStyle(fmt)"
|
|
>
|
|
<Tooltip v-if="fmt === primaryFile?.format">
|
|
<TooltipTrigger as-child>
|
|
<span class="size-1.5 rounded-full shrink-0" :style="{ backgroundColor: 'currentColor' }" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.detail.details.primaryFormat') }}</TooltipContent>
|
|
</Tooltip>
|
|
{{ fmt }}
|
|
</span>
|
|
<div v-if="providerLinks.length || unlinkedCommunityBadges.length" class="flex items-center flex-wrap gap-2 w-full sm:w-auto sm:shrink-0">
|
|
<div class="hidden sm:block w-px h-3.5 bg-border" />
|
|
<a
|
|
v-for="link in providerLinks"
|
|
:key="link.key"
|
|
:href="link.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
:title="communityRatingByProvider[link.key]?.tooltip ?? t('book.detail.details.openIn', { provider: link.label })"
|
|
class="inline-flex h-6 items-center overflow-hidden rounded-md border transition-colors hover:bg-muted/60"
|
|
:style="providerLinkStyle(link.key)"
|
|
>
|
|
<span class="flex size-6 items-center justify-center">
|
|
<img
|
|
v-if="link.iconUrl && !providerIconErrors[link.key]"
|
|
:src="link.iconUrl"
|
|
:alt="link.label"
|
|
class="size-3.5 rounded-[2px] object-contain"
|
|
loading="lazy"
|
|
@error="providerIconErrors[link.key] = true"
|
|
/>
|
|
<span v-else class="text-[8px] font-bold leading-none text-foreground">{{ link.fallback }}</span>
|
|
</span>
|
|
<span
|
|
v-if="communityRatingByProvider[link.key]"
|
|
class="flex h-full items-center border-l border-border/60 bg-background/50 px-1.5 text-[11px] font-semibold tabular-nums text-foreground"
|
|
>
|
|
{{ communityRatingByProvider[link.key]?.score }}
|
|
</span>
|
|
</a>
|
|
<span
|
|
v-for="badge in unlinkedCommunityBadges"
|
|
:key="badge.key"
|
|
:title="badge.tooltip"
|
|
class="inline-flex h-6 items-center overflow-hidden rounded-md border"
|
|
:style="providerLinkStyle(badge.key)"
|
|
>
|
|
<span class="flex size-6 items-center justify-center">
|
|
<img
|
|
v-if="!providerIconErrors[badge.key]"
|
|
:src="badge.iconUrl ?? undefined"
|
|
:alt="badge.label"
|
|
class="size-3.5 rounded-[2px] object-contain"
|
|
loading="lazy"
|
|
@error="providerIconErrors[badge.key] = true"
|
|
/>
|
|
<span v-else class="text-[8px] font-bold leading-none text-foreground">{{ badge.fallback }}</span>
|
|
</span>
|
|
<span
|
|
class="flex h-full items-center border-l border-border/60 bg-background/50 px-1.5 text-[11px] font-semibold tabular-nums text-foreground"
|
|
>
|
|
{{ badge.score }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Genres + Tags -->
|
|
<div v-if="book.genres.length || book.tags.length" class="space-y-1">
|
|
<div v-if="book.genres.length" class="relative">
|
|
<div data-test="genre-row" class="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
|
<span
|
|
v-for="(genre, index) in displayedGenres"
|
|
:key="`${genre}-${index}`"
|
|
data-test="visible-genre"
|
|
class="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground"
|
|
>
|
|
{{ genre }}
|
|
</span>
|
|
<Popover v-if="genreHiddenCount > 0">
|
|
<PopoverTrigger as-child>
|
|
<button
|
|
type="button"
|
|
data-test="genre-overflow-trigger"
|
|
class="shrink-0 whitespace-nowrap rounded-md border border-border px-2 py-0.5 text-[11px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
:aria-label="t('book.detail.details.moreCount', { count: genreHiddenCount })"
|
|
>
|
|
+{{ genreHiddenCount }}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" class="w-80 max-w-[calc(100vw-2rem)] p-3">
|
|
<div data-test="hidden-genres" class="flex flex-wrap gap-1.5">
|
|
<span
|
|
v-for="(genre, index) in hiddenGenres"
|
|
:key="`hidden-${genre}-${index}`"
|
|
class="rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground"
|
|
>
|
|
{{ genre }}
|
|
</span>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div
|
|
ref="genreMeasureContainer"
|
|
aria-hidden="true"
|
|
class="pointer-events-none invisible absolute left-0 top-0 -z-10 flex w-full items-center gap-1.5 whitespace-nowrap"
|
|
>
|
|
<span
|
|
v-for="(genre, index) in book.genres"
|
|
:key="`measure-${genre}-${index}`"
|
|
data-genre-pill="true"
|
|
class="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground"
|
|
>
|
|
{{ genre }}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
tabindex="-1"
|
|
data-genre-more-measure="true"
|
|
class="shrink-0 whitespace-nowrap rounded-md border border-border px-2 py-0.5 text-[11px] font-medium"
|
|
>
|
|
+{{ book.genres.length }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5">
|
|
<span
|
|
v-for="tag in book.tags"
|
|
:key="tag"
|
|
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400"
|
|
>
|
|
#{{ tag }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Collapsible Personal Review container -->
|
|
<div v-show="showPersonalReview" class="mt-4 p-4 border border-border/70 rounded-lg bg-card/60 shadow-sm">
|
|
<div class="mb-3 flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{{ t('book.detail.details.personalReview.title') }}
|
|
</p>
|
|
<p v-if="personalNoteUpdatedLabel && !personalNoteEditing" class="mt-0.5 text-[11px] text-muted-foreground">
|
|
Updated {{ personalNoteUpdatedLabel }}
|
|
</p>
|
|
</div>
|
|
<Tooltip v-if="!personalNoteEditing">
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
:aria-label="t('book.detail.details.personalReview.editAria')"
|
|
@click="startPersonalNoteEdit"
|
|
>
|
|
<Pencil class="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ hasPersonalNote ? 'Edit personal review' : 'Add personal review' }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<template v-if="personalNoteEditing">
|
|
<textarea
|
|
v-model="personalNoteDraft"
|
|
class="min-h-24 max-h-72 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary"
|
|
rows="4"
|
|
:maxlength="PERSONAL_NOTE_MAX_LENGTH"
|
|
:placeholder="t('book.detail.details.personalReview.placeholder')"
|
|
/>
|
|
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<p class="text-[11px] text-muted-foreground">{{ personalNoteCharCount }}/{{ PERSONAL_NOTE_MAX_LENGTH }}</p>
|
|
<div class="flex items-center gap-1.5">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
:aria-label="t('book.detail.details.personalReview.clearAria')"
|
|
:disabled="!canClearPersonalNoteDraft || personalNoteSaving"
|
|
@click="clearPersonalNoteDraft"
|
|
>
|
|
<Trash2 class="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('book.detail.details.personalReview.clearAria') }}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded border border-input text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
:aria-label="t('book.detail.details.personalReview.cancelEditAria')"
|
|
:disabled="personalNoteSaving"
|
|
@click="cancelPersonalNoteEdit"
|
|
>
|
|
<X class="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ t('common.cancel') }}</TooltipContent>
|
|
</Tooltip>
|
|
<button
|
|
type="button"
|
|
class="inline-flex h-8 items-center gap-1.5 rounded bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="!canSavePersonalNote"
|
|
@click="savePersonalNote"
|
|
>
|
|
<Check class="size-3.5" />
|
|
{{ personalNoteSaving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="personalNoteError" class="mt-2 text-xs text-rose-500">{{ personalNoteError }}</p>
|
|
</template>
|
|
<template v-else-if="hasPersonalNote">
|
|
<p class="line-clamp-4 whitespace-pre-line break-words text-sm leading-relaxed text-foreground">{{ personalNotePreview }}</p>
|
|
</template>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-md border border-dashed border-input px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
|
@click="startPersonalNoteEdit"
|
|
>
|
|
<Pencil class="size-3.5" />
|
|
Add personal review
|
|
</button>
|
|
</div>
|
|
|
|
<section class="rounded-xl border border-border bg-card px-4 py-3.5">
|
|
<!-- Synopsis. Expanding scrolls inside this block, never the column. -->
|
|
<div>
|
|
<div class="flex items-baseline gap-3">
|
|
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{{ t('book.detail.details.synopsis') }}
|
|
</p>
|
|
<button
|
|
v-if="book.description"
|
|
class="ml-auto shrink-0 text-[11px] font-semibold text-primary transition-colors hover:underline"
|
|
@click="toggleDescription"
|
|
>
|
|
{{ descriptionExpanded ? t('book.detail.details.showLess') : t('book.detail.details.showMore') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="book.description"
|
|
class="mt-2 text-sm leading-relaxed text-foreground"
|
|
:class="descriptionExpanded ? 'max-h-44 overflow-y-auto pr-2' : 'line-clamp-4'"
|
|
v-html="safeDescription"
|
|
/>
|
|
<p v-else class="mt-2 text-sm italic text-muted-foreground">{{ t('book.detail.details.noDescription') }}</p>
|
|
</div>
|
|
<div class="mt-3 flex items-center gap-2.5 border-t border-border pt-3">
|
|
<p class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{{ t('book.detail.details.yourReview') }}
|
|
</p>
|
|
<p class="truncate text-[11px] text-muted-foreground">
|
|
{{ hasPersonalNote ? personalNotePreview : t('book.detail.details.reviewNotWritten') }}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="ml-auto inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-input px-2.5 text-xs font-medium transition-colors hover:bg-muted"
|
|
@click="startPersonalNoteEdit"
|
|
>
|
|
<Pencil class="size-3" />
|
|
{{ t('book.detail.details.writeReview') }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<BookReadingActivityCard
|
|
class="@min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:flex-1"
|
|
:stats="readingStats"
|
|
:sessions="readingSessions"
|
|
:loading="readingLogLoading"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Detail rail -->
|
|
<div
|
|
class="flex min-w-0 flex-col gap-3 @min-[46rem]/book-detail:col-start-3 @min-[46rem]/book-detail:row-start-1 @min-[46rem]/book-detail:min-h-0 @min-[46rem]/book-detail:overflow-y-auto"
|
|
>
|
|
<section
|
|
class="flex min-h-0 flex-col rounded-xl border border-border bg-card px-4 pt-3 pb-2 @min-[46rem]/book-detail:flex-1"
|
|
:aria-label="t('book.detail.details.detailsHeading')"
|
|
>
|
|
<h3 class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{{ t('book.detail.details.detailsHeading') }}
|
|
</h3>
|
|
|
|
<dl class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.publisher') }}</dt>
|
|
<template v-if="book.publisher">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<dd class="truncate text-[13px] font-medium cursor-default">{{ book.publisher }}</dd>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ book.publisher }}</TooltipContent>
|
|
</Tooltip>
|
|
</template>
|
|
<dd v-else class="truncate text-[13px] font-medium">-</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.published') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">
|
|
{{ book.publishedDate ? formatDisplayDate(book.publishedDate) : book.publishedYear || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.language') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium capitalize">{{ book.language || '-' }}</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.pages') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">{{ book.pageCount || '-' }}</dd>
|
|
</div>
|
|
<div
|
|
v-if="book.audioMetadata?.durationSeconds != null"
|
|
class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0"
|
|
>
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.duration') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">{{ formatDuration(book.audioMetadata.durationSeconds) }}</dd>
|
|
</div>
|
|
<div
|
|
v-if="book.audioMetadata?.durationSeconds != null"
|
|
class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0"
|
|
>
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.edition') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">
|
|
{{ book.audioMetadata.abridged ? t('book.detail.details.abridged') : t('book.detail.details.unabridged') }}
|
|
</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.isbn') }}</dt>
|
|
<dd v-if="book.isbn13 || book.isbn10" class="truncate text-right text-[13px] font-medium font-mono">
|
|
<div v-if="book.isbn13">{{ book.isbn13 }}</div>
|
|
<div v-if="book.isbn10" :class="book.isbn13 ? 'text-xs text-muted-foreground' : ''">{{ book.isbn10 }}</div>
|
|
</dd>
|
|
<dd v-else class="truncate text-[13px] font-medium">-</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.fileSize') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">{{ formatFileSize(primaryFile?.sizeBytes) }}</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.library') }}</dt>
|
|
<dd class="truncate text-[13px] font-medium">{{ book.libraryName || '-' }}</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.added') }}</dt>
|
|
<template v-if="editingAddedDate">
|
|
<dd class="mt-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<input
|
|
ref="addedDateInput"
|
|
v-model="draftAddedDate"
|
|
type="date"
|
|
required
|
|
:max="todayDateInput"
|
|
:aria-label="t('book.detail.details.added')"
|
|
:aria-invalid="addedDateError ? 'true' : undefined"
|
|
:aria-describedby="addedDateError ? 'added-date-error' : undefined"
|
|
class="w-full rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
|
|
@input="clearAddedDateError"
|
|
/>
|
|
<button
|
|
class="h-6 rounded bg-primary px-2 text-[10px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
:disabled="!canSaveAddedDate"
|
|
@click="saveAddedDate"
|
|
>
|
|
{{ savingAddedDate ? t('book.detail.details.saving') : t('common.save') }}
|
|
</button>
|
|
<button
|
|
class="inline-flex h-6 w-6 items-center justify-center rounded border border-destructive/30 text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
|
|
:title="t('book.detail.details.cancelDateAddedEdit')"
|
|
:aria-label="t('book.detail.details.cancelDateAddedEdit')"
|
|
:disabled="savingAddedDate"
|
|
@click="cancelAddedDateEdit"
|
|
>
|
|
<X class="size-3" />
|
|
</button>
|
|
</div>
|
|
<p v-if="addedDateError" id="added-date-error" role="alert" class="mt-1 text-[10px] text-destructive">{{ addedDateError }}</p>
|
|
</dd>
|
|
</template>
|
|
<dd v-else class="flex items-center gap-1.5">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<span class="truncate text-[13px] font-medium cursor-default">{{ formatDate(book.addedAt) }}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ formatAddedDateTime(book.addedAt) }}</TooltipContent>
|
|
</Tooltip>
|
|
<button
|
|
v-if="canEditMetadata"
|
|
ref="addedDateEditButton"
|
|
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
:title="t('book.detail.details.editDateAdded')"
|
|
:aria-label="t('book.detail.details.editDateAdded')"
|
|
:disabled="isEditingAnyDate || savingAddedDate || savingReadingDates"
|
|
@click="startEditingAddedDate"
|
|
>
|
|
<Pencil class="size-3" />
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<HardcoverBookSyncGridItem :book-id="book.id" />
|
|
<StorygraphBookSyncGridItem :book-id="book.id" />
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.dateStarted') }}</dt>
|
|
<template v-if="isEditingReadingDate('startedAt')">
|
|
<dd class="mt-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<input
|
|
v-model="draftReadingDates.startedAt"
|
|
type="date"
|
|
:max="todayDateInput"
|
|
class="w-full rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
|
|
/>
|
|
<button
|
|
class="h-6 rounded bg-primary px-2 text-[10px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
:disabled="!hasReadingDateFieldChanges('startedAt') || savingReadingDates"
|
|
@click="saveReadingDateField('startedAt')"
|
|
>
|
|
{{ savingReadingDates ? t('book.detail.details.saving') : t('common.save') }}
|
|
</button>
|
|
<button
|
|
class="inline-flex h-6 w-6 items-center justify-center rounded border border-destructive/30 text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
|
|
:title="t('book.detail.details.cancelDateStartedEdit')"
|
|
:aria-label="t('book.detail.details.cancelDateStartedEdit')"
|
|
:disabled="savingReadingDates"
|
|
@click="cancelReadingDateEdit('startedAt')"
|
|
>
|
|
<X class="size-3" />
|
|
</button>
|
|
</div>
|
|
<p v-if="readingDatesError" class="mt-1 text-[10px] text-rose-500">{{ readingDatesError }}</p>
|
|
</dd>
|
|
</template>
|
|
<dd v-else class="flex items-center gap-1.5">
|
|
<span class="text-[13px] font-medium">{{ formatDisplayDate(savedReadingDates.startedAt) }}</span>
|
|
<button
|
|
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
:title="t('book.detail.details.editDateStarted')"
|
|
:disabled="isEditingAnyDate || savingReadingDates"
|
|
@click="startEditingReadingDate('startedAt')"
|
|
>
|
|
<Pencil class="size-3" />
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<div class="flex items-baseline justify-between gap-3 border-b border-border py-[7px] last:border-b-0">
|
|
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t('book.detail.details.dateFinished') }}</dt>
|
|
<template v-if="isEditingReadingDate('finishedAt')">
|
|
<dd class="mt-1">
|
|
<div class="flex items-center gap-1.5">
|
|
<input
|
|
v-model="draftReadingDates.finishedAt"
|
|
type="date"
|
|
:max="todayDateInput"
|
|
class="w-full rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
|
|
/>
|
|
<button
|
|
class="h-6 rounded bg-primary px-2 text-[10px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
:disabled="!hasReadingDateFieldChanges('finishedAt') || savingReadingDates"
|
|
@click="saveReadingDateField('finishedAt')"
|
|
>
|
|
{{ savingReadingDates ? t('book.detail.details.saving') : t('common.save') }}
|
|
</button>
|
|
<button
|
|
class="inline-flex h-6 w-6 items-center justify-center rounded border border-destructive/30 text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
|
|
:title="t('book.detail.details.cancelDateFinishedEdit')"
|
|
:aria-label="t('book.detail.details.cancelDateFinishedEdit')"
|
|
:disabled="savingReadingDates"
|
|
@click="cancelReadingDateEdit('finishedAt')"
|
|
>
|
|
<X class="size-3" />
|
|
</button>
|
|
</div>
|
|
<p v-if="readingDatesError" class="mt-1 text-[10px] text-rose-500">{{ readingDatesError }}</p>
|
|
</dd>
|
|
</template>
|
|
<dd v-else class="flex items-center gap-1.5">
|
|
<span class="text-[13px] font-medium">{{ formatDisplayDate(savedReadingDates.finishedAt) }}</span>
|
|
<button
|
|
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
:title="t('book.detail.details.editDateFinished')"
|
|
:disabled="isEditingAnyDate || savingReadingDates"
|
|
@click="startEditingReadingDate('finishedAt')"
|
|
>
|
|
<Pencil class="size-3" />
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
|
|
<BookEditionsCard :book="book" :progress="editionProgress" :resetting-file-ids="resettingFileIds" @reset-progress="handleEditionReset" />
|
|
|
|
<div v-if="filledCustomMetadata.length > 0" class="mt-6 pt-5 border-t border-border">
|
|
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground mb-3">{{ t('book.detail.details.customMetadata') }}</p>
|
|
<dl class="grid gap-3 sm:grid-cols-2">
|
|
<div v-for="field in filledCustomMetadata" :key="field.fieldId" class="min-w-0">
|
|
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">{{ field.label }}</dt>
|
|
<dd class="mt-1 text-sm text-foreground break-words">
|
|
<a
|
|
v-if="field.type === 'url' && typeof field.value === 'string'"
|
|
:href="field.value"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-primary hover:underline"
|
|
>
|
|
{{ field.value }}
|
|
</a>
|
|
<span v-else>{{ formatCustomMetadataValue(field) }}</span>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Discovery shelf -->
|
|
<div class="min-w-0 @min-[46rem]/book-detail:col-span-3 @min-[46rem]/book-detail:row-start-2 @min-[46rem]/book-detail:min-h-0">
|
|
<DiscoverRow class="h-full" :book-id="book.id" :series-name="book.seriesName" :author-count="book.authors.length" size="lg" flush />
|
|
</div>
|
|
</div>
|
|
|
|
<AddToCollectionSheet
|
|
:open="addToCollectionOpen"
|
|
:selection-payload="{ bookIds: [book.id] }"
|
|
:selected-count="1"
|
|
@update:open="addToCollectionOpen = $event"
|
|
@done="void loadSupplemental()"
|
|
/>
|
|
|
|
<SendBookDialog
|
|
v-model:open="showSendDialog"
|
|
:selection-payload="{ bookIds: [book.id] }"
|
|
:selected-count="1"
|
|
:book-title="book.title ?? undefined"
|
|
:book-files="book.files"
|
|
/>
|
|
|
|
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
|
|
|
<MoveToLibrarySheet
|
|
:open="moveToLibraryOpen"
|
|
:selection-payload="{ bookIds: [book.id] }"
|
|
:selected-count="1"
|
|
:current-library-id="book.libraryId"
|
|
@update:open="handleMoveOpenChange"
|
|
@moved="handleBookMoved"
|
|
/>
|
|
|
|
<ResetReadingStateDialog
|
|
:open="resetReadingStateDialogOpen"
|
|
:resetting="resettingReadingState"
|
|
:error="resetReadingStateError"
|
|
@close="closeResetReadingStateDialog"
|
|
@confirm="handleResetReadingState"
|
|
/>
|
|
|
|
<!-- Cover lightbox -->
|
|
<DialogRoot :open="coverLightboxOpen" @update:open="coverLightboxOpen = $event">
|
|
<DialogPortal>
|
|
<DialogOverlay
|
|
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
/>
|
|
<DialogContent
|
|
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-[90vw] max-h-[90vh] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
|
>
|
|
<img :src="coverSrc" :alt="book.title ?? ''" class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain" />
|
|
<DialogClose
|
|
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X class="size-4" />
|
|
</DialogClose>
|
|
</DialogContent>
|
|
</DialogPortal>
|
|
</DialogRoot>
|
|
</template>
|