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