feat(book-detail): rebuild the details tab as a single-screen layout

Three columns fill the pane height and a discovery shelf takes the row
beneath them, so the whole record reads without scrolling the page.

- synopsis clamps and expands inside its own block, never the column
- per-format progress folds into Editions instead of a duplicate list
- multi-track audiobooks collapse to one edition row, not one per file
- the cover frame keeps its library aspect ratio at any column height

Ports formatColorVar, the pill-format tokens and formatRelativeFromNow,
which the new cards depend on.
This commit is contained in:
neon
2026-08-26 05:53:29 +00:00
parent 0c02795a1c
commit 2861aff338
11 changed files with 1223 additions and 946 deletions
+49
View File
@@ -146,6 +146,30 @@
--audit-collections: oklch(0.55 0.09 255);
--audit-integrations: oklch(0.58 0.15 150);
--audit-settings: oklch(0.55 0.15 25);
/* File format, worn as a chip whose own text carries the colour. Tuned per theme for the same
reason every other text colour is: one set of hex values for both themes left the brightest
(yellow on white, around 1.9:1) unreadable in light mode and the darkest invisible in dark. */
--pill-format-default: oklch(0.55 0.02 260);
--pill-format-epub: oklch(0.55 0.14 150);
--pill-format-kepub: oklch(0.55 0.12 178);
--pill-format-mobi: oklch(0.55 0.18 275);
--pill-format-azw3: oklch(0.53 0.11 185);
--pill-format-azw: oklch(0.55 0.14 235);
--pill-format-fb2: oklch(0.55 0.17 355);
--pill-format-djvu: oklch(0.55 0.04 255);
--pill-format-txt: oklch(0.55 0.03 60);
--pill-format-pdf: oklch(0.55 0.17 25);
--pill-format-cbz: oklch(0.55 0.16 260);
--pill-format-cbr: oklch(0.58 0.15 55);
--pill-format-cb7: oklch(0.55 0.19 300);
--pill-format-cbx: oklch(0.55 0.17 325);
--pill-format-m4b: oklch(0.58 0.13 75);
--pill-format-m4a: oklch(0.58 0.13 95);
--pill-format-mp3: oklch(0.56 0.15 145);
--pill-format-opus: oklch(0.55 0.11 205);
--pill-format-ogg: oklch(0.56 0.15 130);
--pill-format-flac: oklch(0.55 0.13 165);
}
@media (min-width: 40rem) {
@@ -267,4 +291,29 @@
--audit-collections: oklch(0.72 0.09 255);
--audit-integrations: oklch(0.75 0.13 150);
--audit-settings: oklch(0.72 0.15 25);
/* File format, worn as a chip whose own text carries the colour. Tuned per theme for the same
reason every other text colour is: one set of hex values for both themes left the brightest
(yellow on white, around 1.9:1) unreadable in light mode and the darkest invisible in dark. */
--pill-format-default: oklch(0.75 0.02 260);
--pill-format-epub: oklch(0.75 0.13 150);
--pill-format-kepub: oklch(0.75 0.11 178);
--pill-format-mobi: oklch(0.72 0.16 275);
--pill-format-azw3: oklch(0.76 0.11 185);
--pill-format-azw: oklch(0.72 0.13 235);
--pill-format-fb2: oklch(0.73 0.15 355);
--pill-format-djvu: oklch(0.75 0.04 255);
--pill-format-txt: oklch(0.76 0.03 60);
--pill-format-pdf: oklch(0.72 0.15 25);
--pill-format-cbz: oklch(0.72 0.14 260);
--pill-format-cbr: oklch(0.76 0.13 55);
--pill-format-cb7: oklch(0.72 0.17 300);
--pill-format-cbx: oklch(0.73 0.15 325);
--pill-format-m4b: oklch(0.79 0.13 75);
--pill-format-m4a: oklch(0.8 0.13 95);
--pill-format-mp3: oklch(0.78 0.15 145);
--pill-format-opus: oklch(0.76 0.11 205);
--pill-format-ogg: oklch(0.78 0.15 130);
--pill-format-flac: oklch(0.75 0.13 165);
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
@@ -27,14 +27,24 @@ const props = withDefaults(
currentBookId?: number | null
showSeriesIndex?: boolean
showHeader?: boolean
/** 'lg' is the book detail shelf, where the cover is the whole point. */
size?: 'md' | 'lg'
}>(),
{
currentBookId: null,
showSeriesIndex: false,
showHeader: true,
size: 'md',
},
)
/** Both ratios resolve to the same cover height, so a mixed shelf stays level. */
const cardWidthClass = computed(() => {
const square = props.size === 'lg' ? 'w-46' : 'w-38'
const portrait = props.size === 'lg' ? 'w-31' : 'w-30'
return { square, portrait }
})
const router = useRouter()
const { coverUrl } = useCoverVersions()
const scrollEl = ref<HTMLElement | null>(null)
@@ -108,8 +118,8 @@ defineExpose({ scroll })
</div>
</div>
<div v-if="loading" class="flex gap-3 overflow-x-auto pb-2">
<div v-for="i in 10" :key="i" class="w-24 shrink-0">
<div v-if="loading" class="flex gap-6 overflow-x-auto pb-2">
<div v-for="i in 10" :key="i" class="shrink-0" :class="cardWidthClass.portrait">
<div class="w-full rounded-sm bg-muted animate-shimmer" style="aspect-ratio: 2/3" />
</div>
</div>
@@ -120,7 +130,7 @@ defineExpose({ scroll })
:key="book.id"
:data-book-id="book.id"
class="shrink-0 text-left group animate-fade-up"
:class="book.coverAspectRatio === '1/1' ? 'w-38' : 'w-30'"
:class="book.coverAspectRatio === '1/1' ? cardWidthClass.square : cardWidthClass.portrait"
:style="{ animationDelay: `${index * 40}ms` }"
@click="navigateToBook(book.id)"
>
@@ -9,11 +9,18 @@ import BookCarousel from '@/features/book/components/detail/BookCarousel.vue'
type Section = 'series' | 'author' | 'similar'
const props = defineProps<{
bookId: number
seriesName: string | null
authorCount: number
}>()
const props = withDefaults(
defineProps<{
bookId: number
seriesName: string | null
authorCount: number
/** Cover size for the shelf; 'lg' is the book detail layout. */
size?: 'md' | 'lg'
/** Drops the top rule and margin when the row sits in its own grid cell. */
flush?: boolean
}>(),
{ size: 'md', flush: false },
)
const { t } = useI18n()
@@ -135,7 +142,7 @@ const hasAnyContent = computed(() => availablePills.value.length > 0 || Object.v
</script>
<template>
<div v-if="hasAnyContent" class="mt-8 pt-6 border-t border-border">
<div v-if="hasAnyContent" :class="flush ? 'flex h-full min-h-0 flex-col' : 'mt-8 pt-6 border-t border-border'">
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-1 rounded-lg bg-muted/55 p-1">
<button
@@ -178,6 +185,7 @@ const hasAnyContent = computed(() => availablePills.value.length > 0 || Object.v
</div>
</div>
<BookCarousel
:size="size"
v-else-if="activeSection === 'series'"
ref="seriesCarouselRef"
:books="booksForSection('series')"
@@ -187,6 +195,7 @@ const hasAnyContent = computed(() => availablePills.value.length > 0 || Object.v
:show-header="false"
/>
<BookCarousel
:size="size"
v-else-if="activeSection === 'author'"
ref="authorCarouselRef"
:books="booksForSection('author')"
@@ -194,6 +203,7 @@ const hasAnyContent = computed(() => availablePills.value.length > 0 || Object.v
:show-header="false"
/>
<BookCarousel
:size="size"
v-else-if="activeSection === 'similar'"
ref="similarCarouselRef"
:books="booksForSection('similar')"
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RotateCcw } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { formatBytes } from '@/lib/formatting'
import { formatNumber, formatPercent } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import type { BookDetail } from '@bookorbit/types'
export interface EditionProgress {
/** Lower-cased format the bar belongs to. */
format: string
percentage: number
finished: boolean
/** Present when this edition's progress can be reset. */
resetFileId?: number | null
}
const props = withDefaults(
defineProps<{
book: BookDetail
progress?: EditionProgress[]
/** Rows shown before the rest collapse behind a count. */
maxRows?: number
resettingFileIds?: number[]
}>(),
{ progress: () => [], maxRows: 4, resettingFileIds: () => [] },
)
const emit = defineEmits<{ resetProgress: [format: string] }>()
function handleReset(row: EditionRow) {
emit('resetProgress', row.format)
}
function isResetting(row: EditionRow): boolean {
const entry = progressFor(row)
return entry?.resetFileId != null && props.resettingFileIds.includes(entry.resetFileId)
}
const { t } = useI18n()
interface EditionRow {
key: string
format: string
sizeBytes: number
durationSeconds: number | null
trackCount: number
isPrimary: boolean
isAudio: boolean
}
/**
* A multi-track audiobook is one edition, not thirty-eight. Collapsing it here matches how the
* reader treats those files and keeps a 38-file book from printing 38 rows.
*/
const rows = computed<EditionRow[]>(() => {
const files = props.book.files.filter((file) => file.format != null)
const audio = files.filter((file) => FORMAT_TO_GROUP[file.format!] === 'audio')
const rest = files.filter((file) => FORMAT_TO_GROUP[file.format!] !== 'audio')
const result: EditionRow[] = []
const firstAudio = audio[0]
if (firstAudio?.format != null) {
result.push({
key: 'audio',
format: firstAudio.format,
sizeBytes: audio.reduce((total, file) => total + (file.sizeBytes ?? 0), 0),
durationSeconds: props.book.audioMetadata?.durationSeconds ?? null,
trackCount: audio.length,
isPrimary: audio.some((file) => file.role === 'primary'),
isAudio: true,
})
}
const byFormat = new Map<string, EditionRow>()
for (const file of rest) {
const format = file.format!
const existing = byFormat.get(format)
if (existing) {
existing.sizeBytes += file.sizeBytes ?? 0
existing.trackCount += 1
existing.isPrimary = existing.isPrimary || file.role === 'primary'
continue
}
byFormat.set(format, {
key: format,
format,
sizeBytes: file.sizeBytes ?? 0,
durationSeconds: null,
trackCount: 1,
isPrimary: file.role === 'primary',
isAudio: false,
})
}
result.push(...byFormat.values())
return result.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || a.format.localeCompare(b.format))
})
const visibleRows = computed(() => rows.value.slice(0, props.maxRows))
const hiddenCount = computed(() => Math.max(0, rows.value.length - visibleRows.value.length))
const totalBytes = computed(() => rows.value.reduce((total, row) => total + row.sizeBytes, 0))
const fileCount = computed(() => props.book.files.filter((file) => file.format != null).length)
const progressByFormat = computed(() => {
const map = new Map<string, EditionProgress>()
for (const entry of props.progress) map.set(entry.format.toLowerCase(), entry)
return map
})
function progressFor(row: EditionRow): EditionProgress | null {
return progressByFormat.value.get(row.format.toLowerCase()) ?? null
}
function formatBadgeStyle(format: string) {
const color = formatColorVar(format)
return {
color,
borderColor: `color-mix(in oklch, ${color} 45%, transparent)`,
backgroundColor: `color-mix(in oklch, ${color} 12%, transparent)`,
}
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.round((seconds % 3600) / 60)
return hours > 0 ? t('book.detail.details.durationHm', { hours, minutes }) : t('book.detail.details.durationM', { minutes })
}
/** Mirrors the detail tab's rule: never round a live position down to 0% or up to 100%. */
function progressLabel(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage))
if (clamped > 0 && clamped < 1) return t('book.detail.details.percentUnderOne')
if (clamped > 99 && clamped < 100) return t('book.detail.details.percentOverNinetyNine')
return formatPercent(Math.round(clamped) / 100)
}
function rowMeasure(row: EditionRow): string {
if (row.isAudio && row.durationSeconds != null) return formatDuration(row.durationSeconds)
return formatBytes(row.sizeBytes)
}
</script>
<template>
<section class="rounded-xl border border-border bg-card px-3.5 py-3" :aria-label="t('book.detail.details.editions')">
<div class="flex items-baseline gap-2">
<h3 class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('book.detail.details.editions') }}
</h3>
<p class="ml-auto text-[11px] tabular-nums text-muted-foreground">
{{ t('book.detail.details.editionsSummary', { count: rows.length, size: formatBytes(totalBytes) }) }}
</p>
</div>
<ul class="mt-2.5 flex flex-col gap-2">
<li v-for="row in visibleRows" :key="row.key" class="flex items-center gap-2.5">
<span
class="inline-flex h-5 w-13 shrink-0 items-center justify-center gap-1 rounded-md border text-[10px] font-bold uppercase leading-none tracking-wider"
:style="formatBadgeStyle(row.format)"
>
<span v-if="row.isPrimary" class="size-1.5 shrink-0 rounded-full bg-current" />
{{ row.format }}
</span>
<template v-if="progressFor(row)">
<span class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<span
class="block h-full rounded-full"
:class="progressFor(row)!.finished ? 'bg-emerald-500' : 'bg-primary'"
:style="{ width: `${Math.min(100, progressFor(row)!.percentage)}%` }"
/>
</span>
<span class="w-8 shrink-0 text-right text-[11px] tabular-nums text-muted-foreground">
{{ progressLabel(progressFor(row)!.percentage) }}
</span>
</template>
<span v-else class="min-w-0 flex-1" />
<span class="w-16 shrink-0 truncate text-right text-[11px] tabular-nums text-muted-foreground">
{{ rowMeasure(row) }}
</span>
<button
v-if="progressFor(row)?.resetFileId != null"
type="button"
class="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
:aria-label="t('book.detail.details.resetFileProgress')"
:disabled="isResetting(row)"
@click="handleReset(row)"
>
<RotateCcw class="size-3" />
</button>
</li>
</ul>
<p v-if="hiddenCount > 0" class="mt-2 text-[11px] text-muted-foreground">
{{ t('book.detail.details.moreFormats', { count: hiddenCount }) }}
</p>
<div class="mt-2.5 flex items-baseline gap-2 border-t border-border pt-2">
<p class="text-[11px] text-muted-foreground">{{ t('book.detail.details.filesOnDisk') }}</p>
<p class="ml-auto text-[11px] font-semibold tabular-nums">{{ formatNumber(fileCount) }}</p>
</div>
</section>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber, formatPercent, formatRelativeFromNow } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import type { BookReadingSession, BookReadingSessionStats } from '@bookorbit/types'
const props = withDefaults(
defineProps<{
stats: BookReadingSessionStats | null
sessions?: BookReadingSession[]
loading?: boolean
/** Recent sessions listed under the summary. 0 hides the list entirely. */
sessionRows?: number
}>(),
{ sessions: () => [], loading: false, sessionRows: 4 },
)
const { t } = useI18n()
const hasActivity = computed(() => (props.stats?.totalSessions ?? 0) > 0)
/**
* The bar strip covers the span the book was actually read over, not a fixed window: a book read
* across two days should not render as two marks lost in a month of empty columns.
*/
const days = computed(() => {
const summary = props.stats?.dailySummary ?? []
const firstEntry = summary[0]
const lastEntry = summary[summary.length - 1]
if (!firstEntry || !lastEntry) return []
const minutesByDay = new Map(summary.map((entry) => [entry.day, entry.totalMinutes]))
const first = Date.parse(`${firstEntry.day}T00:00:00Z`)
const last = Date.parse(`${lastEntry.day}T00:00:00Z`)
const span = Math.round((last - first) / 86_400_000) + 1
const count = Math.min(28, Math.max(7, span + 2))
const result: { day: string; minutes: number }[] = []
for (let offset = count - 1; offset >= 0; offset -= 1) {
const day = new Date(last - offset * 86_400_000).toISOString().slice(0, 10)
result.push({ day, minutes: minutesByDay.get(day) ?? 0 })
}
return result
})
const peakMinutes = computed(() => Math.max(1, ...days.value.map((day) => day.minutes)))
function barHeight(minutes: number): string {
if (minutes <= 0) return '0%'
return `${Math.max(16, Math.round((minutes / peakMinutes.value) * 100))}%`
}
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return t('book.detail.details.durationM', { minutes: 0 })
if (seconds < 60) return t('book.detail.details.durationS', { seconds: Math.round(seconds) })
const hours = Math.floor(seconds / 3600)
const minutes = Math.round((seconds % 3600) / 60)
return hours > 0 ? t('book.detail.details.durationHm', { hours, minutes }) : t('book.detail.details.durationM', { minutes })
}
const summaryStats = computed(() => {
const stats = props.stats
if (!stats) return []
return [
{ key: 'sessions', label: t('book.detail.details.activitySessions'), value: formatNumber(stats.totalSessions) },
{ key: 'time', label: t('book.detail.details.activityTime'), value: formatDuration(stats.totalSeconds) },
{ key: 'avg', label: t('book.detail.details.activityAverage'), value: formatDuration(stats.avgDurationSeconds) },
{
key: 'progress',
label: t('book.detail.details.activityProgress'),
value: formatPercent((stats.latestEndProgress ?? 0) / 100),
},
]
})
const recentSessions = computed(() => props.sessions.slice(0, props.sessionRows))
function relative(iso: string | null | undefined): string {
if (!iso) return '-'
const parsed = Date.parse(iso)
return Number.isNaN(parsed) ? '-' : formatRelativeFromNow(parsed)
}
function dayLabel(day: string, minutes: number): string {
return t('book.detail.details.activityDayTooltip', { day, minutes: Math.round(minutes) })
}
function formatBadgeStyle(format: string) {
const color = formatColorVar(format)
return {
color,
borderColor: `color-mix(in oklch, ${color} 45%, transparent)`,
backgroundColor: `color-mix(in oklch, ${color} 12%, transparent)`,
}
}
</script>
<template>
<section class="flex min-h-0 flex-col rounded-xl border border-border bg-card px-3.5 py-3" :aria-label="t('book.detail.details.yourReading')">
<div class="flex items-baseline gap-2">
<h3 class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('book.detail.details.yourReading') }}
</h3>
<p class="ml-auto text-[11px] text-muted-foreground">
<template v-if="hasActivity">{{ t('book.detail.details.lastRead', { when: relative(stats!.lastSessionAt) }) }}</template>
<template v-else>{{ t('book.detail.details.notStarted') }}</template>
</p>
</div>
<p v-if="loading" class="mt-3 text-[13px] text-muted-foreground">{{ t('common.loading') }}</p>
<p v-else-if="!hasActivity" class="mt-3 text-[13px] leading-relaxed text-muted-foreground">
{{ t('book.detail.details.noSessionsYet') }}
</p>
<template v-else>
<div class="mt-3 flex items-end gap-6">
<dl class="flex shrink-0 gap-6">
<div v-for="stat in summaryStats" :key="stat.key">
<dt class="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground">{{ stat.label }}</dt>
<dd class="mt-0.5 text-sm font-semibold tabular-nums">{{ stat.value }}</dd>
</div>
</dl>
<div class="ml-auto flex h-7 shrink-0 items-stretch gap-[3px]" aria-hidden="true">
<span
v-for="day in days"
:key="day.day"
:title="dayLabel(day.day, day.minutes)"
class="flex w-2.5 items-end overflow-hidden rounded-[2px] bg-muted"
>
<span class="w-full rounded-[2px] bg-primary" :style="{ height: barHeight(day.minutes) }" />
</span>
</div>
</div>
<ul v-if="recentSessions.length > 0" class="mt-2.5 min-h-0 flex-1 overflow-y-auto">
<li v-for="session in recentSessions" :key="session.id" class="flex items-center gap-2.5 border-t border-border py-1.5 first:border-t-0">
<span class="w-18 shrink-0 text-[11px] text-muted-foreground">{{ relative(session.startedAt) }}</span>
<span class="w-12 shrink-0 text-[11px] font-semibold tabular-nums">{{ formatDuration(session.durationSeconds) }}</span>
<span class="min-w-0 flex-1 truncate text-[11px] tabular-nums text-muted-foreground">
{{ t('book.detail.details.activityReached', { percent: formatPercent((session.endProgress ?? 0) / 100) }) }}
</span>
<span
v-if="session.format"
class="inline-flex h-4.5 shrink-0 items-center rounded border px-1.5 text-[9px] font-bold uppercase leading-none tracking-wider"
:style="formatBadgeStyle(session.format)"
>
{{ session.source ?? session.format }}
</span>
</li>
</ul>
</template>
</section>
</template>
File diff suppressed because it is too large Load Diff
@@ -132,7 +132,7 @@ function mountDetails(book: BookDetail) {
async function loadCoverImages(wrapper: ReturnType<typeof mountDetails>, naturalWidth = 1000, naturalHeight = 1000) {
const imgs = wrapper.findAll(`img[alt="${wrapper.props('book').title}"]`)
expect(imgs.length).toBe(2)
expect(imgs.length).toBe(1)
for (const img of imgs) {
Object.defineProperty(img.element, 'naturalWidth', { configurable: true, value: naturalWidth })
@@ -199,14 +199,14 @@ describe('DetailsTab cover surface', () => {
await flushPromises()
const surfaces = wrapper.findAll('.book-cover-surface')
expect(surfaces.length).toBe(2)
expect(surfaces.length).toBe(1)
expect(surfaces.every((surface) => surface.attributes('data-cover-spine') === 'strong')).toBe(true)
expect(wrapper.findAll('.book-cover-spine-layer').length).toBe(0)
await loadCoverImages(wrapper)
const spineLayers = wrapper.findAll('.book-cover-spine-layer')
expect(spineLayers.length).toBe(2)
expect(spineLayers.length).toBe(1)
expect(spineLayers[0]!.attributes('style')).toContain('translateY(-50%)')
})
@@ -218,7 +218,7 @@ describe('DetailsTab cover surface', () => {
await loadCoverImages(wrapper, 1200, 600)
const surfaces = wrapper.findAll('.book-cover-surface')
expect(surfaces.length).toBe(2)
expect(surfaces.length).toBe(1)
expect(surfaces.every((surface) => surface.attributes('style')?.includes('aspect-ratio: 2 / 1'))).toBe(true)
})
@@ -244,7 +244,7 @@ describe('DetailsTab cover surface', () => {
await flushPromises()
const surfaces = wrapper.findAll('.book-cover-surface')
expect(surfaces.length).toBe(2)
expect(surfaces.length).toBe(1)
expect(surfaces.every((surface) => surface.attributes('data-cover-spine') === 'off')).toBe(true)
await loadCoverImages(wrapper)
@@ -443,7 +443,7 @@ describe('DetailsTab cover surface', () => {
await flushPromises()
const sendButtons = wrapper.findAll('button[aria-label="Send via Email"]')
expect(sendButtons).toHaveLength(2)
expect(sendButtons).toHaveLength(1)
// Find the stubbed SendBookDialog
const sendDialog = wrapper.findComponent({ name: 'SendBookDialog' })
@@ -463,7 +463,7 @@ describe('DetailsTab cover surface', () => {
expect(sendButtons.length).toBe(0)
})
it('offers reset in both overflow menus and refreshes supplemental reading state after confirmation', async () => {
it('offers reset in the overflow menu and refreshes supplemental reading state after confirmation', async () => {
mocks.hasPermission.mockImplementation(
(permission) => permission === 'library_edit_metadata' || permission === 'kobo_sync' || permission === 'koreader_sync',
)
@@ -471,7 +471,7 @@ describe('DetailsTab cover surface', () => {
await flushPromises()
const resetButtons = wrapper.findAll('button').filter((button) => button.text().includes('Reset reading state'))
expect(resetButtons).toHaveLength(2)
expect(resetButtons).toHaveLength(1)
const resetDialog = wrapper.findComponent({ name: 'ResetReadingStateDialog' })
expect(resetDialog.exists()).toBe(true)
@@ -1,16 +1,20 @@
const DEFAULT_FORMAT_COLOR = '#6b7280'
/** Every format BookOrbit imports, plus `txt`. Each one also has a `--pill-format-*` token. */
const FORMAT_COLORS: Record<string, string> = {
epub: '#16a34a',
kepub: '#0d9488',
mobi: '#6366f1',
azw3: '#14b8a6',
azw: '#0ea5e9',
fb2: '#ec4899',
djvu: '#64748b',
txt: '#78716c',
pdf: '#dc2626',
cbz: '#3b82f6',
cbr: '#f97316',
cb7: '#8b5cf6',
cbx: '#c026d3',
m4b: '#f59e0b',
m4a: '#eab308',
mp3: '#22c55e',
@@ -19,7 +23,24 @@ const FORMAT_COLORS: Record<string, string> = {
flac: '#10b981',
}
/**
* A fixed hex per format, for the places a CSS variable cannot reach: a chart draws to a canvas,
* where `var(--x)` is not a colour. Everything rendered as DOM should use `formatColorVar`.
*/
export function getFormatColor(format: string | null | undefined): string {
if (!format) return DEFAULT_FORMAT_COLOR
return FORMAT_COLORS[format.toLowerCase()] ?? DEFAULT_FORMAT_COLOR
}
/**
* The same palette as a per-theme token.
*
* One hex for both themes cannot be a text colour: `#eab308` is about 1.9:1 on white, and the
* darker end of the palette is as bad against a dark surface. The tokens in `theme/tokens.css`
* are tuned per theme the way every other text colour is, so this is what a format chip, badge or
* label paints with. Alpha variants belong in `color-mix`, never composited onto the text itself.
*/
export function formatColorVar(format: string | null | undefined): string {
const key = format?.toLowerCase()
return key && key in FORMAT_COLORS ? `var(--pill-format-${key})` : 'var(--pill-format-default)'
}
+25
View File
@@ -111,3 +111,28 @@ export function formatLanguageName(value: string): string {
return value
}
}
/** Largest unit whose threshold the elapsed time clears, so "2 minutes ago" beats "120 seconds ago". */
const RELATIVE_UNITS: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'year', ms: 31_536_000_000 },
{ unit: 'month', ms: 2_592_000_000 },
{ unit: 'week', ms: 604_800_000 },
{ unit: 'day', ms: 86_400_000 },
{ unit: 'hour', ms: 3_600_000 },
{ unit: 'minute', ms: 60_000 },
]
/**
* "4 minutes ago" rather than a timestamp, for values whose recency is the point: when an indexer
* was last reached, when a request was asked for. Anything under a minute reads as "now", because
* a connection tested 12 seconds ago and one tested 41 seconds ago are the same fact.
*/
export function formatRelativeFromNow(value: Date | number, now: Date | number = Date.now()): string {
const elapsed = new Date(value).getTime() - new Date(now).getTime()
const magnitude = Math.abs(elapsed)
for (const { unit, ms } of RELATIVE_UNITS) {
if (magnitude >= ms) return formatRelativeTime(Math.round(elapsed / ms), unit)
}
return formatRelativeTime(0, 'second')
}
+27 -1
View File
@@ -4376,7 +4376,33 @@
"resetHoldNotice": "{device} still has the old position ({percent})",
"resetHoldRelease": "Use this device's position",
"resetHoldReleasing": "Releasing...",
"resetHoldReleaseFailed": "Could not use this position. Try again."
"resetHoldReleaseFailed": "Could not use this position. Try again.",
"editions": "Editions",
"editionsSummary": "{count} · {size}",
"moreFormats": "+{count} more",
"filesOnDisk": "Files on disk",
"yourReading": "Your reading",
"lastRead": "last read {when}",
"notStarted": "not started",
"noSessionsYet": "No reading sessions yet. Opening this book in the reader tracks time, pace and per-device progress.",
"activitySessions": "Sessions",
"activityTime": "Time",
"activityAverage": "Avg",
"activityProgress": "Progress",
"activityDayTooltip": "{day}: {minutes} min",
"activityReached": "{percent} reached",
"durationS": "{seconds}s",
"durationM": "{minutes}m",
"durationHm": "{hours}h {minutes}m",
"yourReview": "Your review",
"reviewNotWritten": "Not written yet",
"writeReview": "Write",
"collections": "Collections",
"noCollections": "None",
"onDisk": "On disk",
"detailsHeading": "Details",
"percentUnderOne": "<1%",
"percentOverNinetyNine": ">99%"
},
"editMetadata": {
"fieldCount": "{count, plural, =0 {no fields} one {one field} other {# fields}}",
+1 -1
View File
@@ -119,7 +119,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<template>
<BookDetailLayout :book-id="bookId" :cover-tint="coverTint">
<Transition name="content" mode="out-in">
<div v-if="detail" key="detail">
<div v-if="detail" key="detail" class="h-full">
<DetailsTab v-if="tab === 'details'" :book="detail" @saved="onMetadataSaved" @moved="handleMovedToLibrary" />
<EditMetadataTab
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"