feat(books): rebuild the Files tab around the file list

Replace the tree-plus-inspector split with a hero strip, one Files card and a
rail holding only the selected file and a write-back summary. The nine stacked
bands stated a file's format five times and its role three, and the 28-row
metadata dump was the largest block on a tab about files; that table now lives
behind a link to Edit Metadata. The tab reflows on its own width via container
queries, so a collapsed sidebar and a narrow window behave alike, and the rail
becomes a bottom sheet on a phone.
This commit is contained in:
neon
2026-08-27 20:57:09 +00:00
parent 9652e985d7
commit 58c3ef8b95
10 changed files with 1275 additions and 830 deletions
@@ -0,0 +1,287 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, Copy, Download, Eye, Headphones, MoreVertical } from '@lucide/vue'
import { formatBytes } from '@/lib/formatting'
import { formatDate, formatPercent, formatRelativeFromNow } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
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'
const RING_SIZE = 42
const RING_RADIUS = RING_SIZE / 2 - 3
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const props = defineProps<{
file: TreeFile
audioFiles: TreeFile[]
isMultiTrackAudio: boolean
runtimeSeconds: number | null
isWriteTarget: boolean
canDownload: boolean
canEdit: boolean
canDelete: boolean
}>()
const emit = defineEmits<{
open: [file: TreeFile, mode?: 'peek']
download: [file: TreeFile]
rename: [file: TreeFile]
remove: [file: TreeFile]
copyPath: [file: TreeFile]
}>()
const { t } = useI18n()
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 progressPercent = computed(() => props.file.progress?.percentage ?? 0)
const isAudioTrack = computed(() => props.isMultiTrackAudio && props.file.isAudio && props.file.track != null)
const specs = computed(() => {
const rows: { label: string; value: string }[] = [{ label: t('book.detail.files.spec.size'), value: formatBytes(props.file.sizeBytes) }]
const length = formatDuration(props.file.durationSeconds)
if (length) rows.push({ label: t('book.detail.files.spec.length'), value: length })
rows.push({
label: t('book.detail.files.spec.added'),
value: formatDate(new Date(props.file.createdAt), { year: 'numeric', month: 'short', day: 'numeric' }),
})
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 ringOffset = computed(() => RING_CIRCUMFERENCE * (1 - Math.min(progressPercent.value, 100) / 100))
const ribbonLabel = computed(() =>
t('book.detail.files.trackFrom', {
track: props.file.track,
total: props.audioFiles.length,
time: formatDuration(trackStartSeconds.value) ?? '0s',
}),
)
const openLabel = computed(() => {
if (props.file.isAudio) return t('book.detail.files.play')
return progressPercent.value > 0 ? t('book.detail.files.resume') : t('book.detail.files.read')
})
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)
}
</script>
<template>
<section class="flex shrink-0 flex-col rounded-xl border border-border bg-card" :aria-label="t('book.detail.files.thisFile')">
<div class="flex h-[38px] shrink-0 items-center border-b border-border px-3.5">
<h3 class="text-[10.5px] font-bold uppercase leading-none tracking-[0.09em] text-muted-foreground">{{ t('book.detail.files.thisFile') }}</h3>
</div>
<div class="flex flex-col gap-3.5 px-3.5 py-3.5">
<div class="flex items-start gap-3">
<FileFormatGlyph :format="file.formatKey" size="lg" />
<div class="min-w-0 flex-1">
<p class="line-clamp-3 text-[13.5px] font-semibold leading-snug [overflow-wrap:anywhere]" :title="file.leaf">{{ file.leaf }}</p>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<span
v-if="file.role === 'primary'"
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.roles.primary') }}
</span>
<span
v-if="isWriteTarget"
class="inline-flex h-[18px] items-center rounded bg-success/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-success"
>
{{ t('book.detail.files.writeTarget') }}
</span>
<span
v-if="isAudioTrack"
class="inline-flex h-[18px] items-center rounded bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground tabular-nums"
>
{{ t('book.detail.files.trackOf', { track: file.track, total: audioFiles.length }) }}
</span>
</div>
</div>
</div>
<div class="flex h-[34px] items-center gap-2 rounded-lg border border-border bg-muted/40 px-2">
<code class="path-tail min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted-foreground" :title="file.absolutePath">
<bdi>{{ file.absolutePath }}</bdi>
</code>
<button
class="inline-flex size-[22px] shrink-0 items-center justify-center rounded 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" aria-hidden="true" />
</button>
</div>
<dl>
<div
v-for="spec in specs"
:key="spec.label"
class="flex h-[31px] items-center justify-between gap-3.5 border-b border-border last:border-b-0"
>
<dt class="shrink-0 text-[12.5px] leading-none text-muted-foreground">{{ spec.label }}</dt>
<dd class="truncate text-[13px] font-medium leading-none tabular-nums">{{ spec.value }}</dd>
</div>
</dl>
<!-- Where this track sits in the whole runtime -->
<div v-if="isAudioTrack" class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2.5 text-xs leading-none text-muted-foreground">
<span class="truncate">
{{ t('book.detail.files.trackFrom', { track: file.track, total: audioFiles.length, time: formatDuration(trackStartSeconds) ?? '0s' }) }}
</span>
<span v-if="runtimeSeconds" class="shrink-0 tabular-nums">
{{ t('book.detail.files.totalRuntime', { duration: formatDuration(runtimeSeconds) }) }}
</span>
</div>
<div class="flex h-5 gap-px overflow-hidden rounded-md" role="img" :aria-label="ribbonLabel">
<span
v-for="track in audioFiles"
:key="track.id"
class="h-full min-w-px"
: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)} 22%, transparent)`,
}"
/>
</div>
</div>
<div class="flex items-center gap-3">
<svg :width="RING_SIZE" :height="RING_SIZE" :viewBox="`0 0 ${RING_SIZE} ${RING_SIZE}`" class="shrink-0" aria-hidden="true">
<circle :cx="RING_SIZE / 2" :cy="RING_SIZE / 2" :r="RING_RADIUS" fill="none" stroke="var(--muted)" stroke-width="3" />
<circle
:cx="RING_SIZE / 2"
:cy="RING_SIZE / 2"
:r="RING_RADIUS"
fill="none"
stroke="var(--primary)"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="RING_CIRCUMFERENCE"
:stroke-dashoffset="ringOffset"
:transform="`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`"
/>
<text x="50%" y="50%" text-anchor="middle" dy="0.35em" fill="currentColor" font-size="12" font-weight="600">
{{ formatPercent(progressPercent / 100) }}
</text>
</svg>
<div class="min-w-0 flex-1">
<p class="text-[12.5px] font-medium leading-snug">
{{
progressPercent > 0
? t('book.detail.files.throughCopy', { percent: formatPercent(progressPercent / 100) })
: t('book.detail.files.notOpened')
}}
</p>
<p class="mt-0.5 text-[11.5px] leading-snug text-muted-foreground">
{{
file.progress?.updatedAt
? t('book.detail.files.lastRead', { time: formatRelativeFromNow(new Date(file.progress.updatedAt)) })
: t('book.detail.files.noPosition')
}}
</p>
</div>
</div>
<div class="flex items-center gap-1.5">
<button
v-if="file.openable"
class="inline-flex h-[34px] flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-semibold 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" aria-hidden="true" />
<BookOpen v-else class="size-4" aria-hidden="true" />
{{ openLabel }}
</button>
<Tooltip v-if="file.openable && !file.isAudio">
<TooltipTrigger as-child>
<button
class="inline-flex size-[34px] items-center justify-center rounded-lg 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.peek')"
@click="handlePeek"
>
<Eye class="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.files.peek') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canDownload">
<TooltipTrigger as-child>
<button
class="inline-flex size-[34px] items-center justify-center rounded-lg 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.download')"
@click="handleDownload"
>
<Download class="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('book.detail.files.download') }}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex size-[34px] items-center justify-center rounded-lg 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" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleCopyPath">{{ t('book.detail.files.copyPath') }}</DropdownMenuItem>
<DropdownMenuItem v-if="canEdit" @click="handleRename">{{ 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>
</section>
</template>
<style scoped>
/*
* A path is read from its tail: the volume it starts on matters far less than the folder and the
* file it ends on. Flipping the box's direction moves the ellipsis to the front, and the `bdi`
* keeps the path itself rendering left to right.
*/
.path-tail {
direction: rtl;
text-align: left;
}
</style>
@@ -7,23 +7,23 @@ const props = withDefaults(defineProps<{ format: string | null; size?: 'sm' | 'm
const sizeClass = computed(
() =>
({
sm: 'h-7 w-[1.4rem] pb-[3px] text-[7px]',
sm: 'h-[2.375rem] w-[1.9375rem] pb-[4px] text-[9.5px]',
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 cornerClass = computed(() => (props.size === 'lg' ? 'size-[15px]' : props.size === 'sm' ? 'size-2.5' : 'size-2'))
const glyphStyle = computed(() => {
const color = formatColorVar(props.format)
const notch = props.size === 'lg' ? '15px' : '8px'
const notch = props.size === 'lg' ? '15px' : props.size === 'sm' ? '10px' : '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',
borderRadius: props.size === 'lg' ? '5px 0 5px 5px' : props.size === 'sm' ? '4px 0 4px 4px' : '3px 0 3px 3px',
}
})
</script>
@@ -1,494 +0,0 @@
<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,329 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, MoreVertical } from '@lucide/vue'
import { formatBytes } from '@/lib/formatting'
import { formatPercent } from '@/i18n/formatters'
import { splitExtension } from '@/features/book/lib/filename-stem'
import type { FileGroup, TreeFile } from '@/features/book/composables/useBookFileTree'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import FileFormatGlyph from './FileFormatGlyph.vue'
import FileRoleBadge from './FileRoleBadge.vue'
/** Rows shown before an expanded audiobook offers "show all"; two screens is already generous. */
const TRACK_PREVIEW_LIMIT = 24
const props = defineProps<{
groups: FileGroup[]
selectedId: number | null
runtimeLabel: string | null
canDownload: boolean
canEdit: boolean
canDelete: boolean
}>()
const emit = defineEmits<{
select: [id: number]
open: [file: TreeFile]
download: [file: TreeFile]
rename: [file: TreeFile]
remove: [file: TreeFile]
copyPath: [file: TreeFile]
}>()
const { t } = useI18n()
/**
* A thirty-five track audiobook is one thing you own, not thirty-five decisions. It rolls up to a
* single Play row and opens on request, which is the difference between a page and a wall.
*/
const expandedGroups = ref<Set<string>>(new Set())
const showAllTracks = ref<Set<string>>(new Set())
watch(
() => props.groups.map((group) => group.key).join(','),
() => {
expandedGroups.value = new Set()
showAllTracks.value = new Set()
},
)
function isCollapsible(group: FileGroup): boolean {
return group.key === 'audio' && group.files.length > 1
}
function isRolled(group: FileGroup): boolean {
return group.key === 'audio' && group.files.length > 1 && !expandedGroups.value.has(group.key)
}
function visibleFiles(group: FileGroup): TreeFile[] {
if (group.key !== 'audio' || showAllTracks.value.has(group.key)) return group.files
return group.files.slice(0, TRACK_PREVIEW_LIMIT)
}
function hiddenCount(group: FileGroup): number {
return group.files.length - visibleFiles(group).length
}
function toggleGroup(group: FileGroup) {
const next = new Set(expandedGroups.value)
if (next.has(group.key)) next.delete(group.key)
else next.add(group.key)
expandedGroups.value = next
}
function playGroup(group: FileGroup) {
const first = group.files[0]
if (first) emit('open', first)
}
function revealAllTracks(group: FileGroup) {
showAllTracks.value = new Set(showAllTracks.value).add(group.key)
}
function groupBytes(group: FileGroup): number {
return group.files.reduce((total, file) => total + (file.sizeBytes ?? 0), 0)
}
function durationLabel(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) })
}
/** The stacked phone row states format, size and length once, where the columns cannot fit. */
function stackedMeta(file: TreeFile): string {
const parts = [file.formatKey.toUpperCase() || '?', formatBytes(file.sizeBytes)]
const duration = durationLabel(file.durationSeconds)
if (duration) parts.push(duration)
return parts.join(' · ')
}
function progressOf(file: TreeFile): number {
return file.progress?.percentage ?? 0
}
/** Group accents come from the reader-family tokens; `extras` has no accent of its own. */
const GROUP_ACCENT: Record<string, string> = {
ebook: 'var(--format-ebook)',
document: 'var(--format-document)',
comic: 'var(--format-comic)',
audio: 'var(--format-audio)',
extras: 'var(--format-other)',
}
function handleSelect(file: TreeFile) {
emit('select', file.id)
}
function handleOpen(file: TreeFile) {
emit('open', file)
}
function handleDownload(file: TreeFile) {
emit('download', file)
}
function handleRename(file: TreeFile) {
emit('rename', file)
}
function handleRemove(file: TreeFile) {
emit('remove', file)
}
function handleCopyPath(file: TreeFile) {
emit('copyPath', file)
}
</script>
<template>
<section
class="flex min-h-0 flex-col self-start overflow-hidden rounded-xl border border-border bg-card lg:max-h-full"
:aria-label="t('book.detail.files.filesHeading')"
>
<div class="flex h-[38px] shrink-0 items-center gap-2.5 border-b border-border px-3.5">
<h3 class="text-[10.5px] font-bold uppercase tracking-[0.09em] leading-none text-muted-foreground">
{{ t('book.detail.files.filesHeading') }}
</h3>
<span class="ml-auto truncate text-[11.5px] leading-none text-muted-foreground">{{ t('book.detail.files.groupedByReader') }}</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-2 pb-3 pt-1">
<template v-for="group in groups" :key="group.key">
<div class="flex h-[30px] items-center gap-2.5 px-2.5">
<span class="size-[7px] shrink-0 rounded-full" :style="{ backgroundColor: GROUP_ACCENT[group.key] }" aria-hidden="true" />
<h4 class="shrink-0 text-[11px] font-bold uppercase tracking-[0.07em] leading-none">{{ group.label }}</h4>
<span v-if="group.stem" class="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground" :title="group.stem"
>{{ group.stem }}&hellip;</span
>
<span class="ml-auto shrink-0 whitespace-nowrap text-[11.5px] leading-none text-muted-foreground tabular-nums">
{{ group.files.length }} &middot; {{ formatBytes(groupBytes(group)) }}
</span>
<button
v-if="isCollapsible(group)"
class="-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-expanded="!isRolled(group)"
:aria-label="isRolled(group) ? t('book.detail.files.showAllTracks') : t('book.detail.files.collapseTracks')"
@click="toggleGroup(group)"
>
<ChevronDown class="size-4 transition-transform" :class="isRolled(group) ? '' : 'rotate-180'" aria-hidden="true" />
</button>
</div>
<!-- Rolled-up audiobook -->
<div
v-if="isRolled(group)"
class="file-row my-0.5 flex min-h-[3.25rem] items-center gap-2.5 rounded-lg border border-border bg-muted/30 px-2.5"
>
<FileFormatGlyph :format="group.files[0]?.formatKey ?? 'mp3'" size="sm" />
<span class="min-w-0 flex-1">
<span class="block truncate text-[13.5px] leading-tight">
{{ t('book.detail.files.tracksRolled', { count: group.files.length }) }}
<span class="text-muted-foreground">&middot; {{ (group.files[0]?.formatKey ?? '').toUpperCase() }}</span>
</span>
<span class="row-stacked mt-1 hidden text-[11.5px] leading-none text-muted-foreground tabular-nums">
{{ [runtimeLabel, formatBytes(groupBytes(group))].filter(Boolean).join(' · ') }}
</span>
</span>
<span
v-if="runtimeLabel"
class="row-col w-[3.875rem] shrink-0 whitespace-nowrap text-right text-[12.5px] text-muted-foreground tabular-nums"
>
{{ runtimeLabel }}
</span>
<span class="row-col w-[4.625rem] shrink-0 whitespace-nowrap text-right text-[12.5px] text-muted-foreground tabular-nums">
{{ formatBytes(groupBytes(group)) }}
</span>
<span class="flex shrink-0 items-center justify-end gap-1.5">
<button
class="inline-flex h-7 min-w-[3.5rem] items-center justify-center gap-1.5 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="playGroup(group)"
>
{{ t('book.detail.files.play') }}
</button>
</span>
</div>
<!-- Files -->
<template v-else>
<div
v-for="file in visibleFiles(group)"
:key="file.id"
class="file-row relative flex min-h-[2.875rem] items-center gap-2.5 rounded-lg px-2.5 transition-colors"
:class="file.id === selectedId ? 'bg-primary/15 shadow-[inset_2px_0_0_var(--primary)]' : 'hover:bg-muted/50'"
>
<button
class="absolute inset-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
:aria-label="`${t('book.detail.files.openDetails')}: ${file.leaf}`"
:aria-current="file.id === selectedId ? 'true' : undefined"
@click="handleSelect(file)"
/>
<FileFormatGlyph :format="file.formatKey" size="sm" class="pointer-events-none relative" />
<span class="pointer-events-none relative min-w-0 flex-1">
<span class="row-name flex items-baseline text-[13.5px] leading-tight" :title="file.leaf">
<span class="row-base min-w-0 truncate">{{ splitExtension(file.display).base }}</span>
<span class="shrink-0 text-muted-foreground">{{ splitExtension(file.display).extension }}</span>
</span>
<span class="row-stacked mt-1 hidden text-[11.5px] leading-none text-muted-foreground tabular-nums">{{ stackedMeta(file) }}</span>
</span>
<FileRoleBadge v-if="file.role !== 'content' && file.role !== 'primary'" :role="file.role" class="pointer-events-none relative" />
<span
v-if="progressOf(file) > 0"
class="pointer-events-none relative inline-flex h-[18px] shrink-0 items-center rounded bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary tabular-nums"
>
{{ t('book.detail.files.percentRead', { percent: formatPercent(progressOf(file) / 100) }) }}
</span>
<span
v-if="durationLabel(file.durationSeconds)"
class="row-col pointer-events-none relative w-[3.875rem] shrink-0 whitespace-nowrap text-right text-[12.5px] text-muted-foreground tabular-nums"
>
{{ durationLabel(file.durationSeconds) }}
</span>
<span
class="row-col pointer-events-none relative w-[4.625rem] shrink-0 whitespace-nowrap text-right text-[12.5px] text-muted-foreground tabular-nums"
>
{{ formatBytes(file.sizeBytes) }}
</span>
<span class="relative flex shrink-0 items-center justify-end gap-1.5">
<button
v-if="file.openable"
class="inline-flex h-7 min-w-[3.5rem] items-center justify-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="
file.id === selectedId
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-input text-foreground hover:bg-muted'
"
@click="handleOpen(file)"
>
{{ file.isAudio ? t('book.detail.files.play') : progressOf(file) > 0 ? t('book.detail.files.resume') : t('book.detail.files.read') }}
</button>
<DropdownMenu>
<DropdownMenuTrigger 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.files.moreActions')"
>
<MoreVertical class="size-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="handleCopyPath(file)">{{ t('book.detail.files.copyPath') }}</DropdownMenuItem>
<DropdownMenuItem v-if="canDownload" @click="handleDownload(file)">{{ t('book.detail.files.download') }}</DropdownMenuItem>
<DropdownMenuItem v-if="canEdit" @click="handleRename(file)">{{ t('book.detail.files.rename') }}</DropdownMenuItem>
<DropdownMenuItem v-if="canDelete" class="text-destructive focus:text-destructive" @click="handleRemove(file)">
{{ t('common.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
<div v-if="hiddenCount(group) > 0" class="flex h-8 items-center gap-2.5 px-2.5">
<span class="text-[12.5px] text-muted-foreground tabular-nums">{{
t('book.detail.files.moreTracks', { count: hiddenCount(group) })
}}</span>
<button
class="rounded text-[12.5px] font-medium text-primary transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="revealAllTracks(group)"
>
{{ t('book.detail.files.showAllTracks') }}
</button>
</div>
</template>
</template>
</div>
</section>
</template>
<style scoped>
/*
* Below this the fixed columns cannot hold their numbers, so the row goes two-line: the name across
* the full width, its format, size and length on a second line, and the action stays on the right.
*/
@container filestab (max-width: 35rem) {
.file-row {
align-items: flex-start;
padding-block: 0.5rem;
}
.file-row .row-name {
display: block;
}
.file-row .row-base {
white-space: normal;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.file-row .row-stacked {
display: block;
}
.file-row .row-col {
display: none;
}
}
</style>
@@ -1,142 +0,0 @@
<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>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, ArrowUp, ArrowUpDown, CircleCheck, FilePlus, TriangleAlert } from '@lucide/vue'
import type { BookDetail } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import type { FormatShare, SortDirection, SortKey } from '@/features/book/composables/useBookFileTree'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const props = defineProps<{
book: BookDetail
fileCount: number
totalBytes: number
runtimeLabel: string | null
formatShares: FormatShare[]
folderSegments: string[]
canUpload: boolean
sortKey: SortKey
sortDirection: SortDirection
sortOptions: { key: SortKey; label: string }[]
}>()
const emit = defineEmits<{ sort: [key: SortKey]; addFile: [] }>()
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const hasCover = computed(() => props.book.coverSource !== null)
const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.updatedAt ?? props.book.addedAt))
const isMissing = computed(() => props.book.status === 'missing')
/**
* A format worth naming is worth a visible sliver, however little of the folder it owns; a hairline
* segment reads as a rendering fault rather than as "this one is small".
*/
const meterSegments = computed(() =>
props.formatShares.map((share) => ({
format: share.format,
color: formatColorVar(share.format),
width: `${Math.max(share.fraction * 100, 1.5)}%`,
})),
)
const meterLabel = computed(() =>
[t('book.detail.files.compositionAria'), props.formatShares.map((share) => share.format.toUpperCase()).join(', ')].join(': '),
)
function handleSort(key: SortKey) {
emit('sort', key)
}
function handleAddFile() {
emit('addFile')
}
</script>
<template>
<section class="files-hero flex shrink-0 items-center gap-3.5 rounded-xl border border-border bg-card px-3.5 py-3">
<img
v-if="hasCover"
:src="coverSrc"
alt=""
class="hero-cover h-[4.125rem] w-11 shrink-0 rounded-md border border-border object-cover shadow-sm"
loading="lazy"
decoding="async"
/>
<span
v-else
class="hero-cover flex h-[4.125rem] w-11 shrink-0 items-center justify-center rounded-md border border-border bg-muted"
aria-hidden="true"
/>
<div class="hero-id min-w-0 flex-1">
<h2 class="truncate text-base font-semibold leading-tight tracking-tight">{{ book.title ?? folderSegments.at(-1) }}</h2>
<nav class="hero-crumb mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" :aria-label="t('book.detail.files.folderAria')">
<template v-for="(segment, index) in folderSegments" :key="`${segment}-${index}`">
<span v-if="index > 0" class="crumb-slash shrink-0 opacity-40" aria-hidden="true">/</span>
<span class="crumb-part min-w-[1.5ch] truncate">{{ segment }}</span>
</template>
</nav>
</div>
<dl class="hero-stats flex shrink-0 items-center">
<div class="hero-stat px-4 text-right">
<dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.files') }}</dt>
<dd class="mt-1.5 text-base font-semibold leading-none tracking-tight tabular-nums">{{ fileCount }}</dd>
</div>
<div class="hero-stat border-l border-border px-4 text-right">
<dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.total') }}</dt>
<dd class="mt-1.5 whitespace-nowrap text-base font-semibold leading-none tracking-tight tabular-nums">{{ formatBytes(totalBytes) }}</dd>
</div>
<div v-if="runtimeLabel" class="hero-stat border-l border-border px-4 text-right">
<dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.runtime') }}</dt>
<dd class="mt-1.5 whitespace-nowrap text-base font-semibold leading-none tracking-tight tabular-nums">{{ runtimeLabel }}</dd>
</div>
<div class="hero-stat hero-meter min-w-[8.25rem] border-l border-border px-4">
<dt class="text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.detail.files.stats.formats') }}</dt>
<dd class="mt-1.5 flex h-4 items-center gap-2">
<span class="flex h-1.5 flex-1 gap-px overflow-hidden rounded-full bg-muted" role="img" :aria-label="meterLabel">
<span
v-for="segment in meterSegments"
:key="segment.format"
class="block h-full first:rounded-l-full last:rounded-r-full"
:style="{ width: segment.width, backgroundColor: segment.color }"
/>
</span>
<span class="shrink-0 text-[11.5px] leading-none text-muted-foreground tabular-nums">{{ formatShares.length }}</span>
</dd>
</div>
</dl>
<div class="hero-actions flex shrink-0 items-center gap-2 border-l border-border pl-4">
<span
class="inline-flex h-[18px] items-center gap-1 rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="isMissing ? 'bg-destructive/15 text-destructive' : 'bg-success/15 text-success'"
>
<TriangleAlert v-if="isMissing" class="size-2.5" aria-hidden="true" />
<CircleCheck v-else class="size-2.5" aria-hidden="true" />
{{ isMissing ? t('book.detail.files.missing') : t('book.detail.files.allPresent') }}
</span>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex size-[34px] items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('book.detail.files.sortAria')"
>
<ArrowUpDown class="size-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="option in sortOptions" :key="option.key" class="gap-2" @click="handleSort(option.key)">
<span class="flex-1">{{ option.label }}</span>
<template v-if="option.key === sortKey">
<ArrowUp v-if="sortDirection === 'asc'" class="size-3.5 text-primary" aria-hidden="true" />
<ArrowDown v-else class="size-3.5 text-primary" aria-hidden="true" />
</template>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
v-if="canUpload"
class="inline-flex h-[34px] items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleAddFile"
>
<FilePlus class="size-4" aria-hidden="true" />
{{ t('book.detail.files.addFile') }}
</button>
</div>
</section>
</template>
<style scoped>
/* The crumb spends its truncation budget on the ancestors; the folder itself always reads whole. */
.hero-crumb > .crumb-part {
flex: 0 1 auto;
}
.hero-crumb > .crumb-part:last-child {
flex: 0 0 auto;
color: color-mix(in oklch, var(--foreground) 78%, var(--muted-foreground));
}
/*
* The strip reflows on the tab's own width, not the window's: a collapsed sidebar makes a 1440
* window as roomy as a 1700 one, and the `md` sidebar makes an 834 tablet as tight as a phone.
*/
@container filestab (max-width: 45rem) {
.files-hero {
flex-wrap: wrap;
row-gap: 0.75rem;
}
.hero-id {
flex: 1 1 12rem;
}
/* Three truncated crumb segments read as damage; the folder the files live in survives whole. */
.hero-crumb .crumb-part:not(:last-child),
.hero-crumb span[aria-hidden='true'] {
display: none;
}
.hero-crumb::before {
content: '…/';
flex: none;
opacity: 0.5;
}
.hero-stats {
order: 3;
width: 100%;
justify-content: space-between;
}
.hero-stats .hero-stat:first-child {
padding-inline-start: 0;
}
.hero-actions {
order: 2;
margin-inline-start: auto;
border-inline-start: 0;
padding-inline-start: 0;
}
}
@container filestab (max-width: 35rem) {
.hero-cover {
height: 3.375rem;
width: 2.25rem;
}
.hero-stats {
order: 2;
flex-wrap: wrap;
row-gap: 0.75rem;
}
.hero-stat {
padding-inline: 0.75rem;
}
/* The meter needs a whole row once three numbers already share one. */
.hero-meter {
flex: 1 0 100%;
padding-inline-start: 0;
border-inline-start: 0;
}
.hero-actions {
order: 3;
width: 100%;
margin-inline-start: 0;
}
.hero-actions button:last-child {
flex: 1;
}
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronRight } from '@lucide/vue'
import type { BookDetail } from '@bookorbit/types'
import { formatRelativeFromNow } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { resolveWriteBackFields } from '@/features/book/lib/write-back-fields'
/** Enough field names to make the sentence concrete without turning it back into a table. */
const NAMED_FIELD_LIMIT = 5
const props = defineProps<{ book: BookDetail; canEdit: boolean }>()
const emit = defineEmits<{ editMetadata: [] }>()
const { t } = useI18n()
const status = computed(() => props.book.fileWriteStatus)
const writableFormats = computed(() => status.value?.writableFormats ?? [])
const fields = computed(() => resolveWriteBackFields(props.book, status.value?.writableFields ?? []))
const filled = computed(() => fields.value.filter((entry) => entry.value != null))
const fillFraction = computed(() => (fields.value.length === 0 ? 0 : filled.value.length / fields.value.length))
const targetFormat = computed(() => (writableFormats.value[0] ?? '').toUpperCase())
/**
* The old panel printed twenty-eight rows of book metadata on a tab about files. The question it
* was really answering is "what would a write put in there", which one sentence answers.
*/
const summary = computed(() => {
if (!status.value?.enabled) return t('book.detail.files.writeBackOff')
if (filled.value.length === 0) return t('book.detail.files.writtenNothing', { total: fields.value.length })
const named = filled.value.slice(0, NAMED_FIELD_LIMIT).map((entry) => entry.label)
const rest = filled.value.length - named.length
const list = named.join(', ')
if (rest <= 0) return t('book.detail.files.writtenSummaryAll', { fields: list, format: targetFormat.value })
return t('book.detail.files.writtenSummary', { fields: list, count: rest, format: targetFormat.value })
})
const runLabel = computed(() =>
props.book.lastWrittenAt
? t('book.detail.files.lastRun', { time: formatRelativeFromNow(new Date(props.book.lastWrittenAt)) })
: t('book.detail.files.neverRun'),
)
function handleEditMetadata() {
emit('editMetadata')
}
</script>
<template>
<section class="flex shrink-0 flex-col rounded-xl border border-border bg-card">
<div class="flex h-[38px] shrink-0 items-center gap-2.5 border-b border-border px-3.5">
<h3 class="text-[10.5px] font-bold uppercase leading-none tracking-[0.09em] text-muted-foreground">
{{ t('book.detail.files.writeBackShort') }}
</h3>
<span class="ml-auto truncate text-[11.5px] leading-none text-muted-foreground">{{ runLabel }}</span>
</div>
<div class="flex flex-col gap-2.5 px-3.5 py-3.5">
<div class="flex items-center gap-1.5">
<span
class="inline-flex h-[18px] items-center rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
:class="status?.enabled ? 'bg-success/15 text-success' : 'bg-muted text-muted-foreground'"
>
{{ status?.enabled ? t('book.detail.files.enabled') : t('book.detail.files.disabled') }}
</span>
<span
v-for="format in writableFormats"
:key="format"
class="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>
<span class="ml-auto shrink-0 text-[12.5px] leading-none text-muted-foreground tabular-nums">
{{ t('book.detail.files.fieldsSet', { filled: filled.length, total: fields.length }) }}
</span>
</div>
<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.round(fillFraction * 100)}%` }" />
</div>
<p class="text-[12.5px] leading-relaxed text-muted-foreground">{{ summary }}</p>
<button
v-if="canEdit"
class="-mx-1 inline-flex min-h-7 items-center gap-1 self-start rounded px-1 text-[12.5px] font-medium text-primary transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleEditMetadata"
>
{{ t('book.detail.files.editMetadata') }}
<ChevronRight class="size-3.5" aria-hidden="true" />
</button>
</div>
</section>
</template>
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue'
import { computed, nextTick, ref, toRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMediaQuery } from '@vueuse/core'
import { ArrowUpDown, CircleCheck, FilePlus, Files, Folder, TriangleAlert } from '@lucide/vue'
import type { BookDetail, WriteLogEntry } from '@bookorbit/types'
import { useElementSize } from '@vueuse/core'
import { FilePlus, Files, X } from '@lucide/vue'
import type { BookDetail } from '@bookorbit/types'
import { Permission } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
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 { 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 FilesHeroBar from '../files/FilesHeroBar.vue'
import FileListCard from '../files/FileListCard.vue'
import FileDetailCard from '../files/FileDetailCard.vue'
import WriteBackCard from '../files/WriteBackCard.vue'
import AddBookFileModal from './AddBookFileModal.vue'
const props = defineProps<{ book: BookDetail }>()
@@ -35,8 +35,6 @@ const {
runtimeSeconds,
formatShares,
selectedFile,
siblingFiles,
startedCount,
folderSegments,
sortKey,
sortDirection,
@@ -50,33 +48,36 @@ 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.
* The rail turns into a sheet on the CSS side at 40rem of *tab* width, so the decision to open one
* has to be measured the same way. A viewport media query disagrees with the stylesheet the moment
* the sidebar is involved: an 834px tablet still shows it, leaving the tab under 40rem while the
* viewport is nowhere near a phone, and the rail would have had no way to open at all.
*/
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)
const SHEET_MAX_TAB_WIDTH = 640
const tabRoot = ref<HTMLElement | null>(null)
const { width: tabWidth } = useElementSize(tabRoot)
const isPhone = computed(() => tabWidth.value > 0 && tabWidth.value < SHEET_MAX_TAB_WIDTH)
const sheetOpen = ref(false)
const sheetCloseButton = ref<HTMLButtonElement | null>(null)
/** Where the keyboard was before the sheet took over, so closing it puts focus back. */
let sheetOpener: HTMLElement | null = null
async function openSheet() {
sheetOpener = document.activeElement instanceof HTMLElement ? document.activeElement : null
sheetOpen.value = true
await nextTick()
requestAnimationFrame(() => sheetCloseButton.value?.focus())
}
watch(
() => props.book.id,
() => {
compactShowsInspector.value = false
sheetOpen.value = false
},
)
function handleSelect(id: number) {
selectFile(id)
compactShowsInspector.value = true
}
function handleBack() {
compactShowsInspector.value = false
}
watch(isPhone, (phone) => {
if (!phone) sheetOpen.value = false
})
const sortOptions = computed<{ key: SortKey; label: string }[]>(() => [
{ key: 'name', label: t('book.detail.files.sort.name') },
@@ -85,18 +86,35 @@ const sortOptions = computed<{ key: SortKey; label: string }[]>(() => [
{ key: 'date', label: t('book.detail.files.sortAdded') },
])
const activeSortLabel = computed(() => sortOptions.value.find((option) => option.key === sortKey.value)?.label ?? '')
function handleSort(key: SortKey) {
toggleSort(key)
}
function formatRuntime(seconds: number | null): string | null {
const runtimeLabel = computed(() => {
const seconds = runtimeSeconds.value
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) })
})
const isWriteTarget = computed(() => {
const format = selectedFile.value?.formatKey
if (!format) return false
return (props.book.fileWriteStatus?.writableFormats ?? []).includes(format as never)
})
function handleSort(key: SortKey) {
toggleSort(key)
}
function handleSelect(id: number) {
selectFile(id)
if (isPhone.value) void openSheet()
}
function closeSheet() {
if (!sheetOpen.value) return
sheetOpen.value = false
sheetOpener?.focus()
sheetOpener = null
}
function openFile(file: TreeFile, mode?: 'peek') {
@@ -111,6 +129,10 @@ function downloadFile(file: TreeFile) {
void downloadBookFile(file.id)
}
function goToMetadata() {
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
}
const copiedPathFileId = ref<number | null>(null)
async function copyPath(file: TreeFile) {
@@ -121,27 +143,6 @@ async function copyPath(file: TreeFile) {
}, 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 = []
}
}
watch(() => props.book.id, loadWriteLog, { immediate: true })
watch(
() => props.book.lastWrittenAt,
() => loadWriteLog(props.book.id),
)
// Modals
const addFileModalOpen = ref(false)
const renameTarget = ref<TreeFile | null>(null)
@@ -212,7 +213,7 @@ async function confirmDelete() {
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
sheetOpen.value = false
emit('refetch')
} catch (error) {
deleteError.value = error instanceof Error ? error.message : t('book.detail.files.deleteFailed')
@@ -223,148 +224,101 @@ async function confirmDelete() {
</script>
<template>
<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 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'"
>
<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="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" />
<span class="hidden sm:inline">{{ t('book.detail.files.addFile') }}</span>
</button>
</div>
</div>
<div ref="tabRoot" class="files-tab flex flex-col gap-3.5 lg:h-full lg:min-h-0">
<FilesHeroBar
:book="book"
:file-count="files.length"
:total-bytes="totalBytes"
:runtime-label="runtimeLabel"
:format-shares="formatShares"
:folder-segments="folderSegments.relative"
:can-upload="canUpload"
:sort-key="sortKey"
:sort-direction="sortDirection"
:sort-options="sortOptions"
@sort="handleSort"
@add-file="openAddFileModal"
/>
<!-- 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"
class="flex flex-col items-center justify-center rounded-xl border border-border bg-card px-4 py-16 text-center lg:flex-1"
>
<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="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"
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="openAddFileModal"
>
<FilePlus class="size-3.5" />
<FilePlus class="size-3.5" aria-hidden="true" />
{{ t('book.detail.files.addFile') }}
</button>
</section>
<!--
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"
<div v-else class="files-body relative lg:min-h-0 lg:flex-1">
<FileListCard
class="files-list"
: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"
:runtime-label="runtimeLabel"
:can-download="canDownload"
:can-edit="canEdit"
:can-delete="canDelete"
:show-back="isCompact"
:fits-without-scroll="paneFitsInspector"
@select="selectFile"
@select="handleSelect"
@open="openFile"
@download="downloadFile"
@rename="openRenameModal"
@remove="openDeleteModal"
@copy-path="copyPath"
@back="handleBack"
/>
<Teleport to="body" :disabled="!isPhone">
<button v-if="sheetOpen" class="files-scrim" :aria-label="t('book.detail.files.closeDetails')" @click="closeSheet" />
<div
v-if="selectedFile"
class="files-rail"
:data-sheet="isPhone ? '1' : '0'"
:data-open="sheetOpen ? '1' : '0'"
:role="sheetOpen ? 'dialog' : undefined"
:aria-modal="sheetOpen ? 'true' : undefined"
:aria-label="sheetOpen ? t('book.detail.files.fileDetails') : undefined"
@keydown.esc="closeSheet"
>
<div class="sheet-head">
<span class="sheet-grabber" aria-hidden="true" />
<button
ref="sheetCloseButton"
class="sheet-close inline-flex size-8 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.closeDetails')"
@click="closeSheet"
>
<X class="size-4" aria-hidden="true" />
</button>
</div>
<FileDetailCard
:file="selectedFile"
:audio-files="audioFiles"
:is-multi-track-audio="isMultiTrackAudio"
:runtime-seconds="runtimeSeconds"
:is-write-target="isWriteTarget"
:can-download="canDownload"
:can-edit="canEdit"
:can-delete="canDelete"
@open="openFile"
@download="downloadFile"
@rename="openRenameModal"
@remove="openDeleteModal"
@copy-path="copyPath"
/>
<WriteBackCard :book="book" :can-edit="canEdit" @edit-metadata="goToMetadata" />
</div>
</Teleport>
</div>
<p v-if="copiedPathFileId != null" role="status" class="sr-only">{{ t('book.detail.files.pathCopied') }}</p>
@@ -436,3 +390,161 @@ async function confirmDelete() {
<AddBookFileModal v-if="addFileModalOpen" :book-id="book.id" @close="closeAddFileModal" @uploaded="onFilesAdded" />
</template>
<style scoped>
/*
* The tab is the container every child measures itself against, so the layout answers "how much
* room does this pane have", not "how wide is the window". A collapsed sidebar and a wide screen
* are the same thing to it, and so are an `md` tablet and a phone.
*/
.files-tab {
container-type: inline-size;
container-name: filestab;
}
.files-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.875rem;
}
.files-list {
min-height: 0;
}
.files-rail {
display: flex;
flex-direction: column;
gap: 0.875rem;
min-height: 0;
}
.sheet-head {
display: none;
}
/* Two lanes as soon as the pane can hold a list and a rail without squeezing either. */
@container filestab (min-width: 62.5rem) {
.files-body {
grid-template-columns: minmax(0, 1fr) 24rem;
align-items: start;
min-height: 0;
}
.files-rail {
overflow-y: auto;
max-height: 100%;
}
}
@container filestab (min-width: 75rem) {
.files-body {
grid-template-columns: minmax(0, 1fr) 27rem;
}
}
@container filestab (min-width: 87.5rem) {
.files-body {
grid-template-columns: minmax(0, 1fr) 30rem;
}
}
/* Between those, the rail sits under the list, two cards abreast while they still fit. */
@container filestab (min-width: 45rem) and (max-width: 62.4375rem) {
.files-rail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
}
/*
* A phone shows the files; detail arrives over them and gives the screen back. Teleported to the
* body so "bottom" means the bottom of the screen: anchored to the pane it landed at the foot of a
* scrolling document, which on a phone is nowhere near what you can see.
*/
.files-scrim {
display: block;
position: fixed;
inset: 0;
z-index: 60;
background: oklch(0 0 0 / 45%);
animation: files-scrim-in 140ms ease-out;
}
.files-rail[data-sheet='1'] {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 61;
max-height: 78dvh;
overflow-y: auto;
border-radius: 1rem 1rem 0 0;
border: 1px solid var(--border);
background: var(--card);
padding: 0.5rem 0.75rem calc(1rem + env(safe-area-inset-bottom));
box-shadow: var(--elevation-lg);
transform: translateY(100%);
visibility: hidden;
transition:
transform 180ms ease-out,
visibility 0s linear 180ms;
}
.files-rail[data-sheet='1'][data-open='1'] {
transform: translateY(0);
visibility: visible;
transition:
transform 180ms ease-out,
visibility 0s;
}
.files-rail[data-sheet='1'] .sheet-head {
display: flex;
align-items: center;
padding-bottom: 0.25rem;
}
.files-rail[data-sheet='1'] .sheet-grabber {
position: absolute;
inset-inline: 0;
margin-inline: auto;
top: 0.5rem;
width: 2.25rem;
height: 0.25rem;
border-radius: 999px;
background: var(--border);
}
.files-rail[data-sheet='1'] .sheet-close {
margin-inline-start: auto;
}
/* Inside a sheet the cards are the sheet, so they drop their own frame. */
.files-rail[data-sheet='1'] > section {
border: 0;
background: transparent;
}
.files-rail[data-sheet='1'] > section > :first-child,
.files-rail[data-sheet='1'] > section > :last-child {
padding-inline: 0.25rem;
}
@keyframes files-scrim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.files-rail,
.files-scrim {
transition: none;
animation: none;
}
}
</style>
@@ -169,17 +169,6 @@ export function useBookFileTree(book: Ref<BookDetail>) {
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)
@@ -233,21 +222,16 @@ export function useBookFileTree(book: Ref<BookDetail>) {
return {
files,
groups,
flatFiles,
audioFiles,
isMultiTrackAudio,
totalBytes,
runtimeSeconds,
formatShares,
selectedFile,
selectedFileId,
siblingFiles,
startedCount,
folderSegments,
sortKey,
sortDirection,
selectFile,
toggleSort,
reloadProgress: loadProgress,
}
}
+26
View File
@@ -4665,6 +4665,32 @@
},
"files": {
"title": "Book files",
"stats": {
"files": "Files",
"total": "Total",
"runtime": "Runtime",
"formats": "Formats"
},
"filesHeading": "Files",
"allPresent": "All present",
"groupedByReader": "grouped by reader",
"thisFile": "This file",
"writeBackShort": "Write-back",
"fieldsSet": "{filled} / {total} set",
"writtenSummary": "{fields} and {count, plural, one {# more field} other {# more fields}} are written into the {format}.",
"writtenSummaryAll": "{fields} are written into the {format}.",
"writtenNothing": "None of the {total} writable fields have a value yet.",
"writeBackOff": "Write-back is off for this book.",
"editMetadata": "Edit metadata",
"showAllTracks": "Show all",
"collapseTracks": "Collapse",
"moreTracks": "{count, plural, one {# more track} other {# more tracks}}",
"tracksRolled": "{count, plural, one {# track} other {# tracks}}",
"fileDetails": "File details",
"openDetails": "Open file details",
"closeDetails": "Close file details",
"trackFrom": "Track {track} of {total}, from {time}",
"throughCopy": "You are {percent} through this copy",
"fileCount": "{count, plural, =0 {no files} one {# file} other {# files}}",
"synced": "Synced {time}",
"sortAria": "Sort files",