feat(library): rebuild the library editor as a settings sheet

Replace the seven-step wizard with a sheet whose section rail summarizes each
setting. Format priority shows only the formats a library holds, folders are
checked as they are added, and people can be granted access while creating.
This commit is contained in:
neon
2026-09-29 02:11:31 +00:00
parent d66f853cad
commit e92cc36146
25 changed files with 3320 additions and 1912 deletions
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, inject, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { UserPlus, Trash2 } from '@lucide/vue'
import { api } from '@/lib/api'
import type { LibraryAccessEntry } from '@bookorbit/types'
import { RefreshCw, ShieldCheck, TriangleAlert, UserPlus, X } from '@lucide/vue'
import type { AccessLevel } from '@bookorbit/types'
import { formatList } from '@/i18n/formatters'
import { LIBRARY_ACCESS_KEY } from '../composables/useLibraryAccess'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
const { t } = useI18n()
@@ -11,213 +13,200 @@ const props = defineProps<{
libraryId: number | null
}>()
interface UserOption {
id: number
username: string
name: string
}
const access = inject(LIBRARY_ACCESS_KEY)
if (!access) throw new Error('LibraryCreatorAccess needs the library access state provided by LibraryCreatorModal')
const { entries, pending, loading, busy, loaded, error, availableUsers, failedGrants, load, grant, changeLevel, revoke, applyPending } = access
const LEVELS: AccessLevel[] = ['viewer', 'editor', 'owner']
const accessList = ref<LibraryAccessEntry[]>([])
const availableUsers = ref<UserOption[]>([])
const grantUserId = ref<number | null>(null)
const grantLevel = ref<'viewer' | 'editor' | 'owner'>('viewer')
const loading = ref(false)
const actionLoading = ref(false)
const error = ref<string | null>(null)
const grantLevel = ref<AccessLevel>('viewer')
const confirmingRevoke = ref<number | null>(null)
async function loadAccess() {
if (!props.libraryId) return
loading.value = true
error.value = null
try {
const [accessRes, usersRes] = await Promise.all([api(`/api/v1/libraries/${props.libraryId}/access`), api('/api/v1/users/assignable')])
if (!accessRes.ok || !usersRes.ok) {
error.value = 'Could not load library access settings.'
return
}
accessList.value = await accessRes.json()
if (usersRes.ok) {
const all: UserOption[] = await usersRes.json()
const grantedIds = new Set(accessList.value.map((a) => a.userId))
availableUsers.value = all.filter((u) => !grantedIds.has(u.id))
}
} catch {
error.value = 'Could not connect to the server.'
} finally {
loading.value = false
}
interface PersonRow {
userId: number
name: string
username: string
accessLevel: AccessLevel
queued: boolean
}
async function grant() {
if (!props.libraryId || !grantUserId.value) return
error.value = null
actionLoading.value = true
try {
const res = await api(`/api/v1/libraries/${props.libraryId}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: grantUserId.value, accessLevel: grantLevel.value }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
error.value = body?.message ?? 'Failed to grant access'
return
}
grantUserId.value = null
await loadAccess()
} catch {
error.value = 'Could not connect to the server.'
} finally {
actionLoading.value = false
}
const people = computed<PersonRow[]>(() => [
...entries.value.map((entry) => ({
userId: entry.userId,
name: entry.name,
username: entry.username,
accessLevel: entry.accessLevel,
queued: false,
})),
...pending.value
.filter((grant) => !grant.failed)
.map((grant) => ({ userId: grant.userId, name: grant.name, username: grant.username, accessLevel: grant.accessLevel, queued: true })),
])
const failedNames = computed(() => formatList(failedGrants.value.map((grant) => grant.name || grant.username)))
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0]!.toUpperCase())
.join('')
}
async function changeLevel(userId: number, accessLevel: 'viewer' | 'editor' | 'owner') {
if (!props.libraryId) return
error.value = null
actionLoading.value = true
try {
const res = await api(`/api/v1/libraries/${props.libraryId}/access/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessLevel }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
error.value = body?.message ?? 'Failed to update access level'
return
}
await loadAccess()
} catch {
error.value = 'Could not connect to the server.'
} finally {
actionLoading.value = false
}
async function handleGrant() {
if (grantUserId.value === null) return
if (await grant(grantUserId.value, grantLevel.value)) grantUserId.value = null
}
async function revoke(userId: number) {
if (!props.libraryId) return
const entry = accessList.value.find((item) => item.userId === userId)
if (!window.confirm(`Revoke access for ${entry?.name || entry?.username || 'this user'}?`)) return
error.value = null
actionLoading.value = true
try {
const res = await api(`/api/v1/libraries/${props.libraryId}/access/${userId}`, { method: 'DELETE' })
if (!res.ok) {
const body = await res.json().catch(() => null)
error.value = body?.message ?? 'Failed to revoke access'
return
}
await loadAccess()
} catch {
error.value = 'Could not connect to the server.'
} finally {
actionLoading.value = false
}
function handleLevelChange(row: PersonRow, event: Event) {
void changeLevel(row.userId, (event.target as HTMLSelectElement).value as AccessLevel)
}
onMounted(loadAccess)
function handleRemove(row: PersonRow) {
if (row.queued) {
void revoke(row.userId)
return
}
confirmingRevoke.value = row.userId
}
function cancelRevoke() {
confirmingRevoke.value = null
}
async function confirmRevoke(row: PersonRow) {
confirmingRevoke.value = null
await revoke(row.userId)
}
function retryFailed() {
void applyPending()
}
onMounted(() => {
if (!loaded.value && !loading.value) void load()
})
</script>
<template>
<div class="px-6 py-6 space-y-6">
<div v-if="!libraryId" class="rounded-lg border border-dashed border-border px-5 py-8 text-center">
<UserPlus :size="22" class="text-muted-foreground mx-auto mb-2" />
<p class="text-sm text-muted-foreground">{{ t('library.creator.access.notYetCreated') }}</p>
<div class="flex flex-col gap-3">
<div v-if="failedGrants.length > 0" role="alert" class="flex items-start gap-2.5 rounded-xl border border-warning/40 bg-warning/10 px-3.5 py-3">
<TriangleAlert :size="16" class="mt-0.5 shrink-0 text-warning" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ t('library.creator.access.failed.title', { count: failedGrants.length }) }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t('library.creator.access.failed.body', { names: failedNames }) }}</p>
</div>
<button
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-[13px] font-medium text-foreground hover:bg-muted disabled:opacity-50"
:disabled="busy || props.libraryId === null"
@click="retryFailed"
>
<RefreshCw :size="13" :class="busy ? 'motion-safe:animate-spin' : ''" aria-hidden="true" />
{{ t('common.retry') }}
</button>
</div>
<template v-else>
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">{{ t('library.creator.access.title') }}</p>
<p class="text-xs text-muted-foreground mb-3">{{ t('library.creator.access.description') }}</p>
<LibraryCreatorCard flush :label="t('library.creator.access.peopleTitle')" label-id="library-access-title">
<template #meta>
<span v-if="people.length > 0" class="tabular-nums">{{ t('library.creator.access.peopleCount', { count: people.length }) }}</span>
<span v-else-if="libraryId === null">{{ t('library.creator.optional') }}</span>
</template>
<p v-if="error" class="text-xs text-destructive mb-3" role="alert">{{ error }}</p>
<!-- Grant access row -->
<div class="mb-4 grid grid-cols-2 gap-2 sm:flex">
<form class="flex flex-wrap gap-2 border-t border-border px-3 py-2.5" @submit.prevent="handleGrant">
<div class="relative min-w-0 flex-[1_1_12rem]">
<UserPlus :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<select
id="library-access-user"
v-model="grantUserId"
:aria-label="t('library.creator.access.userSelectAria')"
class="col-span-2 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring sm:flex-1"
:disabled="loading || actionLoading"
class="h-9 w-full rounded-md border border-input bg-background pe-3 ps-9 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
:disabled="loading || busy"
>
<option :value="null" disabled>{{ t('library.creator.access.selectUser') }}</option>
<option v-for="u in availableUsers" :key="u.id" :value="u.id">{{ u.name }} ({{ u.username }})</option>
<option v-for="user in availableUsers" :key="user.id" :value="user.id">{{ user.name }} (@{{ user.username }})</option>
</select>
<select
v-model="grantLevel"
:aria-label="t('library.creator.access.levelSelectAria')"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
:disabled="loading || actionLoading"
>
<option value="viewer">{{ t('library.creator.access.levels.viewer') }}</option>
<option value="editor">{{ t('library.creator.access.levels.editor') }}</option>
<option value="owner">{{ t('library.creator.access.levels.owner') }}</option>
</select>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0"
:disabled="!grantUserId || loading || actionLoading"
@click="grant"
>
<UserPlus :size="14" />
{{ t('library.creator.access.grant') }}
</button>
</div>
<select
id="library-access-level"
v-model="grantLevel"
:aria-label="t('library.creator.access.levelSelectAria')"
class="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
:disabled="loading || busy"
>
<option v-for="level in LEVELS" :key="level" :value="level">{{ t(`library.creator.access.levels.${level}`) }}</option>
</select>
<button
type="submit"
class="inline-flex h-9 items-center rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
:disabled="grantUserId === null || loading || busy"
>
{{ libraryId === null ? t('library.creator.access.add') : t('library.creator.access.grant') }}
</button>
</form>
<!-- Access list -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full min-w-md text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground text-xs">{{ t('library.creator.access.columns.user') }}</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground text-xs">{{ t('library.creator.access.columns.accessLevel') }}</th>
<th class="px-4 py-3 w-10" />
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-if="loading">
<td colspan="3" class="px-4 py-6 text-center text-xs text-muted-foreground">
{{ t('library.creator.access.loading') }}
</td>
</tr>
<tr v-for="entry in accessList" :key="entry.userId" class="hover:bg-muted/20 transition-colors">
<td class="px-4 py-3">
<p class="font-medium text-foreground">{{ entry.name }}</p>
<p class="text-xs text-muted-foreground">{{ entry.username }}</p>
</td>
<td class="px-4 py-3">
<select
:value="entry.accessLevel"
:aria-label="`Access level for ${entry.name}`"
:disabled="actionLoading"
class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@change="changeLevel(entry.userId, ($event.target as HTMLSelectElement).value as 'viewer' | 'editor' | 'owner')"
>
<option value="viewer">{{ t('library.creator.access.levels.viewer') }}</option>
<option value="editor">{{ t('library.creator.access.levels.editor') }}</option>
<option value="owner">{{ t('library.creator.access.levels.owner') }}</option>
</select>
</td>
<td class="px-4 py-3">
<button
type="button"
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
:aria-label="`Revoke access for ${entry.name}`"
:disabled="actionLoading"
@click="revoke(entry.userId)"
>
<Trash2 :size="13" />
</button>
</td>
</tr>
<tr v-if="!loading && accessList.length === 0">
<td colspan="3" class="px-4 py-6 text-center text-xs text-muted-foreground">{{ t('library.creator.access.empty') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<p v-if="error" role="alert" class="border-t border-border px-4 py-2 text-xs text-destructive">{{ error }}</p>
<p v-if="loading && !loaded" class="border-t border-border px-4 py-5 text-center text-xs text-muted-foreground">
{{ t('library.creator.access.loading') }}
</p>
<ul v-else-if="people.length > 0" class="divide-y divide-border border-t border-border" aria-labelledby="library-access-title">
<li v-for="row in people" :key="row.userId" class="flex min-h-12 items-center gap-2.5 px-3 py-2">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-foreground"
aria-hidden="true"
>
{{ initials(row.name || row.username) }}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-medium text-foreground">{{ row.name || row.username }}</span>
<span class="block truncate font-mono text-[11.5px] text-muted-foreground">@{{ row.username }}</span>
</span>
<template v-if="confirmingRevoke === row.userId">
<span class="text-xs text-foreground">{{ t('library.creator.access.revokeQuestion') }}</span>
<button
type="button"
class="h-8 rounded-md bg-destructive px-2.5 text-xs font-medium text-destructive-foreground hover:opacity-90 disabled:opacity-50"
:disabled="busy"
@click="confirmRevoke(row)"
>
{{ t('library.creator.access.revoke') }}
</button>
<button type="button" class="h-8 rounded-md px-2.5 text-xs font-medium text-foreground hover:bg-muted" @click="cancelRevoke">
{{ t('library.creator.access.keep') }}
</button>
</template>
<template v-else>
<select
:value="row.accessLevel"
:aria-label="t('library.creator.access.levelFor', { name: row.name || row.username })"
:disabled="busy"
class="h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@change="handleLevelChange(row, $event)"
>
<option v-for="level in LEVELS" :key="level" :value="level">{{ t(`library.creator.access.levels.${level}`) }}</option>
</select>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-md text-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
:aria-label="t('library.creator.access.revokeFor', { name: row.name || row.username })"
:disabled="busy"
@click="handleRemove(row)"
>
<X :size="15" aria-hidden="true" />
</button>
</template>
</li>
</ul>
<p v-else class="border-t border-border px-4 py-5 text-center text-xs text-muted-foreground">
{{ libraryId === null ? t('library.creator.access.emptyCreate') : t('library.creator.access.empty') }}
</p>
</LibraryCreatorCard>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck :size="13" class="shrink-0" aria-hidden="true" />
{{ t('library.creator.access.superusers') }}
</p>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
label: string
labelId?: string
flush?: boolean
}>()
</script>
<template>
<section class="rounded-xl border border-border bg-card" :class="flush ? 'overflow-hidden' : 'px-4 pb-4 pt-3.5'" :aria-labelledby="labelId">
<div class="flex items-center gap-2.5" :class="flush ? 'px-4 pb-2.5 pt-3' : 'mb-3'">
<h4 :id="labelId" class="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">{{ label }}</h4>
<div v-if="$slots.meta" class="ms-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<slot name="meta" />
</div>
</div>
<slot />
</section>
</template>
@@ -1,18 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check } from '@lucide/vue'
import { APP_FEATURES, type CoverAspectRatio, type LibraryType } from '@bookorbit/types'
import AppIcon from '@/components/AppIcon.vue'
import IconPicker from '@/components/IconPicker.vue'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
const { t } = useI18n()
const aspectRatioOptions = computed<{ value: CoverAspectRatio; label: string; swatchClass: string }[]>(() => [
{ value: '2/3', label: t('library.creator.details.coverStyle.portrait'), swatchClass: 'aspect-[2/3]' },
{ value: '1/1', label: t('library.creator.details.coverStyle.square'), swatchClass: 'aspect-square' },
])
/** A short, library-flavoured starting set. Anything else is one click away in the full picker. */
const SUGGESTED_ICONS = [
'Library',
'LibraryBig',
'BookOpen',
'BookOpenText',
'BookCopy',
'BookMarked',
'FolderBookmark',
'Bookmark',
'BookHeadphones',
'Headphones',
'AudioLines',
'ScrollText',
'Newspaper',
'GraduationCap',
'Feather',
'Sparkles',
'Swords',
'Rocket',
'Ghost',
'Heart',
'Star',
'Moon',
'Baby',
'Layers',
]
defineProps<{
const props = defineProps<{
name: string
icon: string | null
coverAspectRatio: CoverAspectRatio
@@ -25,10 +50,48 @@ const emit = defineEmits<{
'update:icon': [value: string | null]
'update:coverAspectRatio': [value: CoverAspectRatio]
'update:type': [value: LibraryType]
'update:pickerOpen': [value: boolean]
}>()
function updateIcon(value: string) {
emit('update:icon', value || null)
const iconChoices = computed(() => (props.icon && !SUGGESTED_ICONS.includes(props.icon) ? [props.icon, ...SUGGESTED_ICONS] : SUGGESTED_ICONS))
const coverOptions = computed<{ value: CoverAspectRatio; title: string; hint: string; tiles: string[] }[]>(() => [
{
value: '2/3',
title: t('library.creator.details.cover.portraitTitle'),
hint: t('library.creator.details.cover.portraitHint'),
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
},
{
value: '1/1',
title: t('library.creator.details.cover.squareTitle'),
hint: t('library.creator.details.cover.squareHint'),
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
},
])
function iconLabel(name: string): string {
return name.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
}
function updateName(event: Event) {
emit('update:name', (event.target as HTMLInputElement).value)
}
function selectIcon(name: string) {
emit('update:icon', name)
}
function pickIcon(value: string) {
if (value) emit('update:icon', value)
}
function handlePickerOpen(open: boolean) {
emit('update:pickerOpen', open)
}
function updateCoverAspectRatio(event: Event) {
emit('update:coverAspectRatio', (event.target as HTMLInputElement).value as CoverAspectRatio)
}
function selectBooksType() {
@@ -38,103 +101,138 @@ function selectBooksType() {
function selectPodcastsType() {
emit('update:type', 'podcasts')
}
function updateName(event: Event) {
emit('update:name', (event.target as HTMLInputElement).value)
}
function updateCoverAspectRatio(event: Event) {
emit('update:coverAspectRatio', (event.target as HTMLInputElement).value as CoverAspectRatio)
}
</script>
<template>
<div class="px-6 py-6 flex flex-col gap-7 h-full min-h-0">
<div>
<p class="mb-3 text-[11px] font-semibold uppercase tracking-widest text-foreground">Library type</p>
<div class="grid gap-3" :class="APP_FEATURES.podcasts ? 'grid-cols-2' : 'grid-cols-1'">
<div class="flex flex-col gap-3.5">
<LibraryCreatorCard v-if="APP_FEATURES.podcasts" :label="t('library.creator.details.type.title')">
<div class="grid gap-2 @md:grid-cols-2">
<button
type="button"
class="rounded-lg border px-4 py-3 text-left transition-colors"
:class="type === 'books' ? 'border-primary bg-primary/8' : 'border-border hover:bg-muted/50'"
class="rounded-lg border px-3.5 py-2.5 text-start transition-colors disabled:cursor-not-allowed"
:class="type === 'books' ? 'border-primary bg-primary/8 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'"
:aria-pressed="type === 'books'"
:disabled="typeLocked"
@click="selectBooksType"
>
<span class="block text-sm font-medium">Books</span>
<span class="mt-1 block text-xs text-muted-foreground">Ebooks, comics, and audiobooks</span>
<span class="block text-sm font-medium text-foreground">{{ t('library.creator.details.type.books') }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('library.creator.details.type.booksHint') }}</span>
</button>
<button
v-if="APP_FEATURES.podcasts"
type="button"
class="rounded-lg border px-4 py-3 text-left transition-colors"
:class="type === 'podcasts' ? 'border-primary bg-primary/8' : 'border-border hover:bg-muted/50'"
class="rounded-lg border px-3.5 py-2.5 text-start transition-colors disabled:cursor-not-allowed"
:class="type === 'podcasts' ? 'border-primary bg-primary/8 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'"
:aria-pressed="type === 'podcasts'"
:disabled="typeLocked"
@click="selectPodcastsType"
>
<span class="block text-sm font-medium">Podcasts</span>
<span class="mt-1 block text-xs text-muted-foreground">RSS feeds and managed episode storage</span>
<span class="block text-sm font-medium text-foreground">{{ t('library.creator.details.type.podcasts') }}</span>
<span class="mt-0.5 block text-xs text-muted-foreground">{{ t('library.creator.details.type.podcastsHint') }}</span>
</button>
</div>
</div>
<div>
<label for="library-name" class="mb-3 block text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ type === 'podcasts' ? 'Podcast library name' : t('library.creator.details.libraryName') }}
</label>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/20">
<AppIcon :icon="icon || 'Library'" fallback="Library" :size="24" class="text-primary" />
</div>
<input
id="library-name"
type="text"
:value="name"
:placeholder="type === 'podcasts' ? 'My Podcasts' : t('library.creator.details.namePlaceholder')"
maxlength="255"
class="flex-1 rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autocomplete="off"
autofocus
@input="updateName"
/>
</div>
</div>
</LibraryCreatorCard>
<div>
<label class="block text-[11px] font-semibold uppercase tracking-widest text-foreground mb-3">
{{ t('library.creator.details.icon.label') }}
</label>
<IconPicker :model-value="icon ?? ''" :placeholder="t('library.creator.details.icon.placeholder')" @update:model-value="updateIcon" />
</div>
<fieldset v-if="type === 'books'" aria-describedby="cover-style-description">
<legend class="mb-3 text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ t('library.creator.details.coverStyle.title') }}
</legend>
<div class="flex flex-wrap gap-2">
<label
v-for="option in aspectRatioOptions"
:key="option.value"
class="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
:class="
coverAspectRatio === option.value
? 'border-primary bg-primary/10 text-foreground ring-1 ring-primary/30'
: 'border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground'
"
<LibraryCreatorCard :label="t('library.creator.details.identity')">
<div class="flex items-end gap-3.5">
<span
class="flex size-13 shrink-0 items-center justify-center rounded-2xl"
:class="icon ? 'bg-primary/12 text-primary' : 'border-[1.5px] border-dashed border-primary/45 text-primary'"
aria-hidden="true"
>
<AppIcon :icon="icon || 'Library'" fallback="Library" :size="24" />
</span>
<div class="min-w-0 flex-1">
<label for="library-name" class="mb-1.5 block text-xs font-medium text-muted-foreground">
{{ type === 'podcasts' ? t('library.creator.details.podcastName') : t('library.creator.details.libraryName') }}
</label>
<input
type="radio"
name="cover-aspect-ratio"
:value="option.value"
:checked="coverAspectRatio === option.value"
class="sr-only"
@change="updateCoverAspectRatio"
id="library-name"
type="text"
:value="name"
:placeholder="type === 'podcasts' ? t('library.creator.details.podcastPlaceholder') : t('library.creator.details.namePlaceholder')"
maxlength="255"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-[15px] font-medium text-foreground placeholder:font-normal placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autocomplete="off"
@input="updateName"
/>
<span class="h-5 border border-current/70 rounded-sm" :class="option.swatchClass" aria-hidden="true" />
{{ option.label }}
</label>
</div>
</div>
<p id="cover-style-description" class="mt-2 text-xs text-muted-foreground">
{{ t('library.creator.details.coverStyle.hintSlots') }}
</p>
</fieldset>
<div class="mt-4">
<div class="mb-2 flex items-center gap-2">
<p id="library-icon-label" class="text-xs font-medium text-muted-foreground">{{ t('library.creator.details.icon.label') }}</p>
<div class="ms-auto">
<IconPicker
model-value=""
hide-text
:placeholder="t('library.creator.details.icon.more')"
@update:model-value="pickIcon"
@open-change="handlePickerOpen"
/>
</div>
</div>
<div role="group" aria-labelledby="library-icon-label" class="grid grid-cols-[repeat(auto-fill,2.5rem)] justify-between gap-1.5">
<button
v-for="choice in iconChoices"
:key="choice"
type="button"
class="flex size-10 items-center justify-center rounded-lg border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="
icon === choice
? 'border-primary bg-primary/14 text-primary ring-1 ring-primary'
: 'border-border bg-background text-foreground hover:bg-muted'
"
:aria-pressed="icon === choice"
:aria-label="iconLabel(choice)"
:title="iconLabel(choice)"
@click="selectIcon(choice)"
>
<AppIcon :icon="choice" fallback="Library" :size="17" />
</button>
</div>
</div>
</LibraryCreatorCard>
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.coverStyle.title')">
<fieldset aria-describedby="cover-style-description">
<legend class="sr-only">{{ t('library.creator.details.coverStyle.title') }}</legend>
<div class="grid gap-2.5 @md:grid-cols-2">
<label
v-for="option in coverOptions"
:key="option.value"
class="flex cursor-pointer items-center gap-3 rounded-xl border px-3.5 py-3 transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="
coverAspectRatio === option.value ? 'border-primary bg-primary/8 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'
"
>
<input
type="radio"
name="cover-aspect-ratio"
:value="option.value"
:checked="coverAspectRatio === option.value"
class="sr-only"
@change="updateCoverAspectRatio"
/>
<span class="flex h-8 shrink-0 items-end gap-1" aria-hidden="true">
<span
v-for="tile in option.tiles"
:key="tile"
class="rounded-[3px] shadow-xs"
:class="option.value === '2/3' ? 'h-8 w-5' : 'size-7'"
:style="{ backgroundColor: tile }"
/>
</span>
<span class="min-w-0">
<span class="flex items-center gap-1.5 text-sm font-semibold text-foreground">
{{ option.title }}
<Check v-if="coverAspectRatio === option.value" :size="14" class="text-primary" aria-hidden="true" />
</span>
<span class="block text-xs text-muted-foreground">{{ option.hint }}</span>
</span>
</label>
</div>
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.cover.slotsHint') }}</p>
</fieldset>
</LibraryCreatorCard>
</div>
</template>
@@ -1,26 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { FileCode, FilePenLine } from '@lucide/vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatNumber } from '@/i18n/formatters'
type FamilyKey = 'epub' | 'fb2' | 'pdf' | 'cbx' | 'kindle' | 'audio'
const { t } = useI18n()
const props = defineProps<{
fileRenameEnabled: boolean
fileWriteEnabled: boolean
fileWriteWriteCover: boolean
fileWriteEpubEnabled: boolean
fileWriteEpubMaxFileSizeMb: number
fileWriteFb2Enabled: boolean
fileWriteFb2MaxFileSizeMb: number
fileWritePdfEnabled: boolean
fileWritePdfMaxFileSizeMb: number
fileWriteCbxEnabled: boolean
fileWriteCbxMaxFileSizeMb: number
fileWriteKindleEnabled: boolean
fileWriteKindleMaxFileSizeMb: number
fileWriteAudioEnabled: boolean
fileWriteAudioMaxFileSizeMb: number
}>()
const props = withDefaults(
defineProps<{
fileRenameEnabled: boolean
fileWriteEnabled: boolean
fileWriteWriteCover: boolean
fileWriteEpubEnabled: boolean
fileWriteEpubMaxFileSizeMb: number
fileWriteFb2Enabled: boolean
fileWriteFb2MaxFileSizeMb: number
fileWritePdfEnabled: boolean
fileWritePdfMaxFileSizeMb: number
fileWriteCbxEnabled: boolean
fileWriteCbxMaxFileSizeMb: number
fileWriteKindleEnabled: boolean
fileWriteKindleMaxFileSizeMb: number
fileWriteAudioEnabled: boolean
fileWriteAudioMaxFileSizeMb: number
formatCounts?: Record<string, number> | null
}>(),
{ formatCounts: null },
)
const emit = defineEmits<{
'update:fileRenameEnabled': [value: boolean]
@@ -40,291 +49,181 @@ const emit = defineEmits<{
'update:fileWriteAudioMaxFileSizeMb': [value: number]
}>()
function handleFileRenameToggle() {
emit('update:fileRenameEnabled', !props.fileRenameEnabled)
/** The formats each writer handles, as its hint describes them, so the count matches what gets written. */
const FAMILY_FORMATS: Record<FamilyKey, string[]> = {
epub: ['epub'],
fb2: ['fb2'],
pdf: ['pdf'],
cbx: ['cbz', 'cb7'],
kindle: ['mobi', 'azw3', 'azw'],
audio: ['m4b', 'm4a', 'mp3', 'flac'],
}
function handleFileWriteToggle() {
emit('update:fileWriteEnabled', !props.fileWriteEnabled)
interface FamilyRow {
key: FamilyKey
enabled: boolean
size: number
blocked: boolean
count: number | null
}
function handleWriteCoverToggle() {
const next = !props.fileWriteWriteCover
emit('update:fileWriteWriteCover', next)
if (!next && props.fileWriteAudioEnabled) {
emit('update:fileWriteAudioEnabled', false)
const rows = computed<FamilyRow[]>(() => {
const state: Record<FamilyKey, [boolean, number]> = {
epub: [props.fileWriteEpubEnabled, props.fileWriteEpubMaxFileSizeMb],
fb2: [props.fileWriteFb2Enabled, props.fileWriteFb2MaxFileSizeMb],
pdf: [props.fileWritePdfEnabled, props.fileWritePdfMaxFileSizeMb],
cbx: [props.fileWriteCbxEnabled, props.fileWriteCbxMaxFileSizeMb],
kindle: [props.fileWriteKindleEnabled, props.fileWriteKindleMaxFileSizeMb],
audio: [props.fileWriteAudioEnabled, props.fileWriteAudioMaxFileSizeMb],
}
return (Object.keys(FAMILY_FORMATS) as FamilyKey[]).map((key) => {
const blocked = key === 'audio' && !props.fileWriteWriteCover
return {
key,
enabled: state[key][0] && !blocked,
size: state[key][1],
blocked,
count: props.formatCounts ? FAMILY_FORMATS[key].reduce((sum, format) => sum + (props.formatCounts?.[format] ?? 0), 0) : null,
}
})
})
const writtenCount = computed(() => rows.value.filter((row) => row.enabled).length)
function emitEnabled(key: FamilyKey, value: boolean) {
if (key === 'epub') emit('update:fileWriteEpubEnabled', value)
else if (key === 'fb2') emit('update:fileWriteFb2Enabled', value)
else if (key === 'pdf') emit('update:fileWritePdfEnabled', value)
else if (key === 'cbx') emit('update:fileWriteCbxEnabled', value)
else if (key === 'kindle') emit('update:fileWriteKindleEnabled', value)
else emit('update:fileWriteAudioEnabled', value)
}
function handleEpubToggle() {
emit('update:fileWriteEpubEnabled', !props.fileWriteEpubEnabled)
function emitSize(key: FamilyKey, value: number) {
if (key === 'epub') emit('update:fileWriteEpubMaxFileSizeMb', value)
else if (key === 'fb2') emit('update:fileWriteFb2MaxFileSizeMb', value)
else if (key === 'pdf') emit('update:fileWritePdfMaxFileSizeMb', value)
else if (key === 'cbx') emit('update:fileWriteCbxMaxFileSizeMb', value)
else if (key === 'kindle') emit('update:fileWriteKindleMaxFileSizeMb', value)
else emit('update:fileWriteAudioMaxFileSizeMb', value)
}
function handleFb2Toggle() {
emit('update:fileWriteFb2Enabled', !props.fileWriteFb2Enabled)
function handleRenameToggle(value: boolean) {
emit('update:fileRenameEnabled', value)
}
function handlePdfToggle() {
emit('update:fileWritePdfEnabled', !props.fileWritePdfEnabled)
function handleWriteToggle(value: boolean) {
emit('update:fileWriteEnabled', value)
}
function handleCbxToggle() {
emit('update:fileWriteCbxEnabled', !props.fileWriteCbxEnabled)
function handleCoverChange(event: Event) {
const next = (event.target as HTMLInputElement).checked
emit('update:fileWriteWriteCover', next)
if (!next && props.fileWriteAudioEnabled) emit('update:fileWriteAudioEnabled', false)
}
function handleKindleToggle() {
emit('update:fileWriteKindleEnabled', !props.fileWriteKindleEnabled)
function toggleFamily(row: FamilyRow, event: Event) {
emitEnabled(row.key, (event.target as HTMLInputElement).checked)
}
function handleAudioToggle() {
emit('update:fileWriteAudioEnabled', !props.fileWriteAudioEnabled)
}
function onMaxSizeInput(field: 'epub' | 'fb2' | 'pdf' | 'cbx' | 'kindle' | 'audio', e: Event) {
const val = Number((e.target as HTMLInputElement).value)
if (field === 'epub') emit('update:fileWriteEpubMaxFileSizeMb', val)
else if (field === 'fb2') emit('update:fileWriteFb2MaxFileSizeMb', val)
else if (field === 'pdf') emit('update:fileWritePdfMaxFileSizeMb', val)
else if (field === 'cbx') emit('update:fileWriteCbxMaxFileSizeMb', val)
else if (field === 'kindle') emit('update:fileWriteKindleMaxFileSizeMb', val)
else emit('update:fileWriteAudioMaxFileSizeMb', val)
}
function onEpubMaxSizeInput(e: Event) {
onMaxSizeInput('epub', e)
}
function onFb2MaxSizeInput(e: Event) {
onMaxSizeInput('fb2', e)
}
function onPdfMaxSizeInput(e: Event) {
onMaxSizeInput('pdf', e)
}
function onCbxMaxSizeInput(e: Event) {
onMaxSizeInput('cbx', e)
}
function onKindleMaxSizeInput(e: Event) {
onMaxSizeInput('kindle', e)
}
function onAudioMaxSizeInput(e: Event) {
onMaxSizeInput('audio', e)
function updateSize(row: FamilyRow, event: Event) {
emitSize(row.key, Number((event.target as HTMLInputElement).value))
}
</script>
<template>
<div class="space-y-4 px-4 py-5 sm:px-6 sm:py-6">
<div class="flex items-start justify-between gap-4 rounded-lg border border-border bg-card p-4">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.rename.title') }}</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
{{ t('library.creator.fileWrite.rename.hint') }}
</p>
<section class="divide-y divide-border rounded-xl border border-border bg-card">
<div class="flex items-start gap-3.5 px-4 py-3.5">
<FilePenLine :size="17" class="mt-0.5 shrink-0 text-foreground" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">{{ t('library.creator.fileWrite.rename.title') }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.rename.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileRenameEnabled"
:aria-label="t('library.creator.fileWrite.rename.title')"
@update:model-value="handleFileRenameToggle"
@update:model-value="handleRenameToggle"
/>
</div>
<div class="rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.write.title') }}</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">{{ t('library.creator.fileWrite.write.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteEnabled"
:aria-label="t('library.creator.fileWrite.write.title')"
@update:model-value="handleFileWriteToggle"
/>
</div>
<div v-if="fileWriteEnabled" class="mt-4 border-t border-border pt-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.cover.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.cover.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteWriteCover"
:aria-label="t('library.creator.fileWrite.cover.title')"
@update:model-value="handleWriteCoverToggle"
/>
</div>
<div class="flex items-start gap-3.5 px-4 py-3.5">
<FileCode :size="17" class="mt-0.5 shrink-0 text-foreground" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">{{ t('library.creator.fileWrite.write.title') }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.write.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteEnabled"
:aria-label="t('library.creator.fileWrite.write.title')"
@update:model-value="handleWriteToggle"
/>
</div>
<template v-if="fileWriteEnabled">
<p class="pt-2 text-[11px] font-semibold uppercase tracking-widest text-foreground">{{ t('library.creator.fileWrite.formatLimits') }}</p>
<div v-if="fileWriteEnabled" class="px-4 pb-4 pt-3.5">
<label class="mb-3 flex cursor-pointer items-start gap-2.5">
<input type="checkbox" class="mt-0.5 size-4 shrink-0 accent-primary" :checked="fileWriteWriteCover" @change="handleCoverChange" />
<span class="min-w-0">
<span class="block text-[13px] font-medium text-foreground">{{ t('library.creator.fileWrite.cover.title') }}</span>
<span class="block text-xs text-muted-foreground">{{ t('library.creator.fileWrite.coverAudioHint') }}</span>
</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.epub.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.epub.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteEpubEnabled"
:aria-label="t('library.creator.fileWrite.epub.toggleAria')"
@update:model-value="handleEpubToggle"
/>
</div>
<label v-if="fileWriteEpubEnabled" for="epub-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="epub-max-size"
type="number"
:value="fileWriteEpubMaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onEpubMaxSizeInput"
/>
</label>
</div>
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.fb2.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.fb2.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteFb2Enabled"
:aria-label="t('library.creator.fileWrite.fb2.toggleAria')"
@update:model-value="handleFb2Toggle"
/>
</div>
<label v-if="fileWriteFb2Enabled" for="fb2-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="fb2-max-size"
type="number"
:value="fileWriteFb2MaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onFb2MaxSizeInput"
/>
</label>
</div>
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.pdf.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.pdf.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWritePdfEnabled"
:aria-label="t('library.creator.fileWrite.pdf.toggleAria')"
@update:model-value="handlePdfToggle"
/>
</div>
<label v-if="fileWritePdfEnabled" for="pdf-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="pdf-max-size"
type="number"
:value="fileWritePdfMaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onPdfMaxSizeInput"
/>
</label>
</div>
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.cbx.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.cbx.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteCbxEnabled"
:aria-label="t('library.creator.fileWrite.cbx.toggleAria')"
@update:model-value="handleCbxToggle"
/>
</div>
<label v-if="fileWriteCbxEnabled" for="cbx-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="cbx-max-size"
type="number"
:value="fileWriteCbxMaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onCbxMaxSizeInput"
/>
</label>
</div>
<div class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.kindle.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.kindle.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteKindleEnabled"
:aria-label="t('library.creator.fileWrite.kindle.toggleAria')"
@update:model-value="handleKindleToggle"
/>
</div>
<label v-if="fileWriteKindleEnabled" for="kindle-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="kindle-max-size"
type="number"
:value="fileWriteKindleMaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onKindleMaxSizeInput"
/>
</label>
</div>
<div v-if="fileWriteWriteCover" class="space-y-3 rounded-lg border border-border bg-card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.fileWrite.audio.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.audio.hint') }}</p>
</div>
<ToggleSwitch
:model-value="fileWriteAudioEnabled"
:aria-label="t('library.creator.fileWrite.audio.toggleAria')"
@update:model-value="handleAudioToggle"
/>
</div>
<label v-if="fileWriteAudioEnabled" for="audio-max-size" class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
{{ t('library.creator.fileWrite.maxFileSizeMb') }}
<input
id="audio-max-size"
type="number"
:value="fileWriteAudioMaxFileSizeMb"
min="1"
max="10000"
step="1"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="onAudioMaxSizeInput"
/>
</label>
<div class="overflow-hidden rounded-lg border border-border bg-background">
<div
class="flex items-center gap-3 border-b border-border px-3 py-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
>
<span class="flex-1">{{ t('library.creator.fileWrite.table.format') }}</span>
<span v-if="formatCounts" class="hidden w-16 text-end @lg:block">{{ t('library.creator.fileWrite.table.inLibrary') }}</span>
<span class="w-28 text-end">{{ t('library.creator.fileWrite.table.sizeLimit') }}</span>
</div>
<ul class="divide-y divide-border">
<li v-for="row in rows" :key="row.key" class="flex items-center gap-3 px-3 py-2.5" :class="row.blocked ? 'text-muted-foreground' : ''">
<label class="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5" :class="row.blocked ? 'cursor-not-allowed' : ''">
<input
type="checkbox"
class="mt-0.5 size-4 shrink-0 accent-primary disabled:opacity-50"
:checked="row.enabled"
:disabled="row.blocked"
:aria-label="t(`library.creator.fileWrite.${row.key}.toggleAria`)"
@change="toggleFamily(row, $event)"
/>
<span class="min-w-0">
<span class="block text-[13px] font-medium" :class="row.blocked ? 'text-muted-foreground' : 'text-foreground'">
{{ t(`library.creator.fileWrite.${row.key}.title`) }}
</span>
<span class="block text-xs text-muted-foreground">
{{ row.blocked ? t('library.creator.fileWrite.audioNeedsCover') : t(`library.creator.fileWrite.${row.key}.hint`) }}
</span>
</span>
</label>
<span
v-if="formatCounts"
class="hidden w-16 text-end text-xs tabular-nums @lg:block"
:class="row.count ? 'text-foreground' : 'text-muted-foreground'"
>
{{ row.count ? formatNumber(row.count) : '–' }}
</span>
<span class="relative w-28 shrink-0">
<input
:id="`${row.key}-max-size`"
type="number"
min="1"
max="10000"
step="1"
:value="row.size"
:disabled="!row.enabled"
:aria-label="t('library.creator.fileWrite.sizeLimitAria', { format: t(`library.creator.fileWrite.${row.key}.title`) })"
class="h-8 w-full rounded-md border border-input bg-background pe-10 ps-2.5 text-end text-[13px] tabular-nums text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
@input="updateSize(row, $event)"
/>
<span class="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground" aria-hidden="true">
{{ t('library.creator.fileWrite.mb') }}
</span>
</span>
</li>
</ul>
</div>
</template>
</div>
<p class="mt-2 text-xs text-muted-foreground">{{ t('library.creator.fileWrite.summary', { count: writtenCount }) }}</p>
</div>
</section>
</template>
@@ -1,29 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, FolderOpen, FolderPlus, Loader2, Plus, RefreshCw, Trash2, XCircle } from '@lucide/vue'
import type { LibraryType, PrescanPathResult, PrescanResult } from '@bookorbit/types'
import { CircleCheck, CircleX, FolderOpen, FolderPlus, FolderSearch, Info, Loader2, Plus, RefreshCw, Trash2, TriangleAlert } from '@lucide/vue'
import type { LibraryStats, LibraryType } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
import LibraryFormatBar from '@/features/settings/libraries/components/LibraryFormatBar.vue'
import type { FolderCheck } from '../composables/useLibraryCreator'
import { useLibraryFolderSelection } from '../composables/useLibraryFolderSelection'
import FolderPickerModal from './FolderPickerModal.vue'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
import LibraryCreatorLocalFolders from './LibraryCreatorLocalFolders.vue'
const { t } = useI18n()
const props = defineProps<{
folders: string[]
localFolders: string[]
prescanResult: PrescanResult | null
prescanLoading: boolean
libraryType: LibraryType
}>()
const props = withDefaults(
defineProps<{
folders: string[]
localFolders: string[]
libraryType: LibraryType
checks?: Record<string, FolderCheck>
stats?: LibraryStats | null
}>(),
{ checks: () => ({}), stats: null },
)
const emit = defineEmits<{
'update:folders': [value: string[]]
'update:localFolders': [value: string[]]
'update:pickerOpen': [value: boolean]
prescan: []
check: [paths: string[]]
}>()
const {
pickerOpen,
manualPath,
manualError,
openPicker,
closePicker,
addBrowsedFolders: addSelectedFolders,
removeFolder,
clearManualError,
addManualFolder,
} = useLibraryFolderSelection({
folders: () => props.folders,
updateFolders: (folders) => emit('update:folders', folders),
updatePickerOpen: (open) => emit('update:pickerOpen', open),
})
const podcast = computed(() => props.libraryType === 'podcasts')
const canAdd = computed(() => !podcast.value || props.folders.length === 0)
const checkedFolders = computed(() => props.folders.map((folder) => props.checks[folder]).filter((check) => check?.state === 'checked'))
const matchingFiles = computed(() =>
checkedFolders.value.reduce((sum, check) => sum + (check.state === 'checked' && check.accessible ? check.fileCount : 0), 0),
)
const anyChecking = computed(() => props.folders.some((folder) => props.checks[folder]?.state === 'checking'))
const showStats = computed(() => !podcast.value && props.stats !== null && props.stats.totalBooks > 0)
type FolderStatus =
| { kind: 'unchecked' }
| { kind: 'checking' }
| { kind: 'failed' }
| { kind: 'inaccessible' }
| { kind: 'overlap'; library: string }
| { kind: 'ok'; files: number }
interface FolderRow {
folder: string
parent: string
leaf: string
status: FolderStatus
}
const rows = computed<FolderRow[]>(() => props.folders.map((folder) => ({ folder, ...splitPath(folder), status: statusOf(props.checks[folder]) })))
function statusOf(check: FolderCheck | undefined): FolderStatus {
if (!check) return { kind: 'unchecked' }
if (check.state === 'checking') return { kind: 'checking' }
if (check.state === 'failed') return { kind: 'failed' }
if (!check.accessible) return { kind: 'inaccessible' }
if (check.overlapLibrary) return { kind: 'overlap', library: check.overlapLibrary }
return { kind: 'ok', files: check.fileCount }
}
function splitPath(path: string): { parent: string; leaf: string } {
const index = path.lastIndexOf('/')
if (index < 0) return { parent: '', leaf: path }
return { parent: path.slice(0, index + 1), leaf: path.slice(index + 1) }
}
function checkAll() {
emit('check', props.folders)
}
function retry(folder: string) {
emit('check', [folder])
}
function handleRemove(folder: string) {
removeFolder(folder)
}
function handleLocalFoldersUpdate(value: string[]) {
emit('update:localFolders', value)
}
@@ -32,206 +108,165 @@ function handleNestedPickerChange(value: boolean) {
emit('update:pickerOpen', value)
}
const {
pickerOpen,
manualEntryOpen,
manualPath,
manualError,
openPicker,
closePicker,
addBrowsedFolders: addSelectedFolders,
removeFolder,
toggleManualEntry,
closeManualEntry,
clearManualError,
addManualFolder,
} = useLibraryFolderSelection({
folders: () => props.folders,
updateFolders: (folders) => emit('update:folders', folders),
updatePickerOpen: (open) => emit('update:pickerOpen', open),
})
const prescanByPath = computed(() => new Map(props.prescanResult?.paths.map((result) => [result.path, result]) ?? []))
const validationSummary = computed(() => {
const itemLabel = props.libraryType === 'podcasts' ? 'podcast episode files' : 'book files'
if (props.prescanLoading) return `Checking accessibility and counting matching ${itemLabel}...`
if (!props.prescanResult) return `Verify access and estimate matching ${itemLabel}.`
const accessibleCount = props.prescanResult.paths.filter((path) => path.accessible).length
const inaccessibleCount = props.prescanResult.paths.length - accessibleCount
const files = `${props.prescanResult.totalFiles.toLocaleString()} matching file${props.prescanResult.totalFiles === 1 ? '' : 's'}`
return inaccessibleCount > 0
? `${files}. ${inaccessibleCount} folder${inaccessibleCount === 1 ? '' : 's'} could not be accessed.`
: `${files} across ${accessibleCount} folder${accessibleCount === 1 ? '' : 's'}.`
})
function prescanStatusFor(path: string): PrescanPathResult | null {
return prescanByPath.value.get(path) ?? null
}
function statusLabel(path: string): string {
if (props.prescanLoading) return 'Checking'
const status = prescanStatusFor(path)
if (!status) return 'Not checked'
if (!status.accessible) return 'Not accessible'
if (status.overlapLibrary) return 'Overlaps another library'
return `Accessible · ${status.fileCount.toLocaleString()} file${status.fileCount === 1 ? '' : 's'}`
}
function statusClass(path: string): string {
if (props.prescanLoading || !prescanStatusFor(path)) return 'bg-muted text-muted-foreground'
const status = prescanStatusFor(path)!
if (!status.accessible) return 'bg-destructive/10 text-destructive'
if (status.overlapLibrary) return 'bg-muted text-foreground'
return 'bg-primary/10 text-primary'
}
function statusTitle(path: string): string | undefined {
const overlapLibrary = prescanStatusFor(path)?.overlapLibrary
return overlapLibrary ? `Also used by “${overlapLibrary}”` : undefined
}
function handlePrescan() {
emit('prescan')
}
function addBrowsedFolders(paths: string[]) {
addSelectedFolders(props.libraryType === 'podcasts' ? paths.slice(0, 1) : paths)
addSelectedFolders(podcast.value ? paths.slice(0, 1) : paths)
}
</script>
<template>
<div class="space-y-4 px-6 py-6">
<button
v-if="folders.length === 0"
type="button"
class="flex min-h-24 w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/15 px-5 py-3 text-center transition-colors hover:border-primary/50 hover:bg-muted/40"
@click="openPicker"
<div class="flex flex-col gap-3">
<LibraryCreatorCard
flush
label-id="library-folders-title"
:label="podcast ? t('library.creator.folders.storageTitle') : t('library.creator.folders.listTitle')"
>
<span class="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FolderOpen :size="19" />
</span>
<span>
<span class="block text-sm font-semibold text-foreground">{{ t('library.creator.folders.browseTitle') }}</span>
<span class="mt-1 block text-sm text-muted-foreground">
{{
libraryType === 'podcasts' ? 'Choose where downloaded podcast episodes will be stored.' : t('library.creator.folders.browseDescription')
}}
</span>
</span>
</button>
<section v-else aria-labelledby="selected-folders-title">
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<h4 id="selected-folders-title" class="text-sm font-semibold text-foreground">
{{ libraryType === 'podcasts' ? 'Storage folder' : t('library.creator.folders.selectedTitle') }}
</h4>
<span class="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">{{ folders.length }}</span>
</div>
<template v-if="folders.length > 0" #meta>
<template v-if="checkedFolders.length > 0">
<span>{{ t('library.creator.folders.matchingFiles', { count: matchingFiles }) }}</span>
</template>
<template v-else-if="showStats && stats">
<span>{{ t('library.creator.folders.bookCount', { count: stats.totalBooks }) }}</span>
<span aria-hidden="true">&middot;</span>
<span class="tabular-nums">{{ formatBytes(stats.totalSizeBytes) }}</span>
</template>
<button
v-if="libraryType === 'books'"
type="button"
class="flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
@click="openPicker"
class="flex size-7 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted disabled:opacity-50"
:aria-label="t('library.creator.folders.checkAll')"
:title="t('library.creator.folders.checkAll')"
:disabled="anyChecking"
@click="checkAll"
>
<Plus :size="16" />
{{ t('library.creator.folders.addFolders') }}
<RefreshCw :size="13" :class="anyChecking ? 'motion-safe:animate-spin' : ''" aria-hidden="true" />
</button>
</div>
</template>
<div class="overflow-hidden rounded-lg border border-border bg-card divide-y divide-border">
<div v-for="folder in folders" :key="folder" class="flex min-h-12 items-center gap-2.5 px-3 py-2">
<FolderOpen :size="16" class="shrink-0 text-primary" />
<p class="min-w-0 flex-1 truncate font-mono text-[13px] text-foreground" :title="folder">{{ folder }}</p>
<div class="flex shrink-0 items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="statusClass(folder)" :title="statusTitle(folder)">
<Loader2 v-if="prescanLoading" :size="11" class="mr-1 inline animate-spin" />
<XCircle v-else-if="prescanStatusFor(folder) && !prescanStatusFor(folder)!.accessible" :size="11" class="mr-1 inline" />
<AlertTriangle v-else-if="prescanStatusFor(folder)?.overlapLibrary" :size="11" class="mr-1 inline" />
<CheckCircle2 v-else-if="prescanStatusFor(folder)?.accessible" :size="11" class="mr-1 inline" />
{{ statusLabel(folder) }}
<ul v-if="rows.length > 0" class="divide-y divide-border border-t border-border" aria-labelledby="library-folders-title">
<li v-for="row in rows" :key="row.folder" class="flex min-h-12 items-center gap-2.5 px-4 py-2">
<FolderOpen :size="16" class="shrink-0 text-primary" aria-hidden="true" />
<p class="flex min-w-0 flex-1 font-mono text-[12.5px]" dir="ltr" :title="row.folder">
<span class="min-w-0 truncate text-path-dim">{{ row.parent }}</span>
<span class="max-w-[70%] shrink-0 truncate font-semibold text-foreground">{{ row.leaf }}</span>
</p>
<span
v-if="row.status.kind === 'checking'"
class="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
>
<Loader2 :size="11" class="motion-safe:animate-spin" aria-hidden="true" />
{{ t('library.creator.folders.status.checking') }}
</span>
<template v-else-if="row.status.kind === 'failed'">
<span class="inline-flex shrink-0 items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{{ t('library.creator.folders.status.failed') }}
</span>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
:aria-label="t('library.creator.folders.removeFolder', { folder })"
@click="removeFolder(folder)"
class="flex size-7 shrink-0 items-center justify-center rounded-md text-foreground hover:bg-muted"
:aria-label="t('library.creator.folders.checkAgain', { folder: row.folder })"
@click="retry(row.folder)"
>
<Trash2 :size="16" />
<RefreshCw :size="13" aria-hidden="true" />
</button>
</div>
</div>
</template>
<span
v-else-if="row.status.kind === 'inaccessible'"
class="inline-flex shrink-0 items-center gap-1 rounded-full bg-destructive/12 px-2 py-0.5 text-xs font-medium text-destructive"
>
<CircleX :size="11" aria-hidden="true" />
{{ t('library.creator.folders.status.notAccessible') }}
</span>
<span
v-else-if="row.status.kind === 'overlap'"
class="inline-flex max-w-44 shrink-0 items-center gap-1 rounded-full bg-warning/14 px-2 py-0.5 text-xs font-medium text-warning"
:title="t('library.creator.folders.status.overlapTitle', { library: row.status.library })"
>
<TriangleAlert :size="11" class="shrink-0" aria-hidden="true" />
<span class="truncate">{{ t('library.creator.folders.status.overlap', { library: row.status.library }) }}</span>
</span>
<span
v-else-if="row.status.kind === 'ok'"
class="inline-flex shrink-0 items-center gap-1 rounded-full bg-success/12 px-2 py-0.5 text-xs font-medium text-success"
>
<CircleCheck :size="11" aria-hidden="true" />
{{ t('library.creator.folders.status.files', { count: row.status.files }) }}
</span>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-md text-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
:aria-label="t('library.creator.folders.removeFolder', { folder: row.folder })"
@click="handleRemove(row.folder)"
>
<Trash2 :size="15" aria-hidden="true" />
</button>
</li>
</ul>
<div v-else class="border-t border-border px-4 py-6 text-center">
<span class="mx-auto mb-2.5 flex size-9 items-center justify-center rounded-xl bg-primary/12 text-primary" aria-hidden="true">
<FolderOpen :size="18" />
</span>
<p class="text-sm font-semibold text-foreground">{{ t('library.creator.folders.noneAdded') }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">
{{ podcast ? t('library.creator.folders.emptyPodcastHint') : t('library.creator.folders.emptyHint') }}
</p>
</div>
</section>
<div v-if="libraryType === 'books' || folders.length === 0">
<button
type="button"
class="flex h-9 items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
:aria-expanded="manualEntryOpen"
@click="toggleManualEntry"
<div v-if="showStats && stats && checkedFolders.length === 0" class="border-t border-border px-4 pb-3 pt-3">
<LibraryFormatBar show-legend :counts="stats.formatCounts" :legend-limit="6" />
</div>
<form
v-if="canAdd"
class="flex flex-wrap items-start gap-2 border-t border-border bg-background/50 px-3 py-2.5"
@submit.prevent="addManualFolder"
>
<FolderPlus :size="16" />
{{ t('library.creator.folders.manualEntry') }}
<ChevronUp v-if="manualEntryOpen" :size="15" />
<ChevronDown v-else :size="15" />
</button>
<form v-if="manualEntryOpen" class="mt-2 rounded-lg border border-border bg-muted/20 p-3" @submit.prevent="addManualFolder">
<label for="manual-folder-path" class="mb-2 block text-sm font-medium text-foreground">{{ t('library.creator.folders.absolutePath') }}</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
id="manual-folder-path"
v-model="manualPath"
type="text"
:placeholder="libraryType === 'podcasts' ? '/path/to/podcasts' : t('library.creator.folders.pathPlaceholder')"
class="h-10 min-w-0 flex-1 rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground placeholder:font-sans placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@input="clearManualError"
/>
<div class="grid grid-cols-2 gap-2 sm:flex">
<button
type="submit"
class="h-10 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
{{ t('library.creator.folders.addFolder') }}
</button>
<button
type="button"
class="h-10 rounded-md border border-border px-4 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="closeManualEntry"
>
{{ t('common.cancel') }}
</button>
<div class="min-w-0 flex-[1_1_14rem]">
<label for="manual-folder-path" class="sr-only">{{ t('library.creator.folders.absolutePath') }}</label>
<div class="relative">
<FolderPlus :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
id="manual-folder-path"
v-model="manualPath"
type="text"
dir="ltr"
:placeholder="podcast ? t('library.creator.folders.pastePodcastPlaceholder') : t('library.creator.folders.pastePlaceholder')"
class="h-9 w-full rounded-md border border-input bg-background pe-3 ps-9 font-mono text-[12.5px] text-foreground placeholder:font-sans placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
:aria-invalid="manualError ? 'true' : undefined"
:aria-describedby="manualError ? 'manual-folder-error' : undefined"
@input="clearManualError"
/>
</div>
<p v-if="manualError" id="manual-folder-error" class="mt-1.5 text-xs text-destructive" role="alert">{{ manualError }}</p>
</div>
<p v-if="manualError" class="mt-2 text-sm text-destructive">{{ manualError }}</p>
<p v-else class="mt-2 text-xs text-muted-foreground">{{ t('library.creator.folders.manualCheckHint') }}</p>
</form>
</div>
<div v-if="folders.length > 0" class="rounded-lg border border-border bg-muted/20 p-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="min-w-0 text-sm text-muted-foreground">{{ validationSummary }}</p>
<button
type="submit"
class="inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
<Plus :size="14" aria-hidden="true" />
{{ t('library.creator.folders.add') }}
</button>
<button
type="button"
class="flex h-10 shrink-0 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
:disabled="folders.length === 0 || prescanLoading"
@click="handlePrescan"
class="inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
@click="openPicker"
>
<RefreshCw :size="16" :class="prescanLoading ? 'animate-spin' : ''" />
{{ prescanLoading ? 'Checking...' : prescanResult ? 'Check again' : 'Check folders' }}
<FolderSearch :size="14" aria-hidden="true" />
{{ t('library.creator.folders.browse') }}
</button>
</div>
</div>
</div>
</form>
</LibraryCreatorCard>
<LibraryCreatorLocalFolders
v-if="libraryType === 'podcasts'"
:local-folders="localFolders"
@update:local-folders="handleLocalFoldersUpdate"
@update:picker-open="handleNestedPickerChange"
/>
<p v-if="!podcast" class="flex items-start gap-1.5 text-xs text-muted-foreground">
<Info :size="13" class="mt-px shrink-0" aria-hidden="true" />
<span>{{ t('library.creator.folders.checkHint') }}</span>
</p>
<LibraryCreatorLocalFolders
v-if="podcast"
:local-folders="localFolders"
@update:local-folders="handleLocalFoldersUpdate"
@update:picker-open="handleNestedPickerChange"
/>
</div>
<FolderPickerModal v-if="pickerOpen" :selected-paths="folders" @select="addBrowsedFolders" @close="closePicker" />
</template>
@@ -1,181 +1,287 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp, GripVertical, Headphones } from '@lucide/vue'
import { isReadAlongFormatKey } from '@bookorbit/types'
import { ArrowDown, ArrowLeftRight, ArrowUp, FileCode, FileText, GripVertical, Headphones, RotateCcw } from '@lucide/vue'
import { DEFAULT_FORMAT_PRIORITY, isReadAlongFormatKey, withReadAlongFormatPriority } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { formatKeyCode, formatKeyName } from '@/features/book/lib/book-formats'
import { METADATA_LABELS } from '../composables/useLibraryCreator'
import { formatFamilyColor } from '@/features/settings/libraries/lib/library-formats'
import { DEFAULT_METADATA_PRECEDENCE } from '../composables/useLibraryCreator'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
const { t } = useI18n()
const props = defineProps<{
metadataPrecedence: string[]
formatPriority: string[]
}>()
/** Without counts there is no way to tell which formats matter, so only the head of the order shows until asked. */
const PREVIEW_ROWS = 6
const props = withDefaults(
defineProps<{
metadataPrecedence: string[]
formatPriority: string[]
allowedFormats?: string[]
formatCounts?: Record<string, number> | null
}>(),
{ allowedFormats: () => [], formatCounts: null },
)
const emit = defineEmits<{
'update:metadataPrecedence': [value: string[]]
'update:formatPriority': [value: string[]]
}>()
function useDragList(getList: () => string[], onUpdate: (v: string[]) => void) {
const dragFrom = ref<number | null>(null)
const dragOver = ref<number | null>(null)
const showAll = ref(false)
const dragKey = ref<string | null>(null)
const overKey = ref<string | null>(null)
function onDragStart(i: number) {
dragFrom.value = i
}
function onDragEnter(i: number) {
dragOver.value = i
}
function onDrop() {
if (dragFrom.value === null || dragOver.value === null || dragFrom.value === dragOver.value) return
const list = [...getList()]
const [item] = list.splice(dragFrom.value, 1)
if (item !== undefined) list.splice(dragOver.value, 0, item)
onUpdate(list)
dragFrom.value = null
dragOver.value = null
}
function onDragEnd() {
dragFrom.value = null
dragOver.value = null
}
return { dragFrom, dragOver, onDragStart, onDragEnter, onDrop, onDragEnd }
function baseFormat(key: string): string {
return isReadAlongFormatKey(key) ? 'epub' : key.toLowerCase()
}
const metaDrag = useDragList(
() => props.metadataPrecedence,
(v) => emit('update:metadataPrecedence', v),
const present = computed(() => {
if (!props.formatCounts) return null
const held = Object.entries(props.formatCounts)
.filter(([, count]) => count > 0)
.map(([format]) => format.toLowerCase())
return held.length > 0 ? new Set(held) : null
})
const visible = computed(() => {
if (showAll.value) return props.formatPriority
if (present.value) return props.formatPriority.filter((key) => present.value!.has(baseFormat(key)))
return props.formatPriority.slice(0, PREVIEW_ROWS)
})
const hiddenCount = computed(() => props.formatPriority.length - visible.value.length)
const maxCount = computed(() => Math.max(1, ...Object.values(props.formatCounts ?? {})))
interface FormatRow {
key: string
code: string
name: string
readAlong: boolean
count: number | null
imported: boolean
absent: boolean
}
const rows = computed<FormatRow[]>(() =>
visible.value.map((key) => {
const readAlong = isReadAlongFormatKey(key)
const base = baseFormat(key)
return {
key,
code: formatKeyCode(key),
name: formatKeyName(key),
readAlong,
count: props.formatCounts && !readAlong ? (props.formatCounts[base] ?? 0) : null,
imported: props.allowedFormats.length === 0 || props.allowedFormats.includes(base),
absent: present.value !== null && !present.value.has(base),
}
}),
)
const fmtDrag = useDragList(
() => props.formatPriority,
(v) => emit('update:formatPriority', v),
)
const sources = computed(() => {
const known = props.metadataPrecedence.filter((key) => DEFAULT_METADATA_PRECEDENCE.includes(key))
return [...known, ...DEFAULT_METADATA_PRECEDENCE.filter((key) => !known.includes(key))]
})
function moveItem(list: string[], index: number, direction: -1 | 1): string[] | null {
const target = index + direction
if (target < 0 || target >= list.length) return null
const updated = [...list]
const [item] = updated.splice(index, 1)
if (item === undefined) return null
updated.splice(target, 0, item)
return updated
function chipStyle(key: string): Record<string, string> {
const color = formatColorVar(key)
return { color, backgroundColor: `color-mix(in oklch, ${color} 13%, transparent)` }
}
function moveMetadata(index: number, direction: -1 | 1) {
const updated = moveItem(props.metadataPrecedence, index, direction)
if (updated) emit('update:metadataPrecedence', updated)
function barStyle(row: FormatRow): Record<string, string> {
return { width: `${((row.count ?? 0) / maxCount.value) * 100}%`, backgroundColor: formatFamilyColor(baseFormat(row.key)) }
}
function moveFormat(index: number, direction: -1 | 1) {
const updated = moveItem(props.formatPriority, index, direction)
if (updated) emit('update:formatPriority', updated)
/** Moves among the rows on screen, so a hidden format never swallows a click. */
function move(key: string, direction: -1 | 1) {
const target = visible.value[visible.value.indexOf(key) + direction]
if (target) placeRelative(key, target, direction === 1 ? 'after' : 'before')
}
function placeRelative(key: string, target: string, side: 'before' | 'after') {
const next = props.formatPriority.filter((candidate) => candidate !== key)
const index = next.indexOf(target)
if (index === -1) return
next.splice(side === 'after' ? index + 1 : index, 0, key)
emit('update:formatPriority', next)
}
function moveUp(key: string) {
move(key, -1)
}
function moveDown(key: string) {
move(key, 1)
}
function resetOrder() {
emit('update:formatPriority', withReadAlongFormatPriority(DEFAULT_FORMAT_PRIORITY))
}
function toggleShowAll() {
showAll.value = !showAll.value
}
function onDragStart(key: string) {
dragKey.value = key
}
function onDragEnter(key: string) {
overKey.value = key
}
function onDrop(key: string) {
const from = dragKey.value
dragKey.value = null
overKey.value = null
if (!from || from === key) return
const movingDown = props.formatPriority.indexOf(from) < props.formatPriority.indexOf(key)
placeRelative(from, key, movingDown ? 'after' : 'before')
}
function onDragEnd() {
dragKey.value = null
overKey.value = null
}
function swapSources() {
emit('update:metadataPrecedence', [...sources.value].reverse())
}
</script>
<template>
<div class="px-6 py-6 space-y-6">
<!-- Source precedence -->
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">
{{ t('library.creator.metadata.sourcePrecedence.title') }}
</p>
<p class="text-xs text-muted-foreground mb-3">{{ t('library.creator.metadata.sourcePrecedence.hint') }}</p>
<div class="rounded-lg border border-border overflow-hidden divide-y divide-border">
<div
v-for="(key, index) in metadataPrecedence"
:key="key"
draggable="true"
class="flex items-center gap-3 px-3 py-2.5 bg-card cursor-grab active:cursor-grabbing select-none transition-colors"
:class="metaDrag.dragOver.value === index ? 'bg-primary/10 border-l-2 border-l-primary' : ''"
@dragstart="metaDrag.onDragStart(index)"
@dragenter.prevent="metaDrag.onDragEnter(index)"
@dragover.prevent
@drop="metaDrag.onDrop"
@dragend="metaDrag.onDragEnd"
>
<GripVertical :size="13" class="text-muted-foreground shrink-0" />
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[9px] font-bold shrink-0">
{{ index + 1 }}
</span>
<span class="flex-1 text-sm text-foreground truncate">{{ METADATA_LABELS[key] ?? key }}</span>
<div class="flex items-center gap-0.5">
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === 0"
:aria-label="t('library.creator.metadata.moveUp', { item: METADATA_LABELS[key] ?? key })"
@click="moveMetadata(index, -1)"
>
<ChevronUp :size="14" />
</button>
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === metadataPrecedence.length - 1"
:aria-label="t('library.creator.metadata.moveDown', { item: METADATA_LABELS[key] ?? key })"
@click="moveMetadata(index, 1)"
>
<ChevronDown :size="14" />
</button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-3.5">
<LibraryCreatorCard flush :label="t('library.creator.metadata.primary.title')" label-id="primary-file-title">
<template #meta>
<button type="button" class="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" @click="resetOrder">
<RotateCcw :size="12" aria-hidden="true" />
{{ t('library.creator.metadata.primary.reset') }}
</button>
</template>
<p class="-mt-1 px-4 pb-3 text-xs text-muted-foreground">{{ t('library.creator.metadata.primary.hint') }}</p>
<!-- Format priority -->
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">
{{ t('library.creator.metadata.formatPriority.title') }}
</p>
<p class="text-xs text-muted-foreground mb-3">{{ t('library.creator.metadata.formatPriority.hint') }}</p>
<div class="rounded-lg border border-border overflow-hidden divide-y divide-border">
<div
v-for="(fmt, index) in formatPriority"
:key="fmt"
<ol aria-labelledby="primary-file-title" class="border-t border-border">
<li
v-for="(row, index) in rows"
:key="row.key"
draggable="true"
class="flex items-center gap-3 px-3 py-2.5 bg-card cursor-grab active:cursor-grabbing select-none transition-colors"
:class="fmtDrag.dragOver.value === index ? 'bg-primary/10 border-l-2 border-l-primary' : ''"
@dragstart="fmtDrag.onDragStart(index)"
@dragenter.prevent="fmtDrag.onDragEnter(index)"
class="flex min-h-11 cursor-grab items-center gap-2.5 border-b border-border px-3 py-1.5 last:border-b-0 active:cursor-grabbing"
:class="[
index === 0 ? 'bg-primary/6' : '',
overKey === row.key && dragKey !== row.key ? 'shadow-[inset_3px_0_0_var(--primary)]' : '',
dragKey === row.key ? 'opacity-50' : '',
]"
@dragstart="onDragStart(row.key)"
@dragenter.prevent="onDragEnter(row.key)"
@dragover.prevent
@drop="fmtDrag.onDrop"
@dragend="fmtDrag.onDragEnd"
@drop="onDrop(row.key)"
@dragend="onDragEnd"
>
<GripVertical :size="13" class="text-muted-foreground shrink-0" />
<span class="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10.5px] font-bold shrink-0">
{{ index + 1 }}
<GripVertical :size="14" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="w-4 shrink-0 text-center text-xs font-bold tabular-nums" :class="index === 0 ? 'text-primary' : 'text-muted-foreground'">
{{ formatNumber(index + 1) }}
</span>
<span class="flex flex-1 items-center gap-1.5 text-sm font-mono text-foreground">
{{ formatKeyCode(fmt) }}
<Headphones v-if="isReadAlongFormatKey(fmt)" :size="13" :stroke-width="2.5" aria-hidden="true" />
<span class="inline-flex w-16 shrink-0">
<span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tracking-wide" :style="chipStyle(row.key)">
{{ row.code }}
<Headphones v-if="row.readAlong" :size="11" :stroke-width="2.5" aria-hidden="true" />
</span>
</span>
<span class="sr-only text-xs text-muted-foreground sm:not-sr-only">{{ formatKeyName(fmt) }}</span>
<div class="flex items-center gap-0.5">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate text-[13px]" :class="row.imported ? 'text-foreground' : 'text-muted-foreground line-through'">{{ row.name }}</span>
<span v-if="index === 0" class="shrink-0 rounded-full bg-primary/14 px-1.5 py-px text-[10.5px] font-medium text-primary">
{{ t('library.creator.metadata.primary.first') }}
</span>
<span v-if="!row.imported" class="shrink-0 text-[11px] text-muted-foreground">{{
t('library.creator.metadata.primary.notImported')
}}</span>
</span>
<span v-if="formatCounts" class="hidden w-36 shrink-0 items-center gap-2 @lg:flex">
<span class="h-1 flex-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<span v-if="row.count" class="block h-full rounded-full" :style="barStyle(row)" />
</span>
<span class="w-10 text-end text-xs tabular-nums" :class="row.count ? 'text-foreground' : 'text-muted-foreground'">
{{ row.count === null ? '' : row.count ? formatNumber(row.count) : '–' }}
</span>
</span>
<span class="flex shrink-0">
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
class="flex size-7 items-center justify-center rounded-md text-foreground hover:bg-muted disabled:opacity-30"
:disabled="index === 0"
:aria-label="t('library.creator.metadata.moveUp', { item: formatKeyName(fmt) })"
@click="moveFormat(index, -1)"
:aria-label="t('library.creator.metadata.moveUp', { item: row.name })"
@click="moveUp(row.key)"
>
<ChevronUp :size="14" />
<ArrowUp :size="13" aria-hidden="true" />
</button>
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === formatPriority.length - 1"
:aria-label="t('library.creator.metadata.moveDown', { item: formatKeyName(fmt) })"
@click="moveFormat(index, 1)"
class="flex size-7 items-center justify-center rounded-md text-foreground hover:bg-muted disabled:opacity-30"
:disabled="index === rows.length - 1"
:aria-label="t('library.creator.metadata.moveDown', { item: row.name })"
@click="moveDown(row.key)"
>
<ChevronDown :size="14" />
<ArrowDown :size="13" aria-hidden="true" />
</button>
</div>
</div>
</span>
</li>
</ol>
<div v-if="hiddenCount > 0 || showAll" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-dashed border-border px-4 py-2.5">
<p class="text-xs text-muted-foreground">
<template v-if="showAll">{{ t('library.creator.metadata.primary.showingAll') }}</template>
<template v-else-if="present">{{ t('library.creator.metadata.primary.notHeld', { count: hiddenCount }) }}</template>
<template v-else>{{ t('library.creator.metadata.primary.moreBelow', { count: hiddenCount }) }}</template>
</p>
<button type="button" class="ms-auto text-xs font-medium text-primary hover:underline" :aria-expanded="showAll" @click="toggleShowAll">
{{
showAll
? t('library.creator.metadata.primary.showFewer')
: t('library.creator.metadata.primary.showAll', { count: formatPriority.length })
}}
</button>
</div>
</div>
</LibraryCreatorCard>
<LibraryCreatorCard :label="t('library.creator.metadata.source.title')" label-id="metadata-source-title">
<ol aria-labelledby="metadata-source-title" class="flex flex-col items-stretch gap-2 @lg:flex-row @lg:items-center">
<template v-for="(source, index) in sources" :key="source">
<li
class="flex min-w-0 flex-1 items-center gap-3 rounded-xl border px-3 py-2.5"
:class="index === 0 ? 'border-primary/55 bg-primary/7' : 'border-border bg-background'"
>
<span
class="flex size-5.5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
:class="index === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
>
{{ formatNumber(index + 1) }}
</span>
<component :is="source === 'embedded' ? FileText : FileCode" :size="16" class="shrink-0 text-foreground" aria-hidden="true" />
<span class="min-w-0">
<span class="block text-[13px] font-semibold text-foreground">
{{ source === 'embedded' ? t('library.creator.metadata.source.embeddedTitle') : t('library.creator.metadata.source.opfTitle') }}
</span>
<span class="block text-xs text-muted-foreground">
{{ source === 'embedded' ? t('library.creator.metadata.source.embeddedHint') : t('library.creator.metadata.source.opfHint') }}
</span>
</span>
</li>
<li v-if="index === 0" class="flex justify-center">
<button
type="button"
class="flex size-8 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('library.creator.metadata.source.swap')"
:title="t('library.creator.metadata.source.swap')"
@click="swapSources"
>
<ArrowLeftRight :size="14" class="rotate-90 @lg:rotate-0" aria-hidden="true" />
</button>
</li>
</template>
</ol>
<p class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.metadata.source.hint') }}</p>
</LibraryCreatorCard>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber, formatPercent } from '@/i18n/formatters'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
const { t } = useI18n()
defineProps<{
const START_MIN = 0.05
const START_MAX = 5
const FINISH_MIN = 90
const FINISH_MAX = 100
const props = defineProps<{
readingThreshold: number
markAsFinishedPercentComplete: number
}>()
@@ -14,79 +21,222 @@ const emit = defineEmits<{
'update:markAsFinishedPercentComplete': [value: number]
}>()
function onReadingThresholdInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('update:readingThreshold', val)
const startFraction = computed(() => fractionOf(props.readingThreshold, START_MIN, START_MAX))
const finishFraction = computed(() => fractionOf(props.markAsFinishedPercentComplete, FINISH_MIN, FINISH_MAX))
function fractionOf(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(1, Math.max(0, (value - min) / (max - min)))
}
function onFinishPercentInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('update:markAsFinishedPercentComplete', val)
function onReadingThresholdInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value)
if (!Number.isNaN(value)) emit('update:readingThreshold', value)
}
function formatFinishedThreshold(value: number): string {
function onFinishedInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value)
if (!Number.isNaN(value)) emit('update:markAsFinishedPercentComplete', value)
}
function percent(value: number): string {
return formatNumber(value / 100, { style: 'percent', maximumFractionDigits: 2 })
}
</script>
<template>
<div class="px-6 py-6 space-y-6">
<!-- Reading start -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="reading-threshold" class="text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ t('library.creator.reading.readingStart.title') }}
</label>
<output for="reading-threshold" class="text-sm font-medium text-foreground tabular-nums">{{ readingThreshold }}%</output>
<LibraryCreatorCard :label="t('library.creator.reading.statusTitle')">
<div class="mb-7 px-1">
<div class="mb-2 flex items-center text-[11px]" aria-hidden="true">
<span class="text-muted-foreground">{{ t('library.creator.reading.zones.notStarted') }}</span>
<span class="mx-auto font-medium text-primary">{{ t('library.creator.reading.zones.reading') }}</span>
<span class="font-medium text-success">{{ t('library.creator.reading.zones.read') }}</span>
</div>
<p id="reading-threshold-help" class="text-xs text-muted-foreground mb-3">
{{ t('library.creator.reading.readingStart.hint') }}
</p>
<input
id="reading-threshold"
type="range"
:value="readingThreshold"
min="0.05"
max="5"
step="0.05"
class="w-full accent-primary"
aria-describedby="reading-threshold-help"
@input="onReadingThresholdInput"
/>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>0.05%</span>
<span>5%</span>
<!--
Each end is its own slider over its own range, drawn wider than true scale: at scale the whole
0.05% to 5% start range would be a few pixels. The break marks say the axis is not continuous.
-->
<div class="flex items-center gap-1">
<div class="relative w-[30%] shrink-0">
<input
id="reading-threshold-slider"
type="range"
class="reading-range reading-range--start w-full"
:min="START_MIN"
:max="START_MAX"
step="0.05"
:value="readingThreshold"
:style="{ '--fraction': startFraction }"
:aria-label="t('library.creator.reading.startSlider')"
:aria-valuetext="percent(readingThreshold)"
@input="onReadingThresholdInput"
/>
<span class="reading-readout" :style="{ '--fraction': startFraction }" aria-hidden="true">{{ percent(readingThreshold) }}</span>
</div>
<svg
class="h-3.5 w-2 shrink-0 text-muted-foreground"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path d="M1 13 4 1M4 13 7 1" />
</svg>
<span class="h-2.5 min-w-0 flex-1 rounded-full bg-primary/45" aria-hidden="true" />
<svg
class="h-3.5 w-2 shrink-0 text-muted-foreground"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path d="M1 13 4 1M4 13 7 1" />
</svg>
<div class="relative w-[30%] shrink-0">
<input
id="finished-threshold-slider"
type="range"
class="reading-range reading-range--finish w-full"
:min="FINISH_MIN"
:max="FINISH_MAX"
step="0.25"
:value="markAsFinishedPercentComplete"
:style="{ '--fraction': finishFraction }"
:aria-label="t('library.creator.reading.finishSlider')"
:aria-valuetext="percent(markAsFinishedPercentComplete)"
@input="onFinishedInput"
/>
<span class="reading-readout" :style="{ '--fraction': finishFraction }" aria-hidden="true">{{
percent(markAsFinishedPercentComplete)
}}</span>
</div>
</div>
</div>
<!-- Mark as finished -->
<div>
<div class="flex items-center justify-between mb-1">
<label for="finished-threshold" class="text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ t('library.creator.reading.markAsFinished.title') }}
</label>
<output for="finished-threshold" class="text-sm font-medium text-foreground tabular-nums">
{{ formatFinishedThreshold(markAsFinishedPercentComplete) }}
</output>
<div class="grid gap-4 @lg:grid-cols-2">
<div>
<label for="reading-threshold" class="block text-[13px] font-medium text-foreground">{{
t('library.creator.reading.readingStart.title')
}}</label>
<div class="relative mt-1.5 w-36">
<input
id="reading-threshold"
type="number"
inputmode="decimal"
:value="readingThreshold"
:min="START_MIN"
:max="START_MAX"
step="0.05"
class="h-9 w-full rounded-md border border-input bg-background pe-8 ps-3 text-end text-sm tabular-nums text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-describedby="reading-threshold-help"
@input="onReadingThresholdInput"
/>
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground" aria-hidden="true">%</span>
</div>
<p id="reading-threshold-help" class="mt-1.5 text-xs text-muted-foreground">
{{ t('library.creator.reading.readingStart.hint') }}
{{ t('library.creator.reading.range', { min: percent(START_MIN), max: percent(START_MAX) }) }}
</p>
</div>
<p id="finished-threshold-help" class="text-xs text-muted-foreground mb-3">
{{ t('library.creator.reading.markAsFinished.hint') }}
</p>
<input
id="finished-threshold"
type="range"
:value="markAsFinishedPercentComplete"
min="90"
max="100"
step="0.05"
class="w-full accent-primary"
aria-describedby="finished-threshold-help"
@input="onFinishPercentInput"
/>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{{ formatPercent(0.9) }}</span>
<span>{{ formatPercent(1) }}</span>
<div>
<label for="finished-threshold" class="block text-[13px] font-medium text-foreground">{{
t('library.creator.reading.markAsFinished.title')
}}</label>
<div class="relative mt-1.5 w-36">
<input
id="finished-threshold"
type="number"
inputmode="decimal"
:value="markAsFinishedPercentComplete"
:min="FINISH_MIN"
:max="FINISH_MAX"
step="0.05"
class="h-9 w-full rounded-md border border-input bg-background pe-8 ps-3 text-end text-sm tabular-nums text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-describedby="finished-threshold-help"
@input="onFinishedInput"
/>
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground" aria-hidden="true">%</span>
</div>
<p id="finished-threshold-help" class="mt-1.5 text-xs text-muted-foreground">
{{ t('library.creator.reading.markAsFinished.hint') }}
{{ t('library.creator.reading.range', { min: formatPercent(0.9), max: formatPercent(1) }) }}
</p>
</div>
</div>
</div>
</LibraryCreatorCard>
</template>
<style scoped>
/* The thumb centre travels from half a thumb in to half a thumb short of the end, so the fill and the
readout track that span rather than the full width. */
.reading-range {
--thumb: 18px;
--stop: calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--fraction));
appearance: none;
display: block;
height: 10px;
border-radius: 999px;
cursor: pointer;
touch-action: none;
}
.reading-range--start {
background: linear-gradient(to right, var(--surface-4) var(--stop), color-mix(in oklch, var(--primary) 45%, transparent) var(--stop));
}
.reading-range--finish {
background: linear-gradient(to right, color-mix(in oklch, var(--primary) 45%, transparent) var(--stop), var(--success) var(--stop));
}
:dir(rtl) .reading-range--start {
background: linear-gradient(to left, var(--surface-4) var(--stop), color-mix(in oklch, var(--primary) 45%, transparent) var(--stop));
}
:dir(rtl) .reading-range--finish {
background: linear-gradient(to left, color-mix(in oklch, var(--primary) 45%, transparent) var(--stop), var(--success) var(--stop));
}
.reading-range:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
}
.reading-range::-webkit-slider-thumb {
appearance: none;
width: var(--thumb);
height: var(--thumb);
border: 2px solid var(--foreground);
border-radius: 999px;
background: var(--background);
box-shadow: var(--elevation-sm);
}
.reading-range::-moz-range-thumb {
width: var(--thumb);
height: var(--thumb);
border: 2px solid var(--foreground);
border-radius: 999px;
background: var(--background);
box-shadow: var(--elevation-sm);
}
.reading-readout {
position: absolute;
top: 100%;
inset-inline-start: calc(9px + (100% - 18px) * var(--fraction));
margin-top: 6px;
translate: calc(var(--fraction) * -100%) 0;
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--foreground);
}
:dir(rtl) .reading-readout {
translate: calc(var(--fraction) * 100%) 0;
}
</style>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Lock, Plus, RefreshCw, X } from '@lucide/vue'
import type { AddedAtRecomputeJob, AddedAtSource, OrganizationMode } from '@bookorbit/types'
import { ArrowRight, Lock, Plus, RefreshCw, TriangleAlert, X } from '@lucide/vue'
import { BOOK_FORMATS, type AddedAtRecomputeJob, type AddedAtSource, type OrganizationMode } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { BOOK_FORMATS } from '@bookorbit/types'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { formatFamilyColor } from '@/features/settings/libraries/lib/library-formats'
import LibraryCreatorCard from './LibraryCreatorCard.vue'
import LibraryOrganizationPreview from './LibraryOrganizationPreview.vue'
const { t } = useI18n()
@@ -30,6 +32,97 @@ const emit = defineEmits<{
recompute: []
}>()
const ORGANIZATION_MODES: OrganizationMode[] = ['book_per_folder', 'book_per_file']
const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_created']
const FORMAT_GROUPS: { id: 'ebook' | 'kindle' | 'document' | 'comic' | 'audio'; formats: string[] }[] = [
{ id: 'ebook', formats: ['epub', 'kepub', 'fb2'] },
{ id: 'kindle', formats: ['mobi', 'azw3', 'azw'] },
{ id: 'document', formats: ['pdf'] },
{ id: 'comic', formats: ['cbz', 'cbr', 'cb7'] },
{ id: 'audio', formats: ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] },
]
const modeCopy = computed<Record<OrganizationMode, { title: string; hint: string; result: string }>>(() => ({
book_per_folder: {
title: t('library.creator.scanner.scanMode.folderAsBook.title'),
hint: t('library.creator.scanner.mode.folderHint'),
result: t('library.creator.scanner.mode.folderResult'),
},
book_per_file: {
title: t('library.creator.scanner.scanMode.fileAsBook.title'),
hint: t('library.creator.scanner.mode.fileHint'),
result: t('library.creator.scanner.mode.fileResult'),
},
}))
// ── Import ────────────────────────────────────────────────────────────────────
const restricting = ref(props.allowedFormats.length > 0)
const importAll = computed(() => !restricting.value && props.allowedFormats.length === 0)
function isImported(format: string): boolean {
return props.allowedFormats.length === 0 || props.allowedFormats.includes(format)
}
function selectImportAll() {
restricting.value = false
emit('update:allowedFormats', [])
}
function selectImportSome() {
restricting.value = true
if (props.allowedFormats.length === 0) emit('update:allowedFormats', [...BOOK_FORMATS])
}
function toggleFormat(format: string) {
const current = props.allowedFormats.length === 0 ? [...BOOK_FORMATS] : [...props.allowedFormats]
const index = current.indexOf(format)
if (index === -1) current.push(format)
else if (current.length > 1) current.splice(index, 1)
else return
emit('update:allowedFormats', current)
}
function chipStyle(format: string): Record<string, string> {
if (!isImported(format)) return {}
const color = formatColorVar(format)
return { color, backgroundColor: `color-mix(in oklch, ${color} 13%, transparent)`, borderColor: 'transparent' }
}
// ── Skip patterns ───────────────────────────────────────────────────────────────
const newPattern = ref('')
function addPattern() {
const trimmed = newPattern.value.trim()
if (!trimmed || props.excludePatterns.includes(trimmed)) return
emit('update:excludePatterns', [...props.excludePatterns, trimmed])
newPattern.value = ''
}
function removePattern(pattern: string) {
emit(
'update:excludePatterns',
props.excludePatterns.filter((candidate) => candidate !== pattern),
)
}
function onPatternKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
addPattern()
}
}
// ── Organization ────────────────────────────────────────────────────────────────
function selectMode(event: Event) {
if (props.organizationModeLocked) return
emit('update:organizationMode', (event.target as HTMLInputElement).value as OrganizationMode)
}
// ── Date added ────────────────────────────────────────────────────────────────
const confirmingRecompute = ref(false)
const confirmButton = ref<HTMLButtonElement | null>(null)
const sourceGroup = ref<HTMLElement | null>(null)
@@ -40,9 +133,6 @@ const progressText = computed(() => {
const counters = ['processed', 'total', 'updated', 'unchanged', 'skipped', 'failed'] as const
return t('library.creator.scanner.addedAt.progressSummary', Object.fromEntries(counters.map((key) => [key, formatNumber(job[key])])))
})
const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_created']
const recomputeDisabled = computed(
() =>
props.recomputingAddedAt ||
@@ -52,8 +142,8 @@ const recomputeDisabled = computed(
)
const showSaveFirstHint = computed(() => props.canRecomputeAddedAt && props.addedAtSource !== props.storedAddedAtSource)
function selectAddedAtSource(source: AddedAtSource) {
emit('update:addedAtSource', source)
function selectAddedAtSource(event: Event) {
emit('update:addedAtSource', (event.target as HTMLInputElement).value as AddedAtSource)
}
async function requestRecompute() {
@@ -75,179 +165,203 @@ async function confirmRecompute() {
await nextTick()
sourceGroup.value?.querySelector<HTMLInputElement>('input:checked')?.focus()
}
// ── Scan mode ─────────────────────────────────────────────────────────────────
function handleSelectMode(mode: OrganizationMode) {
if (props.organizationModeLocked) return
emit('update:organizationMode', mode)
}
function handleSelectFolderMode() {
handleSelectMode('book_per_folder')
}
function handleSelectFileMode() {
handleSelectMode('book_per_file')
}
// ── Allowed formats ──────────────────────────────────────────────────────────
const ALL_FORMATS: readonly string[] = BOOK_FORMATS
function toggleAllowedFormat(fmt: string) {
const current = [...props.allowedFormats]
const idx = current.indexOf(fmt)
if (idx === -1) {
current.push(fmt)
} else {
if (current.length === 1) return
current.splice(idx, 1)
}
emit('update:allowedFormats', current)
}
function selectAllFormats() {
emit('update:allowedFormats', [])
}
// ── Exclude patterns ─────────────────────────────────────────────────────────
const newPattern = ref('')
function addPattern() {
const trimmed = newPattern.value.trim()
if (!trimmed || props.excludePatterns.includes(trimmed)) return
emit('update:excludePatterns', [...props.excludePatterns, trimmed])
newPattern.value = ''
}
function removePattern(i: number) {
const updated = [...props.excludePatterns]
updated.splice(i, 1)
emit('update:excludePatterns', updated)
}
function onPatternKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
addPattern()
}
}
</script>
<template>
<div class="px-6 py-6 space-y-8">
<!-- Scan mode -->
<div>
<div class="flex items-center gap-2 mb-3">
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground">{{ t('library.creator.scanner.scanMode.title') }}</p>
<Tooltip v-if="organizationModeLocked">
<TooltipTrigger as-child>
<span
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-border text-muted-foreground cursor-help"
:aria-label="t('library.creator.scanner.scanMode.lockedAria')"
>
<Lock :size="11" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-72 text-xs leading-relaxed">
{{ t('library.creator.scanner.scanMode.lockTooltip') }}
</TooltipContent>
</Tooltip>
<div class="flex flex-col gap-3.5">
<LibraryCreatorCard :label="t('library.creator.scanner.mode.title')" label-id="organization-mode-title">
<template v-if="organizationModeLocked" #meta>
<span class="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<Lock :size="10" aria-hidden="true" />
{{ t('library.creator.scanner.mode.locked') }}
</span>
</template>
<div v-if="organizationModeLocked" class="flex flex-col gap-3 @md:flex-row @md:items-center">
<LibraryOrganizationPreview :mode="organizationMode" class="w-full shrink-0 @md:w-44" />
<div class="min-w-0">
<p class="text-sm font-semibold text-foreground">{{ modeCopy[organizationMode].title }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ modeCopy[organizationMode].hint }}</p>
<p class="mt-1.5 text-xs text-muted-foreground">{{ t('library.creator.scanner.scanMode.lockTooltip') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<template v-else>
<div role="radiogroup" aria-labelledby="organization-mode-title" class="grid gap-2.5">
<label
v-for="mode in ORGANIZATION_MODES"
:key="mode"
class="flex cursor-pointer flex-col gap-2.5 rounded-xl border p-3 transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="organizationMode === mode ? 'border-primary bg-primary/7 ring-1 ring-primary' : 'border-border bg-background hover:bg-muted'"
>
<span class="flex items-center gap-2">
<input
type="radio"
name="organization-mode"
:value="mode"
:checked="organizationMode === mode"
class="size-4 shrink-0 accent-primary"
@change="selectMode"
/>
<span class="text-sm font-semibold text-foreground">{{ modeCopy[mode].title }}</span>
<span v-if="mode === 'book_per_folder'" class="ms-auto rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{{ t('library.creator.scanner.scanMode.recommended') }}
</span>
</span>
<span class="flex flex-col gap-2.5 @md:flex-row @md:items-center">
<LibraryOrganizationPreview :mode="mode" class="shrink-0 @md:w-44" />
<span class="flex min-w-0 flex-col gap-1">
<span class="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<ArrowRight :size="12" class="shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />
{{ modeCopy[mode].result }}
</span>
<span class="text-xs text-muted-foreground">{{ modeCopy[mode].hint }}</span>
</span>
</span>
</label>
</div>
<p class="mt-2.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock :size="12" class="shrink-0" aria-hidden="true" />
{{ t('library.creator.scanner.mode.cannotChange') }}
</p>
</template>
</LibraryCreatorCard>
<LibraryCreatorCard :label="t('library.creator.scanner.import.title')" label-id="import-formats-title">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div role="radiogroup" aria-labelledby="import-formats-title" class="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5">
<label
class="cursor-pointer rounded-md px-3 py-1 text-[13px] font-medium transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="importAll ? 'bg-background text-primary shadow-xs' : 'text-foreground hover:bg-background/60'"
>
<input type="radio" name="import-formats" class="sr-only" :checked="importAll" @change="selectImportAll" />
{{ t('library.creator.scanner.import.all') }}
</label>
<label
class="cursor-pointer rounded-md px-3 py-1 text-[13px] font-medium transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="!importAll ? 'bg-background text-primary shadow-xs' : 'text-foreground hover:bg-background/60'"
>
<input type="radio" name="import-formats" class="sr-only" :checked="!importAll" @change="selectImportSome" />
{{ t('library.creator.scanner.import.some') }}
</label>
</div>
<span class="text-xs text-muted-foreground">
{{ importAll ? t('library.creator.scanner.allowedFormats.allAllowed') : t('library.creator.scanner.allowedFormats.onlySelected') }}
</span>
</div>
<template v-if="!importAll">
<div class="mt-3 grid gap-2 @lg:grid-cols-2">
<div v-for="group in FORMAT_GROUPS" :key="group.id" class="rounded-lg border border-border bg-background px-3 py-2.5">
<p :id="`format-group-${group.id}`" class="mb-2 flex items-center gap-2 text-xs font-semibold text-foreground">
<span class="size-1.5 rounded-[2px]" :style="{ backgroundColor: formatFamilyColor(group.formats[0]!) }" aria-hidden="true" />
{{ t(`library.creator.scanner.import.groups.${group.id}`) }}
</p>
<div role="group" :aria-labelledby="`format-group-${group.id}`" class="flex flex-wrap gap-1.5">
<button
v-for="format in group.formats"
:key="format"
type="button"
class="rounded-md border px-2 py-0.5 text-[11px] font-semibold tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="isImported(format) ? '' : 'border-border text-muted-foreground line-through hover:text-foreground'"
:style="chipStyle(format)"
:aria-pressed="isImported(format)"
@click="toggleFormat(format)"
>
{{ format.toUpperCase() }}
</button>
</div>
</div>
</div>
<p class="mt-2.5 flex items-start gap-1.5 text-xs text-warning">
<TriangleAlert :size="13" class="mt-px shrink-0" aria-hidden="true" />
<span>{{ t('library.creator.scanner.allowedFormats.warning') }}</span>
</p>
</template>
</LibraryCreatorCard>
<LibraryCreatorCard :label="t('library.creator.scanner.skip.title')">
<template #meta>{{
excludePatterns.length ? t('library.creator.scanner.skip.count', { count: excludePatterns.length }) : t('library.creator.optional')
}}</template>
<div class="flex flex-wrap items-center gap-1.5 rounded-lg border border-input bg-background p-1.5 focus-within:ring-2 focus-within:ring-ring">
<span
v-for="pattern in excludePatterns"
:key="pattern"
class="inline-flex items-center gap-1 rounded-md border border-border bg-card py-0.5 pe-1 ps-2 font-mono text-xs text-foreground"
dir="ltr"
>
{{ pattern }}
<button
type="button"
class="flex size-4.5 items-center justify-center rounded text-foreground hover:bg-muted hover:text-destructive"
:aria-label="t('library.creator.scanner.skip.remove', { pattern })"
@click="removePattern(pattern)"
>
<X :size="11" aria-hidden="true" />
</button>
</span>
<label for="exclude-pattern-input" class="sr-only">{{ t('library.creator.scanner.skip.inputLabel') }}</label>
<input
id="exclude-pattern-input"
v-model="newPattern"
type="text"
dir="ltr"
class="h-7 min-w-40 flex-1 bg-transparent px-1.5 font-mono text-[12.5px] text-foreground placeholder:font-sans placeholder:text-muted-foreground focus:outline-none"
:placeholder="excludePatterns.length ? t('library.creator.scanner.skip.placeholderMore') : t('library.creator.scanner.skip.placeholder')"
aria-describedby="exclude-pattern-hint"
@keydown="onPatternKeydown"
/>
<button
type="button"
class="text-left rounded-lg border p-4 transition-colors"
:class="[
organizationMode === 'book_per_folder'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border bg-card hover:border-primary/40',
organizationModeLocked ? 'cursor-not-allowed opacity-75' : '',
]"
:disabled="organizationModeLocked"
@click="handleSelectFolderMode"
class="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50"
:disabled="!newPattern.trim()"
@click="addPattern"
>
<div class="flex items-center gap-2 mb-1.5">
<span
class="w-3.5 h-3.5 rounded-full border-2 shrink-0 flex items-center justify-center"
:class="organizationMode === 'book_per_folder' ? 'border-primary' : 'border-muted-foreground/40'"
>
<span v-if="organizationMode === 'book_per_folder'" class="w-1.5 h-1.5 rounded-full bg-primary" />
</span>
<span class="text-sm font-semibold text-foreground">{{ t('library.creator.scanner.scanMode.folderAsBook.title') }}</span>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ t('library.creator.scanner.scanMode.folderAsBook.hint') }}
</p>
</button>
<button
type="button"
class="text-left rounded-lg border p-4 transition-colors"
:class="[
organizationMode === 'book_per_file'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border bg-card hover:border-primary/40',
organizationModeLocked ? 'cursor-not-allowed opacity-75' : '',
]"
:disabled="organizationModeLocked"
@click="handleSelectFileMode"
>
<div class="flex items-center gap-2 mb-1.5">
<span
class="w-3.5 h-3.5 rounded-full border-2 shrink-0 flex items-center justify-center"
:class="organizationMode === 'book_per_file' ? 'border-primary' : 'border-muted-foreground/40'"
>
<span v-if="organizationMode === 'book_per_file'" class="w-1.5 h-1.5 rounded-full bg-primary" />
</span>
<span class="text-sm font-semibold text-foreground">{{ t('library.creator.scanner.scanMode.fileAsBook.title') }}</span>
</div>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ t('library.creator.scanner.scanMode.fileAsBook.hint') }}
</p>
<Plus :size="12" aria-hidden="true" />
{{ t('library.creator.scanner.excludePatterns.add') }}
</button>
</div>
</div>
<p id="exclude-pattern-hint" class="mt-1.5 text-xs text-muted-foreground">{{ t('library.creator.scanner.skip.hint') }}</p>
</LibraryCreatorCard>
<div>
<p id="added-at-title" class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">
{{ t('library.creator.scanner.addedAt.title') }}
</p>
<p id="added-at-hint" class="text-xs text-muted-foreground mb-3">{{ t('library.creator.scanner.addedAt.hint') }}</p>
<div ref="sourceGroup" role="radiogroup" aria-labelledby="added-at-title" aria-describedby="added-at-hint" class="space-y-2">
<LibraryCreatorCard :label="t('library.creator.scanner.addedAt.title')" label-id="added-at-title">
<div
ref="sourceGroup"
role="radiogroup"
aria-labelledby="added-at-title"
aria-describedby="added-at-hint"
class="grid gap-0.5 rounded-lg border border-border bg-muted p-0.5 @lg:grid-cols-3"
>
<label
v-for="source in ADDED_AT_SOURCES"
:key="source"
class="block w-full cursor-pointer rounded-lg border p-3 text-start transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="addedAtSource === source ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border bg-card hover:border-primary/40'"
class="cursor-pointer rounded-md px-3 py-1.5 text-center text-[13px] font-medium transition-colors focus-within:ring-2 focus-within:ring-ring"
:class="addedAtSource === source ? 'bg-background text-primary shadow-xs' : 'text-foreground hover:bg-background/60'"
>
<span class="mb-1 flex items-center gap-2">
<input
type="radio"
name="added-at-source"
:value="source"
:checked="addedAtSource === source"
class="accent-primary"
@change="selectAddedAtSource(source)"
/>
<span class="text-sm font-semibold text-foreground">{{ t(`library.creator.scanner.addedAt.options.${source}.title`) }}</span>
</span>
<span class="block ps-5.5 text-xs text-muted-foreground leading-relaxed">{{
t(`library.creator.scanner.addedAt.options.${source}.hint`)
}}</span>
<input
type="radio"
name="added-at-source"
:value="source"
:checked="addedAtSource === source"
class="sr-only"
@change="selectAddedAtSource"
/>
{{ t(`library.creator.scanner.addedAt.options.${source}.title`) }}
</label>
</div>
<p id="added-at-hint" class="mt-2 text-xs text-muted-foreground">{{ t(`library.creator.scanner.addedAt.options.${addedAtSource}.hint`) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.scanner.addedAt.hint') }}</p>
<div v-if="canRecomputeAddedAt" class="mt-3 flex flex-wrap items-center gap-3">
<button
ref="recomputeButton"
type="button"
class="flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="recomputeDisabled"
@click="requestRecompute"
>
<RefreshCw :size="13" :class="recomputingAddedAt ? 'motion-safe:animate-spin' : ''" />
<RefreshCw :size="13" :class="recomputingAddedAt ? 'motion-safe:animate-spin' : ''" aria-hidden="true" />
{{ recomputingAddedAt ? t('library.creator.scanner.addedAt.recomputing') : t('library.creator.scanner.addedAt.recompute') }}
</button>
<p class="text-xs text-muted-foreground">
@@ -258,7 +372,7 @@ function onPatternKeydown(e: KeyboardEvent) {
v-if="confirmingRecompute"
role="group"
:aria-label="t('library.creator.scanner.addedAt.recompute')"
class="mt-3 rounded-md border border-border p-3"
class="mt-3 rounded-lg border border-border bg-background p-3"
>
<p class="text-sm text-muted-foreground">{{ t('library.creator.scanner.addedAt.backgroundConfirm') }}</p>
<div class="mt-3 flex flex-wrap gap-2">
@@ -266,14 +380,14 @@ function onPatternKeydown(e: KeyboardEvent) {
ref="confirmButton"
type="button"
:disabled="recomputeDisabled"
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
class="h-8 rounded-md border border-border px-3 text-[13px] font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
@click="confirmRecompute"
>
{{ t('library.creator.scanner.addedAt.recompute') }}
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
class="h-8 rounded-md px-3 text-[13px] font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="cancelRecompute"
>
{{ t('common.cancel') }}
@@ -303,102 +417,6 @@ function onPatternKeydown(e: KeyboardEvent) {
</ul>
</div>
<p v-if="recomputeErrorKey" role="alert" class="mt-3 text-sm text-destructive">{{ t(recomputeErrorKey) }}</p>
</div>
<!-- Filtering group -->
<div>
<!-- Allowed formats -->
<div class="mb-8">
<div class="flex items-center justify-between mb-1">
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ t('library.creator.scanner.allowedFormats.title') }}
</p>
<button
v-if="allowedFormats.length > 0"
type="button"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="selectAllFormats"
>
{{ t('library.creator.scanner.allowedFormats.allowAll') }}
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
{{
allowedFormats.length === 0
? t('library.creator.scanner.allowedFormats.allAllowed')
: t('library.creator.scanner.allowedFormats.onlySelected')
}}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="fmt in ALL_FORMATS"
:key="fmt"
type="button"
class="px-2.75 py-1 rounded-full text-[11px] font-medium border transition-colors"
:class="
allowedFormats.length === 0 || allowedFormats.includes(fmt)
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'
"
:aria-pressed="allowedFormats.length === 0 || allowedFormats.includes(fmt)"
@click="toggleAllowedFormat(fmt)"
>
{{ fmt.toUpperCase() }}
</button>
</div>
<p v-if="allowedFormats.length > 0" class="mt-2 text-xs font-medium text-foreground">
{{ t('library.creator.scanner.allowedFormats.warning') }}
</p>
</div>
<!-- Exclude patterns -->
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-1">
{{ t('library.creator.scanner.excludePatterns.title') }}
</p>
<p class="text-xs text-muted-foreground mb-3">
{{ t('library.creator.scanner.excludePatterns.hintBefore') }} <code class="font-mono bg-muted px-1 rounded">**/samples/**</code
>{{ t('library.creator.scanner.excludePatterns.hintAfter') }}
</p>
<div class="flex gap-2 mb-2">
<input
v-model="newPattern"
type="text"
placeholder="**/node_modules/**"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
@keydown="onPatternKeydown"
/>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
:disabled="!newPattern.trim()"
@click="addPattern"
>
<Plus :size="13" />
{{ t('library.creator.scanner.excludePatterns.add') }}
</button>
</div>
<div class="min-h-[40px] rounded-md border border-border bg-muted/30 p-2 flex flex-wrap gap-1.5 overflow-y-auto" style="max-height: 80px">
<span v-if="excludePatterns.length === 0" class="text-xs text-muted-foreground self-center px-1">
{{ t('library.creator.scanner.excludePatterns.empty') }}
</span>
<span
v-for="(pattern, i) in excludePatterns"
:key="pattern"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-background border border-border text-xs font-mono text-foreground"
>
{{ pattern }}
<button
type="button"
class="text-muted-foreground hover:text-destructive transition-colors"
:aria-label="`Remove exclude pattern ${pattern}`"
@click="removePattern(i)"
>
<X :size="11" />
</button>
</span>
</div>
</div>
</div>
</LibraryCreatorCard>
</div>
</template>
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Eye } from '@lucide/vue'
import { CalendarClock, Eye } from '@lucide/vue'
import { isFiveFieldCronExpression } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { parseCronToHuman } from '@/features/library/utils/cron'
const { t, locale } = useI18n()
const CUSTOM = '__custom__'
const NEVER = '__never__'
const props = withDefaults(
defineProps<{
watch: boolean
@@ -23,126 +26,105 @@ const emit = defineEmits<{
}>()
const presets = computed(() => [
{ label: t('library.creator.schedule.presets.never'), value: null },
{ label: t('library.creator.schedule.presets.never'), value: NEVER },
{ label: t('library.creator.schedule.presets.hourly'), value: '0 * * * *' },
{ label: t('library.creator.schedule.presets.every6Hours'), value: '0 */6 * * *' },
{ label: t('library.creator.schedule.presets.every12Hours'), value: '0 */12 * * *' },
{ label: t('library.creator.schedule.presets.daily'), value: '0 0 * * *' },
{ label: t('library.creator.schedule.presets.weekly'), value: '0 0 * * 1' },
{ label: t('library.creator.schedule.presets.custom'), value: '__custom__' },
{ label: t('library.creator.schedule.presets.custom'), value: CUSTOM },
])
const isCustom = computed(() => {
if (props.autoScanCronExpression === null) return false
return !presets.value.some((p) => p.value === props.autoScanCronExpression)
})
const isCronValid = computed(() => {
if (!isCustom.value || !props.autoScanCronExpression) return true
return isFiveFieldCronExpression(props.autoScanCronExpression)
})
const selectedPreset = computed(() => {
if (props.autoScanCronExpression === null) return null
if (isCustom.value) return '__custom__'
return props.autoScanCronExpression
if (props.autoScanCronExpression === null) return NEVER
return presets.value.some((preset) => preset.value === props.autoScanCronExpression) ? props.autoScanCronExpression : CUSTOM
})
const isCustom = computed(() => selectedPreset.value === CUSTOM)
const isCronValid = computed(() => !isCustom.value || !props.autoScanCronExpression || isFiveFieldCronExpression(props.autoScanCronExpression))
function selectPreset(value: string | null) {
if (value === '__custom__') {
emit('update:autoScanCronExpression', '*/30 * * * *')
} else {
emit('update:autoScanCronExpression', value)
}
}
function handleWatchUpdate(value: boolean) {
emit('update:watch', value)
}
function handleCronInput(event: Event) {
emit('update:autoScanCronExpression', (event.target as HTMLInputElement).value || null)
}
function humanReadableCron(cron: string | null): string {
const humanSchedule = computed(() => {
const cron = props.autoScanCronExpression
if (!cron) return t('library.creator.schedule.human.disabled')
const map: Record<string, string> = {
const known: Record<string, string> = {
'0 * * * *': t('library.creator.schedule.human.hourly'),
'0 */6 * * *': t('library.creator.schedule.human.every6Hours'),
'0 */12 * * *': t('library.creator.schedule.human.every12Hours'),
'0 0 * * *': t('library.creator.schedule.human.dailyMidnight'),
'0 0 * * 1': t('library.creator.schedule.human.weeklyMonday'),
}
if (map[cron]) return map[cron]
if (known[cron]) return known[cron]
const description = parseCronToHuman(cron, locale.value)
return !description || description === cron ? t('library.creator.schedule.human.invalid') : description
})
function handleWatchUpdate(value: boolean) {
emit('update:watch', value)
}
function selectPreset(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === NEVER) emit('update:autoScanCronExpression', null)
else if (value === CUSTOM) emit('update:autoScanCronExpression', '*/30 * * * *')
else emit('update:autoScanCronExpression', value)
}
function handleCronInput(event: Event) {
emit('update:autoScanCronExpression', (event.target as HTMLInputElement).value || null)
}
</script>
<template>
<div class="px-6 py-6 space-y-8">
<!-- Watch folders -->
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-3">{{ t('library.creator.schedule.fileWatching') }}</p>
<div class="overflow-hidden rounded-lg border border-border">
<div class="flex items-center justify-between gap-4 bg-card px-4 py-4 sm:px-5">
<div>
<p class="text-sm font-medium text-foreground">{{ t('library.creator.schedule.watchFolders.title') }}</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('library.creator.schedule.watchFolders.hint') }}</p>
</div>
<ToggleSwitch :model-value="watch" :aria-label="t('library.creator.schedule.watchFolders.title')" @update:model-value="handleWatchUpdate" />
<section class="divide-y divide-border rounded-xl border border-border bg-card">
<div class="flex items-start gap-3.5 px-4 py-3.5">
<Eye :size="17" class="mt-0.5 shrink-0 text-foreground" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">{{ t('library.creator.schedule.watchFolders.title') }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ t('library.creator.schedule.watchFolders.hint') }}</p>
</div>
<ToggleSwitch :model-value="watch" :aria-label="t('library.creator.schedule.watchFolders.title')" @update:model-value="handleWatchUpdate" />
</div>
<div v-if="showAutoScanSchedule" class="flex items-start gap-3.5 px-4 py-3.5">
<CalendarClock :size="17" class="mt-0.5 shrink-0 text-foreground" aria-hidden="true" />
<div class="min-w-0 flex-1">
<label for="library-scan-preset" class="text-sm font-semibold text-foreground">{{ t('library.creator.schedule.autoScanSchedule') }}</label>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<select
id="library-scan-preset"
:value="selectedPreset"
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring @md:w-48"
aria-describedby="library-scan-summary"
@change="selectPreset"
>
<option v-for="preset in presets" :key="preset.value" :value="preset.value">{{ preset.label }}</option>
</select>
<p id="library-scan-summary" class="flex items-center gap-1.5 text-xs text-muted-foreground" aria-live="polite">
<Eye :size="12" class="shrink-0" aria-hidden="true" />
{{ humanSchedule }}
</p>
</div>
<div v-if="isCustom" class="mt-3">
<label for="library-scan-cron" class="mb-1.5 block text-xs font-medium text-muted-foreground">{{
t('library.creator.schedule.cronExpression')
}}</label>
<input
id="library-scan-cron"
type="text"
dir="ltr"
:value="autoScanCronExpression ?? ''"
placeholder="0 0 * * *"
class="h-9 w-full rounded-md border bg-background px-3 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 @md:w-60"
:class="isCronValid ? 'border-input focus:ring-ring' : 'border-destructive focus:ring-destructive'"
:aria-invalid="!isCronValid"
aria-describedby="library-scan-cron-help"
@input="handleCronInput"
/>
<p v-if="!isCronValid" id="library-scan-cron-help" class="mt-1 text-xs text-destructive">{{ t('library.creator.schedule.cronInvalid') }}</p>
<p v-else id="library-scan-cron-help" class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.schedule.cronFormat') }}</p>
</div>
<p class="mt-2 text-xs text-muted-foreground">{{ t('library.creator.schedule.timezoneHint') }}</p>
</div>
</div>
<!-- Auto-scan schedule -->
<div v-if="showAutoScanSchedule">
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-3">{{ t('library.creator.schedule.autoScanSchedule') }}</p>
<p class="mb-3 text-xs text-muted-foreground">{{ t('library.creator.schedule.timezoneHint') }}</p>
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="preset in presets"
:key="String(preset.value)"
type="button"
class="px-3 py-2 rounded-lg border text-xs font-medium transition-colors"
:class="
selectedPreset === preset.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/40 hover:text-foreground'
"
@click="selectPreset(preset.value)"
>
{{ preset.label }}
</button>
</div>
<!-- Custom cron input -->
<div v-if="isCustom || selectedPreset === '__custom__'" class="mt-2">
<label for="library-scan-cron" class="mb-1.5 block text-xs font-medium text-muted-foreground">
{{ t('library.creator.schedule.cronExpression') }}
</label>
<input
id="library-scan-cron"
type="text"
:value="autoScanCronExpression ?? ''"
placeholder="0 0 * * *"
class="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2"
:class="isCronValid ? 'border-border focus:ring-ring' : 'border-destructive focus:ring-destructive'"
:aria-invalid="!isCronValid"
aria-describedby="library-scan-cron-help"
@input="handleCronInput"
/>
<p v-if="!isCronValid" id="library-scan-cron-help" class="mt-1 text-xs text-destructive">
{{ t('library.creator.schedule.cronInvalid') }}
</p>
<p v-else id="library-scan-cron-help" class="mt-1 text-xs text-muted-foreground">{{ t('library.creator.schedule.cronFormat') }}</p>
</div>
<!-- Human readable preview -->
<div class="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
<Eye :size="12" />
{{ humanReadableCron(autoScanCronExpression) }}
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { FileAudio, FileText, Folder, Image } from '@lucide/vue'
import type { OrganizationMode } from '@bookorbit/types'
const props = defineProps<{ mode: OrganizationMode }>()
interface TreeLine {
name: string
icon: Component
child: boolean
muted?: boolean
}
// Sample file names, not interface copy: they show the shape of a folder, so they stay untranslated.
const lines = computed<TreeLine[]>(() =>
props.mode === 'book_per_folder'
? [
{ name: 'Dune/', icon: Folder, child: false },
{ name: 'Dune.epub', icon: FileText, child: true },
{ name: 'Dune.m4b', icon: FileAudio, child: true },
{ name: 'cover.jpg', icon: Image, child: true, muted: true },
]
: [
{ name: 'Sci-Fi/', icon: Folder, child: false },
{ name: 'Dune.epub', icon: FileText, child: true },
{ name: 'Foundation.epub', icon: FileText, child: true },
{ name: 'Hyperion.epub', icon: FileText, child: true },
],
)
</script>
<template>
<div class="rounded-lg border border-border bg-background px-2.5 py-2" dir="ltr" aria-hidden="true">
<div
v-for="line in lines"
:key="line.name"
class="flex h-5 items-center gap-1.5"
:class="[line.child ? 'ps-4' : '', line.muted ? 'text-muted-foreground' : 'text-foreground']"
>
<span v-if="line.child" class="-ms-2.5 -mt-2.5 h-2.5 w-2 shrink-0 rounded-es-sm border-b border-s border-border" />
<component :is="line.icon" :size="12" class="shrink-0" />
<span class="truncate font-mono text-[11.5px]">{{ line.name }}</span>
</div>
</div>
</template>
@@ -27,18 +27,6 @@ describe('LibraryCreatorFileWrite', () => {
})
}
it('emits rename toggle updates and hides file-write detail controls when disabled', async () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Rename files after metadata changes')
expect(wrapper.text()).not.toContain('Include cover image')
const renameSwitch = wrapper.findAll('[role="switch"]')[0]
expect(renameSwitch).toBeDefined()
await renameSwitch!.trigger('click')
expect(wrapper.emitted('update:fileRenameEnabled')).toEqual([[true]])
})
const ALL_ENABLED = {
fileRenameEnabled: true,
fileWriteEnabled: true,
@@ -57,26 +45,36 @@ describe('LibraryCreatorFileWrite', () => {
fileWriteAudioMaxFileSizeMb: 40,
}
function toggleByLabel(wrapper: ReturnType<typeof mountComponent>, label: string) {
const control = wrapper.findAll('[role="switch"]').find((node) => node.attributes('aria-label') === label)
if (!control) throw new Error(`no switch labelled "${label}"`)
return control.trigger('click')
function checkbox(wrapper: ReturnType<typeof mountComponent>, label: string) {
const control = wrapper.findAll('input[type="checkbox"]').find((node) => node.attributes('aria-label') === label)
if (!control) throw new Error(`no checkbox labelled "${label}"`)
return control
}
it('emits an update for every format toggle', async () => {
it('emits the two switches and hides the per-format table while writing is off', async () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Rename files after metadata changes')
expect(wrapper.text()).not.toContain('Include cover image')
const switches = wrapper.findAll('[role="switch"]')
expect(switches).toHaveLength(2)
await switches[0]!.trigger('click')
await switches[1]!.trigger('click')
expect(wrapper.emitted('update:fileRenameEnabled')).toEqual([[true]])
expect(wrapper.emitted('update:fileWriteEnabled')).toEqual([[true]])
})
it('emits an update for every format checkbox', async () => {
const wrapper = mountComponent(ALL_ENABLED)
expect(wrapper.findAll('[role="switch"]')).toHaveLength(9)
await checkbox(wrapper, 'Write EPUB metadata').setValue(false)
await checkbox(wrapper, 'Write FB2 metadata').setValue(false)
await checkbox(wrapper, 'Write PDF metadata').setValue(false)
await checkbox(wrapper, 'Write comic archive metadata').setValue(false)
await checkbox(wrapper, 'Write Kindle metadata').setValue(false)
await checkbox(wrapper, 'Write audio covers').setValue(false)
await toggleByLabel(wrapper, 'Write metadata to files')
await toggleByLabel(wrapper, 'Write EPUB metadata')
await toggleByLabel(wrapper, 'Write FB2 metadata')
await toggleByLabel(wrapper, 'Write PDF metadata')
await toggleByLabel(wrapper, 'Write comic archive metadata')
await toggleByLabel(wrapper, 'Write Kindle metadata')
await toggleByLabel(wrapper, 'Write audio covers')
expect(wrapper.emitted('update:fileWriteEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteEpubEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteFb2Enabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWritePdfEnabled')).toEqual([[false]])
@@ -88,8 +86,7 @@ describe('LibraryCreatorFileWrite', () => {
it('emits max-size updates for every format', async () => {
const wrapper = mountComponent(ALL_ENABLED)
const inputs = wrapper.findAll('input[type="number"]')
expect(inputs).toHaveLength(6)
expect(wrapper.findAll('input[type="number"]')).toHaveLength(6)
await wrapper.find('#epub-max-size').setValue('15')
await wrapper.find('#fb2-max-size').setValue('65')
@@ -106,71 +103,36 @@ describe('LibraryCreatorFileWrite', () => {
expect(wrapper.emitted('update:fileWriteAudioMaxFileSizeMb')).toEqual([[45]])
})
it('hides the FB2 size input until the FB2 toggle is on', () => {
const off = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: false })
expect(off.text()).toContain('FictionBook (FB2)')
expect(off.find('#fb2-max-size').exists()).toBe(false)
const on = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: true })
expect(on.find('#fb2-max-size').exists()).toBe(true)
expect(on.find('#fb2-max-size').attributes('value')).toBe('100')
})
it('shows FB2 controls independently of the cover-writing toggle', () => {
const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteFb2Enabled: true })
it('keeps a size limit visible but disabled while its format is off', () => {
const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: false })
expect(wrapper.text()).toContain('FictionBook (FB2)')
expect(wrapper.find('#fb2-max-size').exists()).toBe(true)
expect(wrapper.get('#fb2-max-size').attributes('disabled')).toBeDefined()
expect(wrapper.get('#fb2-max-size').attributes('aria-label')).toBe('FictionBook (FB2) size limit in MB')
})
it('hides the FB2 card until file writing is enabled', () => {
expect(mountComponent({ fileWriteEnabled: false, fileWriteFb2Enabled: true }).text()).not.toContain('FictionBook (FB2)')
})
it('explains why audio is unavailable without the cover image', () => {
const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteAudioEnabled: true })
it('hides the Kindle size input until the Kindle toggle is on', () => {
const off = mountComponent({ fileWriteEnabled: true, fileWriteKindleEnabled: false })
expect(off.text()).toContain('Kindle (MOBI, AZW3)')
expect(off.find('#kindle-max-size').exists()).toBe(false)
const on = mountComponent({ fileWriteEnabled: true, fileWriteKindleEnabled: true })
expect(on.find('#kindle-max-size').exists()).toBe(true)
})
it('shows Kindle controls independently of the cover-writing toggle', () => {
const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteKindleEnabled: true })
expect(wrapper.text()).toContain('Kindle (MOBI, AZW3)')
expect(wrapper.text()).not.toContain('Audio')
})
it('gives the Kindle toggle an accessible label', () => {
const wrapper = mountComponent({ fileWriteEnabled: true })
expect(wrapper.findAll('[role="switch"]').some((node) => node.attributes('aria-label') === 'Write Kindle metadata')).toBe(true)
})
it('renders audio controls only when file write details and cover writing are enabled', () => {
const hidden = mountComponent({ fileWriteEnabled: false, fileWriteAudioEnabled: true })
expect(hidden.text()).not.toContain('Audio')
const coverDisabled = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteAudioEnabled: true })
expect(coverDisabled.text()).not.toContain('Audio')
const visible = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: true, fileWriteAudioEnabled: true })
expect(visible.text()).toContain('Audio')
expect(visible.text()).toContain('M4B, M4A, MP3, and FLAC')
const audio = checkbox(wrapper, 'Write audio covers')
expect(audio.attributes('disabled')).toBeDefined()
expect((audio.element as HTMLInputElement).checked).toBe(false)
expect(wrapper.text()).toContain('Needs “Include cover image” turned on.')
})
it('disables audio embedding when cover writing is turned off', async () => {
const wrapper = mountComponent({
fileWriteEnabled: true,
fileWriteWriteCover: true,
fileWriteAudioEnabled: true,
})
const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: true, fileWriteAudioEnabled: true })
await wrapper.findAll('[role="switch"]')[2]!.trigger('click')
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(false)
expect(wrapper.emitted('update:fileWriteWriteCover')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteAudioEnabled')).toEqual([[false]])
})
it('counts the books each writer would touch when counts are known', () => {
const wrapper = mountComponent({ fileWriteEnabled: true, formatCounts: { mobi: 24, azw3: 35, epub: 378 } })
const kindle = wrapper.findAll('li').find((row) => row.text().includes('Kindle'))!
expect(kindle.text()).toContain('59')
})
})
@@ -1,31 +1,70 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { LibraryStats } from '@bookorbit/types'
import type { FolderCheck } from '../../composables/useLibraryCreator'
import LibraryCreatorFolders from '../LibraryCreatorFolders.vue'
describe('LibraryCreatorFolders', () => {
it('adds manual paths without a separate test request', async () => {
it('adds a pasted path from the always-visible path field', async () => {
const wrapper = mountFolders()
await buttonByText(wrapper, 'Enter a path manually').trigger('click')
await wrapper.get('#manual-folder-path').setValue('/books/fiction')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('update:folders')).toEqual([[['/books/fiction']]])
expect(wrapper.text()).not.toContain('Test path')
})
it('presents validation results beside each selected folder', () => {
it('rejects a relative path with a message tied to the field', async () => {
const wrapper = mountFolders()
await wrapper.get('#manual-folder-path').setValue('books')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('update:folders')).toBeUndefined()
expect(wrapper.get('#manual-folder-error').text()).toBe('Enter an absolute server path beginning with /.')
expect(wrapper.get('#manual-folder-path').attributes('aria-describedby')).toBe('manual-folder-error')
})
it('shows each folder check beside its folder', () => {
const wrapper = mountFolders({
folders: ['/books'],
prescanResult: {
totalFiles: 1250,
paths: [{ path: '/books', accessible: true, fileCount: 1250 }],
folders: ['/books/fantasy', '/books/comics', '/books/missing', '/books/new'],
checks: {
'/books/fantasy': { state: 'checked', accessible: true, fileCount: 1250 },
'/books/comics': { state: 'checked', accessible: true, fileCount: 11, overlapLibrary: 'Comics' },
'/books/missing': { state: 'checked', accessible: false, fileCount: 0 },
'/books/new': { state: 'checking' },
},
})
expect(wrapper.text()).toContain('Accessible')
expect(wrapper.text()).toContain('Accessible · 1,250 files')
expect(wrapper.text()).toContain('1,250 matching files across 1 folder.')
const rows = wrapper.findAll('li')
expect(rows[0]!.text()).toContain('1,250 files')
expect(rows[1]!.text()).toContain('Also in Comics')
expect(rows[2]!.text()).toContain('Not accessible')
expect(rows[3]!.text()).toContain('Checking')
expect(wrapper.text()).toContain('1,261 matching files')
})
it('bolds the folder name and keeps the parent path quiet', () => {
const wrapper = mountFolders({ folders: ['/mnt/media/books/Novels'] })
const path = wrapper.get('li p')
expect(path.findAll('span').map((span) => span.text())).toEqual(['/mnt/media/books/', 'Novels'])
})
it('asks for a recheck of one folder after a failed check', async () => {
const wrapper = mountFolders({ folders: ['/books'], checks: { '/books': { state: 'failed' } } })
await wrapper.get('button[aria-label="Check /books again"]').trigger('click')
expect(wrapper.emitted('check')).toEqual([[['/books']]])
})
it('shows what an existing library holds before any folder is rechecked', () => {
const stats: LibraryStats = { totalBooks: 455, totalSizeBytes: 787_300_000, formatCounts: { epub: 378, pdf: 6 } }
const wrapper = mountFolders({ folders: ['/books'], stats })
expect(wrapper.text()).toContain('455 books')
expect(wrapper.text()).toContain('EPUB')
})
it('offers existing podcast folders only for podcast libraries', () => {
@@ -33,6 +72,14 @@ describe('LibraryCreatorFolders', () => {
expect(mountFolders({ libraryType: 'podcasts' }).text()).toContain('Existing podcast folders')
})
it('stops offering to add once a podcast library has its storage folder', () => {
expect(
mountFolders({ libraryType: 'podcasts', folders: ['/podcasts'] })
.find('#manual-folder-path')
.exists(),
).toBe(false)
})
it('reports the podcast local folders it was given as removable rows', () => {
const wrapper = mountFolders({ libraryType: 'podcasts', folders: ['/podcasts'], localFolders: ['/archive/shows'] })
@@ -46,16 +93,17 @@ function mountFolders(
folders?: string[]
localFolders?: string[]
libraryType?: 'books' | 'podcasts'
prescanResult?: { totalFiles: number; paths: { path: string; accessible: boolean; fileCount: number }[] } | null
checks?: Record<string, FolderCheck>
stats?: LibraryStats | null
} = {},
) {
return mount(LibraryCreatorFolders, {
props: {
folders: props.folders ?? [],
localFolders: props.localFolders ?? [],
prescanResult: props.prescanResult ?? null,
prescanLoading: false,
libraryType: props.libraryType ?? 'books',
checks: props.checks ?? {},
stats: props.stats ?? null,
},
global: {
stubs: {
@@ -64,9 +112,3 @@ function mountFolders(
},
})
}
function buttonByText(wrapper: ReturnType<typeof mountFolders>, text: string) {
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(text))
if (!button) throw new Error(`Button not found: ${text}`)
return button
}
@@ -5,40 +5,94 @@ import { READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types'
import { i18n } from '@/i18n'
import LibraryCreatorMetadata from '../LibraryCreatorMetadata.vue'
function mountMetadata(formatPriority: string[]) {
function mountMetadata(props: {
formatPriority: string[]
formatCounts?: Record<string, number> | null
metadataPrecedence?: string[]
allowedFormats?: string[]
}) {
return mount(LibraryCreatorMetadata, {
props: { metadataPrecedence: ['embedded'], formatPriority },
props: { metadataPrecedence: ['embedded', 'opfFile'], ...props },
})
}
function rowCodes(wrapper: ReturnType<typeof mountMetadata>): string[] {
return wrapper.findAll('ol[aria-labelledby="primary-file-title"] > li').map((row) => row.findAll('span')[2]!.text())
}
describe('LibraryCreatorMetadata', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('shows the read-along entry as an EPUB row marked with headphones', () => {
const wrapper = mountMetadata([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'])
const formatRows = wrapper.findAll('[draggable="true"]').slice(1)
const wrapper = mountMetadata({ formatPriority: [READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'] })
const rows = wrapper.findAll('ol[aria-labelledby="primary-file-title"] > li')
expect(formatRows.map((row) => row.find('.font-mono').text())).toEqual(['EPUB', 'M4B', 'EPUB'])
expect(formatRows[0]!.find('.font-mono svg').exists()).toBe(true)
expect(formatRows[2]!.find('.font-mono svg').exists()).toBe(false)
expect(formatRows[0]!.text()).toContain('Read-along EPUB')
expect(formatRows[2]!.text()).toContain('EPUB e-book')
expect(rowCodes(wrapper)).toEqual(['EPUB', 'M4B', 'EPUB'])
expect(rows[0]!.find('svg.lucide-headphones-icon, svg[class*="headphones"]').exists()).toBe(true)
expect(rows[0]!.text()).toContain('Read-along EPUB')
expect(rows[2]!.text()).toContain('EPUB e-book')
})
it('labels move controls with the translated format name', () => {
const wrapper = mountMetadata([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'])
it('ranks only the formats the library holds, with their counts', () => {
const wrapper = mountMetadata({
formatPriority: ['epub', 'kepub', 'pdf', 'cbz', 'mobi', 'azw3', 'fb2', 'm4b'],
formatCounts: { epub: 378, azw3: 35, mobi: 24, fb2: 12, pdf: 6 },
})
expect(wrapper.find('button[aria-label="Move Read-along EPUB down"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="Move M4B audiobook up"]').exists()).toBe(true)
expect(rowCodes(wrapper)).toEqual(['EPUB', 'PDF', 'MOBI', 'AZW3', 'FB2'])
expect(wrapper.text()).toContain('378')
expect(wrapper.text()).toContain('3 formats this library does not hold keep their default order after these.')
})
it('reveals the rest of the order on request', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub', 'kepub', 'pdf', 'cbz'], formatCounts: { epub: 3 } })
await wrapper
.findAll('button')
.find((button) => button.text() === 'Show all 4')!
.trigger('click')
expect(rowCodes(wrapper)).toEqual(['EPUB', 'KEPUB', 'PDF', 'CBZ'])
})
it('moves among the visible rows even when hidden formats sit between them', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub', 'kepub', 'pdf', 'mobi'], formatCounts: { epub: 3, mobi: 2 } })
await wrapper.find('button[aria-label="Move MOBI e-book up"]').trigger('click')
expect(wrapper.emitted('update:formatPriority')).toEqual([[['mobi', 'epub', 'kepub', 'pdf']]])
})
it('moves the read-along entry like any other format', async () => {
const wrapper = mountMetadata([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'])
const wrapper = mountMetadata({ formatPriority: [READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'] })
await wrapper.find('button[aria-label="Move Read-along EPUB down"]').trigger('click')
expect(wrapper.emitted('update:formatPriority')).toEqual([[['m4b', READ_ALONG_FORMAT_PRIORITY, 'epub']]])
})
it('marks formats that are not imported', () => {
const wrapper = mountMetadata({ formatPriority: ['epub', 'pdf'], allowedFormats: ['epub'] })
expect(wrapper.findAll('ol[aria-labelledby="primary-file-title"] > li')[1]!.text()).toContain('Not imported')
})
it('swaps which source is read first', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub'] })
await wrapper.get('button[aria-label="Swap the order"]').trigger('click')
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded']]])
})
it('fills in a source missing from an older saved list before swapping', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub'], metadataPrecedence: ['embedded'] })
expect(wrapper.text()).toContain('metadata.opf file')
await wrapper.get('button[aria-label="Swap the order"]').trigger('click')
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded']]])
})
})
@@ -1,106 +1,240 @@
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, shallowMount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { useLibraryCreator } from '../../composables/useLibraryCreator'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
import LibraryCreatorDetails from '../LibraryCreatorDetails.vue'
import LibraryCreatorFolders from '../LibraryCreatorFolders.vue'
import LibraryCreatorModal from '../LibraryCreatorModal.vue'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
import LibraryCreatorSchedule from '../LibraryCreatorSchedule.vue'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
/** The editor reads the viewport to decide between the section rail and the drill-in list. */
function stubViewport(wide: boolean) {
window.matchMedia = vi.fn<(query: string) => unknown>().mockImplementation((query: string) => ({
matches: query.includes('min-width') ? wide : !wide,
media: query,
onchange: null,
addEventListener: vi.fn<() => void>(),
removeEventListener: vi.fn<() => void>(),
addListener: vi.fn<() => void>(),
removeListener: vi.fn<() => void>(),
dispatchEvent: vi.fn<() => void>(),
})) as unknown as typeof window.matchMedia
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
function makeLibrary(overrides: Partial<Library> = {}): Library {
return {
...useLibraryCreator().form,
id: 9,
name: 'Fantasy',
icon: 'Swords',
folders: [{ id: 1, path: '/books', role: 'downloads', createdAt: '2026-01-01T00:00:00.000Z' }],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
} as unknown as Library
}
function navButton(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('nav button').find((candidate) => candidate.text().startsWith(label))
if (!button) throw new Error(`No section named ${label}`)
return button
}
function footerButton(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('footer button').find((candidate) => candidate.text().includes(label))
if (!button) throw new Error(`No footer button named ${label}`)
return button
}
async function mountCreator(props: Record<string, unknown> = {}, stubs: Record<string, unknown> = {}) {
const wrapper = shallowMount(LibraryCreatorModal, { props, attachTo: document.body, global: { stubs: { teleport: true, ...stubs } } })
await flushPromises()
return wrapper
}
describe('LibraryCreatorModal', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
stubViewport(true)
apiMock.mockImplementation(async () => jsonResponse(null))
})
afterEach(() => {
document.body.innerHTML = ''
apiMock.mockReset()
vi.restoreAllMocks()
})
it('uses fetched saved settings for delegated managers whose library list omits the source', async () => {
const full = { ...useLibraryCreator().form, id: 7, name: 'Library', icon: 'BookOpen', folders: [], addedAtSource: 'file_created' }
apiMock.mockImplementation(
async (url) => ({ ok: true, json: async () => (String(url).endsWith('recompute-added-at') ? null : full) }) as Response,
)
const listEntry = { id: 7, name: 'Library', folders: [], coverAspectRatio: '2/3' } as unknown as Library
const wrapper = shallowMount(LibraryCreatorModal, { props: { library: listEntry }, global: { stubs: { teleport: true } } })
await flushPromises()
await wrapper
.findAll('nav button')
.find((button) => button.text().includes('Scanning'))!
.trigger('click')
const scanner = wrapper.getComponent(LibraryCreatorScanner)
expect(scanner.props('addedAtSource')).toBe('file_created')
expect(scanner.props('storedAddedAtSource')).toBe('file_created')
wrapper.unmount()
})
it('renders an accessible responsive dialog with required setup identified', async () => {
const wrapper = shallowMount(LibraryCreatorModal, {
attachTo: document.body,
global: { stubs: { teleport: true } },
})
await flushPromises()
it('renders an accessible sheet that names what a new library still needs', async () => {
const wrapper = await mountCreator()
const dialog = wrapper.get('[role="dialog"]')
expect(dialog.attributes('aria-modal')).toBe('true')
expect(dialog.attributes('aria-labelledby')).toBe('library-creator-title')
expect(wrapper.get('#library-creator-title').text()).toBe('Create a library')
expect(wrapper.text()).toContain('Required')
expect(wrapper.get('#library-creator-title').text()).toBe('New library')
expect(wrapper.get('button[aria-label="Close library creator"]')).toBeTruthy()
expect(navButton(wrapper, 'Library').text()).toContain('Needs a name')
expect(navButton(wrapper, 'Folders').text()).toContain('Needs a folder')
expect(footerButton(wrapper, 'Create library').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('unlocks the folder step after valid details are entered', async () => {
const wrapper = shallowMount(LibraryCreatorModal, {
attachTo: document.body,
global: { stubs: { teleport: true } },
})
await flushPromises()
it('offers every section while creating, with no step order to follow', async () => {
const wrapper = await mountCreator()
await navButton(wrapper, 'Metadata').trigger('click')
expect(wrapper.find('main').text()).toContain('Which file leads when a book has several')
wrapper.unmount()
})
it('checks a folder as soon as it is added and enables create once the essentials are in', async () => {
apiMock.mockImplementation(async (url) =>
String(url).endsWith('/prescan')
? jsonResponse({ paths: [{ path: '/books', accessible: true, fileCount: 1284 }], totalFiles: 1284 })
: jsonResponse(null),
)
const wrapper = await mountCreator()
const details = wrapper.getComponent(LibraryCreatorDetails)
details.vm.$emit('update:name', 'Main Library')
details.vm.$emit('update:icon', 'BookOpen')
await wrapper.vm.$nextTick()
const continueButton = wrapper.findAll('button').find((button) => button.text().includes('Continue'))
expect(continueButton?.attributes('disabled')).toBeUndefined()
await continueButton?.trigger('click')
expect(wrapper.text()).toContain('Choose the server folders used by this library.')
details.vm.$emit('update:name', 'Fantasy')
details.vm.$emit('update:icon', 'Swords')
await navButton(wrapper, 'Folders').trigger('click')
wrapper.getComponent(LibraryCreatorFolders).vm.$emit('update:folders', ['/books'])
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/v1/libraries/prescan', expect.objectContaining({ body: JSON.stringify({ paths: ['/books'] }) }))
expect(navButton(wrapper, 'Folders').text()).toContain('1 folder · 1,284 files')
expect(footerButton(wrapper, 'Create library').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('shows required-field validation only after continuing', async () => {
const wrapper = shallowMount(LibraryCreatorModal, {
attachTo: document.body,
global: { stubs: { teleport: true } },
})
await flushPromises()
it('asks in the footer before discarding changes, instead of a browser dialog', async () => {
const confirmSpy = vi.spyOn(window, 'confirm')
const wrapper = await mountCreator()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
const continueButton = wrapper.findAll('button').find((button) => button.text().includes('Continue'))
await continueButton?.trigger('click')
wrapper.getComponent(LibraryCreatorDetails).vm.$emit('update:name', 'Draft')
await wrapper.vm.$nextTick()
await footerButton(wrapper, 'Cancel').trigger('click')
expect(wrapper.get('[role="alert"]').text()).toContain('Enter a library name.')
expect(wrapper.get('footer').text()).toContain('Discard your unsaved changes?')
expect(wrapper.emitted('close')).toBeUndefined()
await footerButton(wrapper, 'Discard').trigger('click')
expect(wrapper.emitted('close')).toEqual([[]])
expect(confirmSpy).not.toHaveBeenCalled()
wrapper.unmount()
})
it('uses fetched saved settings for delegated managers whose library list omits the source', async () => {
const full = { ...makeLibrary({ id: 7, name: 'Library' }), addedAtSource: 'file_created' }
apiMock.mockImplementation(async (url) => jsonResponse(String(url).endsWith('/libraries/7') ? full : null))
const listEntry = { id: 7, name: 'Library', folders: [], coverAspectRatio: '2/3' } as unknown as Library
const wrapper = await mountCreator({ library: listEntry })
await navButton(wrapper, 'Scanning').trigger('click')
const scanner = wrapper.getComponent(LibraryCreatorScanner)
expect(scanner.props('addedAtSource')).toBe('file_created')
expect(scanner.props('storedAddedAtSource')).toBe('file_created')
expect(scanner.props('organizationModeLocked')).toBe(true)
wrapper.unmount()
})
it('keeps save disabled until something changes while editing', async () => {
const library = makeLibrary()
apiMock.mockImplementation(async (url) => jsonResponse(String(url).endsWith('/libraries/9') ? library : null))
const wrapper = await mountCreator({ library })
expect(footerButton(wrapper, 'Save changes').attributes('disabled')).toBeDefined()
wrapper.getComponent(LibraryCreatorDetails).vm.$emit('update:name', 'Epic Fantasy')
await wrapper.vm.$nextTick()
expect(footerButton(wrapper, 'Save changes').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('Unsaved')
wrapper.unmount()
})
it('clamps attempted podcast creation to a book library', async () => {
const wrapper = shallowMount(LibraryCreatorModal, {
attachTo: document.body,
global: { stubs: { teleport: true } },
})
await flushPromises()
const wrapper = await mountCreator()
const details = wrapper.getComponent(LibraryCreatorDetails)
details.vm.$emit('update:type', 'podcasts')
await wrapper.vm.$nextTick()
expect(details.props('type')).toBe('books')
wrapper.unmount()
})
it('shows sections as a drill-in list on narrow screens', async () => {
stubViewport(false)
const wrapper = await mountCreator()
expect(wrapper.find('nav').exists()).toBe(false)
const entry = wrapper.findAll('main button').find((button) => button.text().startsWith('Reading'))!
await entry.trigger('click')
expect(wrapper.get('#library-creator-title').text()).toBe('Reading')
expect(wrapper.find('button[aria-label="Back"]').exists()).toBe(true)
wrapper.unmount()
})
it('grants queued access after creating, and stays open on Access when a grant fails', async () => {
const created = makeLibrary()
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
const method = init?.method ?? 'GET'
if (path.endsWith('/users/assignable')) {
return jsonResponse([
{ id: 2, username: 'maya', name: 'Maya Ortiz' },
{ id: 3, username: 'sam', name: 'Sam Whitfield' },
])
}
if (path.endsWith('/prescan')) return jsonResponse({ paths: [{ path: '/books', accessible: true, fileCount: 3 }], totalFiles: 3 })
if (path === '/api/v1/libraries' && method === 'POST') return jsonResponse(created)
if (path === '/api/v1/libraries/9/access' && method === 'POST') {
const body = JSON.parse(String(init?.body)) as { userId: number }
return body.userId === 2 ? jsonResponse({}) : jsonResponse({ message: 'nope' }, 500)
}
if (path === '/api/v1/libraries/9/access') return jsonResponse([{ userId: 2, username: 'maya', name: 'Maya Ortiz', accessLevel: 'viewer' }])
return jsonResponse(null)
})
const wrapper = await mountCreator({}, { LibraryCreatorAccess: false, LibraryCreatorCard: false })
const details = wrapper.getComponent(LibraryCreatorDetails)
details.vm.$emit('update:name', 'Fantasy')
details.vm.$emit('update:icon', 'Swords')
await navButton(wrapper, 'Folders').trigger('click')
wrapper.getComponent(LibraryCreatorFolders).vm.$emit('update:folders', ['/books'])
await navButton(wrapper, 'Access').trigger('click')
await flushPromises()
for (const userId of ['2', '3']) {
await wrapper.get('#library-access-user').setValue(userId)
await wrapper.get('main form').trigger('submit')
}
expect(navButton(wrapper, 'Access').text()).toContain('2 people to add')
await footerButton(wrapper, 'Create library').trigger('click')
await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.get('[role="alert"]').text()).toContain('1 person could not be added')
expect(wrapper.get('[role="alert"]').text()).toContain('Sam Whitfield')
expect(wrapper.get('#library-creator-title').text()).toBe('Fantasy')
await footerButton(wrapper, 'Done').trigger('click')
expect(wrapper.emitted('saved')).toEqual([[created]])
wrapper.unmount()
})
@@ -3,17 +3,70 @@ import { describe, expect, it } from 'vitest'
import LibraryCreatorReading from '../LibraryCreatorReading.vue'
function mountReading(readingThreshold = 0.25, markAsFinishedPercentComplete = 98) {
return mount(LibraryCreatorReading, { props: { readingThreshold, markAsFinishedPercentComplete } })
}
describe('LibraryCreatorReading', () => {
it('shows and emits a finished threshold at the requested 0.05% precision', async () => {
const wrapper = mount(LibraryCreatorReading, {
props: { readingThreshold: 0.25, markAsFinishedPercentComplete: 99.95 },
})
const wrapper = mountReading(0.25, 99.95)
const slider = wrapper.get<HTMLInputElement>('#finished-threshold')
expect(slider.attributes('step')).toBe('0.05')
expect(wrapper.get('output[for="finished-threshold"]').text()).toBe('99.95%')
const input = wrapper.get<HTMLInputElement>('#finished-threshold')
expect(input.attributes('step')).toBe('0.05')
expect(input.attributes('min')).toBe('90')
expect(input.attributes('max')).toBe('100')
expect(wrapper.text()).toContain('99.95%')
await slider.setValue('98.05')
await input.setValue('98.05')
expect(wrapper.emitted('update:markAsFinishedPercentComplete')).toContainEqual([98.05])
})
it('labels both thresholds and describes their ranges', async () => {
const wrapper = mountReading()
expect(wrapper.get('label[for="reading-threshold"]').text()).toBe('Reading start')
expect(wrapper.get('#reading-threshold-help').text()).toContain('Between 0.05% and 5%.')
await wrapper.get('#reading-threshold').setValue('')
expect(wrapper.emitted('update:readingThreshold')).toBeUndefined()
await wrapper.get('#reading-threshold').setValue('1.5')
expect(wrapper.emitted('update:readingThreshold')).toEqual([[1.5]])
})
it('drags each end over its own range only', async () => {
const wrapper = mountReading()
const start = wrapper.get<HTMLInputElement>('#reading-threshold-slider')
const finish = wrapper.get<HTMLInputElement>('#finished-threshold-slider')
expect([start.attributes('min'), start.attributes('max'), start.attributes('step')]).toEqual(['0.05', '5', '0.05'])
expect([finish.attributes('min'), finish.attributes('max')]).toEqual(['90', '100'])
await start.setValue('1.5')
await finish.setValue('97.5')
expect(wrapper.emitted('update:readingThreshold')).toEqual([[1.5]])
expect(wrapper.emitted('update:markAsFinishedPercentComplete')).toEqual([[97.5]])
})
it('snaps the finish slider to round values while the field keeps full precision', () => {
const wrapper = mountReading()
expect(wrapper.get('#finished-threshold-slider').attributes('step')).toBe('0.25')
expect(wrapper.get('#finished-threshold').attributes('step')).toBe('0.05')
})
it('names the sliders and announces their values as percentages', () => {
const wrapper = mountReading(0.25, 98)
const start = wrapper.get('#reading-threshold-slider')
expect(start.attributes('aria-label')).toBe('Drag to set when a book counts as started')
expect(start.attributes('aria-valuetext')).toBe('0.25%')
expect(wrapper.get('#finished-threshold-slider').attributes('aria-valuetext')).toBe('98%')
})
it('pins an out-of-range value to the end of its slider rather than past it', () => {
const wrapper = mountReading(9, 80)
expect(wrapper.get('#reading-threshold-slider').attributes('style')).toContain('--fraction: 1')
expect(wrapper.get('#finished-threshold-slider').attributes('style')).toContain('--fraction: 0')
})
})
@@ -1,14 +1,9 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { BOOK_FORMATS } from '@bookorbit/types'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
const tooltipStubs = {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
}
function mountScanner(overrides: Partial<InstanceType<typeof LibraryCreatorScanner>['$props']> = {}) {
return mount(LibraryCreatorScanner, {
props: {
@@ -19,9 +14,6 @@ function mountScanner(overrides: Partial<InstanceType<typeof LibraryCreatorScann
excludePatterns: [],
...overrides,
},
global: {
stubs: tooltipStubs,
},
})
}
@@ -29,23 +21,58 @@ describe('LibraryCreatorScanner', () => {
it('emits organization mode changes while creating a library', async () => {
const wrapper = mountScanner()
await wrapper.findAll('button')[1]!.trigger('click')
await wrapper.get('input[name="organization-mode"][value="book_per_file"]').setValue()
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file']])
expect(wrapper.text()).toContain('This is the one setting you cannot change later.')
})
it('disables organization mode changes when locked', async () => {
const wrapper = mountScanner({ organizationModeLocked: true })
const modeButtons = wrapper.findAll('button').slice(0, 2)
it('shows only the chosen mode, with the reason it is fixed, once the library exists', () => {
const wrapper = mountScanner({ organizationModeLocked: true, organizationMode: 'book_per_file' })
expect(modeButtons).toHaveLength(2)
expect(modeButtons.every((button) => button.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.find('input[name="organization-mode"]').exists()).toBe(false)
expect(wrapper.text()).toContain('File as Book')
expect(wrapper.text()).toContain('Set at creation')
expect(wrapper.text()).toContain('Organization mode is fixed after library creation')
await modeButtons[1]!.trigger('click')
expect(wrapper.emitted('update:organizationMode')).toBeUndefined()
})
it('starts restricting imports from every format, then drops the ones unticked', async () => {
const wrapper = mountScanner()
await wrapper.get('input[name="import-formats"]:not(:checked)').setValue()
expect(wrapper.emitted('update:allowedFormats')).toEqual([[[...BOOK_FORMATS]]])
await wrapper.setProps({ allowedFormats: [...BOOK_FORMATS] })
await wrapper
.findAll('button[aria-pressed]')
.find((button) => button.text() === 'PDF')!
.trigger('click')
expect(wrapper.emitted('update:allowedFormats')![1]![0]).not.toContain('pdf')
expect(wrapper.text()).toContain('will be marked as missing on the next scan')
})
it('never lets the last imported format be unticked', async () => {
const wrapper = mountScanner({ allowedFormats: ['epub'] })
await wrapper
.findAll('button[aria-pressed]')
.find((button) => button.text() === 'EPUB')!
.trigger('click')
expect(wrapper.emitted('update:allowedFormats')).toBeUndefined()
})
it('adds skip patterns on Enter and removes them by name', async () => {
const wrapper = mountScanner({ excludePatterns: ['**/samples/**'] })
await wrapper.get('#exclude-pattern-input').setValue('**/extras/**')
await wrapper.get('#exclude-pattern-input').trigger('keydown', { key: 'Enter' })
await wrapper.get('button[aria-label="Remove pattern **/samples/**"]').trigger('click')
expect(wrapper.emitted('update:excludePatterns')).toEqual([[['**/samples/**', '**/extras/**']], [[]]])
})
it('enables recompute only for saved file sources and requires confirmation', async () => {
const wrapper = mountScanner({ addedAtSource: 'file_created', storedAddedAtSource: 'file_created', canRecomputeAddedAt: true })
const recompute = wrapper.findAll('button').find((button) => button.text() === 'Recompute now')!
@@ -59,7 +86,7 @@ describe('LibraryCreatorScanner', () => {
wrapper.unmount()
})
it('uses native radios and exposes partial failures as accessible status', async () => {
it('uses native radios for the date source and exposes partial failures as accessible status', async () => {
const wrapper = mountScanner({
addedAtSource: 'file_created',
recomputeJob: {
@@ -76,7 +103,7 @@ describe('LibraryCreatorScanner', () => {
failureSamples: [],
},
})
const radios = wrapper.findAll('input[type="radio"]')
const radios = wrapper.findAll('input[name="added-at-source"]')
expect(radios).toHaveLength(3)
await radios[1]!.setValue()
expect(wrapper.emitted('update:addedAtSource')).toEqual([['file_modified']])
@@ -0,0 +1,87 @@
// @vitest-environment node
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
const USERS = [
{ id: 2, username: 'maya', name: 'Maya Ortiz' },
{ id: 3, username: 'sam', name: 'Sam Whitfield' },
]
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
describe('useLibraryAccess', () => {
beforeEach(() => {
apiMock.mockReset()
})
it('queues grants while the library does not exist yet', async () => {
apiMock.mockResolvedValue(jsonResponse(USERS))
const { useLibraryAccess } = await import('../useLibraryAccess')
const access = useLibraryAccess(ref<number | null>(null))
await access.load()
await access.grant(2, 'editor')
await access.changeLevel(2, 'owner')
expect(apiMock).toHaveBeenCalledTimes(1)
expect(access.pending.value).toEqual([{ userId: 2, username: 'maya', name: 'Maya Ortiz', accessLevel: 'owner', failed: false }])
expect(access.availableUsers.value.map((user) => user.id)).toEqual([3])
expect(access.count.value).toBe(1)
await access.revoke(2)
expect(access.pending.value).toEqual([])
})
it('sends queued grants once the library exists and keeps the ones that fail', async () => {
const libraryId = ref<number | null>(null)
apiMock.mockImplementation(async (url, init) => {
const path = String(url)
if (path.endsWith('/users/assignable')) return jsonResponse(USERS)
if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as { userId: number }
return body.userId === 2 ? jsonResponse({}) : jsonResponse({ message: 'nope' }, 500)
}
return jsonResponse([{ userId: 2, username: 'maya', name: 'Maya Ortiz', accessLevel: 'viewer' }])
})
const { useLibraryAccess } = await import('../useLibraryAccess')
const access = useLibraryAccess(libraryId)
await access.load()
await access.grant(2, 'viewer')
await access.grant(3, 'viewer')
libraryId.value = 9
const failed = await access.applyPending()
expect(failed).toBe(1)
expect(access.failedGrants.value.map((grant) => grant.userId)).toEqual([3])
expect(access.entries.value.map((entry) => entry.userId)).toEqual([2])
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/libraries/9/access',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ userId: 3, accessLevel: 'viewer' }) }),
)
})
it('writes straight to the server for an existing library and reports its message on failure', async () => {
apiMock.mockImplementation(async (url, init) => {
if (String(url).endsWith('/users/assignable')) return jsonResponse(USERS)
if (init?.method === 'PATCH') return jsonResponse({ message: 'Owners cannot be demoted' }, 400)
return jsonResponse([])
})
const { useLibraryAccess } = await import('../useLibraryAccess')
const access = useLibraryAccess(ref<number | null>(4))
await access.load()
await access.grant(2, 'viewer')
const changed = await access.changeLevel(2, 'editor')
expect(apiMock).toHaveBeenCalledWith('/api/v1/libraries/4/access', expect.objectContaining({ method: 'POST' }))
expect(changed).toBe(false)
expect(access.error.value).toBe('Owners cannot be demoted')
})
})
@@ -38,66 +38,90 @@ describe('useLibraryCreator', () => {
expect(apiMock).not.toHaveBeenCalled()
})
it('does not run prescan without folders', async () => {
it('does not check folders when there are none', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
await creator.runPrescan()
await creator.checkFolders()
expect(apiMock).not.toHaveBeenCalled()
expect(creator.prescanLoading.value).toBe(false)
expect(creator.prescanResult.value).toBeNull()
expect(creator.folderChecks.value).toEqual({})
})
it('stores successful prescan results', async () => {
it('checks only the folders asked about and keys results by the requested path', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
const result: PrescanResult = { paths: [{ path: '/books', accessible: true, fileCount: 2 }], totalFiles: 2 }
const result: PrescanResult = {
paths: [{ path: '/mnt/resolved/new', accessible: true, fileCount: 2, overlapLibrary: 'Comics' }],
totalFiles: 2,
}
apiMock.mockResolvedValue(jsonResponse(result))
creator.form.folders = ['/books', '/books-new']
creator.form.folders = ['/books']
await creator.runPrescan()
await creator.checkFolders(['/books-new'])
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/libraries/prescan',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ paths: ['/books'] }),
}),
expect.objectContaining({ method: 'POST', body: JSON.stringify({ paths: ['/books-new'] }) }),
)
expect(creator.prescanLoading.value).toBe(false)
expect(creator.prescanResult.value).toEqual(result)
expect(creator.folderChecks.value).toEqual({
'/books-new': { state: 'checked', accessible: true, fileCount: 2, overlapLibrary: 'Comics' },
})
})
it('includes the current library ID when prescanning an edit', async () => {
it('includes the current library ID when checking folders during an edit', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
const result: PrescanResult = { paths: [{ path: '/books', accessible: true, fileCount: 2 }], totalFiles: 2 }
apiMock.mockResolvedValue(jsonResponse(result))
creator.initEdit(makeLibrary({ id: 12 }))
await creator.runPrescan()
await creator.checkFolders()
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/libraries/prescan',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ paths: ['/books'], libraryId: 12 }),
}),
expect.objectContaining({ method: 'POST', body: JSON.stringify({ paths: ['/books'], libraryId: 12 }) }),
)
})
it('surfaces prescan connection failures', async () => {
it('marks folders as failed when the check cannot reach the server', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
apiMock.mockRejectedValue(new TypeError('network error'))
creator.form.folders = ['/books']
await creator.runPrescan()
expect(creator.error.value).toBe('Could not connect to the server to scan folders.')
expect(creator.prescanLoading.value).toBe(false)
await creator.checkFolders()
expect(creator.folderChecks.value).toEqual({ '/books': { state: 'failed' } })
expect(creator.error.value).toBeNull()
})
it('ignores a slower, older check of the same folder', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
let resolveFirst: (value: Response) => void = () => undefined
apiMock
.mockImplementationOnce(() => new Promise<Response>((resolve) => (resolveFirst = resolve)))
.mockResolvedValueOnce(jsonResponse({ paths: [{ path: '/books', accessible: true, fileCount: 9 }], totalFiles: 9 }))
const first = creator.checkFolders(['/books'])
await creator.checkFolders(['/books'])
resolveFirst(jsonResponse({ paths: [{ path: '/books', accessible: false, fileCount: 0 }], totalFiles: 0 }))
await first
expect(creator.folderChecks.value['/books']).toEqual({ state: 'checked', accessible: true, fileCount: 9, overlapLibrary: undefined })
})
it('loads counts for a book library being edited', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
apiMock.mockResolvedValue(jsonResponse({ totalBooks: 455, totalSizeBytes: 10, formatCounts: { epub: 400, pdf: 55 } }))
creator.initEdit(makeLibrary({ id: 12 }))
await creator.loadStats()
expect(apiMock).toHaveBeenCalledWith('/api/v1/libraries/12/stats')
expect(creator.stats.value?.formatCounts).toEqual({ epub: 400, pdf: 55 })
})
it('validates cron expressions and file size limits before saving', async () => {
@@ -0,0 +1,173 @@
import { computed, ref, type InjectionKey, type Ref } from 'vue'
import type { AccessLevel, LibraryAccessEntry } from '@bookorbit/types'
import { api } from '@/lib/api'
import { i18n } from '@/i18n'
export interface AssignableUser {
id: number
username: string
name: string
}
/** A grant chosen while creating a library, sent once the library exists. */
export interface PendingGrant {
userId: number
username: string
name: string
accessLevel: AccessLevel
failed: boolean
}
/**
* Library access for the library editor. Once the library exists every change goes straight to the
* server, as before. While it is being created there is nothing to grant against yet, so choices
* queue up and `applyPending` sends them after the create succeeds; any that fail stay queued and
* flagged so the editor can say who was not added and retry.
*/
export function useLibraryAccess(libraryId: Ref<number | null>) {
const entries = ref<LibraryAccessEntry[]>([])
const assignable = ref<AssignableUser[]>([])
const pending = ref<PendingGrant[]>([])
const loading = ref(false)
const busy = ref(false)
const loaded = ref(false)
const error = ref<string | null>(null)
const availableUsers = computed(() => {
const taken = new Set([...entries.value.map((entry) => entry.userId), ...pending.value.map((grant) => grant.userId)])
return assignable.value.filter((user) => !taken.has(user.id))
})
const failedGrants = computed(() => pending.value.filter((grant) => grant.failed))
const count = computed(() => (libraryId.value === null ? pending.value.length : entries.value.length))
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
const [usersRes, accessRes] = await Promise.all([
api('/api/v1/users/assignable'),
libraryId.value === null ? Promise.resolve(null) : api(`/api/v1/libraries/${libraryId.value}/access`),
])
if (!usersRes.ok || (accessRes && !accessRes.ok)) {
error.value = i18n.global.t('library.creator.access.errors.load')
return
}
const users: unknown = await usersRes.json()
const granted: unknown = accessRes ? await accessRes.json() : []
assignable.value = Array.isArray(users) ? (users as AssignableUser[]) : []
entries.value = Array.isArray(granted) ? (granted as LibraryAccessEntry[]) : []
loaded.value = true
} catch {
error.value = i18n.global.t('library.creator.errors.connection')
} finally {
loading.value = false
}
}
async function mutate(request: () => Promise<Response>, fallbackKey: string): Promise<boolean> {
error.value = null
busy.value = true
try {
const res = await request()
if (!res.ok) {
error.value = await messageFrom(res, i18n.global.t(fallbackKey))
return false
}
await load()
return true
} catch {
error.value = i18n.global.t('library.creator.errors.connection')
return false
} finally {
busy.value = false
}
}
async function grant(userId: number, accessLevel: AccessLevel): Promise<boolean> {
const user = assignable.value.find((candidate) => candidate.id === userId)
if (!user) return false
if (libraryId.value === null) {
pending.value = [...pending.value, { userId, username: user.username, name: user.name, accessLevel, failed: false }]
return true
}
const id = libraryId.value
return mutate(() => api(`/api/v1/libraries/${id}/access`, jsonRequest('POST', { userId, accessLevel })), 'library.creator.access.errors.grant')
}
async function changeLevel(userId: number, accessLevel: AccessLevel): Promise<boolean> {
if (pending.value.some((grant) => grant.userId === userId)) {
pending.value = pending.value.map((grant) => (grant.userId === userId ? { ...grant, accessLevel } : grant))
return true
}
if (libraryId.value === null) return false
const id = libraryId.value
return mutate(
() => api(`/api/v1/libraries/${id}/access/${userId}`, jsonRequest('PATCH', { accessLevel })),
'library.creator.access.errors.updateLevel',
)
}
async function revoke(userId: number): Promise<boolean> {
if (pending.value.some((grant) => grant.userId === userId)) {
pending.value = pending.value.filter((grant) => grant.userId !== userId)
return true
}
if (libraryId.value === null) return false
const id = libraryId.value
return mutate(() => api(`/api/v1/libraries/${id}/access/${userId}`, { method: 'DELETE' }), 'library.creator.access.errors.revoke')
}
/** Sends queued grants one at a time. Returns how many could not be added. */
async function applyPending(): Promise<number> {
const id = libraryId.value
if (id === null || pending.value.length === 0) return 0
busy.value = true
const remaining: PendingGrant[] = []
try {
for (const grant of pending.value) {
try {
const res = await api(`/api/v1/libraries/${id}/access`, jsonRequest('POST', { userId: grant.userId, accessLevel: grant.accessLevel }))
if (!res.ok) remaining.push({ ...grant, failed: true })
} catch {
remaining.push({ ...grant, failed: true })
}
}
} finally {
pending.value = remaining
busy.value = false
}
await load()
return remaining.length
}
return {
entries,
assignable,
pending,
loading,
busy,
loaded,
error,
availableUsers,
failedGrants,
count,
load,
grant,
changeLevel,
revoke,
applyPending,
}
}
export type LibraryAccessState = ReturnType<typeof useLibraryAccess>
export const LIBRARY_ACCESS_KEY: InjectionKey<LibraryAccessState> = Symbol('library-access')
function jsonRequest(method: string, body: unknown): RequestInit {
return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
}
async function messageFrom(response: Response, fallback: string): Promise<string> {
const body = await response.json().catch(() => null)
const message = body?.message
return typeof message === 'string' && message.trim() ? message : fallback
}
@@ -6,7 +6,7 @@ import {
withoutImplicitReadAlongFormatPriority,
withReadAlongFormatPriority,
} from '@bookorbit/types'
import type { AddedAtSource, CoverAspectRatio, Library, LibraryType, OrganizationMode, PrescanResult } from '@bookorbit/types'
import type { AddedAtSource, CoverAspectRatio, Library, LibraryStats, LibraryType, OrganizationMode, PrescanResult } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { coveringFolderPath, normalizeFolderPath } from './folder-paths'
@@ -21,6 +21,10 @@ export const METADATA_LABELS: Record<string, string> = {
export type LibraryCreatorSectionId = 'details' | 'folders' | 'scanner' | 'metadata' | 'reading' | 'schedule' | 'fileWrite' | 'access'
/** The result of checking one folder: whether the server can read it, and what it would import. */
export type FolderCheck =
{ state: 'checking' } | { state: 'checked'; accessible: boolean; fileCount: number; overlapLibrary?: string } | { state: 'failed' }
const FILE_SIZE_MIN_MB = 1
const FILE_SIZE_MAX_MB = 10_000
@@ -63,35 +67,38 @@ function blankForm() {
}
export function useLibraryCreator() {
const t = (key: string) => i18n.global.t(key)
const form = reactive(blankForm())
const mode = ref<'create' | 'edit'>('create')
const editingLibraryId = ref<number | null>(null)
const loading = ref(false)
const prescanLoading = ref(false)
const prescanResult = ref<PrescanResult | null>(null)
const folderChecks = ref<Record<string, FolderCheck>>({})
const stats = ref<LibraryStats | null>(null)
const error = ref<string | null>(null)
const storedAddedAtSource = ref<AddedAtSource | null>(null)
const latestCheck = new Map<string, number>()
let checkSequence = 0
const validationErrors = computed<Partial<Record<LibraryCreatorSectionId, string>>>(() => {
const errors: Partial<Record<LibraryCreatorSectionId, string>> = {}
if (!form.name.trim()) errors.details = 'Enter a library name.'
else if (!form.icon?.trim()) errors.details = 'Choose an icon.'
if (form.folders.length === 0) errors.folders = 'Add at least one folder.'
else if (form.type === 'podcasts' && form.folders.length !== 1) errors.folders = 'Choose exactly one storage folder for podcasts.'
if (!form.name.trim()) errors.details = t('library.creator.errors.nameRequired')
else if (!form.icon?.trim()) errors.details = t('library.creator.errors.iconRequired')
if (form.folders.length === 0) errors.folders = t('library.creator.errors.folderRequired')
else if (form.type === 'podcasts' && form.folders.length !== 1) errors.folders = t('library.creator.errors.podcastStorageFolder')
else if (form.type === 'podcasts' && overlappingPodcastFolder(form.folders, form.localFolders)) {
errors.folders = 'Existing podcast folders must sit outside the storage folder.'
errors.folders = t('library.creator.errors.podcastFolderOverlap')
}
if (form.autoScanCronExpression && !isFiveFieldCronExpression(form.autoScanCronExpression)) {
errors.schedule = 'Enter a valid 5-field cron expression.'
errors.schedule = t('library.creator.errors.cronInvalid')
}
if (form.readingThreshold < 0.05 || form.readingThreshold > 5) {
errors.reading = 'Reading start must be between 0.05% and 5%.'
errors.reading = t('library.creator.errors.readingStartRange')
} else if (
!Number.isFinite(form.markAsFinishedPercentComplete) ||
form.markAsFinishedPercentComplete < 90 ||
form.markAsFinishedPercentComplete > 100
) {
errors.reading = i18n.global.t('library.creator.reading.markAsFinished.invalidThreshold')
errors.reading = t('library.creator.reading.markAsFinished.invalidThreshold')
}
const fileSizes = [
form.fileWriteEpubMaxFileSizeMb,
@@ -101,7 +108,7 @@ export function useLibraryCreator() {
form.fileWriteAudioMaxFileSizeMb,
]
if (fileSizes.some((value) => !Number.isInteger(value) || value < FILE_SIZE_MIN_MB || value > FILE_SIZE_MAX_MB)) {
errors.fileWrite = 'File-size limits must be whole numbers from 1 to 10,000 MB.'
errors.fileWrite = t('library.creator.errors.fileSizeRange')
}
return errors
})
@@ -111,7 +118,8 @@ export function useLibraryCreator() {
Object.assign(form, blankForm())
mode.value = 'create'
editingLibraryId.value = null
prescanResult.value = null
folderChecks.value = {}
stats.value = null
error.value = null
}
@@ -153,34 +161,56 @@ export function useLibraryCreator() {
form.fileRenameEnabled = library.fileRenameEnabled
mode.value = 'edit'
editingLibraryId.value = library.id
prescanResult.value = null
folderChecks.value = {}
error.value = null
}
async function runPrescan() {
if (form.folders.length === 0) return
prescanLoading.value = true
prescanResult.value = null
error.value = null
/**
* Checks folders one request at a time per call, keyed by the requested path because the server
* answers with the resolved path. Only the folders asked about are counted: recounting a large
* existing folder every time another is added would walk tens of thousands of files for nothing.
*/
async function checkFolders(paths: string[] = form.folders): Promise<void> {
const targets = [...new Set(paths)].filter((path) => path.trim())
if (targets.length === 0) return
const sequence = ++checkSequence
const pending = { ...folderChecks.value }
for (const path of targets) {
latestCheck.set(path, sequence)
pending[path] = { state: 'checking' }
}
folderChecks.value = pending
let results: PrescanResult['paths'] | null = null
try {
const payload = {
paths: form.folders,
...(editingLibraryId.value === null ? {} : { libraryId: editingLibraryId.value }),
}
const res = await api('/api/v1/libraries/prescan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({ paths: targets, ...(editingLibraryId.value === null ? {} : { libraryId: editingLibraryId.value }) }),
})
if (res.ok) {
prescanResult.value = await res.json()
} else {
error.value = await responseError(res, 'Could not scan the selected folders.')
}
if (res.ok) results = ((await res.json()) as PrescanResult).paths
} catch {
error.value = 'Could not connect to the server to scan folders.'
} finally {
prescanLoading.value = false
results = null
}
const next = { ...folderChecks.value }
targets.forEach((path, index) => {
if (latestCheck.get(path) !== sequence) return
const result = results?.[index]
next[path] = result
? { state: 'checked', accessible: result.accessible, fileCount: result.fileCount, overlapLibrary: result.overlapLibrary }
: { state: 'failed' }
})
folderChecks.value = next
}
async function loadStats(): Promise<void> {
if (editingLibraryId.value === null || form.type !== 'books') return
try {
const res = await api(`/api/v1/libraries/${editingLibraryId.value}/stats`)
if (res.ok) stats.value = (await res.json()) as LibraryStats
} catch {
stats.value = null
}
}
@@ -231,12 +261,12 @@ export function useLibraryCreator() {
})
}
if (!res.ok) {
error.value = await responseError(res, 'Failed to save library.')
error.value = await responseError(res, t('library.creator.errors.saveFailed'))
return null
}
return await res.json()
} catch {
error.value = 'Could not connect to the server. Check your connection and try again.'
error.value = t('library.creator.errors.connection')
return null
} finally {
loading.value = false
@@ -249,13 +279,14 @@ export function useLibraryCreator() {
mode,
editingLibraryId,
loading,
prescanLoading,
prescanResult,
folderChecks,
stats,
error,
validationErrors,
initCreate,
initEdit,
runPrescan,
checkFolders,
loadStats,
save,
}
}
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { i18n } from '@/i18n'
import { consolidateFolderPaths, coveringFolderPath, normalizeFolderPath } from './folder-paths'
interface FolderSelectionOptions {
@@ -50,23 +51,23 @@ export function useLibraryFolderSelection(options: FolderSelectionOptions) {
function addManualFolder() {
const path = normalizeFolderPath(manualPath.value)
if (!manualPath.value.trim()) {
manualError.value = 'Enter a server folder path.'
manualError.value = i18n.global.t('library.creator.folders.errors.empty')
return
}
if (!path.startsWith('/')) {
manualError.value = 'Enter an absolute server path beginning with /.'
manualError.value = i18n.global.t('library.creator.folders.errors.notAbsolute')
return
}
const existing = options.folders().map(normalizeFolderPath)
if (existing.includes(path)) {
manualError.value = 'That folder has already been added.'
manualError.value = i18n.global.t('library.creator.folders.errors.duplicate')
return
}
const coveringPath = coveringFolderPath(path, existing)
if (coveringPath) {
manualError.value = `That folder is already covered by ${coveringPath}.`
manualError.value = i18n.global.t('library.creator.folders.errors.covered', { path: coveringPath })
return
}
@@ -269,6 +269,17 @@ async function onSaved(library: LibraryType) {
void overview.load()
}
/** Both open their own confirmation, which cannot sit on top of the editor, so the editor closes first. */
function syncFilesFromEditor(lib: LibraryType) {
closeCreator()
promptSyncFiles(lib)
}
function removeFromEditor(lib: LibraryType) {
closeCreator()
openDelete(lib)
}
function openDelete(lib: LibraryType) {
deletingLibrary.value = lib
deleteConfirmName.value = ''
@@ -411,7 +422,16 @@ async function confirmDelete() {
</div>
</div>
<LibraryCreatorModal v-if="creatorOpen" :library="editingLibrary" @close="closeCreator" @saved="onSaved" />
<LibraryCreatorModal
v-if="creatorOpen"
:library="editingLibrary"
@close="closeCreator"
@saved="onSaved"
@scan="scan"
@refresh-covers="refreshCovers"
@sync-files="syncFilesFromEditor"
@remove="removeFromEditor"
/>
<ConfirmDialog
v-if="deletingLibrary"
@@ -46,7 +46,7 @@ vi.mock('vue-sonner', () => ({
}))
vi.mock('@/features/library/components/LibraryCreatorModal.vue', () => ({
default: { template: '<div />' },
default: { name: 'LibraryCreatorModal', props: ['library'], template: '<div data-testid="library-creator" />' },
}))
// --- Module-level mutable state ---
@@ -650,6 +650,28 @@ describe('LibrariesSettings ledger', () => {
headerTarget.remove()
})
it('closes the editor before asking to delete from its menu, so the dialog is not stacked on it', async () => {
const headerTarget = document.createElement('div')
headerTarget.id = 'settings-header-actions'
document.body.appendChild(headerTarget)
librariesRef.value = [makeLibrary({ id: 5, name: 'Novels' })]
const wrapper = await mountLoaded({ realTeleport: true })
wrapper.findComponent(LibraryRowActions).vm.$emit('edit', librariesRef.value[0]!)
await flushPromises()
const editor = wrapper.findComponent({ name: 'LibraryCreatorModal' })
expect(editor.props('library')).toEqual(librariesRef.value[0])
editor.vm.$emit('remove', librariesRef.value[0]!)
await flushPromises()
expect(wrapper.find('[data-testid="library-creator"]').exists()).toBe(false)
expect(document.querySelector('[role="dialog"]')!.textContent).toContain('Delete "Novels"?')
wrapper.unmount()
headerTarget.remove()
})
it('puts the page actions in the settings header slot when one exists', async () => {
const headerTarget = document.createElement('div')
headerTarget.id = 'settings-header-actions'
+213 -5
View File
@@ -8484,7 +8484,26 @@
"icon": {
"label": "Icon",
"placeholder": "Choose an icon...",
"selected": "Selected"
"selected": "Selected",
"more": "More icons"
},
"identity": "Identity",
"podcastName": "Podcast library name",
"podcastPlaceholder": "My Podcasts",
"podcastDefaultName": "Podcasts",
"type": {
"title": "Library type",
"books": "Books",
"booksHint": "Ebooks, comics, and audiobooks",
"podcasts": "Podcasts",
"podcastsHint": "RSS feeds and managed episode storage"
},
"cover": {
"portraitTitle": "Portrait 2:3",
"portraitHint": "Ebooks and mixed libraries",
"squareTitle": "Square 1:1",
"squareHint": "Audiobook-only libraries",
"slotsHint": "A book with both an ebook and an audiobook shows the cover that fits this shape."
}
},
"folders": {
@@ -8519,6 +8538,32 @@
"addDescription": "Every folder directly inside becomes a show.",
"add": "Add another folder",
"remove": "Remove {folder}"
},
"listTitle": "Folders",
"storageTitle": "Storage folder",
"matchingFiles": "{count, plural, one {# matching file} other {# matching files}}",
"bookCount": "{count, plural, one {# book} other {# books}}",
"checkAll": "Check folders again",
"checkAgain": "Check {folder} again",
"checkHint": "Each folder is checked as soon as you add it. A folder another library already reads is flagged, because both libraries would import the same files.",
"emptyHint": "Paste a server path below, or browse for one.",
"emptyPodcastHint": "Choose where downloaded podcast episodes will be stored.",
"pastePlaceholder": "Paste a server path, e.g. /books/Sci-Fi",
"pastePodcastPlaceholder": "Paste a server path, e.g. /podcasts",
"browse": "Browse...",
"status": {
"checking": "Checking",
"failed": "Check failed",
"notAccessible": "Not accessible",
"overlap": "Also in {library}",
"overlapTitle": "The library \"{library}\" already reads this folder.",
"files": "{count, plural, one {# file} other {# files}}"
},
"errors": {
"empty": "Enter a server folder path.",
"notAbsolute": "Enter an absolute server path beginning with /.",
"duplicate": "That folder has already been added.",
"covered": "That folder is already covered by {path}."
}
},
"scanner": {
@@ -8592,6 +8637,36 @@
"hintAfter": ").",
"add": "Add",
"empty": "No patterns added."
},
"mode": {
"title": "How files become books",
"locked": "Set at creation",
"cannotChange": "This is the one setting you cannot change later.",
"folderHint": "Every folder is one book. Best when a title keeps several formats together, or an audiobook spans many files.",
"fileHint": "Every file is its own book. Best for flat folders with one format per title.",
"folderResult": "One book with two formats",
"fileResult": "Three books, one per file"
},
"import": {
"title": "Import",
"all": "All formats",
"some": "Only these",
"groups": {
"ebook": "Ebooks",
"kindle": "Kindle",
"document": "Documents",
"comic": "Comics",
"audio": "Audio"
}
},
"skip": {
"title": "Skip files matching",
"count": "{count, plural, one {# pattern} other {# patterns}}",
"hint": "Glob patterns, matched against paths inside the library folders.",
"inputLabel": "Pattern to skip",
"placeholder": "Type a pattern like **/samples/** and press Enter",
"placeholderMore": "Add another pattern",
"remove": "Remove pattern {pattern}"
}
},
"metadata": {
@@ -8604,7 +8679,28 @@
"hint": "When a book has multiple formats, the format highest in the list is preferred."
},
"moveUp": "Move {item} up",
"moveDown": "Move {item} down"
"moveDown": "Move {item} down",
"primary": {
"title": "Primary file",
"hint": "When a book has several files, the first format in this order becomes its primary file: the one that opens first and supplies the book's details. Drag a row, or use the arrows.",
"reset": "Reset order",
"first": "First choice",
"notImported": "Not imported",
"notHeld": "{count, plural, one {# format this library does not hold keeps its default place after these.} other {# formats this library does not hold keep their default order after these.}}",
"moreBelow": "{count, plural, one {# more format follows in this order.} other {# more formats follow in this order.}}",
"showingAll": "Showing every format.",
"showAll": "Show all {count}",
"showFewer": "Show fewer"
},
"source": {
"title": "Book details come from",
"embeddedTitle": "Tags in the book file",
"embeddedHint": "Read from the primary file",
"opfTitle": "metadata.opf file",
"opfHint": "Saved next to the book by Calibre",
"swap": "Swap the order",
"hint": "BookOrbit uses the first one a book has and ignores the other. Calibre libraries usually want metadata.opf first."
}
},
"fileWrite": {
"maxFileSizeMb": "Max file size (MB)",
@@ -8650,7 +8746,17 @@
"title": "Audio",
"hint": "Embeds the stored cover into M4B, M4A, MP3, and FLAC files.",
"toggleAria": "Write audio covers"
}
},
"coverAudioHint": "Writes the stored cover into the file too. Audio files can only take a cover.",
"audioNeedsCover": "Needs “Include cover image” turned on.",
"table": {
"format": "Format",
"inLibrary": "Here",
"sizeLimit": "Size limit"
},
"sizeLimitAria": "{format} size limit in MB",
"mb": "MB",
"summary": "{count, plural, =0 {No file types are written.} one {# kind of file is written. Files over their size limit are skipped.} other {# kinds of file are written. Files over their size limit are skipped.}}"
},
"reading": {
"readingStart": {
@@ -8661,7 +8767,16 @@
"title": "Mark as finished",
"hint": "A book is automatically marked as \"read\" when this percentage of progress is reached.",
"invalidThreshold": "Finished progress must be between 90% and 100%."
}
},
"statusTitle": "Reading status",
"zones": {
"notStarted": "Not started",
"reading": "Reading",
"read": "Read"
},
"range": "Between {min} and {max}.",
"startSlider": "Drag to set when a book counts as started",
"finishSlider": "Drag to set when a book counts as finished"
},
"schedule": {
"fileWatching": "File watching",
@@ -8716,8 +8831,101 @@
"errors": {
"grant": "Failed to grant access",
"updateLevel": "Failed to update access level",
"revoke": "Failed to revoke access"
"revoke": "Failed to revoke access",
"load": "Could not load library access settings."
},
"peopleTitle": "People with access",
"peopleCount": "{count, plural, one {# person} other {# people}}",
"add": "Add",
"emptyCreate": "Nobody else yet. People you add here get access as soon as the library exists.",
"superusers": "Superusers always have full access.",
"levelFor": "Access level for {name}",
"revokeFor": "Remove access for {name}",
"revokeQuestion": "Remove access?",
"revoke": "Remove",
"keep": "Keep",
"failed": {
"title": "{count, plural, one {# person could not be added} other {# people could not be added}}",
"body": "The library was created. Try adding {names} again, or add them later from this section."
}
},
"optional": "Optional",
"nav": {
"details": "Library",
"folders": "Folders",
"scanner": "Scanning",
"metadata": "Metadata",
"reading": "Reading",
"schedule": "Automation",
"fileWrite": "File updates",
"access": "Access",
"descriptions": {
"details": "What the library is called and how its covers look.",
"folders": "The server folders this library reads from.",
"scanner": "How files are grouped into books, and which files are skipped.",
"metadata": "Which file leads when a book has several, and where its details come from.",
"reading": "When a book moves from unread to reading to read.",
"schedule": "Keep the library current without pressing Scan.",
"fileWrite": "Optional changes BookOrbit may make to the files on disk.",
"access": "Who else can see and work with this library."
}
},
"sheet": {
"createTitle": "New library",
"createHint": "Name it and point it at a folder. Everything else starts with a sensible default.",
"editFallbackTitle": "Library settings",
"editSubtitle": "{books, plural, one {# book} other {# books}} · {folders, plural, one {# folder} other {# folders}} · {mode}",
"editSubtitleNoBooks": "{folders, plural, one {# folder} other {# folders}} · {mode}",
"readyHint": "Ready to create. The rest can wait.",
"needsHint": "Needs a name, an icon and a folder.",
"discardQuestion": "Discard your unsaved changes?",
"discard": "Discard",
"keepEditing": "Keep editing",
"done": "Done",
"loading": "Loading settings...",
"staleWarning": "The latest library settings could not be loaded. You can still edit the cached settings shown here.",
"scanNow": "Scan now"
},
"summary": {
"needsName": "Needs a name",
"needsIcon": "Needs an icon",
"needsFolder": "Needs a folder",
"detailsPortrait": "{name} · Portrait covers",
"detailsSquare": "{name} · Square covers",
"folders": "{folders, plural, one {# folder} other {# folders}}",
"foldersFiles": "{folders, plural, one {# folder} other {# folders}} · {files, plural, one {# file} other {# files}}",
"foldersBooks": "{folders, plural, one {# folder} other {# folders}} · {books, plural, one {# book} other {# books}}",
"scannerAll": "{mode} · All formats",
"scannerSome": "{mode} · {count, plural, one {# format} other {# formats}}",
"metadataFile": "{format} leads · Book file first",
"metadataOpf": "{format} leads · metadata.opf first",
"reading": "Started {start} · Finished {finish}",
"watching": "Watching folders",
"notWatching": "Not watching",
"watchingScheduled": "Watching · {schedule}",
"watchingOnly": "Watching · No schedule",
"scheduledOnly": "Not watching · {schedule}",
"manualOnly": "Manual scans only",
"customSchedule": "Custom schedule",
"writesAndRenames": "{count, plural, =0 {Writes nothing} one {Writes # kind} other {Writes # kinds}} · Renames",
"writes": "{count, plural, =0 {Writes nothing} one {Writes # kind of file} other {Writes # kinds of file}}",
"renames": "Renames files",
"untouched": "Files left untouched",
"onlySuperusers": "Only superusers",
"accessQueued": "{count, plural, one {# person to add} other {# people to add}}",
"accessPeople": "{count, plural, =0 {Only superusers} one {# person} other {# people}}"
},
"errors": {
"nameRequired": "Enter a library name.",
"iconRequired": "Choose an icon.",
"folderRequired": "Add at least one folder.",
"podcastStorageFolder": "Choose exactly one storage folder for podcasts.",
"podcastFolderOverlap": "Existing podcast folders must sit outside the storage folder.",
"cronInvalid": "Enter a valid 5-field cron expression.",
"readingStartRange": "Reading start must be between 0.05% and 5%.",
"fileSizeRange": "File-size limits must be whole numbers from 1 to 10,000 MB.",
"saveFailed": "Failed to save library.",
"connection": "Could not connect to the server. Check your connection and try again."
}
},
"folderPicker": {