feat(books): overhaul files tab with tree rail and file inspector

Redesign the book details Files tab into a two-pane layout with an interactive file tree rail
and detailed file inspector.

Support inspecting audio tracks, embedded metadata, and write-back properties, and add filename
stem normalization helpers.
This commit is contained in:
neon
2026-08-26 14:07:52 +00:00
parent 095113a594
commit d587d8f2a8
10 changed files with 1500 additions and 418 deletions
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatColorVar } from '@/features/book/lib/format-colors'
const props = withDefaults(defineProps<{ format: string | null; size?: 'sm' | 'md' | 'lg' }>(), { size: 'md' })
const sizeClass = computed(
() =>
({
sm: 'h-7 w-[1.4rem] pb-[3px] text-[7px]',
md: 'h-[2.125rem] w-7 pb-1 text-[8px]',
lg: 'h-[4.125rem] w-[3.375rem] pb-[9px] text-[13px]',
})[props.size],
)
/** The dog-eared corner is drawn, not an image, so it inherits the format colour at any size. */
const cornerClass = computed(() => (props.size === 'lg' ? 'size-[15px]' : props.size === 'sm' ? 'size-1.5' : 'size-2'))
const glyphStyle = computed(() => {
const color = formatColorVar(props.format)
const notch = props.size === 'lg' ? '15px' : '8px'
return {
color,
backgroundColor: `color-mix(in oklch, ${color} 22%, transparent)`,
clipPath: `polygon(0 0, calc(100% - ${notch}) 0, 100% ${notch}, 100% 100%, 0 100%)`,
borderRadius: props.size === 'lg' ? '5px 0 5px 5px' : '3px 0 3px 3px',
}
})
</script>
<template>
<span
class="relative flex shrink-0 items-end justify-center font-bold uppercase leading-none tracking-wide"
:class="sizeClass"
:style="glyphStyle"
aria-hidden="true"
>
<span class="absolute right-0 top-0 bg-current opacity-25" :class="cornerClass" />
<span class="relative">{{ format || '?' }}</span>
</span>
</template>
@@ -0,0 +1,494 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, BookOpen, Clock, Copy, Download, Eye, Grid2x2, HardDrive, Headphones, History, MoreVertical, RefreshCw } from '@lucide/vue'
import type { BookDetail, WriteLogEntry } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { formatDate, formatPercent, formatRelativeFromNow } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { resolveWriteBackFields } from '@/features/book/lib/write-back-fields'
import type { TreeFile } from '@/features/book/composables/useBookFileTree'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import FileFormatGlyph from './FileFormatGlyph.vue'
import FileRoleBadge from './FileRoleBadge.vue'
const props = defineProps<{
book: BookDetail
file: TreeFile
siblings: TreeFile[]
/** Every file in the folder, for the timeline: siblings hide the audio tracks. */
allFiles: TreeFile[]
audioFiles: TreeFile[]
isMultiTrackAudio: boolean
runtimeSeconds: number | null
startedCount: number
fileCount: number
formatCount: number
folderRelative: string[]
writeLog: WriteLogEntry[]
canDownload: boolean
canEdit: boolean
canDelete: boolean
/** Set on mobile, where the inspector replaces the tree rather than sitting beside it. */
showBack?: boolean
/**
* The pane is tall enough to hold every section at once, so the field list and the history
* each scroll inside themselves. Otherwise the inspector scrolls as one document: a wide but
* short window is wide enough for the split and far too short for the fixed layout.
*/
fitsWithoutScroll?: boolean
}>()
const emit = defineEmits<{
select: [id: number]
open: [file: TreeFile, mode?: 'peek']
download: [file: TreeFile]
rename: [file: TreeFile]
remove: [file: TreeFile]
copyPath: [file: TreeFile]
back: []
}>()
const { t } = useI18n()
function handleBack() {
emit('back')
}
function handleOpen() {
emit('open', props.file)
}
function handlePeek() {
emit('open', props.file, 'peek')
}
function handleDownload() {
emit('download', props.file)
}
function handleRename() {
emit('rename', props.file)
}
function handleRemove() {
emit('remove', props.file)
}
function handleCopyPath() {
emit('copyPath', props.file)
}
function handleSelect(id: number) {
emit('select', id)
}
function formatDuration(seconds: number | null | undefined): string | null {
if (seconds == null) return null
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes })
const remainder = Math.floor(seconds % 60)
if (minutes > 0) return t('book.detail.files.duration.minutesSeconds', { minutes, seconds: remainder })
return t('book.detail.files.duration.seconds', { seconds: remainder })
}
const writableFormats = computed(() => props.book.fileWriteStatus?.writableFormats ?? [])
const isWriteTarget = computed(() => writableFormats.value.includes(props.file.formatKey as never))
const progressPercent = computed(() => props.file.progress?.percentage ?? 0)
const readerLabel = computed(() => {
if (!props.file.openable) return t('book.detail.files.reader.none')
if (props.file.isAudio) return t('book.detail.files.reader.audio')
if (props.file.group === 'comic') return t('book.detail.files.reader.comic')
if (props.file.group === 'document') return t('book.detail.files.reader.document')
return t('book.detail.files.reader.ebook')
})
const specs = computed(() => {
const rows = [
{ label: t('book.detail.files.spec.format'), value: props.file.formatKey.toUpperCase() || '-' },
{ label: t('book.detail.files.spec.role'), value: t(`book.detail.files.roles.${props.file.role}`) },
{ label: t('book.detail.files.spec.size'), value: formatBytes(props.file.sizeBytes) },
{ label: t('book.detail.files.spec.length'), value: formatDuration(props.file.durationSeconds) ?? '-' },
{
label: t('book.detail.files.spec.added'),
value: formatDate(new Date(props.file.createdAt), { year: 'numeric', month: 'short', day: 'numeric' }),
},
{ label: t('book.detail.files.spec.indexed'), value: formatRelativeFromNow(new Date(props.file.createdAt)) },
{ label: t('book.detail.files.spec.opensIn'), value: readerLabel.value },
{
label: t('book.detail.files.spec.writeBack'),
value: isWriteTarget.value ? t('book.detail.files.spec.targetFile') : t('book.detail.files.spec.notWritten'),
},
]
if (props.isMultiTrackAudio && props.file.track != null) {
rows.push({
label: t('book.detail.files.spec.track'),
value: t('book.detail.files.trackOf', { track: props.file.track, total: props.audioFiles.length }),
})
}
return rows
})
const trackStartSeconds = computed(() => {
if (props.file.track == null) return 0
return props.audioFiles.slice(0, props.file.track - 1).reduce((total, entry) => total + (entry.durationSeconds ?? 0), 0)
})
const writeBackFields = computed(() => resolveWriteBackFields(props.book, props.book.fileWriteStatus?.writableFields ?? []))
const filledFieldCount = computed(() => writeBackFields.value.filter((entry) => entry.value != null).length)
type TimelineEvent = { key: string; at: number; tone: 'ok' | 'failed' | 'muted'; title: string; detail: string }
/**
* One write-back run touches every file, so a thirty-eight track audiobook logs thirty-eight
* identical rows. Runs collapse to one line with a count, and file index dates join them so the
* timeline says something even for a book that has never been written to.
*/
const timeline = computed<TimelineEvent[]>(() => {
const events: TimelineEvent[] = []
const runs = new Map<string, { entry: WriteLogEntry; count: number; durationMs: number; at: string }>()
for (const entry of props.writeLog) {
const key = `${entry.status}|${entry.format}|${entry.writtenAt.slice(0, 13)}`
const run = runs.get(key) ?? { entry, count: 0, durationMs: 0, at: entry.writtenAt }
run.count += 1
run.durationMs += entry.durationMs ?? 0
if (entry.writtenAt > run.at) run.at = entry.writtenAt
runs.set(key, run)
}
for (const [key, run] of runs) {
events.push({
key,
at: new Date(run.at).getTime(),
tone: run.entry.status === 'success' ? 'ok' : run.entry.status === 'failed' ? 'failed' : 'muted',
title:
run.count > 1
? t('book.detail.files.timeline.writtenMany', { format: run.entry.format.toUpperCase(), count: run.count })
: t('book.detail.files.timeline.written', { format: run.entry.format.toUpperCase() }),
detail:
run.entry.status === 'failed'
? (run.entry.errorMessage ?? t('book.detail.files.timeline.failed'))
: t('book.detail.files.timeline.writeDetail', {
count: run.entry.fieldsWritten.length,
ms: run.durationMs,
trigger: run.entry.triggeredBy,
}),
})
}
const indexedByMinute = new Map<string, TreeFile[]>()
for (const file of props.allFiles) {
const key = `${file.createdAt.slice(0, 16)}|${file.formatKey}`
const bucket = indexedByMinute.get(key)
if (bucket) bucket.push(file)
else indexedByMinute.set(key, [file])
}
for (const [key, bucket] of indexedByMinute) {
const first = bucket[0]
if (!first) continue
events.push({
key: `indexed-${key}`,
at: new Date(first.createdAt).getTime(),
tone: 'muted',
title:
bucket.length > 1
? t('book.detail.files.timeline.indexedMany', { count: bucket.length, format: first.formatKey.toUpperCase() })
: t('book.detail.files.timeline.indexed', { name: first.leaf }),
detail: formatBytes(bucket.reduce((total, entry) => total + (entry.sizeBytes ?? 0), 0)),
})
}
return events.sort((a, b) => b.at - a.at).slice(0, 30)
})
const toneClass: Record<TimelineEvent['tone'], string> = {
ok: 'bg-emerald-500',
failed: 'bg-destructive',
muted: 'bg-muted-foreground/60',
}
</script>
<template>
<section
class="flex flex-col gap-3.5 rounded-xl border border-border bg-card p-4 lg:min-h-0 lg:overflow-y-auto"
:class="fitsWithoutScroll ? 'lg:overflow-hidden' : ''"
:aria-label="t('book.detail.files.inspectorAria')"
>
<!-- Header -->
<div class="flex items-start gap-3.5">
<button
v-if="showBack"
class="-ml-1 inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.files.backToFolder')"
@click="handleBack"
>
<ArrowLeft class="size-4" />
</button>
<FileFormatGlyph :format="file.formatKey" size="lg" class="hidden sm:flex" />
<div class="min-w-0 flex-1">
<h2 class="break-words text-[15px] font-bold leading-snug sm:text-base">{{ file.leaf }}</h2>
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
<FileRoleBadge :role="file.role" />
<span
class="inline-flex h-[18px] items-center rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:style="{
color: formatColorVar(file.formatKey),
backgroundColor: `color-mix(in oklch, ${formatColorVar(file.formatKey)} 16%, transparent)`,
}"
>{{ file.formatKey || '?' }}</span
>
<span
v-if="isMultiTrackAudio && file.track != null"
class="inline-flex h-[18px] items-center rounded bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground"
>
{{ t('book.detail.files.trackOf', { track: file.track, total: audioFiles.length }) }}
</span>
<span
v-if="isWriteTarget"
class="inline-flex h-[18px] items-center gap-1 rounded bg-emerald-500/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400"
>
<RefreshCw class="size-2.5" />{{ t('book.detail.files.writeTarget') }}
</span>
<span
v-if="progressPercent > 0"
class="inline-flex h-[18px] items-center rounded bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary"
>
{{ t('book.detail.files.percentRead', { percent: formatPercent(progressPercent / 100) }) }}
</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
v-if="file.openable"
class="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleOpen"
>
<Headphones v-if="file.isAudio" class="size-4" />
<BookOpen v-else class="size-4" />
<span class="hidden sm:inline">{{
progressPercent > 0 ? t('book.detail.files.resume') : file.isAudio ? t('book.detail.files.play') : t('book.detail.files.read')
}}</span>
</button>
<Tooltip v-if="file.openable && !file.isAudio">
<TooltipTrigger as-child>
<button
class="hidden size-9 items-center justify-center rounded-md border border-input transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:inline-flex"
:aria-label="t('book.detail.files.peek')"
@click="handlePeek"
>
<Eye class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.files.peek') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canDownload">
<TooltipTrigger as-child>
<button
class="hidden size-9 items-center justify-center rounded-md border border-input transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:inline-flex"
:aria-label="t('book.detail.files.download')"
@click="handleDownload"
>
<Download class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.files.download') }}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex size-9 items-center justify-center rounded-md border border-input transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.files.moreActions')"
>
<MoreVertical class="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="file.openable && !file.isAudio" class="sm:hidden" @click="handlePeek">
<Eye class="mr-2 size-4" />{{ t('book.detail.files.peek') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="canDownload" class="sm:hidden" @click="handleDownload">
<Download class="mr-2 size-4" />{{ t('book.detail.files.download') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleCopyPath"> <Copy class="mr-2 size-4" />{{ t('book.detail.files.copyPath') }} </DropdownMenuItem>
<DropdownMenuItem v-if="canEdit" @click="handleRename">
<HardDrive class="mr-2 size-4" />{{ t('book.detail.files.rename') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="canDelete" class="text-destructive focus:text-destructive" @click="handleRemove">
{{ t('common.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<!-- Path -->
<div class="flex shrink-0 items-center gap-2 rounded-lg border border-border bg-muted/40 px-2.5 py-2">
<HardDrive class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<code class="min-w-0 shrink truncate font-mono text-[11px] text-muted-foreground">&hellip;/{{ folderRelative.join('/') }}/</code>
<b class="hidden shrink-0 font-mono text-[11px] font-semibold sm:inline">{{ file.leaf }}</b>
<span class="flex-1" />
<button
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.files.copyPath')"
@click="handleCopyPath"
>
<Copy class="size-3.5" />
</button>
</div>
<!-- Specs -->
<dl class="grid shrink-0 grid-cols-1 gap-x-6 sm:grid-cols-2 xl:grid-cols-3">
<div v-for="spec in specs" :key="spec.label" class="flex items-baseline justify-between gap-3 border-b border-border py-[7px]">
<dt class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ spec.label }}</dt>
<dd class="truncate text-[12.5px] font-semibold">{{ spec.value }}</dd>
</div>
</dl>
<!-- Reading position -->
<div class="flex shrink-0 flex-col gap-2">
<h3 class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<BookOpen class="size-3" aria-hidden="true" />
{{ t('book.detail.files.positionHeading') }}
<span class="ml-auto font-semibold normal-case tracking-normal">
{{
startedCount > 0
? t('book.detail.files.copiesStarted', { started: startedCount, total: fileCount })
: t('book.detail.files.nothingStarted')
}}
</span>
</h3>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<span class="block h-full rounded-full bg-primary transition-[width]" :style="{ width: `${Math.min(progressPercent, 100)}%` }" />
</div>
<div class="flex justify-between gap-3 text-[11.5px] text-muted-foreground">
<span>{{
progressPercent > 0
? t('book.detail.files.throughFile', { percent: formatPercent(progressPercent / 100, 1) })
: t('book.detail.files.notOpened')
}}</span>
<span>{{
file.progress?.updatedAt
? t('book.detail.files.lastRead', { time: formatRelativeFromNow(new Date(file.progress.updatedAt)) })
: t('book.detail.files.noPosition')
}}</span>
</div>
</div>
<!-- Where the track sits -->
<div v-if="isMultiTrackAudio && file.isAudio" class="flex shrink-0 flex-col gap-2">
<h3 class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Clock class="size-3" aria-hidden="true" />
{{ t('book.detail.files.trackPositionHeading') }}
<span v-if="runtimeSeconds" class="ml-auto font-semibold normal-case tracking-normal">{{
t('book.detail.files.totalRuntime', { duration: formatDuration(runtimeSeconds) })
}}</span>
</h3>
<div class="flex h-6 gap-px overflow-hidden rounded-md">
<button
v-for="track in audioFiles"
:key="track.id"
class="h-full min-w-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:style="{
flex: `${Math.max(track.durationSeconds ?? 1, 1)} 1 0`,
backgroundColor:
track.id === file.id ? formatColorVar(track.formatKey) : `color-mix(in oklch, ${formatColorVar(track.formatKey)} 26%, transparent)`,
}"
:aria-label="t('book.detail.files.trackOf', { track: track.track, total: audioFiles.length })"
:aria-current="track.id === file.id ? 'true' : undefined"
@click="handleSelect(track.id)"
/>
</div>
<div class="flex justify-between gap-3 text-[10.5px] text-muted-foreground">
<span>{{ t('book.detail.files.startsAt', { time: formatDuration(trackStartSeconds) ?? '0s' }) }}</span>
<span>{{ t('book.detail.files.trackLength', { duration: formatDuration(file.durationSeconds) ?? '-' }) }}</span>
</div>
</div>
<!-- Switch file -->
<div v-if="siblings.length" class="flex shrink-0 flex-col gap-2">
<h3 class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Grid2x2 class="size-3" aria-hidden="true" />
{{ t('book.detail.files.switchFileHeading') }}
<span class="ml-auto font-semibold normal-case tracking-normal">{{ t('book.detail.files.formatsInFolder', { count: formatCount }) }}</span>
</h3>
<ul class="flex flex-wrap gap-2">
<li v-for="sibling in siblings" :key="sibling.id">
<button
class="flex items-center gap-2 rounded-lg border border-border py-1.5 pl-1.5 pr-2.5 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleSelect(sibling.id)"
>
<FileFormatGlyph :format="sibling.formatKey" size="sm" />
<span class="text-[11.5px] font-semibold">{{
isMultiTrackAudio && sibling.isAudio ? t('book.detail.files.audiobook') : sibling.formatKey.toUpperCase()
}}</span>
<span class="text-[10.5px] text-muted-foreground">{{
isMultiTrackAudio && sibling.isAudio ? t('book.detail.files.trackCount', { count: audioFiles.length }) : formatBytes(sibling.sizeBytes)
}}</span>
<FileRoleBadge v-if="sibling.role !== 'content' && sibling.role !== 'primary'" :role="sibling.role" />
</button>
</li>
</ul>
</div>
<!-- Write-back values -->
<div class="flex flex-col gap-2" :class="fitsWithoutScroll ? 'min-h-[8.25rem] shrink' : ''">
<h3 class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<RefreshCw class="size-3" aria-hidden="true" />
{{ t('book.detail.files.writeBackHeading') }}
<span class="ml-auto font-semibold normal-case tracking-normal">{{
book.lastWrittenAt
? t('book.detail.files.lastRun', { time: formatRelativeFromNow(new Date(book.lastWrittenAt)) })
: t('book.detail.files.neverRun')
}}</span>
</h3>
<div class="flex flex-col overflow-hidden rounded-lg border border-border bg-muted/40" :class="fitsWithoutScroll ? 'min-h-0 flex-1' : ''">
<p class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2 text-[11.5px] text-muted-foreground">
<span
class="inline-flex h-[18px] items-center rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="book.fileWriteStatus?.enabled ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' : 'bg-muted text-muted-foreground'"
>{{ book.fileWriteStatus?.enabled ? t('book.detail.files.enabled') : t('book.detail.files.disabled') }}</span
>
<span>{{ t('book.detail.files.fieldsWithValue', { filled: filledFieldCount, total: writeBackFields.length }) }}</span>
<span
v-for="format in writableFormats"
:key="format"
class="ml-auto inline-flex h-[18px] items-center rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:style="{ color: formatColorVar(format), backgroundColor: `color-mix(in oklch, ${formatColorVar(format)} 16%, transparent)` }"
>{{ format }}</span
>
</p>
<dl
class="grid grid-cols-1 gap-x-5 px-3 pb-2 pt-0.5 xl:grid-cols-2"
:class="fitsWithoutScroll ? 'min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,black_calc(100%-14px),transparent)]' : ''"
>
<div v-for="entry in writeBackFields" :key="entry.field" class="flex items-baseline justify-between gap-3.5 border-b border-border py-1">
<dt class="shrink-0 text-[11px] text-muted-foreground">{{ entry.label }}</dt>
<dd
class="truncate text-[11.5px]"
:class="entry.value == null ? 'italic text-muted-foreground' : 'font-semibold'"
:title="entry.value ?? ''"
>
{{ entry.value ?? t('book.detail.files.notSet') }}
</dd>
</div>
</dl>
</div>
</div>
<!-- History -->
<div class="flex flex-col gap-1" :class="fitsWithoutScroll ? 'min-h-[7rem] flex-[1_1_0]' : ''">
<h3 class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<History class="size-3" aria-hidden="true" />
{{ t('book.detail.files.historyHeading') }}
</h3>
<ul
:class="fitsWithoutScroll ? 'min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,black_calc(100%-12px),transparent)]' : ''"
>
<li v-for="event in timeline" :key="event.key" class="flex gap-2.5 border-b border-border py-1.5 last:border-b-0">
<span class="mt-[5px] size-[7px] shrink-0 rounded-full" :class="toneClass[event.tone]" aria-hidden="true" />
<div class="min-w-0">
<p class="truncate text-[11.5px] font-semibold">{{ event.title }}</p>
<p class="truncate text-[10.5px] text-muted-foreground">{{ event.detail }} · {{ formatRelativeFromNow(event.at) }}</p>
</div>
</li>
</ul>
</div>
</section>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ role: string }>()
const { t } = useI18n()
/**
* `supplement` and `cover` are real roles the scanner assigns and the old tab never showed, so a
* folder looked like six interchangeable copies of the book when two of them are not copies at all.
*/
const ROLES: Record<string, { label: string; class: string }> = {
primary: { label: 'book.detail.files.roles.primary', class: 'bg-primary/15 text-primary' },
supplement: { label: 'book.detail.files.roles.supplement', class: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' },
cover: { label: 'book.detail.files.roles.cover', class: 'bg-sky-500/15 text-sky-600 dark:text-sky-400' },
}
const role = computed(() => ROLES[props.role] ?? null)
</script>
<template>
<span
v-if="role"
class="inline-flex h-[18px] shrink-0 items-center rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="role.class"
>
{{ t(role.label) }}
</span>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, Folder, PieChart } from '@lucide/vue'
import { formatBytes } from '@/lib/formatting'
import { formatPercent } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { splitExtension } from '@/features/book/lib/filename-stem'
import type { FileGroup, FormatShare, TreeFile } from '@/features/book/composables/useBookFileTree'
import FileFormatGlyph from './FileFormatGlyph.vue'
import FileRoleBadge from './FileRoleBadge.vue'
const props = defineProps<{
groups: FileGroup[]
formatShares: FormatShare[]
folderName: string
folderPath: string
totalBytes: number
selectedId: number | null
fileCount: number
}>()
const emit = defineEmits<{ select: [id: number] }>()
const { t } = useI18n()
function handleSelect(id: number) {
emit('select', id)
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null) return null
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes })
return t('book.detail.files.duration.minutes', { minutes: Math.max(minutes, 1) })
}
function metaParts(file: TreeFile): string[] {
const parts = [formatBytes(file.sizeBytes)]
const duration = formatDuration(file.durationSeconds)
if (duration) parts.push(duration)
if ((file.progress?.percentage ?? 0) > 0) {
parts.push(t('book.detail.files.percentRead', { percent: formatPercent((file.progress?.percentage ?? 0) / 100) }))
}
return parts
}
const shareStyles = computed(() =>
props.formatShares.map((share) => ({
...share,
color: formatColorVar(share.format),
// A format worth a row is worth a visible sliver, however little of the folder it owns.
width: `${Math.max(share.fraction * 100, 1.2)}%`,
})),
)
</script>
<template>
<section class="flex flex-col rounded-xl border border-border bg-card lg:min-h-0" :aria-label="t('book.detail.files.folderContents')">
<div class="p-1.5 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<div class="flex items-start gap-2.5 rounded-lg px-2 py-1.5" :title="folderPath">
<Folder class="mt-px size-[15px] shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="line-clamp-3 min-w-0 flex-1 break-words text-[12.5px] font-bold leading-[1.35]">{{ folderName }}</span>
<span class="mt-px shrink-0 text-[11px] text-muted-foreground">{{ formatBytes(totalBytes) }}</span>
</div>
<div class="relative pl-[22px] before:absolute before:bottom-0 before:left-[11px] before:top-0 before:w-px before:bg-border">
<template v-for="group in groups" :key="group.key">
<div class="flex items-center gap-2 px-2 py-1.5">
<ChevronDown class="size-[13px] shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{{ group.label }}</span>
<span v-if="group.stem" class="min-w-0 truncate font-mono text-[9.5px] text-muted-foreground" :title="group.stem"
>{{ group.stem }}&hellip;</span
>
<span class="ml-auto shrink-0 text-[11px] text-muted-foreground">{{ group.files.length }}</span>
</div>
<ul class="contents">
<li v-for="file in group.files" :key="file.id">
<button
class="relative flex w-full items-start gap-2.5 rounded-lg py-1.5 pl-[26px] pr-2 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="file.id === selectedId ? 'bg-primary/15' : ''"
:title="file.leaf"
:aria-current="file.id === selectedId ? 'true' : undefined"
@click="handleSelect(file.id)"
>
<span v-if="file.id === selectedId" class="absolute inset-y-1.5 left-1.5 w-[2.5px] rounded-full bg-primary" aria-hidden="true" />
<FileFormatGlyph :format="file.formatKey" size="sm" class="mt-px" />
<!--
A filename has no length limit, so it never shares a line with the size and the
role. `min(100%, max-content)` keeps both on one line while the name fits and
pushes the meta onto its own line the moment it does not, at any rail width.
-->
<span class="flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span class="line-clamp-3 min-w-[min(100%,max-content)] break-all text-[12.5px] font-semibold leading-[1.35]">
<span>{{ splitExtension(file.display).base }}</span
><span class="text-muted-foreground">{{ splitExtension(file.display).extension }}</span>
</span>
<span class="flex shrink-0 items-center gap-1.5 text-[10.5px] text-muted-foreground">
<span>{{ metaParts(file).join(' · ') }}</span>
<FileRoleBadge v-if="file.role !== 'content'" :role="file.role" />
</span>
</span>
</button>
</li>
</ul>
</template>
</div>
</div>
<div class="flex flex-col gap-2.5 border-t border-border px-3.5 py-3 lg:shrink-0">
<div class="flex items-center gap-2">
<PieChart class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
<h3 class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('book.detail.files.formatCount', { count: formatShares.length }) }}
</h3>
<span class="ml-auto text-[11px] text-muted-foreground">{{ formatBytes(totalBytes) }}</span>
</div>
<div class="flex h-2 gap-px overflow-hidden rounded-full bg-muted" role="img" :aria-label="t('book.detail.files.compositionAria')">
<span
v-for="share in shareStyles"
:key="share.format"
class="block h-full first:rounded-l-full last:rounded-r-full"
:style="{ width: share.width, backgroundColor: share.color }"
/>
</div>
<dl class="flex flex-col">
<div v-for="share in shareStyles" :key="share.format" class="flex items-center gap-2.5 border-b border-border py-1.5 last:border-b-0">
<span class="size-[7px] shrink-0 rounded-sm" :style="{ backgroundColor: share.color }" aria-hidden="true" />
<dt class="w-11 shrink-0 text-[12px] font-bold uppercase tracking-wide" :style="{ color: share.color }">{{ share.format }}</dt>
<dd class="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{{ t('book.detail.files.fileCount', { count: share.count }) }}
</dd>
<dd class="shrink-0 text-[11.5px] font-semibold">{{ formatBytes(share.sizeBytes) }}</dd>
<dd class="w-9 shrink-0 text-right text-[10.5px] text-muted-foreground">{{ formatPercent(share.fraction) }}</dd>
</div>
</dl>
</div>
</section>
</template>
@@ -1186,7 +1186,7 @@ watch(
that runs long scrolls inside itself so the page itself never does.
-->
<div
class="flex flex-col gap-5 xl:grid xl:h-full xl:min-h-0 xl:grid-cols-[19rem_minmax(0,1fr)_21.25rem] xl:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)] xl:gap-x-6 xl:gap-y-5"
class="flex flex-col gap-5 xl:grid xl:h-full xl:min-h-0 xl:grid-cols-[17rem_minmax(0,1fr)_19.25rem] xl:grid-rows-[minmax(0,1fr)_clamp(11.25rem,29%,17.5rem)] xl:gap-x-6 xl:gap-y-5"
>
<!-- Cover column -->
<div class="flex min-w-0 flex-col gap-3 xl:min-h-0 xl:col-start-1 xl:row-start-1">
@@ -1,131 +1,105 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { computed, ref, toRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
import { useMediaQuery } from '@vueuse/core'
import { ArrowUpDown, CircleCheck, FilePlus, Files, Folder, TriangleAlert } from '@lucide/vue'
import type { BookDetail, WriteLogEntry } from '@bookorbit/types'
import { Permission } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { BookOpen, Download, Eye, FilePlus, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical, Pencil, Trash2 } from '@lucide/vue'
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
import { Permission, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/clipboard'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookFileTree, type SortKey, type TreeFile } from '@/features/book/composables/useBookFileTree'
import FileTreeRail from '../files/FileTreeRail.vue'
import FileInspector from '../files/FileInspector.vue'
import AddBookFileModal from './AddBookFileModal.vue'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ refetch: [] }>()
const { t } = useI18n()
const router = useRouter()
const { downloadFile: downloadBookFile } = useBookDownload()
const { hasPermission } = usePermissions()
const AUDIO_FORMATS = new Set(['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'])
const book = toRef(props, 'book')
const {
files,
groups,
audioFiles,
isMultiTrackAudio,
totalBytes,
runtimeSeconds,
formatShares,
selectedFile,
siblingFiles,
startedCount,
folderSegments,
sortKey,
sortDirection,
selectFile,
toggleSort,
} = useBookFileTree(book)
function isAudioFile(file: BookDetailFile): boolean {
return !!file.format && AUDIO_FORMATS.has(file.format.toLowerCase())
const canUpload = computed(() => hasPermission(Permission.LibraryUpload))
const canDownload = computed(() => hasPermission('library_download'))
const canEdit = computed(() => hasPermission('library_edit_metadata'))
const canDelete = computed(() => hasPermission('library_delete_books'))
/**
* Below lg the two panes cannot sit side by side, so the tab becomes master-detail: the folder
* first, the inspector in its place once a file is picked, with a back control to return.
*/
const isCompact = useMediaQuery('(max-width: 1023px)')
/**
* Whether the inspector can hold every section at a fixed height. Width alone is the wrong test:
* a 1600x900 window is wide enough for the two-pane split and 200px too short for the sections,
* so it clipped them instead of scrolling.
*/
const paneFitsInspector = useMediaQuery('(min-width: 1536px) and (min-height: 1000px)')
const compactShowsInspector = ref(false)
watch(
() => props.book.id,
() => {
compactShowsInspector.value = false
},
)
function handleSelect(id: number) {
selectFile(id)
compactShowsInspector.value = true
}
const audioTrackIndex = computed(() => {
const map = new Map<number, number>()
let track = 1
for (const file of props.book.files) {
if (isAudioFile(file)) {
map.set(file.id, track++)
}
}
return map
})
function handleBack() {
compactShowsInspector.value = false
}
const audioTrackCount = computed(() => audioTrackIndex.value.size)
type SortKey = 'name' | 'format' | 'size' | 'date'
type SortDir = 'asc' | 'desc'
const sortOptions = computed<[SortKey, string][]>(() => [
['name', t('book.detail.files.sort.name')],
['format', t('book.detail.files.sort.format')],
['size', t('book.detail.files.sort.size')],
['date', t('book.detail.files.sort.date')],
const sortOptions = computed<{ key: SortKey; label: string }[]>(() => [
{ key: 'name', label: t('book.detail.files.sort.name') },
{ key: 'format', label: t('book.detail.files.sort.format') },
{ key: 'size', label: t('book.detail.files.sort.size') },
{ key: 'date', label: t('book.detail.files.sortAdded') },
])
const sortKey = ref<SortKey>('name')
const sortDir = ref<SortDir>('asc')
const activeSortLabel = computed(() => sortOptions.value.find((option) => option.key === sortKey.value)?.label ?? '')
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDir.value = 'asc'
}
function handleSort(key: SortKey) {
toggleSort(key)
}
const sortedFiles = computed(() => {
const files = [...props.book.files]
const dir = sortDir.value === 'asc' ? 1 : -1
return files.sort((a, b) => {
switch (sortKey.value) {
case 'name':
return dir * (a.filename ?? '').localeCompare(b.filename ?? '')
case 'format':
return dir * (a.format ?? '').localeCompare(b.format ?? '')
case 'size':
return dir * ((a.sizeBytes ?? 0) - (b.sizeBytes ?? 0))
case 'date':
return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}
})
})
const fileSummary = computed(() => {
const formats = new Map<string, number>()
let totalBytes = 0
for (const file of props.book.files) {
if (file.sizeBytes != null) totalBytes += file.sizeBytes
const format = file.format?.toUpperCase() ?? 'Unknown'
formats.set(format, (formats.get(format) ?? 0) + 1)
}
return {
totalBytes,
formats: [...formats.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([format, count]) => (count > 1 ? `${format} × ${count}` : format)),
}
})
function formatDuration(seconds: number | null | undefined): string | null {
function formatRuntime(seconds: number | null): string | null {
if (seconds == null) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes })
return t('book.detail.files.duration.minutes', { minutes: Math.max(minutes, 1) })
}
function formatDate(iso: string): string {
return formatLocaleDate(new Date(iso), { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return t('book.detail.files.relative.seconds', { value: s })
const m = Math.floor(s / 60)
if (m < 60) return t('book.detail.files.relative.minutes', { value: m })
const h = Math.floor(m / 60)
if (h < 24) return t('book.detail.files.relative.hours', { value: h })
const d = Math.floor(h / 24)
return t('book.detail.files.relative.days', { value: d })
}
function openFile(file: BookDetailFile, mode?: 'peek') {
function openFile(file: TreeFile, mode?: 'peek') {
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
@@ -133,363 +107,208 @@ function openFile(file: BookDetailFile, mode?: 'peek') {
})
}
function downloadFile(file: BookDetailFile) {
function downloadFile(file: TreeFile) {
void downloadBookFile(file.id)
}
function fileIconStyle(format: string | null): Record<string, string> {
const color = getFormatColor(format)
return {
backgroundColor: `${color}35`,
color,
const copiedPathFileId = ref<number | null>(null)
async function copyPath(file: TreeFile) {
if (!(await copyToClipboard(file.absolutePath))) return
copiedPathFileId.value = file.id
window.setTimeout(() => {
if (copiedPathFileId.value === file.id) copiedPathFileId.value = null
}, 2000)
}
const writeLog = ref<WriteLogEntry[]>([])
async function loadWriteLog(bookId: number) {
writeLog.value = []
if (!props.book.lastWrittenAt) return
try {
const response = await api(`/api/v1/books/${bookId}/write-log`)
if (!response.ok) return
const data = (await response.json()) as { entries: WriteLogEntry[] }
if (bookId === props.book.id) writeLog.value = data.entries
} catch {
writeLog.value = []
}
}
const showPaths = useLocalStorage('bookDetailShowFilePaths', false)
const writeLogOpen = ref(false)
const writeLog = ref<WriteLogEntry[]>([])
const writeLogLoading = ref(false)
watch(() => props.book.id, loadWriteLog, { immediate: true })
watch(
() => props.book.lastWrittenAt,
() => loadWriteLog(props.book.id),
)
// Modals
const renameFileTarget = ref<BookDetailFile | null>(null)
const addFileModalOpen = ref(false)
const renameTarget = ref<TreeFile | null>(null)
const renameInput = ref('')
const renaming = ref(false)
const deleteFileTarget = ref<BookDetailFile | null>(null)
const deletingFile = ref(false)
const addFileModalOpen = ref(false)
function handleTogglePaths() {
showPaths.value = !showPaths.value
}
const renameError = ref<string | null>(null)
const deleteTarget = ref<TreeFile | null>(null)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
function openAddFileModal() {
addFileModalOpen.value = true
}
function closeAddFileModal() {
addFileModalOpen.value = false
}
function onFilesAdded() {
addFileModalOpen.value = false
emit('refetch')
}
function openRenameModal(file: BookDetailFile) {
renameFileTarget.value = file
renameInput.value = file.filename ?? ''
function openRenameModal(file: TreeFile) {
renameTarget.value = file
renameInput.value = file.leaf
renameError.value = null
}
function closeRenameModal() {
if (renaming.value) return
renameFileTarget.value = null
renameTarget.value = null
}
async function submitRename() {
if (!renameFileTarget.value || renaming.value || !renameInput.value.trim()) return
const target = renameTarget.value
if (!target || renaming.value || !renameInput.value.trim()) return
renaming.value = true
renameError.value = null
try {
const res = await api(`/api/v1/books/files/${renameFileTarget.value.id}`, {
const response = await api(`/api/v1/books/files/${target.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: renameInput.value.trim() }),
})
if (!res.ok) throw new Error(t('book.detail.files.renameFailed'))
renameFileTarget.value = null
if (!response.ok) throw new Error(t('book.detail.files.renameFailed'))
renameTarget.value = null
emit('refetch')
} catch (err) {
alert(err instanceof Error ? err.message : String(err))
} catch (error) {
renameError.value = error instanceof Error ? error.message : t('book.detail.files.renameFailed')
} finally {
renaming.value = false
renameFileTarget.value = null
}
}
function openDeleteModal(file: BookDetailFile) {
deleteFileTarget.value = file
function openDeleteModal(file: TreeFile) {
deleteTarget.value = file
deleteError.value = null
}
function closeDeleteModal() {
if (deletingFile.value) return
deleteFileTarget.value = null
if (deleting.value) return
deleteTarget.value = null
}
async function confirmDelete() {
if (!deleteFileTarget.value || deletingFile.value) return
deletingFile.value = true
const target = deleteTarget.value
if (!target || deleting.value) return
deleting.value = true
deleteError.value = null
try {
const res = await api(`/api/v1/books/files/${deleteFileTarget.value.id}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(t('book.detail.files.deleteFailed'))
const response = await api(`/api/v1/books/files/${target.id}`, { method: 'DELETE' })
if (!response.ok) throw new Error(t('book.detail.files.deleteFailed'))
deleteTarget.value = null
compactShowsInspector.value = false
emit('refetch')
} catch (err) {
alert(err instanceof Error ? err.message : String(err))
} catch (error) {
deleteError.value = error instanceof Error ? error.message : t('book.detail.files.deleteFailed')
} finally {
deletingFile.value = false
deleteFileTarget.value = null
}
}
watch(
() => props.book.id,
() => {
writeLogOpen.value = false
writeLog.value = []
},
)
async function toggleWriteLog() {
if (writeLogOpen.value) {
writeLogOpen.value = false
return
}
writeLogOpen.value = true
if (writeLog.value.length > 0) return
writeLogLoading.value = true
try {
const res = await api(`/api/v1/books/${props.book.id}/write-log`)
if (res.ok) {
const data: { entries: WriteLogEntry[] } = await res.json()
writeLog.value = data.entries
}
} finally {
writeLogLoading.value = false
deleting.value = false
}
}
</script>
<template>
<div class="space-y-0">
<section class="flex flex-col gap-3 px-1 pb-4 pt-1 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Files class="size-4" />
</div>
<div class="min-w-0">
<h1 class="text-base font-semibold tracking-tight text-foreground">{{ t('book.detail.files.title') }}</h1>
<p class="truncate text-xs text-muted-foreground">
{{ t('book.detail.files.fileCount', { count: book.files.length }) }}
<span class="mx-1 opacity-40">·</span>
{{ formatBytes(fileSummary.totalBytes) }}
<template v-if="fileSummary.formats.length"> · {{ fileSummary.formats.join(' · ') }}</template>
<template v-if="book.lastWrittenAt"> · {{ t('book.detail.files.synced', { time: formatRelative(book.lastWrittenAt) }) }}</template>
</p>
</div>
</div>
<div class="flex flex-col gap-3.5 lg:h-full lg:min-h-0">
<!-- Command bar -->
<div class="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2">
<Folder class="hidden size-[15px] shrink-0 text-muted-foreground sm:block" aria-hidden="true" />
<nav class="flex min-w-0 flex-1 items-center gap-1.5 text-[11.5px] text-muted-foreground" :aria-label="t('book.detail.files.folderAria')">
<span class="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10.5px] sm:inline" :title="folderSegments.root">{{
t('book.detail.files.libraryRoot')
}}</span>
<template v-for="(segment, index) in folderSegments.relative" :key="`${segment}-${index}`">
<span class="shrink-0 opacity-40" aria-hidden="true">/</span>
<span class="min-w-[3ch] truncate" :class="index === folderSegments.relative.length - 1 ? 'font-semibold text-foreground' : ''">{{
segment
}}</span>
</template>
</nav>
<div class="flex items-center gap-2">
<button
v-if="book.lastWrittenAt"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="toggleWriteLog"
<div class="flex shrink-0 items-center gap-2">
<span
class="inline-flex h-[18px] items-center gap-1 rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="book.status === 'missing' ? 'bg-destructive/15 text-destructive' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'"
>
<History class="size-3.5" />
{{ writeLogOpen ? t('book.detail.files.hideLog') : t('book.detail.files.viewSyncLog') }}
</button>
<TriangleAlert v-if="book.status === 'missing'" class="size-2.5" />
<CircleCheck v-else class="size-2.5" />
{{ book.status === 'missing' ? t('book.detail.files.missing') : t('book.detail.files.onDisk') }}
</span>
<span class="hidden rounded bg-muted px-1.5 py-0.5 text-[10.5px] font-semibold text-muted-foreground md:inline">{{
t('book.detail.files.fileCount', { count: files.length })
}}</span>
<span class="hidden rounded bg-muted px-1.5 py-0.5 text-[10.5px] font-semibold text-muted-foreground lg:inline">{{
formatBytes(totalBytes)
}}</span>
<span v-if="runtimeSeconds" class="hidden rounded bg-muted px-1.5 py-0.5 text-[10.5px] font-semibold text-muted-foreground xl:inline">{{
formatRuntime(runtimeSeconds)
}}</span>
<div class="hidden items-center rounded-lg border border-border xl:flex" role="group" :aria-label="t('book.detail.files.sortAria')">
<button
v-for="option in sortOptions"
:key="option.key"
class="h-8 border-r border-border px-2.5 text-xs font-semibold transition-colors last:border-r-0 first:rounded-l-[7px] last:rounded-r-[7px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="sortKey === option.key ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
:aria-pressed="sortKey === option.key"
@click="handleSort(option.key)"
>
{{ option.label }}{{ sortKey === option.key ? (sortDirection === 'asc' ? ' ↑' : ' ↓') : '' }}
</button>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring xl:hidden"
:aria-label="t('book.detail.files.sortAria')"
>
<ArrowUpDown class="size-3.5" />
<span class="hidden sm:inline">{{ activeSortLabel }}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="option in sortOptions" :key="option.key" @click="handleSort(option.key)">
{{ option.label }}{{ sortKey === option.key ? (sortDirection === 'asc' ? ' ↑' : ' ↓') : '' }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
v-if="hasPermission(Permission.LibraryUpload)"
class="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
v-if="canUpload"
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary px-2.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="openAddFileModal"
>
<FilePlus class="size-3.5" />
{{ t('book.detail.files.addFile') }}
<span class="hidden sm:inline">{{ t('book.detail.files.addFile') }}</span>
</button>
</div>
</section>
<div class="flex flex-col gap-2 border-y border-border/70 py-2.5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-2 overflow-x-auto scrollbar-none">
<ArrowUpDown class="ml-1 size-3.5 shrink-0 text-muted-foreground" />
<div class="flex shrink-0 items-center rounded-lg bg-muted p-1" role="group" :aria-label="t('book.detail.files.sortAria')">
<button
v-for="opt in sortOptions"
:key="opt[0]"
class="h-7 rounded-md px-2.5 text-xs font-medium transition-colors"
:class="sortKey === opt[0] ? 'bg-card text-foreground shadow-[var(--elevation-xs)]' : 'text-muted-foreground hover:text-foreground'"
@click="toggleSort(opt[0])"
>
{{ opt[1] }}{{ sortKey === opt[0] ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '' }}
</button>
</div>
</div>
<button
class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
:class="showPaths ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
:aria-pressed="showPaths"
@click="handleTogglePaths"
>
<FolderOpen class="size-3.5" />
{{ showPaths ? t('book.detail.files.hidePaths') : t('book.detail.files.showPaths') }}
</button>
</div>
<section v-if="writeLogOpen" class="border-b border-border/70 px-1 py-3">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-foreground">{{ t('book.detail.files.syncHistory') }}</p>
<span class="text-xs text-muted-foreground">{{ t('book.detail.files.metadataWriteBack') }}</span>
</div>
<p v-if="writeLogLoading" class="text-sm text-muted-foreground">{{ t('common.loading') }}</p>
<p v-else-if="writeLog.length === 0" class="text-sm text-muted-foreground">{{ t('book.detail.files.noWriteHistory') }}</p>
<div v-else class="space-y-2">
<div v-for="entry in writeLog" :key="entry.id" class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span
class="font-medium"
:class="{
'text-primary': entry.status === 'success',
'text-destructive': entry.status === 'failed',
'text-muted-foreground': entry.status === 'skipped',
}"
>{{ entry.status }}</span
>
<span class="text-muted-foreground">{{ formatRelative(entry.writtenAt) }}</span>
<span class="font-mono uppercase text-muted-foreground">{{ entry.format }}</span>
<span v-if="entry.status === 'failed' && entry.errorMessage" class="min-w-0 flex-1 basis-full truncate text-destructive sm:basis-auto">{{
entry.errorMessage
}}</span>
<span v-else-if="entry.fieldsWritten.length" class="min-w-0 flex-1 basis-full truncate text-muted-foreground sm:basis-auto">{{
t('book.detail.files.fieldsWritten', { count: entry.fieldsWritten.length })
}}</span>
</div>
</div>
</section>
<section v-if="sortedFiles.length" class="divide-y divide-border pt-1">
<div v-for="file in sortedFiles" :key="file.id" class="flex items-center gap-3 px-1 py-3 transition-colors hover:bg-muted/30 sm:px-2">
<div
class="relative flex h-10 w-8 shrink-0 items-end justify-center pb-1"
:style="fileIconStyle(file.format)"
style="clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%); border-radius: 3px 0 3px 3px"
>
<div class="absolute right-0 top-0 h-2 w-2 bg-current opacity-25" />
<div class="absolute left-1 right-1 top-3 flex flex-col gap-[2px]">
<div class="h-px rounded-full bg-current opacity-20" />
<div class="h-px w-3/4 rounded-full bg-current opacity-20" />
</div>
<span class="text-[8px] font-bold uppercase tracking-wide leading-none">{{ file.format ?? '?' }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<p class="min-w-0 truncate text-sm font-medium text-foreground">{{ file.filename ?? '-' }}</p>
<span
v-if="file.role === 'primary'"
class="hidden shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary sm:inline"
>{{ t('book.detail.files.primary') }}</span
>
<span
v-else-if="isAudioFile(file) && audioTrackCount > 1"
class="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>{{ t('book.detail.files.track', { number: audioTrackIndex.get(file.id) }) }}</span
>
</div>
<p v-if="showPaths" class="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">{{ file.absolutePath }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">
{{ formatBytes(file.sizeBytes) }}
<span class="mx-1 opacity-40">·</span>
{{ formatDate(file.createdAt) }}
<template v-if="formatDuration(file.durationSeconds)">
<span class="mx-1 opacity-40">·</span>
{{ formatDuration(file.durationSeconds) }}
</template>
</p>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<span v-if="file.role === 'primary'" class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary sm:hidden">{{
t('book.detail.files.primary')
}}</span>
<span
v-else-if="isAudioFile(file) && audioTrackCount > 1"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:hidden"
>{{ audioTrackIndex.get(file.id) }}</span
>
<div class="hidden items-center gap-1.5 md:flex">
<button
v-if="READER_OPENABLE_FORMATS.has(file.format ?? '') && !isAudioFile(file)"
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
@click="openFile(file)"
>
<BookOpen class="size-3.5" />
{{ t('book.detail.files.read') }}
</button>
<button
v-if="isAudioFile(file)"
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
@click="openFile(file)"
>
<Headphones class="size-3.5" />
{{ t('book.detail.files.play') }}
</button>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<button
class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="downloadFile(file)"
>
<Download class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.files.download') }}</TooltipContent>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:title="t('book.detail.files.moreActions')"
>
<MoreVertical class="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="READER_OPENABLE_FORMATS.has(file.format ?? '') && !isAudioFile(file)" class="md:hidden" @click="openFile(file)">
<BookOpen class="mr-2 size-4" />
{{ t('book.detail.files.read') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="isAudioFile(file)" class="md:hidden" @click="openFile(file)">
<Headphones class="mr-2 size-4" />
{{ t('book.detail.files.play') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="READER_OPENABLE_FORMATS.has(file.format ?? '')" @click="openFile(file, 'peek')">
<Eye class="mr-2 size-4" />
{{ t('book.detail.files.peek') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_download')" class="md:hidden" @click="downloadFile(file)">
<Download class="mr-2 size-4" />
{{ t('book.detail.files.download') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" @click="openRenameModal(file)">
<Pencil class="mr-2 size-4" />
{{ t('book.detail.files.rename') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="hasPermission('library_delete_books')"
class="text-destructive focus:text-destructive"
@click="openDeleteModal(file)"
>
<Trash2 class="mr-2 size-4" />
{{ t('common.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</section>
<section v-else class="flex flex-col items-center justify-center px-4 py-16 text-center">
<div class="flex size-10 items-center justify-center text-muted-foreground">
<Files class="size-5" />
</div>
<p class="mt-3 text-sm font-semibold text-foreground">{{ t('book.detail.files.empty.title') }}</p>
<!-- Empty state -->
<section
v-if="files.length === 0"
class="flex flex-col items-center justify-center lg:flex-1 rounded-xl border border-border bg-card px-4 py-16 text-center"
>
<Files class="size-5 text-muted-foreground" aria-hidden="true" />
<p class="mt-3 text-sm font-semibold">{{ t('book.detail.files.empty.title') }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ t('book.detail.files.empty.description') }}</p>
<button
v-if="hasPermission(Permission.LibraryUpload)"
v-if="canUpload"
class="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@click="openAddFileModal"
>
@@ -498,29 +317,85 @@ async function toggleWriteLog() {
</button>
</section>
<!-- Rename Modal -->
<div v-if="renameFileTarget" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeRenameModal">
<button class="absolute inset-0 bg-black/45" @click="closeRenameModal" />
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5">
<p class="text-base font-semibold text-foreground">{{ t('book.detail.files.renameModal.title') }}</p>
<!--
From lg the folder and the inspector sit side by side. The rail is sized in rem rather than a
fraction so it stays legible as the pane grows, and the inspector takes every pixel left.
-->
<div
v-else
class="grid grid-cols-1 gap-3.5 lg:min-h-0 lg:flex-1 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[22rem_minmax(0,1fr)] xl:gap-5 2xl:grid-cols-[25rem_minmax(0,1fr)]"
>
<FileTreeRail
v-show="!isCompact || !compactShowsInspector"
:groups="groups"
:format-shares="formatShares"
:folder-name="folderSegments.relative.at(-1) ?? '/'"
:folder-path="folderSegments.relative.join('/')"
:total-bytes="totalBytes"
:selected-id="selectedFile?.id ?? null"
:file-count="files.length"
@select="handleSelect"
/>
<FileInspector
v-if="selectedFile"
v-show="!isCompact || compactShowsInspector"
:book="book"
:file="selectedFile"
:siblings="siblingFiles"
:all-files="files"
:audio-files="audioFiles"
:is-multi-track-audio="isMultiTrackAudio"
:runtime-seconds="runtimeSeconds"
:started-count="startedCount"
:file-count="files.length"
:format-count="formatShares.length"
:folder-relative="folderSegments.relative"
:write-log="writeLog"
:can-download="canDownload"
:can-edit="canEdit"
:can-delete="canDelete"
:show-back="isCompact"
:fits-without-scroll="paneFitsInspector"
@select="selectFile"
@open="openFile"
@download="downloadFile"
@rename="openRenameModal"
@remove="openDeleteModal"
@copy-path="copyPath"
@back="handleBack"
/>
</div>
<p v-if="copiedPathFileId != null" role="status" class="sr-only">{{ t('book.detail.files.pathCopied') }}</p>
<!-- Rename -->
<div v-if="renameTarget" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeRenameModal">
<button class="absolute inset-0 bg-black/45" :aria-label="t('common.cancel')" @click="closeRenameModal" />
<div
class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5"
role="dialog"
aria-modal="true"
>
<p class="text-base font-semibold">{{ t('book.detail.files.renameModal.title') }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ t('book.detail.files.renameModal.description') }}</p>
<div class="mt-4">
<label class="mt-4 block">
<span class="sr-only">{{ t('book.detail.files.renameModal.placeholder') }}</span>
<input
v-model="renameInput"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
:placeholder="t('book.detail.files.renameModal.placeholder')"
:aria-invalid="renameError ? 'true' : undefined"
@keyup.enter="submitRename"
/>
</div>
</label>
<p v-if="renameError" role="alert" class="mt-2 text-xs text-destructive">{{ renameError }}</p>
<div class="mt-4 flex items-center justify-end gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
@click="closeRenameModal"
>
<button class="rounded-md border border-border px-3 py-2 text-sm transition-colors hover:bg-muted" @click="closeRenameModal">
{{ t('common.cancel') }}
</button>
<button
class="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
class="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
:disabled="renaming"
@click="submitRename"
>
@@ -530,27 +405,29 @@ async function toggleWriteLog() {
</div>
</div>
<!-- Delete Modal -->
<div v-if="deleteFileTarget" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeDeleteModal">
<button class="absolute inset-0 bg-black/45" @click="closeDeleteModal" />
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5">
<p class="text-base font-semibold text-foreground">{{ t('book.detail.files.deleteModal.title') }}</p>
<!-- Delete -->
<div v-if="deleteTarget" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeDeleteModal">
<button class="absolute inset-0 bg-black/45" :aria-label="t('common.cancel')" @click="closeDeleteModal" />
<div
class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5"
role="dialog"
aria-modal="true"
>
<p class="text-base font-semibold">{{ t('book.detail.files.deleteModal.title') }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ t('book.detail.files.deleteModal.description', { filename: deleteFileTarget.filename }) }}
{{ t('book.detail.files.deleteModal.description', { filename: deleteTarget.leaf }) }}
</p>
<p v-if="deleteError" role="alert" class="mt-2 text-xs text-destructive">{{ deleteError }}</p>
<div class="mt-4 flex items-center justify-end gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
@click="closeDeleteModal"
>
<button class="rounded-md border border-border px-3 py-2 text-sm transition-colors hover:bg-muted" @click="closeDeleteModal">
{{ t('common.cancel') }}
</button>
<button
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 transition-colors"
:disabled="deletingFile"
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-50"
:disabled="deleting"
@click="confirmDelete"
>
{{ deletingFile ? t('book.detail.files.deleting') : t('common.delete') }}
{{ deleting ? t('book.detail.files.deleting') : t('common.delete') }}
</button>
</div>
</div>
@@ -0,0 +1,253 @@
import { computed, ref, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS, type BookDetail, type BookDetailFile } from '@bookorbit/types'
import { api } from '@/lib/api'
import { applyCommonStem } from '@/features/book/lib/filename-stem'
/** Per-file reading position. Mirrors the row shape `GET /api/v1/books/:id/progress` returns. */
export type FileProgress = {
percentage: number
updatedAt: string | null
}
/** Reader families, in the order a folder reads best: what you open, then what supports it. */
const GROUP_ORDER = ['ebook', 'document', 'comic', 'audio', 'extras'] as const
export type FileGroupKey = (typeof GROUP_ORDER)[number]
export type TreeFile = BookDetailFile & {
/** Lower-cased format, or an empty string when the scan could not name one. */
formatKey: string
group: FileGroupKey
/** Filename with no directory part, always present even when `filename` is null. */
leaf: string
/** What the row shows: the tail when the group's stem applies, the whole filename otherwise. */
display: string
/** 1-based position among the audio files, for a multi-track audiobook. */
track: number | null
progress: FileProgress | null
openable: boolean
isAudio: boolean
}
export type FileGroup = {
key: FileGroupKey
label: string
/** The prefix every row in this group gave up, shown once in the group header. */
stem: string
files: TreeFile[]
}
export type FormatShare = {
format: string
count: number
sizeBytes: number
/** Share of the folder's bytes, 0-1. */
fraction: number
}
export type SortKey = 'name' | 'format' | 'size' | 'date'
export type SortDirection = 'asc' | 'desc'
function groupOf(format: string): FileGroupKey {
const group = FORMAT_TO_GROUP[format]
if (group === 'audio') return 'audio'
if (group === 'pdf') return 'document'
if (group === 'cbx') return 'comic'
return group === 'epub' ? 'ebook' : 'extras'
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
export function useBookFileTree(book: Ref<BookDetail>) {
const { t } = useI18n()
const sortKey = ref<SortKey>('name')
const sortDirection = ref<SortDirection>('asc')
const selectedFileId = ref<number | null>(null)
const progressByFileId = ref<Record<number, FileProgress>>({})
const files = computed<TreeFile[]>(() => {
const audioIds = new Map<number, number>()
let track = 0
for (const file of book.value.files) {
if (file.format && FORMAT_TO_GROUP[file.format] === 'audio') {
track += 1
audioIds.set(file.id, track)
}
}
return book.value.files.map((file) => {
const formatKey = file.format?.toLowerCase() ?? ''
return {
...file,
formatKey,
group: formatKey ? groupOf(formatKey) : 'extras',
leaf: file.filename ?? file.absolutePath.split('/').pop() ?? '-',
display: file.filename ?? file.absolutePath.split('/').pop() ?? '-',
track: audioIds.get(file.id) ?? null,
progress: progressByFileId.value[file.id] ?? null,
openable: READER_OPENABLE_FORMATS.has(formatKey),
isAudio: FORMAT_TO_GROUP[formatKey] === 'audio',
}
})
})
const audioFiles = computed(() => files.value.filter((file) => file.isAudio))
const isMultiTrackAudio = computed(() => audioFiles.value.length > 1)
const totalBytes = computed(() => files.value.reduce((total, file) => total + (file.sizeBytes ?? 0), 0))
const runtimeSeconds = computed(() => {
const declared = book.value.audioMetadata?.durationSeconds
if (declared != null) return declared
const summed = audioFiles.value.reduce((total, file) => total + (file.durationSeconds ?? 0), 0)
return summed > 0 ? summed : null
})
const formatShares = computed<FormatShare[]>(() => {
const byFormat = new Map<string, FormatShare>()
for (const file of files.value) {
const format = file.formatKey || '?'
const entry = byFormat.get(format) ?? { format, count: 0, sizeBytes: 0, fraction: 0 }
entry.count += 1
entry.sizeBytes += file.sizeBytes ?? 0
byFormat.set(format, entry)
}
const total = totalBytes.value
return [...byFormat.values()]
.map((entry) => ({ ...entry, fraction: total > 0 ? entry.sizeBytes / total : 0 }))
.sort((a, b) => b.sizeBytes - a.sizeBytes)
})
function compare(a: TreeFile, b: TreeFile): number {
const direction = sortDirection.value === 'asc' ? 1 : -1
switch (sortKey.value) {
case 'format':
return direction * (collator.compare(a.formatKey, b.formatKey) || collator.compare(a.leaf, b.leaf))
case 'size':
return direction * ((a.sizeBytes ?? 0) - (b.sizeBytes ?? 0))
case 'date':
return direction * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
default:
return direction * collator.compare(a.leaf, b.leaf)
}
}
const groupLabels: Record<FileGroupKey, string> = {
ebook: 'book.detail.files.groups.ebook',
document: 'book.detail.files.groups.document',
comic: 'book.detail.files.groups.comic',
audio: 'book.detail.files.groups.audio',
extras: 'book.detail.files.groups.extras',
}
const groups = computed<FileGroup[]>(() => {
const byGroup = new Map<FileGroupKey, TreeFile[]>()
for (const file of files.value) {
const bucket = byGroup.get(file.group)
if (bucket) bucket.push(file)
else byGroup.set(file.group, [file])
}
return GROUP_ORDER.filter((key) => byGroup.has(key)).map((key) => {
const sorted = [...(byGroup.get(key) ?? [])].sort(compare)
const { stem, names } = applyCommonStem(sorted.map((file) => file.leaf))
return {
key,
label: t(groupLabels[key]),
stem,
files: sorted.map((file, index) => ({ ...file, display: names[index]?.display ?? file.leaf })),
}
})
})
const flatFiles = computed(() => groups.value.flatMap((group) => group.files))
const selectedFile = computed<TreeFile | null>(() => {
const list = flatFiles.value
if (list.length === 0) return null
return list.find((file) => file.id === selectedFileId.value) ?? list.find((file) => file.role === 'primary') ?? list[0] ?? null
})
/** Every other file worth switching to: a thirty-five track audiobook counts once, not thirty-five times. */
const siblingFiles = computed<TreeFile[]>(() => {
const current = selectedFile.value
if (!current) return []
const rest = flatFiles.value.filter((file) => file.id !== current.id && !(isMultiTrackAudio.value && file.isAudio))
if (isMultiTrackAudio.value && !current.isAudio && audioFiles.value[0]) rest.push(audioFiles.value[0])
return rest
})
const startedCount = computed(() => files.value.filter((file) => (file.progress?.percentage ?? 0) > 0).length)
/** Library root split off the front, so a row never repeats ninety characters of absolute path. */
const folderSegments = computed(() => {
const segments = (book.value.folderPath ?? '').split('/').filter(Boolean)
const libraryIndex = segments.indexOf(book.value.libraryName)
const cut = libraryIndex >= 0 ? libraryIndex : Math.max(0, segments.length - 3)
return { root: `/${segments.slice(0, cut).join('/')}`, relative: segments.slice(cut) }
})
function selectFile(id: number) {
selectedFileId.value = id
}
function toggleSort(key: SortKey) {
if (sortKey.value === key) sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
else {
sortKey.value = key
sortDirection.value = 'asc'
}
}
let progressRequestId = 0
async function loadProgress() {
const requestId = ++progressRequestId
try {
const response = await api(`/api/v1/books/${book.value.id}/progress`)
if (!response.ok) return
const rows = (await response.json()) as { fileId: number; percentage: number; updatedAt: string | null }[]
if (requestId !== progressRequestId) return
const next: Record<number, FileProgress> = {}
for (const row of rows) {
if (!Number.isFinite(row.fileId)) continue
next[row.fileId] = { percentage: row.percentage, updatedAt: row.updatedAt }
}
progressByFileId.value = next
} catch {
if (requestId === progressRequestId) progressByFileId.value = {}
}
}
watch(
() => book.value.id,
() => {
selectedFileId.value = null
progressByFileId.value = {}
void loadProgress()
},
{ immediate: true },
)
return {
files,
groups,
flatFiles,
audioFiles,
isMultiTrackAudio,
totalBytes,
runtimeSeconds,
formatShares,
selectedFile,
selectedFileId,
siblingFiles,
startedCount,
folderSegments,
sortKey,
sortDirection,
selectFile,
toggleSort,
reloadProgress: loadProgress,
}
}
@@ -0,0 +1,67 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { applyCommonStem, commonStem, splitExtension, stemTail } from '../filename-stem'
const audiobook = Array.from({ length: 34 }, (_, index) => `01. The Three-Body Problem (2014)-Part${String(index + 2).padStart(2, '0')}.mp3`)
describe('commonStem', () => {
it('hoists the stem a multi-part audiobook repeats on every track', () => {
expect(commonStem(audiobook)).toBe('01. The Three-Body Problem (2014)')
})
it('survives a single outlier that shares no part suffix', () => {
expect(commonStem([...audiobook, '01. The Three-Body Problem (2014).mp3'])).toBe('01. The Three-Body Problem (2014)')
})
it('cuts back to a word boundary so rows read Part02 rather than 02', () => {
const stem = commonStem(audiobook)
expect(stemTail(audiobook[0], stem)).toBe('Part02.mp3')
})
it('cuts a stem that ends at a separator to the whole token', () => {
const names = ['Show - Episode 01.mp3', 'Show - Episode 02.mp3', 'Show - Episode 03.mp3', 'Show - Episode 10.mp3']
expect(commonStem(names)).toBe('Show - Episode')
expect(stemTail(names[0], commonStem(names))).toBe('01.mp3')
})
it('refuses a stem when the tails would only be extensions', () => {
expect(commonStem(['Ticktock - Dean Koontz.azw3', 'Ticktock - Dean Koontz.epub', 'Ticktock - Dean Koontz.mobi'])).toBe('')
})
it('refuses a stem whose tails are all the same word', () => {
expect(commonStem(['Ticktock - Dean Koontz.azw3', 'Ticktock - Dean Koontz.epub', 'Ticktock - Dean Koontz.lit'])).toBe('')
})
it('leaves short lists alone', () => {
expect(commonStem(['Book (2007).epub', 'Book (2007).mobi'])).toBe('')
})
it('leaves unrelated names alone', () => {
expect(commonStem(['cover.jpg', 'metadata.opf', 'Ticktock.epub'])).toBe('')
})
})
describe('applyCommonStem', () => {
it('shortens the names the stem fits and leaves the outlier whole', () => {
const { stem, names } = applyCommonStem([...audiobook, '01. The Three-Body Problem (2014).mp3'])
expect(stem).toBe('01. The Three-Body Problem (2014)')
expect(names[0]).toEqual({ display: 'Part02.mp3', stem })
expect(names.at(-1)).toEqual({ display: '01. The Three-Body Problem (2014).mp3', stem: '' })
})
it('returns every name untouched when there is no stem', () => {
const names = ['Ticktock - Dean Koontz.epub', 'Ticktock - Dean Koontz.mobi', 'cover.jpg']
expect(applyCommonStem(names).names.map((entry) => entry.display)).toEqual(names)
})
})
describe('splitExtension', () => {
it('keeps the extension separable so it cannot be truncated away', () => {
expect(splitExtension('Ticktock - Dean Koontz.epub')).toEqual({ base: 'Ticktock - Dean Koontz', extension: '.epub' })
})
it('handles a name with no extension', () => {
expect(splitExtension('Audiobook')).toEqual({ base: 'Audiobook', extension: '' })
})
})
@@ -0,0 +1,99 @@
/**
* Thirty-five rows that all begin `01. The Three-Body Problem (2014)-` say the stem thirty-five
* times and the part number once. Hoisting the stem into the group header and leaving the rows to
* carry only what differs is the difference between a scannable track list and a wall of near
* identical strings.
*/
const SEPARATORS = /[\s\-_.]+$/
const LEADING_SEPARATORS = /^[\s\-_.)\]]+/
const TRAILING_WORD = /[A-Za-z0-9]+$/
const WITH_EXTENSION = /^(.+)\.[a-z0-9]{1,8}$/i
/** Shortest stem worth hoisting: below this the rows lose more than they gain. */
const MIN_STEM_LENGTH = 8
/** A stem has to describe most of the folder, not just the two files that happen to sort together. */
const MIN_COVERAGE = 0.7
/** What is left of `name` once `stem` and any separator joining the two are removed. */
export function stemTail(name: string, stem: string): string | null {
return name.startsWith(stem) ? name.slice(stem.length).replace(LEADING_SEPARATORS, '') : null
}
/** The tail without its extension, or null when the tail is not a usable filename in its own right. */
function tailBase(name: string, stem: string): string | null {
const tail = stemTail(name, stem)
if (tail == null) return null
const base = WITH_EXTENSION.exec(tail)?.[1]
return base != null && base.length >= 2 ? base : null
}
/**
* A stem earns its place only when most names keep a distinct, still-readable tail. Without the
* distinctness test a shelf of `Ticktock - Dean Koontz.epub` would render as a column of `Koontz`.
*/
function isUsableStem(stem: string, names: string[]): boolean {
if (stem.length < MIN_STEM_LENGTH) return false
const bases = names.map((name) => tailBase(name, stem)).filter((base): base is string => base != null)
const required = Math.max(3, Math.ceil(names.length * MIN_COVERAGE))
return bases.length >= required && new Set(bases).size >= Math.ceil(bases.length * MIN_COVERAGE)
}
/**
* The stem shared by `names`, or an empty string when there is nothing worth hoisting.
*
* A plain common prefix is too brittle for a real folder: one stray `Book (2014).mp3` among
* thirty-four `Book (2014)-PartNN.mp3` collapses it to nothing. Taking the median of neighbouring
* prefixes instead lets the outliers keep their full name without costing everyone else theirs.
*/
export function commonStem(names: string[]): string {
if (names.length < 3) return ''
const sorted = [...names].sort()
const prefixLengths: number[] = []
for (let index = 0; index < sorted.length - 1; index += 1) {
const left = sorted[index] ?? ''
const right = sorted[index + 1] ?? ''
let shared = 0
while (shared < left.length && shared < right.length && left[shared] === right[shared]) shared += 1
prefixLengths.push(shared)
}
prefixLengths.sort((a, b) => a - b)
const medianLength = prefixLengths[Math.floor(prefixLengths.length / 2)] ?? 0
const median = (sorted[0] ?? '').slice(0, medianLength).replace(SEPARATORS, '')
// Cutting back to a word boundary turns rows reading "02.mp3" into "Part02.mp3".
const withoutDanglingWord = median.replace(TRAILING_WORD, '').replace(SEPARATORS, '')
if (isUsableStem(withoutDanglingWord, names)) return withoutDanglingWord
return isUsableStem(median, names) ? median : ''
}
export type StemmedName = {
/** What the row shows: the tail when the stem applies, the whole filename when it does not. */
display: string
/** The stem this name actually gave up, so an outlier can be told apart from the rest. */
stem: string
}
/** Applies {@link commonStem} to a list, leaving names it does not fit untouched. */
export function applyCommonStem(names: string[]): { stem: string; names: StemmedName[] } {
const stem = commonStem(names)
return {
stem,
names: names.map((name) => {
const tail = stem ? (tailBase(name, stem) != null ? stemTail(name, stem) : null) : null
return tail ? { display: tail, stem } : { display: name, stem: '' }
}),
}
}
/**
* Splits a filename so the extension can be rendered as its own element. A files list that
* truncates `Ticktock - Dean Koontz.epub` to `Ticktock - Dean ...` has hidden the one thing it
* exists to show, so the extension never shares a text node with the part that can be clipped.
*/
export function splitExtension(name: string): { base: string; extension: string } {
const match = /^(.*)(\.[A-Za-z0-9]{1,8})$/.exec(name)
return match ? { base: match[1] ?? name, extension: match[2] ?? '' } : { base: name, extension: '' }
}
@@ -0,0 +1,80 @@
import { BOOK_FILE_WRITE_FIELD_LABELS, type BookDetail, type BookFileWriteField } from '@bookorbit/types'
export type WriteBackField = {
field: BookFileWriteField
label: string
/** The value that would land in the file, or null when the book has nothing to write. */
value: string | null
}
/** A synopsis is a paragraph; in a two-column value list it only needs to prove it is there. */
const DESCRIPTION_PREVIEW_LENGTH = 90
function joinNames(values: readonly ({ name: string } | string)[] | null | undefined): string | null {
const joined = (values ?? [])
.map((entry) => (typeof entry === 'string' ? entry : entry.name))
.filter(Boolean)
.join(', ')
return joined || null
}
function plainText(html: string | null): string | null {
if (!html) return null
const text = html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
return text.length > DESCRIPTION_PREVIEW_LENGTH ? `${text.slice(0, DESCRIPTION_PREVIEW_LENGTH)}…` : text
}
/**
* What write-back would actually put in the file. The tab has always been able to say which fields
* sync; it has never been able to say what they contain, which is the question you ask before
* letting something rewrite your library.
*/
export function resolveWriteBackFields(book: BookDetail, fields: readonly BookFileWriteField[]): WriteBackField[] {
return fields.map((field) => ({
field,
label: BOOK_FILE_WRITE_FIELD_LABELS[field] ?? field,
value: resolveValue(book, field),
}))
}
function resolveValue(book: BookDetail, field: BookFileWriteField): string | null {
switch (field) {
case 'authors':
return joinNames(book.authors)
case 'narrators':
return joinNames(book.audioMetadata?.narrators)
case 'genres':
return joinNames(book.genres)
case 'tags':
return joinNames(book.tags)
case 'description':
return plainText(book.description)
case 'coverBytes':
return book.coverSource
case 'seriesIndex':
return book.seriesIndex != null ? String(book.seriesIndex) : null
case 'seriesName':
return book.seriesName
case 'comicIssueNumber':
return book.comicMetadata?.issueNumber ?? null
case 'comicVolumeName':
return book.comicMetadata?.volumeName ?? null
default: {
const direct = (book as unknown as Record<string, unknown>)[field]
if (direct != null && direct !== '') return Array.isArray(direct) ? joinNames(direct) : String(direct)
const comic = book.comicMetadata as unknown as Record<string, unknown> | null
const fromComic = comic?.[field]
if (Array.isArray(fromComic)) return joinNames(fromComic)
if (fromComic != null && fromComic !== '') return String(fromComic)
// Provider ids arrive keyed without the `Id` suffix the write field carries.
const provider = book.providerIds[field.replace(/Id$/, '') as keyof typeof book.providerIds]
return provider != null && provider !== '' ? String(provider) : null
}
}
}