mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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(
|
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]',
|
md: 'h-[2.125rem] w-7 pb-1 text-[8px]',
|
||||||
lg: 'h-[4.125rem] w-[3.375rem] pb-[9px] text-[13px]',
|
lg: 'h-[4.125rem] w-[3.375rem] pb-[9px] text-[13px]',
|
||||||
})[props.size],
|
})[props.size],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The dog-eared corner is drawn, not an image, so it inherits the format colour at any 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 glyphStyle = computed(() => {
|
||||||
const color = formatColorVar(props.format)
|
const color = formatColorVar(props.format)
|
||||||
const notch = props.size === 'lg' ? '15px' : '8px'
|
const notch = props.size === 'lg' ? '15px' : props.size === 'sm' ? '10px' : '8px'
|
||||||
return {
|
return {
|
||||||
color,
|
color,
|
||||||
backgroundColor: `color-mix(in oklch, ${color} 22%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${color} 22%, transparent)`,
|
||||||
clipPath: `polygon(0 0, calc(100% - ${notch}) 0, 100% ${notch}, 100% 100%, 0 100%)`,
|
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>
|
</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">…/{{ 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 }}…</span
|
||||||
|
>
|
||||||
|
<span class="ml-auto shrink-0 whitespace-nowrap text-[11.5px] leading-none text-muted-foreground tabular-nums">
|
||||||
|
{{ group.files.length }} · {{ 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">· {{ (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 }}…</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">
|
<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 { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
import { ArrowUpDown, CircleCheck, FilePlus, Files, Folder, TriangleAlert } from '@lucide/vue'
|
import { FilePlus, Files, X } from '@lucide/vue'
|
||||||
import type { BookDetail, WriteLogEntry } from '@bookorbit/types'
|
import type { BookDetail } from '@bookorbit/types'
|
||||||
import { Permission } from '@bookorbit/types'
|
import { Permission } from '@bookorbit/types'
|
||||||
import { formatBytes } from '@/lib/formatting'
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { copyToClipboard } from '@/lib/clipboard'
|
import { copyToClipboard } from '@/lib/clipboard'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
||||||
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||||
import { useBookFileTree, type SortKey, type TreeFile } from '@/features/book/composables/useBookFileTree'
|
import { useBookFileTree, type SortKey, type TreeFile } from '@/features/book/composables/useBookFileTree'
|
||||||
import FileTreeRail from '../files/FileTreeRail.vue'
|
import FilesHeroBar from '../files/FilesHeroBar.vue'
|
||||||
import FileInspector from '../files/FileInspector.vue'
|
import FileListCard from '../files/FileListCard.vue'
|
||||||
|
import FileDetailCard from '../files/FileDetailCard.vue'
|
||||||
|
import WriteBackCard from '../files/WriteBackCard.vue'
|
||||||
import AddBookFileModal from './AddBookFileModal.vue'
|
import AddBookFileModal from './AddBookFileModal.vue'
|
||||||
|
|
||||||
const props = defineProps<{ book: BookDetail }>()
|
const props = defineProps<{ book: BookDetail }>()
|
||||||
@@ -35,8 +35,6 @@ const {
|
|||||||
runtimeSeconds,
|
runtimeSeconds,
|
||||||
formatShares,
|
formatShares,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
siblingFiles,
|
|
||||||
startedCount,
|
|
||||||
folderSegments,
|
folderSegments,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
@@ -50,33 +48,36 @@ const canEdit = computed(() => hasPermission('library_edit_metadata'))
|
|||||||
const canDelete = computed(() => hasPermission('library_delete_books'))
|
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
|
* The rail turns into a sheet on the CSS side at 40rem of *tab* width, so the decision to open one
|
||||||
* first, the inspector in its place once a file is picked, with a back control to return.
|
* 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)')
|
const SHEET_MAX_TAB_WIDTH = 640
|
||||||
/**
|
const tabRoot = ref<HTMLElement | null>(null)
|
||||||
* Whether the inspector can hold every section at a fixed height. Width alone is the wrong test:
|
const { width: tabWidth } = useElementSize(tabRoot)
|
||||||
* a 1600x900 window is wide enough for the two-pane split and 200px too short for the sections,
|
const isPhone = computed(() => tabWidth.value > 0 && tabWidth.value < SHEET_MAX_TAB_WIDTH)
|
||||||
* so it clipped them instead of scrolling.
|
const sheetOpen = ref(false)
|
||||||
*/
|
const sheetCloseButton = ref<HTMLButtonElement | null>(null)
|
||||||
const paneFitsInspector = useMediaQuery('(min-width: 1536px) and (min-height: 1000px)')
|
/** Where the keyboard was before the sheet took over, so closing it puts focus back. */
|
||||||
const compactShowsInspector = ref(false)
|
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(
|
watch(
|
||||||
() => props.book.id,
|
() => props.book.id,
|
||||||
() => {
|
() => {
|
||||||
compactShowsInspector.value = false
|
sheetOpen.value = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
watch(isPhone, (phone) => {
|
||||||
function handleSelect(id: number) {
|
if (!phone) sheetOpen.value = false
|
||||||
selectFile(id)
|
})
|
||||||
compactShowsInspector.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
compactShowsInspector.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortOptions = computed<{ key: SortKey; label: string }[]>(() => [
|
const sortOptions = computed<{ key: SortKey; label: string }[]>(() => [
|
||||||
{ key: 'name', label: t('book.detail.files.sort.name') },
|
{ 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') },
|
{ key: 'date', label: t('book.detail.files.sortAdded') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeSortLabel = computed(() => sortOptions.value.find((option) => option.key === sortKey.value)?.label ?? '')
|
const runtimeLabel = computed(() => {
|
||||||
|
const seconds = runtimeSeconds.value
|
||||||
function handleSort(key: SortKey) {
|
|
||||||
toggleSort(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRuntime(seconds: number | null): string | null {
|
|
||||||
if (seconds == null) return null
|
if (seconds == null) return null
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes })
|
if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes })
|
||||||
return t('book.detail.files.duration.minutes', { minutes: Math.max(minutes, 1) })
|
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') {
|
function openFile(file: TreeFile, mode?: 'peek') {
|
||||||
@@ -111,6 +129,10 @@ function downloadFile(file: TreeFile) {
|
|||||||
void downloadBookFile(file.id)
|
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)
|
const copiedPathFileId = ref<number | null>(null)
|
||||||
|
|
||||||
async function copyPath(file: TreeFile) {
|
async function copyPath(file: TreeFile) {
|
||||||
@@ -121,27 +143,6 @@ async function copyPath(file: TreeFile) {
|
|||||||
}, 2000)
|
}, 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
|
// Modals
|
||||||
const addFileModalOpen = ref(false)
|
const addFileModalOpen = ref(false)
|
||||||
const renameTarget = ref<TreeFile | null>(null)
|
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' })
|
const response = await api(`/api/v1/books/files/${target.id}`, { method: 'DELETE' })
|
||||||
if (!response.ok) throw new Error(t('book.detail.files.deleteFailed'))
|
if (!response.ok) throw new Error(t('book.detail.files.deleteFailed'))
|
||||||
deleteTarget.value = null
|
deleteTarget.value = null
|
||||||
compactShowsInspector.value = false
|
sheetOpen.value = false
|
||||||
emit('refetch')
|
emit('refetch')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deleteError.value = error instanceof Error ? error.message : t('book.detail.files.deleteFailed')
|
deleteError.value = error instanceof Error ? error.message : t('book.detail.files.deleteFailed')
|
||||||
@@ -223,148 +224,101 @@ async function confirmDelete() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3.5 lg:h-full lg:min-h-0">
|
<div ref="tabRoot" class="files-tab flex flex-col gap-3.5 lg:h-full lg:min-h-0">
|
||||||
<!-- Command bar -->
|
<FilesHeroBar
|
||||||
<div class="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2">
|
:book="book"
|
||||||
<Folder class="hidden size-[15px] shrink-0 text-muted-foreground sm:block" aria-hidden="true" />
|
:file-count="files.length"
|
||||||
<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')">
|
:total-bytes="totalBytes"
|
||||||
<span class="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10.5px] sm:inline" :title="folderSegments.root">{{
|
:runtime-label="runtimeLabel"
|
||||||
t('book.detail.files.libraryRoot')
|
:format-shares="formatShares"
|
||||||
}}</span>
|
:folder-segments="folderSegments.relative"
|
||||||
<template v-for="(segment, index) in folderSegments.relative" :key="`${segment}-${index}`">
|
:can-upload="canUpload"
|
||||||
<span class="shrink-0 opacity-40" aria-hidden="true">/</span>
|
:sort-key="sortKey"
|
||||||
<span class="min-w-[3ch] truncate" :class="index === folderSegments.relative.length - 1 ? 'font-semibold text-foreground' : ''">{{
|
:sort-direction="sortDirection"
|
||||||
segment
|
:sort-options="sortOptions"
|
||||||
}}</span>
|
@sort="handleSort"
|
||||||
</template>
|
@add-file="openAddFileModal"
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<section
|
<section
|
||||||
v-if="files.length === 0"
|
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" />
|
<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-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>
|
<p class="mt-1 text-sm text-muted-foreground">{{ t('book.detail.files.empty.description') }}</p>
|
||||||
<button
|
<button
|
||||||
v-if="canUpload"
|
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"
|
@click="openAddFileModal"
|
||||||
>
|
>
|
||||||
<FilePlus class="size-3.5" />
|
<FilePlus class="size-3.5" aria-hidden="true" />
|
||||||
{{ t('book.detail.files.addFile') }}
|
{{ t('book.detail.files.addFile') }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!--
|
<div v-else class="files-body relative lg:min-h-0 lg:flex-1">
|
||||||
From lg the folder and the inspector sit side by side. The rail is sized in rem rather than a
|
<FileListCard
|
||||||
fraction so it stays legible as the pane grows, and the inspector takes every pixel left.
|
class="files-list"
|
||||||
-->
|
|
||||||
<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"
|
:groups="groups"
|
||||||
:format-shares="formatShares"
|
|
||||||
:folder-name="folderSegments.relative.at(-1) ?? '/'"
|
|
||||||
:folder-path="folderSegments.relative.join('/')"
|
|
||||||
:total-bytes="totalBytes"
|
|
||||||
:selected-id="selectedFile?.id ?? null"
|
:selected-id="selectedFile?.id ?? null"
|
||||||
:file-count="files.length"
|
:runtime-label="runtimeLabel"
|
||||||
@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-download="canDownload"
|
||||||
:can-edit="canEdit"
|
:can-edit="canEdit"
|
||||||
:can-delete="canDelete"
|
:can-delete="canDelete"
|
||||||
:show-back="isCompact"
|
@select="handleSelect"
|
||||||
:fits-without-scroll="paneFitsInspector"
|
|
||||||
@select="selectFile"
|
|
||||||
@open="openFile"
|
@open="openFile"
|
||||||
@download="downloadFile"
|
@download="downloadFile"
|
||||||
@rename="openRenameModal"
|
@rename="openRenameModal"
|
||||||
@remove="openDeleteModal"
|
@remove="openDeleteModal"
|
||||||
@copy-path="copyPath"
|
@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>
|
</div>
|
||||||
|
|
||||||
<p v-if="copiedPathFileId != null" role="status" class="sr-only">{{ t('book.detail.files.pathCopied') }}</p>
|
<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" />
|
<AddBookFileModal v-if="addFileModalOpen" :book-id="book.id" @close="closeAddFileModal" @uploaded="onFilesAdded" />
|
||||||
</template>
|
</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
|
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. */
|
/** Library root split off the front, so a row never repeats ninety characters of absolute path. */
|
||||||
const folderSegments = computed(() => {
|
const folderSegments = computed(() => {
|
||||||
const segments = (book.value.folderPath ?? '').split('/').filter(Boolean)
|
const segments = (book.value.folderPath ?? '').split('/').filter(Boolean)
|
||||||
@@ -233,21 +222,16 @@ export function useBookFileTree(book: Ref<BookDetail>) {
|
|||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
groups,
|
groups,
|
||||||
flatFiles,
|
|
||||||
audioFiles,
|
audioFiles,
|
||||||
isMultiTrackAudio,
|
isMultiTrackAudio,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
runtimeSeconds,
|
runtimeSeconds,
|
||||||
formatShares,
|
formatShares,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
selectedFileId,
|
|
||||||
siblingFiles,
|
|
||||||
startedCount,
|
|
||||||
folderSegments,
|
folderSegments,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
selectFile,
|
selectFile,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
reloadProgress: loadProgress,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4665,6 +4665,32 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Book 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}}",
|
"fileCount": "{count, plural, =0 {no files} one {# file} other {# files}}",
|
||||||
"synced": "Synced {time}",
|
"synced": "Synced {time}",
|
||||||
"sortAria": "Sort files",
|
"sortAria": "Sort files",
|
||||||
|
|||||||
Reference in New Issue
Block a user