mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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)'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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')"
|
||||
|
||||
Reference in New Issue
Block a user