feat(metadata): rebuild custom fields as a roster with an editor sheet

Show each custom field on one row with its type, libraries and how many
books are filled, and edit, create, archive and delete it in the settings
sheet. Order Edit Metadata and card values by the order set on this page,
and add usageByLibrary to the admin field list, an additive field.
This commit is contained in:
neon
2026-10-16 16:15:44 +00:00
parent 83f17d6ff8
commit f89ae86d69
29 changed files with 2200 additions and 736 deletions
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { CustomMetadataFieldType, CustomMetadataPrimitiveValue } from '@bookorbit/types'
/**
* One custom field cell as Edit Metadata draws it. Settings › Custom Fields previews fields with this
* same component, so the preview cannot drift from the form.
*/
const props = defineProps<{
label: string
type: CustomMetadataFieldType
modelValue: CustomMetadataPrimitiveValue
}>()
const emit = defineEmits<{ 'update:modelValue': [value: CustomMetadataPrimitiveValue] }>()
const INPUT_CLASS =
'h-10 w-full rounded-lg border border-input bg-background px-3 text-sm transition-shadow outline-none focus:ring-1 focus:ring-ring sm:h-8'
function handleText(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
function handleNumber(e: Event) {
const raw = (e.target as HTMLInputElement).value
if (raw === '') {
emit('update:modelValue', null)
return
}
const n = Number(raw)
emit('update:modelValue', Number.isFinite(n) ? n : null)
}
function handleDate(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value || null)
}
function handleBoolean(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).checked)
}
function textValue(): string {
return typeof props.modelValue === 'string' ? props.modelValue : ''
}
function numberValue(): number | string {
return typeof props.modelValue === 'number' ? props.modelValue : ''
}
</script>
<template>
<label class="space-y-1">
<span class="text-[10px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{ label }}</span>
<input
v-if="type === 'text' || type === 'url'"
:value="textValue()"
:type="type === 'url' ? 'url' : 'text'"
:class="INPUT_CLASS"
@input="handleText"
/>
<input v-else-if="type === 'number'" :value="numberValue()" type="number" :class="INPUT_CLASS" @input="handleNumber" />
<input v-else-if="type === 'date'" :value="textValue()" type="date" :class="INPUT_CLASS" @input="handleDate" />
<span v-else class="flex h-10 items-center rounded-lg border border-input bg-background px-3 sm:h-8">
<input
type="checkbox"
class="size-4 rounded-xs border-input accent-primary"
:checked="modelValue === true"
:aria-label="label"
@change="handleBoolean"
/>
</span>
</label>
</template>
@@ -16,15 +16,7 @@ import {
X,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import type {
BookCommunityRating,
BookDetail,
BookMetadataLockField,
CoverMedium,
CustomMetadataPrimitiveValue,
MetadataProviderInfo,
WriteResult,
} from '@bookorbit/types'
import type { BookCommunityRating, BookDetail, BookMetadataLockField, CoverMedium, MetadataProviderInfo, WriteResult } from '@bookorbit/types'
import {
BOOK_FILE_WRITE_FIELD_LABELS,
FORMAT_TO_GROUP,
@@ -68,6 +60,7 @@ import { useMetadataReminders } from '@/features/metadata-reminders/composables/
import { missingMetadataFields } from '@/features/metadata-reminders/lib/missing-metadata'
import MetadataReminderSheet from '@/features/metadata-reminders/components/MetadataReminderSheet.vue'
import MetadataSourceCard from './MetadataSourceCard.vue'
import CustomMetadataValueInput from '../../CustomMetadataValueInput.vue'
const AUTO_FILL_EMPTY_TOAST_DURATION_MS = 10_000
@@ -233,38 +226,6 @@ function setPublishedDateField(e: Event) {
}
}
function isTextLikeCustomField(type: string): boolean {
return type === 'text' || type === 'url'
}
function setCustomMetadataValue(fieldId: number, value: CustomMetadataPrimitiveValue) {
const field = form.customMetadata.find((item) => item.fieldId === fieldId)
if (field) field.value = value
}
function setCustomNumberField(fieldId: number, e: Event) {
const val = (e.target as HTMLInputElement).value
if (val === '') {
setCustomMetadataValue(fieldId, null)
return
}
const n = Number(val)
setCustomMetadataValue(fieldId, Number.isFinite(n) ? n : null)
}
function setCustomTextField(fieldId: number, e: Event) {
setCustomMetadataValue(fieldId, (e.target as HTMLInputElement).value)
}
function setCustomDateField(fieldId: number, e: Event) {
const val = (e.target as HTMLInputElement).value
setCustomMetadataValue(fieldId, val || null)
}
function setCustomBooleanField(fieldId: number, e: Event) {
setCustomMetadataValue(fieldId, (e.target as HTMLInputElement).checked)
}
let watchedBookId: number | null = null
watch(
() => props.book,
@@ -1197,39 +1158,13 @@ function handleCoverChanged(medium: CoverMedium | null) {
</MetadataFieldLabel>
<div v-if="form.customMetadata.length > 0" class="grid grid-cols-1 gap-2.5 border-t border-border pt-2.5 @sm/edit:grid-cols-2">
<label v-for="field in form.customMetadata" :key="field.fieldId" class="space-y-1">
<span class="text-[10px] font-bold tracking-[0.1em] text-muted-foreground uppercase">{{ field.label }}</span>
<input
v-if="isTextLikeCustomField(field.type)"
:value="typeof field.value === 'string' ? field.value : ''"
:type="field.type === 'url' ? 'url' : 'text'"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm transition-shadow outline-none focus:ring-1 focus:ring-ring sm:h-8"
@input="setCustomTextField(field.fieldId, $event)"
/>
<input
v-else-if="field.type === 'number'"
:value="typeof field.value === 'number' ? field.value : ''"
type="number"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm transition-shadow outline-none focus:ring-1 focus:ring-ring sm:h-8"
@input="setCustomNumberField(field.fieldId, $event)"
/>
<input
v-else-if="field.type === 'date'"
:value="typeof field.value === 'string' ? field.value : ''"
type="date"
class="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm transition-shadow outline-none focus:ring-1 focus:ring-ring sm:h-8"
@input="setCustomDateField(field.fieldId, $event)"
/>
<span v-else class="flex h-10 items-center rounded-lg border border-input bg-background px-3 sm:h-8">
<input
type="checkbox"
class="size-4 rounded-xs border-input accent-primary"
:checked="field.value === true"
:aria-label="field.label"
@change="setCustomBooleanField(field.fieldId, $event)"
/>
</span>
</label>
<CustomMetadataValueInput
v-for="field in form.customMetadata"
:key="field.fieldId"
v-model="field.value"
:label="field.label"
:type="field.type"
/>
</div>
</div>
</section>
@@ -1,26 +1,27 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Archive, ArchiveRestore, ChevronDown, GripVertical, LayoutList, Loader2, Plus, Save, Search, Trash2 } from '@lucide/vue'
import { VueDraggable } from 'vue-draggable-plus'
import { Plus, Search, X } from '@lucide/vue'
import type { CustomMetadataFieldDefinition, CustomMetadataFieldType } from '@bookorbit/types'
import { CUSTOM_METADATA_FIELD_TYPES } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { formatNumber } from '@/i18n/formatters'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useCustomMetadataFields } from './composables/useCustomMetadataFields'
import { CUSTOM_FIELD_TYPE_META, formatAbsoluteDateTime, formatRelativeTime } from './lib/custom-field-types'
import CustomFieldPreviewInput from './components/CustomFieldPreviewInput.vue'
import LibraryToggleGroup from './components/LibraryToggleGroup.vue'
import { typeLabelKey } from './lib/custom-field-types'
import CustomFieldEditorSheet from './components/CustomFieldEditorSheet.vue'
import type { CustomFieldDraft, CustomFieldSheetMode } from './components/CustomFieldEditorSheet.vue'
import CustomFieldEmptyState from './components/CustomFieldEmptyState.vue'
import CustomFieldRosterCards from './components/CustomFieldRosterCards.vue'
import CustomFieldRosterTable from './components/CustomFieldRosterTable.vue'
type Draft = {
label: string
enabledLibraryIds: number[]
}
const HEADER_ACTIONS_TARGET = '#settings-header-actions'
const { t } = useI18n()
const { libraries, fetchLibraries } = useLibraries()
const {
fields,
loading,
loaded,
creating,
savingId,
archivingId,
@@ -36,445 +37,256 @@ const {
restoreField,
deleteFieldPermanently,
reorderFields,
moveField,
} = useCustomMetadataFields()
const drafts = reactive<Record<number, Draft>>({})
const createOpen = ref(false)
const createForm = reactive({
label: '',
type: 'text' as CustomMetadataFieldType,
enabledLibraryIds: [] as number[],
})
const searchQuery = ref('')
const showArchived = ref(false)
const showCreatePreview = ref(false)
const deleteConfirmField = ref<CustomMetadataFieldDefinition | null>(null)
const deleteConfirmInput = ref('')
const headerSlotAvailable = ref(false)
const search = ref('')
const view = ref<'active' | 'archived'>('active')
const orderableFields = ref<CustomMetadataFieldDefinition[]>([])
watch(activeFields, (value) => (orderableFields.value = [...value]), { immediate: true })
const sheetOpen = ref(false)
const sheetMode = ref<CustomFieldSheetMode>('edit')
const sheetFieldId = ref<number | null>(null)
const sheetStarter = ref<{ label: string; type: CustomMetadataFieldType } | null>(null)
const sheetConfirmDelete = ref(false)
const normalizedQuery = computed(() => searchQuery.value.trim().toLowerCase())
const isSearching = computed(() => normalizedQuery.value.length > 0)
const visibleFieldCount = computed(() => orderableFields.value.filter(matchesSearch).length)
const query = computed(() => search.value.trim().toLowerCase())
const isSearching = computed(() => query.value.length > 0)
const createLabelError = computed(() => labelError(createForm.label))
const canCreate = computed(() => createForm.label.trim().length > 0 && !createLabelError.value && !creating.value)
const canConfirmDelete = computed(() => deleteConfirmInput.value === deleteConfirmField.value?.label && deletingPermanentlyId.value === null)
function labelError(label: string, excludeId?: number): string | null {
const trimmed = label.trim()
if (!trimmed) return null
if (trimmed.length > 255) return t('settings.metadata.customFields.labelTooLong')
const duplicate = activeFields.value.some((field) => field.id !== excludeId && field.label.toLowerCase() === trimmed.toLowerCase())
if (duplicate) return t('settings.metadata.customFields.labelDuplicate')
return null
}
function matchesSearch(field: CustomMetadataFieldDefinition): boolean {
if (!normalizedQuery.value) return true
const query = normalizedQuery.value
function matches(field: CustomMetadataFieldDefinition): boolean {
if (!query.value) return true
return (
field.label.toLowerCase().includes(query) ||
field.key.toLowerCase().includes(query) ||
CUSTOM_FIELD_TYPE_META[field.type].label.toLowerCase().includes(query)
field.label.toLowerCase().includes(query.value) ||
field.key.includes(query.value) ||
t(typeLabelKey(field.type)).toLowerCase().includes(query.value)
)
}
function draftFor(field: CustomMetadataFieldDefinition): Draft {
drafts[field.id] ??= { label: field.label, enabledLibraryIds: [...field.enabledLibraryIds] }
return drafts[field.id]!
const listed = computed(() => (view.value === 'archived' ? archivedFields.value : activeFields.value).filter(matches))
const canReorder = computed(() => !isSearching.value && !reordering.value)
const headerFacts = computed(() => {
const values = fields.value.reduce((total, field) => total + field.usageCount, 0)
return t('settings.metadata.customFields.pageFacts', { fields: activeFields.value.length, values })
})
const sheetField = computed(() => fields.value.find((field) => field.id === sheetFieldId.value) ?? null)
const sheetIndex = computed(() => activeFields.value.findIndex((field) => field.id === sheetFieldId.value))
const sheetBusy = computed(() => {
const id = sheetFieldId.value
if (sheetMode.value === 'create') return creating.value
return id !== null && (savingId.value === id || archivingId.value === id || restoringId.value === id || deletingPermanentlyId.value === id)
})
// Nothing archived left to show: fall back to the active list rather than an empty filter.
watch(
() => archivedFields.value.length,
(count) => {
if (count === 0) view.value = 'active'
},
)
function showActive() {
view.value = 'active'
}
function setDraftLibraries(field: CustomMetadataFieldDefinition, libraryIds: number[]) {
draftFor(field).enabledLibraryIds = libraryIds
function showArchived() {
view.value = 'archived'
}
function draftLabelError(field: CustomMetadataFieldDefinition): string | null {
return labelError(draftFor(field).label, field.id)
function clearSearch() {
search.value = ''
}
function isDirty(field: CustomMetadataFieldDefinition): boolean {
const draft = draftFor(field)
if (draft.label.trim() !== field.label) return true
const current = [...draft.enabledLibraryIds].sort((a, b) => a - b)
const saved = [...field.enabledLibraryIds].sort((a, b) => a - b)
return current.length !== saved.length || current.some((id, index) => id !== saved[index])
function openCreate() {
sheetMode.value = 'create'
sheetFieldId.value = null
sheetStarter.value = null
sheetConfirmDelete.value = false
sheetOpen.value = true
}
function canSave(field: CustomMetadataFieldDefinition): boolean {
return isDirty(field) && !draftLabelError(field) && draftFor(field).label.trim().length > 0 && savingId.value !== field.id
function openStarter(starter: { label: string; type: CustomMetadataFieldType }) {
openCreate()
sheetStarter.value = starter
}
function usageLabel(field: CustomMetadataFieldDefinition): string {
return t('settings.metadata.customFields.usage', { count: field.usageCount })
function openField(field: CustomMetadataFieldDefinition) {
sheetMode.value = field.archivedAt ? 'archived' : 'edit'
sheetFieldId.value = field.id
sheetConfirmDelete.value = false
sheetOpen.value = true
}
function toggleCreate() {
createOpen.value = !createOpen.value
function openDelete(field: CustomMetadataFieldDefinition) {
openField(field)
sheetConfirmDelete.value = true
}
function toggleArchived() {
showArchived.value = !showArchived.value
function closeSheet() {
sheetOpen.value = false
}
function toggleCreatePreview() {
showCreatePreview.value = !showCreatePreview.value
async function handleCreate(draft: CustomFieldDraft) {
const created = await createField(draft)
if (created) sheetOpen.value = false
}
async function handleCreate() {
if (!canCreate.value) return
const created = await createField({
label: createForm.label.trim(),
type: createForm.type,
enabledLibraryIds: createForm.enabledLibraryIds,
})
if (created) {
drafts[created.id] = { label: created.label, enabledLibraryIds: [...created.enabledLibraryIds] }
createForm.label = ''
createForm.type = 'text'
createForm.enabledLibraryIds = []
}
}
async function handleSave(field: CustomMetadataFieldDefinition) {
if (!canSave(field)) return
const draft = draftFor(field)
const updated = await saveField(field.id, { label: draft.label.trim(), enabledLibraryIds: draft.enabledLibraryIds })
if (updated) drafts[updated.id] = { label: updated.label, enabledLibraryIds: [...updated.enabledLibraryIds] }
async function handleSave(field: CustomMetadataFieldDefinition, draft: Pick<CustomFieldDraft, 'label' | 'enabledLibraryIds'>) {
const updated = await saveField(field, draft)
if (updated) sheetOpen.value = false
}
async function handleArchive(field: CustomMetadataFieldDefinition) {
const archived = await archiveField(field.id)
if (archived) delete drafts[field.id]
const archived = await archiveField(field)
if (archived && sheetFieldId.value === field.id) sheetOpen.value = false
}
async function handleRestore(field: CustomMetadataFieldDefinition) {
const restored = await restoreField(field.id)
if (restored) drafts[restored.id] = { label: restored.label, enabledLibraryIds: [...restored.enabledLibraryIds] }
const restored = await restoreField(field)
if (restored && sheetFieldId.value === field.id) sheetOpen.value = false
}
async function handleReorder() {
await reorderFields(orderableFields.value.map((field) => field.id))
async function handleDelete(field: CustomMetadataFieldDefinition) {
const deleted = await deleteFieldPermanently(field)
if (deleted) sheetOpen.value = false
}
function openDeleteConfirm(field: CustomMetadataFieldDefinition) {
deleteConfirmField.value = field
deleteConfirmInput.value = ''
async function handleMove(field: CustomMetadataFieldDefinition, step: -1 | 1) {
await moveField(field, step)
}
function cancelDeleteConfirm() {
deleteConfirmField.value = null
deleteConfirmInput.value = ''
}
async function confirmDeletePermanently() {
if (!canConfirmDelete.value || !deleteConfirmField.value) return
const deleted = await deleteFieldPermanently(deleteConfirmField.value.id)
if (deleted) {
delete drafts[deleteConfirmField.value.id]
cancelDeleteConfirm()
}
async function handleReorder(orderedIds: number[]) {
await reorderFields(orderedIds)
}
onMounted(() => {
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
void Promise.all([fetchLibraries(), loadFields()])
})
</script>
<template>
<div class="space-y-5">
<section class="rounded-lg border border-border bg-card overflow-hidden shadow-xs">
<button
<div class="space-y-3">
<Teleport :to="HEADER_ACTIONS_TARGET" defer :disabled="!headerSlotAvailable">
<p v-if="fields.length > 0" class="hidden text-sm tabular-nums text-muted-foreground xl:block">{{ headerFacts }}</p>
<Button
size="sm"
type="button"
class="w-full px-4 py-3.5 md:px-5 md:py-4 flex items-center justify-between gap-3 bg-muted/30 hover:bg-muted/50 transition-colors"
@click="toggleCreate"
class="max-sm:size-8 max-sm:px-0"
:aria-label="t('settings.metadata.customFields.newFieldAction')"
@click="openCreate"
>
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Plus :size="15" />
</div>
<div class="text-left">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{
t('settings.metadata.customFields.newField.title')
}}</span>
<p class="settings-hint">{{ t('settings.metadata.customFields.newField.hint') }}</p>
</div>
</div>
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground transition-transform" :class="createOpen ? 'rotate-180' : ''" />
</button>
<Plus :size="14" aria-hidden="true" />
<span class="max-sm:sr-only">{{ t('settings.metadata.customFields.newFieldAction') }}</span>
</Button>
</Teleport>
<div v-if="createOpen" class="p-4 md:p-5 space-y-4 border-t border-border">
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem]">
<label class="space-y-1.5">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ t('settings.metadata.customFields.label') }}</span>
<input
v-model="createForm.label"
class="input-field w-full"
:placeholder="t('settings.metadata.customFields.labelPlaceholder')"
:aria-invalid="!!createLabelError"
@keydown.enter="handleCreate"
/>
<span v-if="createLabelError" class="text-xs text-destructive">{{ createLabelError }}</span>
</label>
<label class="space-y-1.5">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ t('settings.metadata.customFields.type') }}</span>
<select v-model="createForm.type" class="select-field w-full">
<option v-for="type in CUSTOM_METADATA_FIELD_TYPES" :key="type" :value="type">{{ CUSTOM_FIELD_TYPE_META[type].label }}</option>
</select>
<span class="text-xs text-muted-foreground">{{ CUSTOM_FIELD_TYPE_META[createForm.type].example }}</span>
</label>
</div>
<p v-if="loading && !loaded" role="status" class="settings-loading-state">{{ t('common.loading') }}</p>
<LibraryToggleGroup v-model="createForm.enabledLibraryIds" :libraries="libraries" />
<CustomFieldEmptyState v-else-if="loaded && fields.length === 0" @create="openCreate" @start="openStarter" />
<div class="rounded-md border border-dashed border-border bg-muted/20 p-3">
<Button variant="ghost" size="sm" type="button" @click="toggleCreatePreview">
<ChevronDown class="h-3.5 w-3.5 transition-transform" :class="showCreatePreview ? 'rotate-180' : ''" />
{{ t('settings.metadata.customFields.previewToggle') }}
</Button>
<div v-if="showCreatePreview" class="mt-3 max-w-xs">
<span class="text-xs font-medium text-muted-foreground">{{
createForm.label.trim() || t('settings.metadata.customFields.untitledField')
}}</span>
<CustomFieldPreviewInput class="mt-1" :type="createForm.type" />
</div>
</div>
<template v-else-if="loaded">
<div class="flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:w-80">
<span class="sr-only">{{ t('settings.metadata.customFields.searchAriaLabel') }}</span>
<Search :size="14" class="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
v-model="search"
type="search"
class="input-field h-9 w-full ps-8 pe-8"
:placeholder="t('settings.metadata.customFields.searchPlaceholder')"
/>
<button
v-if="search"
type="button"
class="absolute end-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('components.viewHeader.clearSearch')"
@click="clearSearch"
>
<X :size="14" aria-hidden="true" />
</button>
</label>
<div class="flex justify-end">
<Button size="sm" :disabled="!canCreate" @click="handleCreate" type="button">
<Loader2 v-if="creating" :size="14" class="animate-spin" />
<Plus v-else :size="14" />
{{ t('settings.metadata.customFields.addField') }}
</Button>
</div>
</div>
</section>
<section class="rounded-lg border border-border bg-card overflow-hidden shadow-xs">
<div class="px-4 py-3.5 md:px-5 md:py-4 border-b border-border flex flex-col gap-3 bg-muted/30 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<LayoutList :size="15" />
</div>
<div>
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ t('settings.metadata.customFields.fields') }}</span>
<p class="settings-hint">{{ t('settings.metadata.customFields.fieldsHint') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<div v-if="activeFields.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
v-model="searchQuery"
type="search"
:placeholder="t('settings.metadata.customFields.searchPlaceholder')"
class="input-field w-full pl-8 sm:w-48"
:aria-label="t('settings.metadata.customFields.searchAriaLabel')"
/>
</div>
<Loader2 v-if="loading || reordering" :size="15" class="animate-spin text-muted-foreground shrink-0" />
<div
v-if="archivedFields.length > 0"
role="group"
:aria-label="t('settings.metadata.customFields.views.label')"
class="flex min-w-0 gap-0.5 overflow-x-auto rounded-lg border border-border bg-muted p-0.5"
>
<button
type="button"
class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="view === 'active' ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="view === 'active'"
@click="showActive"
>
{{ t('settings.metadata.customFields.views.active') }}
<span
class="rounded-xs px-1 text-xs tabular-nums"
:class="view === 'active' ? 'bg-primary/15 text-primary' : 'bg-foreground/8 text-muted-foreground'"
>
{{ formatNumber(activeFields.length) }}
</span>
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="view === 'archived' ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="view === 'archived'"
@click="showArchived"
>
{{ t('settings.metadata.customFields.views.archived') }}
<span
class="rounded-xs px-1 text-xs tabular-nums"
:class="view === 'archived' ? 'bg-primary/15 text-primary' : 'bg-foreground/8 text-muted-foreground'"
>
{{ formatNumber(archivedFields.length) }}
</span>
</button>
</div>
</div>
<div v-if="activeFields.length === 0 && !loading" class="mx-4 my-5 rounded-lg border border-dashed border-border px-4 py-10 text-center">
<p class="text-sm font-medium text-foreground">{{ t('settings.metadata.customFields.emptyTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.emptyHint') }}</p>
</div>
<div v-else-if="isSearching && visibleFieldCount === 0" class="mx-4 my-5 rounded-lg border border-dashed border-border px-4 py-10 text-center">
<p class="text-sm font-medium text-foreground">{{ t('settings.metadata.customFields.noMatchTitle', { query: searchQuery.trim() }) }}</p>
<div v-if="listed.length === 0" class="rounded-lg border border-dashed border-border px-4 py-10 text-center">
<p class="text-sm font-medium text-foreground">{{ t('settings.metadata.customFields.noMatchTitle', { query: search.trim() }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.noMatchHint') }}</p>
</div>
<template v-else>
<CustomFieldRosterTable
:fields="listed"
:view="view"
:libraries="libraries"
:can-reorder="canReorder"
:restoring-id="restoringId"
@open="openField"
@archive="handleArchive"
@restore="handleRestore"
@delete="openDelete"
@move="handleMove"
@reorder="handleReorder"
/>
<CustomFieldRosterCards :fields="listed" :view="view" :libraries="libraries" @open="openField" />
</template>
</template>
<VueDraggable
v-else
v-model="orderableFields"
tag="div"
class="divide-y divide-border"
:animation="150"
handle=".custom-field-handle"
ghost-class="opacity-40"
:disabled="reordering || isSearching"
@end="handleReorder"
>
<div v-for="field in orderableFields" v-show="matchesSearch(field)" :key="field.id" class="px-4 py-4 md:px-5 space-y-3">
<div class="flex items-start gap-3">
<button
type="button"
class="custom-field-handle mt-7 shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed cursor-grab active:cursor-grabbing"
:disabled="reordering || isSearching"
:title="isSearching ? t('settings.metadata.customFields.clearSearchToReorder') : t('settings.metadata.customFields.dragToReorder')"
:aria-label="t('settings.metadata.customFields.dragToReorderField')"
>
<GripVertical :size="16" />
</button>
<div class="min-w-0 flex-1 space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_9rem]">
<label class="space-y-1.5">
<span class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase tracking-widest">
{{ t('settings.metadata.customFields.label') }}
<span
v-if="isDirty(field)"
class="inline-flex items-center gap-1 normal-case tracking-normal font-medium text-amber-600 dark:text-amber-400"
>
<span class="h-1.5 w-1.5 rounded-full bg-amber-500" />
{{ t('settings.metadata.customFields.unsaved') }}
</span>
</span>
<input
v-model="draftFor(field).label"
class="input-field w-full"
:aria-invalid="!!draftLabelError(field)"
@keydown.enter="handleSave(field)"
/>
<span v-if="draftLabelError(field)" class="text-xs text-destructive">{{ draftLabelError(field) }}</span>
</label>
<div class="space-y-1.5">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{
t('settings.metadata.customFields.type')
}}</span>
<div class="h-9 flex items-center gap-1.5 px-3 rounded-md border border-border bg-muted/30 text-sm">
<component :is="CUSTOM_FIELD_TYPE_META[field.type].icon" :size="14" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ CUSTOM_FIELD_TYPE_META[field.type].label }}</span>
</div>
</div>
</div>
<LibraryToggleGroup
:model-value="draftFor(field).enabledLibraryIds"
:libraries="libraries"
@update:model-value="setDraftLibraries(field, $event)"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<span
class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
:class="CUSTOM_FIELD_TYPE_META[field.type].badgeClass"
>
{{ usageLabel(field) }}
</span>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" :disabled="!canSave(field)" @click="handleSave(field)" type="button">
<Loader2 v-if="savingId === field.id" :size="14" class="animate-spin" />
<Save v-else :size="14" />
{{ t('common.save') }}
</Button>
<Button variant="destructive" size="sm" :disabled="archivingId === field.id" @click="handleArchive(field)" type="button">
<Loader2 v-if="archivingId === field.id" :size="14" class="animate-spin" />
<Archive v-else :size="14" />
{{ t('settings.metadata.customFields.archive') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</VueDraggable>
</section>
<section v-if="archivedFields.length > 0" class="rounded-lg border border-border bg-card overflow-hidden shadow-xs">
<button
type="button"
class="w-full px-4 py-3.5 md:px-5 md:py-4 flex items-center justify-between gap-3 bg-muted/30 hover:bg-muted/50 transition-colors"
@click="toggleArchived"
>
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Archive :size="15" />
</div>
<div class="text-left">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{
t('settings.metadata.customFields.archivedFields')
}}</span>
<p class="settings-hint">
{{ t('settings.metadata.customFields.archivedSummary', { count: archivedFields.length }) }}
</p>
</div>
</div>
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground transition-transform" :class="showArchived ? 'rotate-180' : ''" />
</button>
<div v-if="showArchived" class="divide-y divide-border">
<div
v-for="field in archivedFields"
:key="field.id"
class="px-4 py-4 md:px-5 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div class="min-w-0 space-y-1">
<p class="text-sm font-medium text-foreground truncate">{{ field.label }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-xs px-1.5 py-0.5 text-xs font-medium"
:class="CUSTOM_FIELD_TYPE_META[field.type].badgeClass"
>
<component :is="CUSTOM_FIELD_TYPE_META[field.type].icon" :size="12" />
{{ CUSTOM_FIELD_TYPE_META[field.type].label }}
</span>
<span class="text-xs text-muted-foreground">{{ usageLabel(field) }}</span>
<span class="text-xs text-muted-foreground" :title="formatAbsoluteDateTime(field.archivedAt!)">
{{ t('settings.metadata.customFields.archivedAt', { when: formatRelativeTime(field.archivedAt!) }) }}
</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" :disabled="restoringId === field.id" @click="handleRestore(field)" type="button">
<Loader2 v-if="restoringId === field.id" :size="14" class="animate-spin" />
<ArchiveRestore v-else :size="14" />
{{ t('settings.metadata.customFields.restore') }}
</Button>
<Button variant="destructive" size="sm" :disabled="restoringId === field.id" @click="openDeleteConfirm(field)" type="button">
<Trash2 :size="14" />
{{ t('settings.metadata.customFields.deleteForever') }}
</Button>
</div>
</div>
</div>
</section>
<CustomFieldEditorSheet
:open="sheetOpen"
:mode="sheetMode"
:field="sheetField"
:fields="fields"
:libraries="libraries"
:starter="sheetStarter"
:confirm-delete="sheetConfirmDelete"
:busy="sheetBusy"
:can-move-up="canReorder && sheetIndex > 0"
:can-move-down="canReorder && sheetIndex >= 0 && sheetIndex < activeFields.length - 1"
@close="closeSheet"
@create="handleCreate"
@save="handleSave"
@archive="handleArchive"
@restore="handleRestore"
@delete="handleDelete"
@move="handleMove"
/>
</div>
<Teleport to="body">
<div v-if="deleteConfirmField" class="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4" @click.self="cancelDeleteConfirm">
<div class="w-full max-w-md rounded-xl border border-border bg-card shadow-xl">
<div class="p-5 border-b border-border flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<Trash2 :size="16" />
</div>
<div>
<p class="text-sm font-semibold text-foreground">{{ t('settings.metadata.customFields.deleteDialog.title') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ t('settings.metadata.customFields.deleteDialog.bodyBefore') }}
<span class="font-medium text-foreground">{{ deleteConfirmField.label }}</span>
{{ t('settings.metadata.customFields.deleteDialog.bodyMiddle') }}
<span class="font-medium text-foreground">{{ usageLabel(deleteConfirmField).toLowerCase() }}</span
>{{ t('settings.metadata.customFields.deleteDialog.bodyAfter') }}
</p>
</div>
</div>
<div class="p-5 space-y-3">
<label class="space-y-1.5">
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
{{ t('settings.metadata.customFields.deleteDialog.confirmBefore') }}
<span class="text-foreground normal-case tracking-normal font-semibold">{{ deleteConfirmField.label }}</span>
{{ t('settings.metadata.customFields.deleteDialog.confirmAfter') }}
</span>
<input
v-model="deleteConfirmInput"
class="input-field w-full"
:placeholder="t('settings.metadata.customFields.deleteDialog.confirmPlaceholder')"
@keydown.enter="confirmDeletePermanently"
/>
</label>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" @click="cancelDeleteConfirm" type="button">{{ t('common.cancel') }}</Button>
<Button variant="destructive" size="sm" :disabled="!canConfirmDelete" @click="confirmDeletePermanently" type="button">
<Loader2 v-if="deletingPermanentlyId !== null" :size="14" class="animate-spin" />
<Trash2 v-else :size="14" />
{{ t('settings.metadata.customFields.deleteForever') }}
</Button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,142 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
import CustomFieldEditorSheet from '../components/CustomFieldEditorSheet.vue'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown[]> }>>(async () => ({ ok: true, json: async () => [] })),
}))
// The real sheet portals into a reka dialog; the stub renders its slots in place and lets a test fire its events.
const SheetStub = defineComponent({
name: 'SettingsEditorSheet',
props: ['open', 'title', 'description', 'dirty', 'busy', 'menuActions'],
emits: ['save', 'cancel', 'action'],
setup(_, { slots }) {
return () => h('div', [slots.status?.(), slots.default?.(), slots.footer?.({ requestClose: () => {} })])
},
})
const libraries = [
{ id: 2, name: 'Audiobooks', icon: 'AudioLines', bookCount: 4, fileWriteEnabled: true, fileWriteEpubEnabled: false, fileWriteFb2Enabled: false },
{ id: 6, name: 'Comics', icon: null, bookCount: 10, fileWriteEnabled: false, fileWriteEpubEnabled: true, fileWriteFb2Enabled: false },
{ id: 1, name: 'Novels', icon: null, bookCount: 454, fileWriteEnabled: true, fileWriteEpubEnabled: true, fileWriteFb2Enabled: true },
] as unknown as Library[]
function field(overrides: Partial<CustomMetadataFieldDefinition> = {}): CustomMetadataFieldDefinition {
return {
id: 3,
key: 'shelf_number',
label: 'Shelf Number',
type: 'number',
displayOrder: 0,
archivedAt: null,
createdAt: '2026-10-08T00:00:00.000Z',
updatedAt: '2026-10-08T00:00:00.000Z',
enabledLibraryIds: [1, 6],
usageCount: 217,
usageByLibrary: { 1: 207, 6: 10 },
...overrides,
}
}
function mountSheet(props: Partial<InstanceType<typeof CustomFieldEditorSheet>['$props']>) {
return mount(CustomFieldEditorSheet, {
props: { open: true, mode: 'edit', field: null, fields: [], libraries, ...props },
global: { stubs: { SettingsEditorSheet: SheetStub } },
})
}
describe('CustomFieldEditorSheet', () => {
it('starts a new field on every library and shows the key it will be saved under', async () => {
const wrapper = mountSheet({ mode: 'create', fields: [field()] })
const boxes = wrapper.findAll('input[type="checkbox"]')
expect(boxes).toHaveLength(3)
expect(boxes.every((box) => (box.element as HTMLInputElement).checked)).toBe(true)
await wrapper.find('input.input-field').setValue('Original Title')
expect(wrapper.text()).toContain('original_title')
expect(wrapper.text()).toContain('On all 3 libraries')
})
it('creates with the chosen type and libraries', async () => {
const wrapper = mountSheet({ mode: 'create' })
await wrapper.find('input.input-field').setValue(' Acquired On ')
await wrapper.find('input[type="radio"][value="date"]').trigger('change')
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(false)
await wrapper
.findAll('button')
.find((button) => button.text() === 'Create field')!
.trigger('click')
expect(wrapper.emitted('create')).toEqual([[{ label: 'Acquired On', type: 'date', enabledLibraryIds: [6, 1] }]])
})
it('refuses a label another active field already uses', async () => {
const wrapper = mountSheet({ mode: 'create', fields: [field()] })
await wrapper.find('input.input-field').setValue('shelf number')
expect(wrapper.text()).toContain('A field with this label already exists')
const create = wrapper.findAll('button').find((button) => button.text() === 'Create field')!
expect(create.attributes('disabled')).toBeDefined()
})
it('warns that switching a library off hides the values stored there', async () => {
const wrapper = mountSheet({ field: field() })
const comics = wrapper.findAll('input[type="checkbox"]')[1]!
await comics.setValue(false)
expect(wrapper.text()).toContain('10 values in Comics stay stored but hidden while the field is off there.')
expect(wrapper.text()).toContain('10 hidden')
})
it('saves the edited label and libraries, and closes without a request when nothing changed', async () => {
const wrapper = mountSheet({ field: field() })
const sheet = wrapper.findComponent({ name: 'SettingsEditorSheet' })
sheet.vm.$emit('save')
expect(wrapper.emitted('close')).toHaveLength(1)
expect(wrapper.emitted('save')).toBeUndefined()
await wrapper.find('input.input-field').setValue('Shelf')
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
sheet.vm.$emit('save')
expect(wrapper.emitted('save')?.[0]?.[1]).toEqual({ label: 'Shelf', enabledLibraryIds: [1, 6, 2] })
})
it('names the libraries that write the field into files', () => {
const wrapper = mountSheet({ field: field() })
expect(wrapper.text()).toContain('bookorbit:custom:shelf_number')
expect(wrapper.text()).toContain('Written into EPUB and FB2 files in Novels')
})
it('offers archive behind a confirm in the sheet menu', async () => {
const wrapper = mountSheet({ field: field(), canMoveUp: false, canMoveDown: true })
const sheet = wrapper.findComponent({ name: 'SettingsEditorSheet' })
const actions = sheet.props('menuActions') as Array<{ id: string; confirm?: string; disabled?: boolean }>
expect(actions.map((action) => action.id)).toEqual(['move-up', 'move-down', 'archive'])
expect(actions[0]!.disabled).toBe(true)
expect(actions[2]!.confirm).toBe('Archive Shelf Number?')
sheet.vm.$emit('action', 'archive')
await nextTick()
expect(wrapper.emitted('archive')).toHaveLength(1)
})
it('asks for the field name before deleting an archived field that holds values', async () => {
const wrapper = mountSheet({ mode: 'archived', field: field({ archivedAt: '2026-10-01T00:00:00.000Z' }), confirmDelete: true })
const remove = () => wrapper.findAll('button').find((button) => button.text().includes('Delete forever'))!
expect(remove().attributes('disabled')).toBeDefined()
await wrapper.find('input[placeholder="Type Shelf Number to confirm"]').setValue('Shelf Number')
expect(remove().attributes('disabled')).toBeUndefined()
await remove().trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
})
@@ -0,0 +1,76 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
import CustomFieldRosterTable from '../components/CustomFieldRosterTable.vue'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown[]> }>>(async () => ({ ok: true, json: async () => [] })),
}))
const libraries = [
{ id: 6, name: 'Comics', icon: null, bookCount: 10 },
{ id: 1, name: 'Novels', icon: null, bookCount: 454 },
] as unknown as Library[]
function field(overrides: Partial<CustomMetadataFieldDefinition>): CustomMetadataFieldDefinition {
return {
id: 1,
key: 'edition_note',
label: 'Edition Note',
type: 'text',
displayOrder: 0,
archivedAt: null,
createdAt: '2026-10-08T00:00:00.000Z',
updatedAt: '2026-10-08T00:00:00.000Z',
enabledLibraryIds: [1],
usageCount: 38,
usageByLibrary: { 1: 38 },
...overrides,
}
}
function mountTable(fields: CustomMetadataFieldDefinition[], view: 'active' | 'archived' = 'active') {
return mount(CustomFieldRosterTable, { props: { fields, view, libraries, canReorder: true, restoringId: null } })
}
describe('CustomFieldRosterTable', () => {
it('shows each field on one row with its type, libraries and coverage', () => {
const wrapper = mountTable([field({}), field({ id: 2, label: 'Loaned To', enabledLibraryIds: [6, 1], usageCount: 3, usageByLibrary: { 1: 3 } })])
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows[0]!.text()).toContain('Edition Note')
expect(rows[0]!.text()).toContain('Text')
expect(rows[0]!.text()).toContain('Novels')
expect(rows[0]!.find('[aria-label="38 of 454 books have a value"]').exists()).toBe(true)
expect(rows[1]!.text()).toContain('All libraries')
})
it('flags a field on no library', () => {
const wrapper = mountTable([field({ enabledLibraryIds: [], usageCount: 0, usageByLibrary: {} })])
expect(wrapper.text()).toContain('No libraries')
expect(wrapper.find('tbody tr').classes().join(' ')).toContain('--pill-warning')
})
it('opens a field when its row is clicked, but not from the drag handle', async () => {
const wrapper = mountTable([field({})])
await wrapper.find('.custom-field-handle').trigger('click')
expect(wrapper.emitted('open')).toBeUndefined()
await wrapper.find('tbody tr').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1)
})
it('lists archived fields with the values they keep and a visible Restore', async () => {
const wrapper = mountTable([field({ archivedAt: '2026-09-24T00:00:00.000Z', usageCount: 212 })], 'archived')
expect(wrapper.text()).toContain('212')
const restore = wrapper.findAll('button').find((button) => button.text() === 'Restore')!
await restore.trigger('click')
expect(wrapper.emitted('restore')).toHaveLength(1)
expect(wrapper.emitted('open')).toBeUndefined()
})
})
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CustomMetadataFieldType, CustomMetadataPrimitiveValue, Library } from '@bookorbit/types'
import CustomMetadataValueInput from '@/features/book/components/CustomMetadataValueInput.vue'
import { formatList } from '@/i18n/formatters'
import { filterOperatorsFor, typeExampleKey } from '../lib/custom-field-types'
/**
* Everywhere a field shows up once it exists. The Edit Metadata cell is the form's own component,
* so what is previewed here is what a book gets.
*/
const props = defineProps<{
type: CustomMetadataFieldType
label: string
fieldKey: string
libraryIds: number[]
libraries: Library[]
}>()
const { t } = useI18n()
const previewValue = ref<CustomMetadataPrimitiveValue>(null)
watch(
() => props.type,
() => (previewValue.value = null),
)
const shownLabel = computed(() => props.label.trim() || t('settings.metadata.customFields.untitledField'))
const example = computed(() => t(typeExampleKey(props.type)))
const exampleText = computed(() => (props.type === 'boolean' ? t('common.yes') : example.value))
const operators = computed(() => filterOperatorsFor(props.type).map((operator) => t(`book.filter.operators.${operator}`)))
const writers = computed(() =>
props.libraries.filter(
(library) => props.libraryIds.includes(library.id) && library.fileWriteEnabled && (library.fileWriteEpubEnabled || library.fileWriteFb2Enabled),
),
)
const writerNames = computed(() => formatList(writers.value.map((library) => library.name)))
</script>
<template>
<section class="space-y-2">
<h3 class="settings-label">{{ t('settings.metadata.customFields.sheet.appears.title') }}</h3>
<dl class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card">
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.editMetadata') }}</dt>
<dd class="max-w-[17rem]"><CustomMetadataValueInput v-model="previewValue" :label="shownLabel" :type="type" /></dd>
</div>
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.details') }}</dt>
<dd class="min-w-0">
<p class="text-[10px] font-medium tracking-wider text-muted-foreground uppercase">{{ shownLabel }}</p>
<p class="mt-1 text-sm break-words" :class="type === 'url' ? 'text-primary' : 'text-foreground'">{{ exampleText }}</p>
</dd>
</div>
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.filters') }}</dt>
<dd class="flex flex-wrap gap-1">
<span
v-for="operator in operators"
:key="operator"
class="inline-flex h-5 items-center rounded-[5px] bg-foreground/6 px-1.5 text-[11.5px] whitespace-nowrap text-foreground"
>
{{ operator }}
</span>
</dd>
</div>
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.table') }}</dt>
<dd class="text-sm text-foreground">{{ t('settings.metadata.customFields.sheet.appears.tableBody') }}</dd>
</div>
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.files') }}</dt>
<dd class="min-w-0 space-y-1.5">
<code
class="inline-block max-w-full rounded-[5px] border border-foreground/9 bg-foreground/6 px-1.5 py-px font-mono text-xs break-all text-foreground"
>bookorbit:custom:{{ fieldKey }}</code
>
<p class="text-xs text-muted-foreground">
{{
writers.length > 0
? t('settings.metadata.customFields.sheet.appears.filesWritten', { libraries: writerNames })
: t('settings.metadata.customFields.sheet.appears.filesNotWritten')
}}
</p>
</dd>
</div>
<div class="grid grid-cols-1 gap-1.5 px-3.5 py-3 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:gap-3">
<dt class="pt-0.5 text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.appears.exports') }}</dt>
<dd class="min-w-0">
<code
class="inline-block max-w-full rounded-[5px] border border-foreground/9 bg-foreground/6 px-1.5 py-px font-mono text-xs break-all text-foreground"
>custom.{{ fieldKey }}</code
>
</dd>
</div>
</dl>
</section>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters'
import type { FieldCoverage } from '../lib/custom-field-coverage'
const props = withDefaults(defineProps<{ coverage: FieldCoverage; bar?: boolean }>(), { bar: true })
const { t } = useI18n()
/** A sliver stays visible for one value in hundreds, so "some" never reads as "none". */
const width = computed(() => {
const { filled, books } = props.coverage
if (!books || !filled) return '0%'
return `${Math.max(4, Math.round((filled / books) * 100))}%`
})
const filled = computed(() => formatNumber(props.coverage.filled))
const total = computed(() => formatNumber(props.coverage.books))
</script>
<template>
<span v-if="coverage.none" class="text-muted-foreground" aria-hidden="true">-</span>
<span
v-else
class="inline-flex items-center gap-2.5 whitespace-nowrap tabular-nums"
:aria-label="t('settings.metadata.customFields.coverageAria', { filled, total })"
>
<span v-if="bar" class="relative inline-block h-1 w-11 shrink-0 overflow-hidden rounded-full bg-foreground/10" aria-hidden="true">
<span class="absolute inset-y-0 start-0 rounded-full bg-primary" :style="{ width }" />
</span>
<i18n-t keypath="settings.metadata.customFields.coverage" tag="span" scope="global" class="text-muted-foreground" aria-hidden="true">
<template #filled>
<span :class="coverage.filled ? 'text-foreground' : 'text-muted-foreground'">{{ filled }}</span>
</template>
<template #total>{{ total }}</template>
</i18n-t>
</span>
</template>
@@ -0,0 +1,396 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Archive, ArchiveRestore, Loader2, Lock, MoreHorizontal, Trash2 } from '@lucide/vue'
import type { CustomMetadataFieldDefinition, CustomMetadataFieldType, Library } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { formatDate, formatNumber, formatRelativeTimeFromNow } from '@/i18n/formatters'
import SettingsEditorSheet from '@/features/settings/components/SettingsEditorSheet.vue'
import type { EditorMenuAction } from '@/features/settings/lib/editor-actions'
import { fieldCoverage, previewFieldKey, sameLibraries } from '../lib/custom-field-coverage'
import { typeHintKey, typeLabelKey } from '../lib/custom-field-types'
import CustomFieldAppearance from './CustomFieldAppearance.vue'
import CustomFieldLibraryChecklist from './CustomFieldLibraryChecklist.vue'
import CustomFieldLibrarySummary from './CustomFieldLibrarySummary.vue'
import CustomFieldTypeChip from './CustomFieldTypeChip.vue'
import CustomFieldTypePicker from './CustomFieldTypePicker.vue'
import CustomFieldTypeTile from './CustomFieldTypeTile.vue'
export type CustomFieldSheetMode = 'create' | 'edit' | 'archived'
export type CustomFieldDraft = { label: string; type: CustomMetadataFieldType; enabledLibraryIds: number[] }
const props = defineProps<{
open: boolean
mode: CustomFieldSheetMode
field: CustomMetadataFieldDefinition | null
/** Every field, archived included: labels must be unique among active ones and keys among all. */
fields: CustomMetadataFieldDefinition[]
libraries: Library[]
/** Prefills a new field, from an empty-state example. */
starter?: { label: string; type: CustomMetadataFieldType } | null
/** Opens an archived field straight at the delete question. */
confirmDelete?: boolean
busy?: boolean
canMoveUp?: boolean
canMoveDown?: boolean
}>()
const emit = defineEmits<{
close: []
create: [draft: CustomFieldDraft]
save: [field: CustomMetadataFieldDefinition, draft: Pick<CustomFieldDraft, 'label' | 'enabledLibraryIds'>]
archive: [field: CustomMetadataFieldDefinition]
restore: [field: CustomMetadataFieldDefinition]
delete: [field: CustomMetadataFieldDefinition]
move: [field: CustomMetadataFieldDefinition, step: -1 | 1]
}>()
const { t } = useI18n()
const label = ref('')
const type = ref<CustomMetadataFieldType>('text')
const libraryIds = ref<number[]>([])
const deleting = ref(false)
const typedName = ref('')
const labelInput = ref<HTMLInputElement | null>(null)
function reset() {
deleting.value = props.mode === 'archived' && !!props.confirmDelete
typedName.value = ''
if (props.mode === 'create') {
label.value = props.starter?.label ?? ''
type.value = props.starter?.type ?? 'text'
// A field on no library shows up nowhere, so a new one starts everywhere.
libraryIds.value = props.libraries.map((library) => library.id)
void nextTick(() => labelInput.value?.focus())
return
}
label.value = props.field?.label ?? ''
type.value = props.field?.type ?? 'text'
libraryIds.value = [...(props.field?.enabledLibraryIds ?? [])]
}
watch(
() => [props.open, props.mode, props.field?.id] as const,
([open]) => {
if (open) reset()
},
{ immediate: true },
)
const trimmed = computed(() => label.value.trim())
const labelError = computed(() => {
if (!trimmed.value) return null
if (trimmed.value.length > 255) return t('settings.metadata.customFields.labelTooLong')
const lower = trimmed.value.toLowerCase()
const clash = props.fields.some((other) => !other.archivedAt && other.id !== props.field?.id && other.label.toLowerCase() === lower)
return clash ? t('settings.metadata.customFields.labelDuplicate') : null
})
const dirty = computed(() => {
if (props.mode === 'create') return trimmed.value.length > 0
if (props.mode !== 'edit' || !props.field) return false
return trimmed.value !== props.field.label || !sameLibraries(libraryIds.value, props.field.enabledLibraryIds)
})
const fieldKey = computed(() =>
props.mode === 'create'
? previewFieldKey(
trimmed.value,
props.fields.map((field) => field.key),
)
: (props.field?.key ?? ''),
)
const coverage = computed(() => (props.field ? fieldCoverage(props.field, props.libraries) : null))
const title = computed(() => (props.mode === 'create' ? t('settings.metadata.customFields.sheet.newTitle') : (props.field?.label ?? '')))
const description = computed(() => {
if (props.mode === 'create') return t('settings.metadata.customFields.sheet.newDescription')
if (props.mode === 'archived') return t('settings.metadata.customFields.sheet.archivedDescription')
return t('settings.metadata.customFields.sheet.editDescription')
})
const statusLine = computed(() => {
const field = props.field
const cov = coverage.value
if (!field || !cov) return ''
const parts = [
cov.none
? t('settings.metadata.customFields.sheet.statusNone')
: t('settings.metadata.customFields.sheet.statusFilled', { filled: formatNumber(cov.filled), total: cov.books }),
]
if (cov.hidden > 0) parts.push(t('settings.metadata.customFields.sheet.statusHidden', { count: formatNumber(cov.hidden) }))
parts.push(t('settings.metadata.customFields.sheet.statusCreated', { date: formatDate(new Date(field.createdAt), { dateStyle: 'medium' }) }))
return parts.join(' · ')
})
const menuActions = computed<EditorMenuAction[]>(() => {
if (props.mode !== 'edit' || !props.field) return []
return [
{ id: 'move-up', label: t('settings.metadata.customFields.rowActions.moveUp'), disabled: !props.canMoveUp },
{ id: 'move-down', label: t('settings.metadata.customFields.rowActions.moveDown'), disabled: !props.canMoveDown },
{
id: 'archive',
label: t('settings.metadata.customFields.sheet.archiveAction'),
separated: true,
confirm: t('settings.metadata.customFields.sheet.archiveConfirm', { label: props.field.label }),
consequence: t('settings.metadata.customFields.sheet.archiveConsequence', { count: props.field.usageCount }),
},
]
})
const canDelete = computed(() => !!props.field && (props.field.usageCount === 0 || typedName.value === props.field.label))
const libraryCountLine = computed(() =>
libraryIds.value.length === props.libraries.length
? t('settings.metadata.customFields.sheet.onAllLibraries', { total: props.libraries.length })
: t('settings.metadata.customFields.sheet.onLibraries', { count: libraryIds.value.length, total: props.libraries.length }),
)
function handleClose() {
emit('close')
}
function handleSave() {
if (props.mode !== 'edit' || !props.field) return
if (!dirty.value) {
emit('close')
return
}
if (!trimmed.value || labelError.value) return
emit('save', props.field, { label: trimmed.value, enabledLibraryIds: [...libraryIds.value] })
}
function handleCreate() {
if (!trimmed.value || labelError.value) {
labelInput.value?.focus()
return
}
emit('create', { label: trimmed.value, type: type.value, enabledLibraryIds: [...libraryIds.value] })
}
function handleAction(id: string) {
if (!props.field) return
if (id === 'archive') emit('archive', props.field)
else if (id === 'move-up') emit('move', props.field, -1)
else if (id === 'move-down') emit('move', props.field, 1)
}
function handleRestore() {
if (props.field) emit('restore', props.field)
}
function askDelete() {
deleting.value = true
}
function cancelDelete() {
deleting.value = false
typedName.value = ''
}
function handleDelete() {
if (props.field && canDelete.value) emit('delete', props.field)
}
function handleLabelEnter() {
if (props.mode === 'create') handleCreate()
else handleSave()
}
</script>
<template>
<SettingsEditorSheet
:open="open"
:title="title"
:description="description"
:dirty="dirty"
:busy="busy"
:menu-actions="menuActions"
@save="handleSave"
@cancel="handleClose"
@action="handleAction"
>
<template #badge>
<span
v-if="mode === 'archived'"
class="inline-flex h-5 items-center rounded-full bg-foreground/8 px-2 text-[11px] font-semibold text-muted-foreground"
>
{{ t('settings.metadata.customFields.sheet.archivedBadge') }}
</span>
</template>
<template #status>
<p v-if="mode === 'edit' && statusLine" class="text-xs tabular-nums text-muted-foreground">{{ statusLine }}</p>
</template>
<template v-if="mode !== 'archived'">
<label class="block space-y-1.5">
<span class="settings-label">{{ t('settings.metadata.customFields.label') }}</span>
<input
ref="labelInput"
v-model="label"
class="input-field w-full"
:class="labelError ? 'border-destructive' : ''"
:placeholder="t('settings.metadata.customFields.labelPlaceholder')"
:aria-invalid="!!labelError"
maxlength="300"
@keydown.enter.prevent="handleLabelEnter"
/>
<span v-if="labelError" class="block text-xs text-destructive" role="alert">{{ labelError }}</span>
<i18n-t
v-if="mode === 'edit'"
keypath="settings.metadata.customFields.sheet.renameHint"
tag="span"
scope="global"
class="settings-hint block"
>
<template #key>
<code class="rounded-[5px] border border-foreground/9 bg-foreground/6 px-1 font-mono text-[11.5px] text-foreground">{{ fieldKey }}</code>
</template>
</i18n-t>
<i18n-t v-else-if="trimmed" keypath="settings.metadata.customFields.sheet.keyHint" tag="span" scope="global" class="settings-hint block">
<template #key>
<code class="rounded-[5px] border border-foreground/9 bg-foreground/6 px-1 font-mono text-[11.5px] text-foreground">{{ fieldKey }}</code>
</template>
</i18n-t>
<span v-else class="settings-hint block">{{ t('settings.metadata.customFields.sheet.labelHint') }}</span>
</label>
<CustomFieldTypePicker v-if="mode === 'create'" v-model="type" name="custom-field-type" />
<div v-else-if="field" class="space-y-1.5">
<span class="settings-label">{{ t('settings.metadata.customFields.type') }}</span>
<div class="flex items-center gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
<CustomFieldTypeTile :type="field.type" :size="30" />
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">{{ t(typeLabelKey(field.type)) }}</p>
<p class="text-xs text-muted-foreground">{{ t(typeHintKey(field.type)) }}</p>
</div>
<span
class="ms-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
:title="t('settings.metadata.customFields.sheet.typeLockedHint')"
>
<Lock :size="12" aria-hidden="true" />
{{ t('settings.metadata.customFields.sheet.typeLocked') }}
</span>
</div>
</div>
<CustomFieldLibraryChecklist v-model="libraryIds" :libraries="libraries" :field="mode === 'edit' ? field : null" />
<CustomFieldAppearance :type="type" :label="trimmed" :field-key="fieldKey" :library-ids="libraryIds" :libraries="libraries" />
</template>
<template v-else-if="field">
<div class="flex items-start gap-3 rounded-lg border border-primary/15 bg-primary/5 px-4 py-3 shadow-xs">
<Archive :size="16" class="mt-0.5 shrink-0 text-primary" aria-hidden="true" />
<p class="text-xs leading-relaxed text-muted-foreground">
<span class="font-medium text-foreground">{{
t('settings.metadata.customFields.sheet.archivedTitle', { when: field.archivedAt ? formatRelativeTimeFromNow(field.archivedAt) : '' })
}}</span>
{{ ' ' }}{{ t('settings.metadata.customFields.sheet.archivedBody', { count: field.usageCount }) }}
</p>
</div>
<dl class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card text-sm">
<div class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.type') }}</dt>
<dd><CustomFieldTypeChip :type="field.type" size="sm" /></dd>
</div>
<div class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.libraries') }}</dt>
<dd class="min-w-0 overflow-hidden">
<CustomFieldLibrarySummary :library-ids="field.enabledLibraryIds" :libraries="libraries" :max="3" />
</dd>
</div>
<div class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.valuesKept') }}</dt>
<dd class="tabular-nums text-foreground">
{{ field.usageCount ? formatNumber(field.usageCount) : t('settings.metadata.customFields.noValues') }}
</dd>
</div>
<div class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.fileKey') }}</dt>
<dd class="min-w-0">
<code class="rounded-[5px] border border-foreground/9 bg-foreground/6 px-1.5 py-px font-mono text-xs break-all text-foreground"
>bookorbit:custom:{{ field.key }}</code
>
</dd>
</div>
<div class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.created') }}</dt>
<dd class="text-foreground">{{ formatDate(new Date(field.createdAt), { dateStyle: 'medium' }) }}</dd>
</div>
<div v-if="field.archivedAt" class="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3 px-3.5 py-2.5">
<dt class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.sheet.facts.archived') }}</dt>
<dd class="text-foreground">{{ formatDate(new Date(field.archivedAt), { dateStyle: 'medium' }) }}</dd>
</div>
</dl>
</template>
<template v-if="mode === 'create'" #footer="{ requestClose }">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<Button size="sm" type="button" :disabled="busy || !trimmed || !!labelError" @click="handleCreate">
<Loader2 v-if="busy" class="animate-spin" aria-hidden="true" />
{{ t('settings.metadata.customFields.sheet.create') }}
</Button>
<Button size="sm" variant="outline" type="button" :disabled="busy" @click="requestClose">{{ t('common.cancel') }}</Button>
</div>
<span class="ms-auto text-xs text-muted-foreground">{{ libraryCountLine }}</span>
</div>
</template>
<template v-else-if="mode === 'archived' && field" #footer="{ requestClose }">
<div v-if="deleting" class="space-y-3">
<p role="alert" class="text-xs">
<span class="text-foreground">{{ t('settings.metadata.customFields.sheet.deleteQuestion', { label: field.label }) }}</span>
<span class="ms-1 text-muted-foreground">{{
t('settings.metadata.customFields.sheet.deleteConsequence', { count: field.usageCount })
}}</span>
</p>
<label v-if="field.usageCount > 0" class="block">
<span class="sr-only">{{ t('settings.metadata.customFields.sheet.deleteConfirmLabel') }}</span>
<input
v-model="typedName"
class="input-field w-full"
:placeholder="t('settings.metadata.customFields.sheet.deleteConfirmPlaceholder', { label: field.label })"
@keydown.enter.prevent="handleDelete"
/>
</label>
<div class="flex items-center justify-end gap-2">
<Button size="sm" variant="destructive" type="button" :disabled="busy || !canDelete" @click="handleDelete">
<Loader2 v-if="busy" class="animate-spin" aria-hidden="true" />
<Trash2 v-else :size="14" aria-hidden="true" />
{{ t('settings.metadata.customFields.deleteForever') }}
</Button>
<Button size="sm" variant="outline" type="button" :disabled="busy" @click="cancelDelete">{{ t('common.cancel') }}</Button>
</div>
</div>
<div v-else class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<Button size="sm" type="button" :disabled="busy" @click="handleRestore">
<Loader2 v-if="busy" class="animate-spin" aria-hidden="true" />
<ArchiveRestore v-else :size="14" aria-hidden="true" />
{{ t('settings.metadata.customFields.restore') }}
</Button>
<Button size="sm" variant="outline" type="button" :disabled="busy" @click="requestClose">{{ t('common.close') }}</Button>
</div>
<div class="ms-auto">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon-sm" variant="outline" type="button" :disabled="busy" :aria-label="t('settings.editor.moreActions')">
<MoreHorizontal :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<DropdownMenuItem variant="destructive" @click="askDelete">
<Trash2 aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.deletePermanently') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
</SettingsEditorSheet>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Plus, Tag } from '@lucide/vue'
import type { CustomMetadataFieldType } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { CUSTOM_FIELD_TYPE_META } from '../lib/custom-field-types'
const emit = defineEmits<{ create: []; start: [starter: { label: string; type: CustomMetadataFieldType }] }>()
const { t } = useI18n()
const EXAMPLES: Array<{ key: string; type: CustomMetadataFieldType }> = [
{ key: 'shelfNumber', type: 'number' },
{ key: 'acquiredOn', type: 'date' },
{ key: 'signedCopy', type: 'boolean' },
{ key: 'loanedTo', type: 'text' },
]
function handleCreate() {
emit('create')
}
function startFrom(example: { key: string; type: CustomMetadataFieldType }) {
emit('start', { label: t(`settings.metadata.customFields.empty.${example.key}`), type: example.type })
}
</script>
<template>
<div class="rounded-lg border border-border bg-card px-6 py-12 shadow-xs">
<div class="mx-auto flex max-w-md flex-col items-center text-center">
<span class="grid size-11 place-items-center rounded-[10px] border border-primary/30 bg-primary/12 text-primary" aria-hidden="true">
<Tag :size="20" />
</span>
<h2 class="mt-4 text-[15px] font-semibold text-balance text-foreground">{{ t('settings.metadata.customFields.emptyTitle') }}</h2>
<p class="mt-1.5 text-sm text-muted-foreground">{{ t('settings.metadata.customFields.empty.body') }}</p>
<Button size="sm" type="button" class="mt-5" @click="handleCreate">
<Plus :size="14" aria-hidden="true" />
{{ t('settings.metadata.customFields.newFieldAction') }}
</Button>
<div class="mt-6 w-full border-t border-border pt-4">
<p class="text-[10.5px] leading-[13px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
{{ t('settings.metadata.customFields.empty.examples') }}
</p>
<div class="mt-3 flex flex-wrap justify-center gap-2">
<Button v-for="example in EXAMPLES" :key="example.key" variant="outline" size="sm" type="button" @click="startFrom(example)">
<component :is="CUSTOM_FIELD_TYPE_META[example.type].icon" :size="14" class="text-muted-foreground" aria-hidden="true" />
{{ t(`settings.metadata.customFields.empty.${example.key}`) }}
</Button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { EyeOff, TriangleAlert } from '@lucide/vue'
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
import AppIcon from '@/components/AppIcon.vue'
import { Button } from '@/components/ui/button'
import { formatList, formatNumber } from '@/i18n/formatters'
import { librariesHidingValues } from '../lib/custom-field-coverage'
const props = defineProps<{
modelValue: number[]
libraries: Library[]
/** The saved field, for per-library counts. Absent while creating. */
field?: CustomMetadataFieldDefinition | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: number[]] }>()
const { t } = useI18n()
const labelId = useId()
const allSelected = computed(() => props.libraries.length > 0 && props.libraries.every((library) => props.modelValue.includes(library.id)))
const hiding = computed(() => (props.field ? librariesHidingValues(props.field, props.libraries, props.modelValue) : []))
const hidingCount = computed(() => hiding.value.reduce((total, entry) => total + entry.count, 0))
const hidingNames = computed(() => formatList(hiding.value.map((entry) => entry.library.name)))
function isOn(libraryId: number): boolean {
return props.modelValue.includes(libraryId)
}
function toggle(libraryId: number) {
emit('update:modelValue', isOn(libraryId) ? props.modelValue.filter((id) => id !== libraryId) : [...props.modelValue, libraryId])
}
function toggleAll() {
emit('update:modelValue', allSelected.value ? [] : props.libraries.map((library) => library.id))
}
function valuesIn(libraryId: number): number {
return props.field?.usageByLibrary?.[libraryId] ?? 0
}
function books(library: Library): string {
return t('settings.metadata.customFields.sheet.books', { count: library.bookCount ?? 0 })
}
function filledOf(library: Library): string {
return t('settings.metadata.customFields.sheet.filledOfBooks', { filled: formatNumber(valuesIn(library.id)), total: library.bookCount ?? 0 })
}
</script>
<template>
<div class="space-y-2" role="group" :aria-labelledby="labelId">
<div class="flex items-center justify-between gap-3">
<span :id="labelId" class="settings-label">{{ t('settings.metadata.customFields.sheet.libraries') }}</span>
<span v-if="libraries.length > 0" class="flex items-center gap-3 text-xs whitespace-nowrap">
<span class="tabular-nums text-muted-foreground">{{
t('settings.metadata.customFields.countOf', { selected: modelValue.length, total: libraries.length })
}}</span>
<Button variant="link" size="sm" type="button" class="h-auto p-0 text-xs" @click="toggleAll">
{{ allSelected ? t('settings.metadata.customFields.clear') : t('settings.metadata.customFields.selectAll') }}
</Button>
</span>
</div>
<p v-if="libraries.length === 0" class="text-xs text-muted-foreground">{{ t('settings.metadata.customFields.noLibraries') }}</p>
<div v-else class="divide-y divide-foreground/7 overflow-hidden rounded-lg border border-border bg-card">
<label
v-for="library in libraries"
:key="library.id"
class="grid min-h-[38px] cursor-pointer grid-cols-[16px_16px_minmax(0,1fr)_auto] items-center gap-2.5 px-3 transition-colors hover:bg-foreground/3"
>
<input type="checkbox" class="size-4 rounded-xs border-input accent-primary" :checked="isOn(library.id)" @change="toggle(library.id)" />
<AppIcon :icon="library.icon" fallback="Library" :size="15" class="text-muted-foreground" aria-hidden="true" />
<span class="truncate text-sm text-foreground">{{ library.name }}</span>
<span v-if="field && !isOn(library.id) && valuesIn(library.id) > 0" class="inline-flex items-center gap-1 text-xs text-[var(--pill-warning)]">
<EyeOff :size="12" aria-hidden="true" />
{{ t('settings.metadata.customFields.sheet.hiddenCount', { count: formatNumber(valuesIn(library.id)) }) }}
</span>
<span v-else class="text-xs tabular-nums text-muted-foreground">
{{ field && isOn(library.id) && valuesIn(library.id) > 0 ? filledOf(library) : books(library) }}
</span>
</label>
</div>
<p v-if="hiding.length > 0" class="flex items-start gap-2 text-xs text-muted-foreground" role="status">
<EyeOff :size="13" class="mt-px shrink-0 text-[var(--pill-warning)]" aria-hidden="true" />
<span>{{ t('settings.metadata.customFields.sheet.leavingNote', { count: hidingCount, libraries: hidingNames }) }}</span>
</p>
<p v-if="libraries.length > 0 && modelValue.length === 0" class="flex items-start gap-2 text-xs text-[var(--pill-warning)]" role="status">
<TriangleAlert :size="13" class="mt-px shrink-0" aria-hidden="true" />
<span>{{ t('settings.metadata.customFields.sheet.noLibraryWarning') }}</span>
</p>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Library as LibraryIcon, TriangleAlert } from '@lucide/vue'
import type { Library } from '@bookorbit/types'
import AppIcon from '@/components/AppIcon.vue'
const props = withDefaults(defineProps<{ libraryIds: number[]; libraries: Library[]; max?: number }>(), { max: 2 })
const { t } = useI18n()
const enabled = computed(() => props.libraries.filter((library) => props.libraryIds.includes(library.id)))
const all = computed(() => props.libraries.length > 0 && enabled.value.length === props.libraries.length)
const shown = computed(() => enabled.value.slice(0, props.max))
const more = computed(() => enabled.value.length - shown.value.length)
const moreTitle = computed(() =>
enabled.value
.slice(props.max)
.map((library) => library.name)
.join(', '),
)
</script>
<template>
<span
v-if="enabled.length === 0"
class="inline-flex items-center gap-1.5 text-[var(--pill-warning)]"
:title="t('settings.metadata.customFields.onNoLibraryHint')"
>
<TriangleAlert :size="13" aria-hidden="true" />
<span>{{ t('settings.metadata.customFields.onNoLibrary') }}</span>
</span>
<span v-else-if="all" class="inline-flex items-center gap-1.5 text-foreground">
<LibraryIcon :size="13" class="text-muted-foreground" aria-hidden="true" />
<span>{{ t('settings.metadata.customFields.allLibraries') }}</span>
</span>
<span v-else class="inline-flex min-w-0 items-center gap-2.5">
<span v-for="library in shown" :key="library.id" class="inline-flex min-w-0 items-center gap-1.5 text-foreground">
<AppIcon :icon="library.icon" fallback="Library" :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate">{{ library.name }}</span>
</span>
<span v-if="more > 0" class="shrink-0 rounded-xs bg-foreground/8 px-1 text-xs tabular-nums text-muted-foreground" :title="moreTitle">
{{ t('settings.metadata.customFields.moreLibraries', { count: more }) }}
</span>
</span>
</template>
@@ -1,47 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CustomMetadataFieldType } from '@bookorbit/types'
const { t } = useI18n()
defineProps<{ type: CustomMetadataFieldType }>()
const textValue = ref('')
const numberValue = ref('')
const dateValue = ref('')
const booleanValue = ref(false)
function toggleBoolean() {
booleanValue.value = !booleanValue.value
}
</script>
<template>
<div class="space-y-1">
<span class="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">{{ t('settings.metadata.customFields.preview') }}</span>
<input
v-if="type === 'text' || type === 'url'"
v-model="textValue"
:type="type === 'url' ? 'url' : 'text'"
:placeholder="type === 'url' ? 'https://example.com' : t('settings.metadata.customFields.sampleValue')"
class="input-field w-full"
/>
<input v-else-if="type === 'number'" v-model="numberValue" type="number" placeholder="0" class="input-field w-full" />
<input v-else-if="type === 'date'" v-model="dateValue" type="date" class="input-field w-full" />
<button
v-else
type="button"
role="switch"
:aria-checked="booleanValue"
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors"
:class="booleanValue ? 'bg-primary' : 'bg-muted'"
@click="toggleBoolean"
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-background shadow transition-transform"
:class="booleanValue ? 'translate-x-5' : 'translate-x-0.5'"
/>
</button>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
import { formatNumber, formatRelativeTimeFromNow } from '@/i18n/formatters'
import { fieldCoverage } from '../lib/custom-field-coverage'
import CustomFieldCoverage from './CustomFieldCoverage.vue'
import CustomFieldLibrarySummary from './CustomFieldLibrarySummary.vue'
import CustomFieldTypeChip from './CustomFieldTypeChip.vue'
/** The roster below `md`. Reordering on a phone goes through the sheet's menu, not drag. */
const props = defineProps<{
fields: CustomMetadataFieldDefinition[]
view: 'active' | 'archived'
libraries: Library[]
}>()
const emit = defineEmits<{ open: [field: CustomMetadataFieldDefinition] }>()
const { t } = useI18n()
function openField(field: CustomMetadataFieldDefinition) {
emit('open', field)
}
function coverageOf(field: CustomMetadataFieldDefinition) {
return fieldCoverage(field, props.libraries)
}
</script>
<template>
<ul class="space-y-2 md:hidden">
<li v-for="field in fields" :key="field.id">
<button
type="button"
class="block w-full rounded-lg border border-border bg-card px-3.5 py-3 text-start shadow-xs transition-colors hover:bg-foreground/4"
:class="view === 'active' && field.enabledLibraryIds.length === 0 ? 'shadow-[inset_2px_0_0_var(--pill-warning)]' : ''"
@click="openField(field)"
>
<span class="flex items-start gap-2">
<span class="line-clamp-2 min-w-0 flex-1 font-semibold text-foreground">{{ field.label }}</span>
<CustomFieldTypeChip :type="field.type" size="sm" />
</span>
<span v-if="view === 'active'" class="mt-1.5 flex items-center justify-between gap-3 text-[13px]">
<span class="min-w-0 overflow-hidden"
><CustomFieldLibrarySummary :library-ids="field.enabledLibraryIds" :libraries="libraries" :max="1"
/></span>
<CustomFieldCoverage :coverage="coverageOf(field)" :bar="false" />
</span>
<span v-else class="mt-1.5 flex items-center justify-between gap-3 text-[13px] text-muted-foreground">
<span>{{
t('settings.metadata.customFields.archivedAt', { when: field.archivedAt ? formatRelativeTimeFromNow(field.archivedAt) : '' })
}}</span>
<span class="tabular-nums">{{ field.usageCount ? formatNumber(field.usageCount) : t('settings.metadata.customFields.noValues') }}</span>
</span>
</button>
</li>
</ul>
</template>
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Archive, ArchiveRestore, ChevronDown, ChevronUp, GripVertical, Info, Loader2, MoreVertical, Pencil, Trash2 } from '@lucide/vue'
import { VueDraggable } from 'vue-draggable-plus'
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { formatDateTime, formatNumber, formatRelativeTimeFromNow } from '@/i18n/formatters'
import { fieldCoverage } from '../lib/custom-field-coverage'
import CustomFieldCoverage from './CustomFieldCoverage.vue'
import CustomFieldLibrarySummary from './CustomFieldLibrarySummary.vue'
import CustomFieldTypeChip from './CustomFieldTypeChip.vue'
const props = defineProps<{
fields: CustomMetadataFieldDefinition[]
view: 'active' | 'archived'
libraries: Library[]
/** Off while searching, since a filtered list cannot say where a dropped row lands. */
canReorder: boolean
restoringId: number | null
}>()
const emit = defineEmits<{
open: [field: CustomMetadataFieldDefinition]
archive: [field: CustomMetadataFieldDefinition]
restore: [field: CustomMetadataFieldDefinition]
delete: [field: CustomMetadataFieldDefinition]
move: [field: CustomMetadataFieldDefinition, step: -1 | 1]
reorder: [orderedIds: number[]]
}>()
const { t } = useI18n()
const rows = ref<CustomMetadataFieldDefinition[]>([])
watch(
() => props.fields,
(value) => (rows.value = [...value]),
{ immediate: true },
)
function openField(field: CustomMetadataFieldDefinition) {
emit('open', field)
}
function archiveField(field: CustomMetadataFieldDefinition) {
emit('archive', field)
}
function restoreField(field: CustomMetadataFieldDefinition) {
emit('restore', field)
}
function deleteField(field: CustomMetadataFieldDefinition) {
emit('delete', field)
}
function moveUp(field: CustomMetadataFieldDefinition) {
emit('move', field, -1)
}
function moveDown(field: CustomMetadataFieldDefinition) {
emit('move', field, 1)
}
function handleDragEnd() {
emit(
'reorder',
rows.value.map((field) => field.id),
)
}
function coverageOf(field: CustomMetadataFieldDefinition) {
return fieldCoverage(field, props.libraries)
}
</script>
<template>
<div class="hidden rounded-lg border border-border bg-card shadow-xs md:block">
<table class="w-full table-fixed text-sm">
<colgroup v-if="view === 'active'">
<col class="w-10" />
<col />
<col class="w-32" />
<col class="w-[30%]" />
<col class="w-[10.5rem]" />
<col class="w-28" />
</colgroup>
<colgroup v-else>
<col />
<col class="w-32" />
<col class="w-[22%]" />
<col class="w-[11.25rem]" />
<col class="w-[9.75rem]" />
</colgroup>
<thead class="bg-foreground/3">
<tr v-if="view === 'active'">
<th scope="col">
<span class="sr-only">{{ t('settings.metadata.customFields.columns.order') }}</span>
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.field') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.type') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.libraries') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.filled') }}
</th>
<th scope="col">
<span class="sr-only">{{ t('settings.metadata.customFields.columns.actions') }}</span>
</th>
</tr>
<tr v-else>
<th scope="col" class="px-4 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.field') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.type') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.valuesKept') }}
</th>
<th scope="col" class="px-3 py-2.5 text-start text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.customFields.columns.archived') }}
</th>
<th scope="col">
<span class="sr-only">{{ t('settings.metadata.customFields.columns.actions') }}</span>
</th>
</tr>
</thead>
<VueDraggable
v-if="view === 'active'"
v-model="rows"
tag="tbody"
class="divide-y divide-border"
:animation="150"
handle=".custom-field-handle"
ghost-class="opacity-40"
:disabled="!canReorder"
@end="handleDragEnd"
>
<tr
v-for="(field, index) in rows"
:key="field.id"
class="group cursor-pointer bg-card transition-colors hover:bg-foreground/4 focus-within:bg-foreground/4"
:class="field.enabledLibraryIds.length === 0 ? 'shadow-[inset_2px_0_0_var(--pill-warning)]' : ''"
@click="openField(field)"
>
<td class="ps-2" @click.stop>
<button
type="button"
class="custom-field-handle grid h-7 w-5 cursor-grab place-items-center rounded-[5px] text-muted-foreground transition-colors hover:bg-(--shell-accent-wash) hover:text-foreground active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-30"
:disabled="!canReorder"
:title="canReorder ? t('settings.metadata.customFields.dragToReorder') : t('settings.metadata.customFields.clearSearchToReorder')"
:aria-label="t('settings.metadata.customFields.dragToReorderField')"
>
<GripVertical :size="15" aria-hidden="true" />
</button>
</td>
<td class="px-3 py-2.5">
<span class="block truncate font-semibold text-foreground">{{ field.label }}</span>
</td>
<td class="px-3 py-2.5"><CustomFieldTypeChip :type="field.type" /></td>
<td class="overflow-hidden px-3 py-2.5">
<CustomFieldLibrarySummary :library-ids="field.enabledLibraryIds" :libraries="libraries" />
</td>
<td class="px-3 py-2.5"><CustomFieldCoverage :coverage="coverageOf(field)" /></td>
<td class="px-3 py-2" @click.stop>
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
type="button"
class="opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
@click="openField(field)"
>
<Pencil :size="14" aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.edit') }}
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
type="button"
:aria-label="t('settings.metadata.customFields.rowActions.more', { label: field.label })"
>
<MoreVertical :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52">
<DropdownMenuItem @click="openField(field)">
<Pencil aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.editField') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canReorder || index === 0" @click="moveUp(field)">
<ChevronUp aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.moveUp') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canReorder || index === rows.length - 1" @click="moveDown(field)">
<ChevronDown aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.moveDown') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="archiveField(field)">
<Archive aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.archive') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
</VueDraggable>
<tbody v-else class="divide-y divide-border">
<tr
v-for="field in rows"
:key="field.id"
class="group cursor-pointer bg-card transition-colors hover:bg-foreground/4 focus-within:bg-foreground/4"
@click="openField(field)"
>
<td class="px-4 py-2.5">
<span class="block truncate font-semibold text-foreground">{{ field.label }}</span>
</td>
<td class="px-3 py-2.5"><CustomFieldTypeChip :type="field.type" /></td>
<td class="px-3 py-2.5 tabular-nums" :class="field.usageCount ? 'text-foreground' : 'text-muted-foreground'">
{{ field.usageCount ? formatNumber(field.usageCount) : t('settings.metadata.customFields.noValues') }}
</td>
<td class="px-3 py-2.5 text-muted-foreground" :title="field.archivedAt ? formatDateTime(new Date(field.archivedAt)) : undefined">
{{ field.archivedAt ? formatRelativeTimeFromNow(field.archivedAt) : '' }}
</td>
<td class="px-3 py-2" @click.stop>
<div class="flex items-center justify-end gap-1">
<Button variant="outline" size="sm" type="button" :disabled="restoringId === field.id" @click="restoreField(field)">
<Loader2 v-if="restoringId === field.id" :size="14" class="animate-spin" aria-hidden="true" />
<ArchiveRestore v-else :size="14" aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.restore') }}
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
type="button"
:aria-label="t('settings.metadata.customFields.rowActions.more', { label: field.label })"
>
<MoreVertical :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<DropdownMenuItem variant="destructive" @click="deleteField(field)">
<Trash2 aria-hidden="true" />
{{ t('settings.metadata.customFields.rowActions.deletePermanently') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
</tbody>
</table>
<p class="flex items-center gap-2 border-t border-border px-4 py-2.5 text-xs text-muted-foreground">
<GripVertical v-if="view === 'active'" :size="13" aria-hidden="true" />
<Info v-else :size="13" aria-hidden="true" />
{{ view === 'active' ? t('settings.metadata.customFields.orderHint') : t('settings.metadata.customFields.archivedHint') }}
</p>
</div>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CustomMetadataFieldType } from '@bookorbit/types'
import { CUSTOM_FIELD_TYPE_META, typeLabelKey, typeTintStyle } from '../lib/custom-field-types'
const props = withDefaults(defineProps<{ type: CustomMetadataFieldType; size?: 'md' | 'sm' }>(), { size: 'md' })
const { t } = useI18n()
const style = computed(() => typeTintStyle(props.type))
</script>
<template>
<span
class="inline-flex shrink-0 items-center rounded-full font-semibold whitespace-nowrap"
:class="size === 'sm' ? 'h-5 gap-1 ps-1.5 pe-2 text-[11px]' : 'h-[22px] gap-1.5 ps-2 pe-2.5 text-[11.5px]'"
:style="style"
>
<component :is="CUSTOM_FIELD_TYPE_META[type].icon" :size="size === 'sm' ? 11 : 12" :stroke-width="2.25" aria-hidden="true" />
{{ t(typeLabelKey(type)) }}
</span>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Check, Lock } from '@lucide/vue'
import type { CustomMetadataFieldType } from '@bookorbit/types'
import { CUSTOM_FIELD_TYPE_ORDER, typeHintKey, typeLabelKey } from '../lib/custom-field-types'
import CustomFieldTypeTile from './CustomFieldTypeTile.vue'
/** Native radios behind the cards, so arrow keys move between types as in any radio group. */
defineProps<{ modelValue: CustomMetadataFieldType; name: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: CustomMetadataFieldType] }>()
const { t } = useI18n()
function select(type: CustomMetadataFieldType) {
emit('update:modelValue', type)
}
</script>
<template>
<fieldset class="space-y-2">
<legend class="settings-label mb-2">{{ t('settings.metadata.customFields.type') }}</legend>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-5">
<label
v-for="type in CUSTOM_FIELD_TYPE_ORDER"
:key="type"
class="relative flex min-w-0 cursor-pointer flex-col gap-2 rounded-[10px] border bg-card px-3 py-2.5 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring"
:class="modelValue === type ? 'border-primary/70 bg-primary/7' : 'border-border hover:border-foreground/22'"
>
<input type="radio" class="sr-only" :name="name" :value="type" :checked="modelValue === type" @change="select(type)" />
<CustomFieldTypeTile :type="type" :size="28" />
<span class="block">
<span class="block text-[13px] font-semibold text-foreground">{{ t(typeLabelKey(type)) }}</span>
<span class="mt-0.5 block text-[11.5px] leading-[15px] text-muted-foreground">{{ t(typeHintKey(type)) }}</span>
</span>
<span
v-if="modelValue === type"
class="absolute end-2 top-2 grid size-4 place-items-center rounded-full bg-primary text-primary-foreground"
aria-hidden="true"
>
<Check :size="11" :stroke-width="3" />
</span>
</label>
</div>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock :size="12" aria-hidden="true" />
{{ t('settings.metadata.customFields.sheet.typeLockedHint') }}
</p>
</fieldset>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CustomMetadataFieldType } from '@bookorbit/types'
import { CUSTOM_FIELD_TYPE_META, typeTintStyle } from '../lib/custom-field-types'
const props = withDefaults(defineProps<{ type: CustomMetadataFieldType; size?: number }>(), { size: 28 })
const style = computed(() => ({
...typeTintStyle(props.type, 12, 28),
width: `${props.size}px`,
height: `${props.size}px`,
borderRadius: `${Math.round(props.size * 0.24)}px`,
}))
</script>
<template>
<span class="inline-grid shrink-0 place-items-center" :style="style" aria-hidden="true">
<component :is="CUSTOM_FIELD_TYPE_META[type].icon" :size="Math.round(size * 0.5)" :stroke-width="2.1" />
</span>
</template>
@@ -1,90 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Library } from '@bookorbit/types'
const { t } = useI18n()
const props = defineProps<{ modelValue: number[]; libraries: Library[] }>()
const emit = defineEmits<{ 'update:modelValue': [number[]] }>()
const selectedCount = computed(() => props.modelValue.length)
const allSelected = computed(() => props.libraries.length > 0 && props.modelValue.length === props.libraries.length)
function isSelected(libraryId: number): boolean {
return props.modelValue.includes(libraryId)
}
function toggleLibrary(libraryId: number) {
const next = isSelected(libraryId) ? props.modelValue.filter((id) => id !== libraryId) : [...props.modelValue, libraryId]
emit('update:modelValue', next)
}
function selectAll() {
emit(
'update:modelValue',
props.libraries.map((library) => library.id),
)
}
function clearAll() {
emit('update:modelValue', [])
}
function toggleAll() {
if (allSelected.value) clearAll()
else selectAll()
}
</script>
<template>
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ t('settings.metadata.customFields.enabledLibraries') }}</p>
<div v-if="libraries.length > 0" class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground tabular-nums">{{
t('settings.metadata.customFields.countOf', { selected: selectedCount, total: libraries.length })
}}</span>
<Button variant="link" size="sm" type="button" class="h-auto p-0" :disabled="allSelected" @click="selectAll">
{{ t('settings.metadata.customFields.selectAll') }}
</Button>
<span class="text-border">|</span>
<Button variant="link" size="sm" type="button" class="h-auto p-0" :disabled="selectedCount === 0" @click="clearAll">
{{ t('settings.metadata.customFields.clear') }}
</Button>
</div>
</div>
<p v-if="libraries.length === 0" class="text-xs text-muted-foreground italic">{{ t('settings.metadata.customFields.noLibraries') }}</p>
<div v-else class="flex flex-wrap gap-2">
<button
type="button"
class="px-2.5 py-1 rounded-md text-xs font-semibold border transition-colors"
:aria-pressed="allSelected"
:class="
allSelected
? 'bg-primary/15 text-primary border-primary/40'
: 'bg-background text-muted-foreground border-dashed border-border hover:bg-muted hover:text-foreground'
"
@click="toggleAll"
>
{{ t('settings.metadata.customFields.allLibraries') }}
</button>
<button
v-for="library in libraries"
:key="library.id"
type="button"
class="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors"
:aria-pressed="isSelected(library.id)"
:class="
isSelected(library.id)
? 'bg-primary/10 text-primary border-primary/30'
: 'bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground'
"
@click="toggleLibrary(library.id)"
>
{{ library.name }}
</button>
</div>
</div>
</template>
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import type { CustomMetadataFieldDefinition, CustomMetadataFieldType } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { api } from '@/lib/api'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
@@ -19,6 +20,7 @@ export function useCustomMetadataFields() {
const { refresh: refreshActiveFields } = useActiveCustomFields()
const fields = ref<CustomMetadataFieldDefinition[]>([])
const loading = ref(false)
const loaded = ref(false)
const creating = ref(false)
const savingId = ref<number | null>(null)
const archivingId = ref<number | null>(null)
@@ -34,14 +36,19 @@ export function useCustomMetadataFields() {
[...fields.value].filter((f) => f.archivedAt).sort((a, b) => (b.archivedAt ?? '').localeCompare(a.archivedAt ?? '')),
)
function replace(updated: CustomMetadataFieldDefinition) {
fields.value = fields.value.map((item) => (item.id === updated.id ? updated : item))
}
async function loadFields(): Promise<void> {
loading.value = true
try {
const res = await api('/api/v1/custom-metadata/fields?includeArchived=true')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
fields.value = (await res.json()) as CustomMetadataFieldDefinition[]
loaded.value = true
} catch {
toast.error('Failed to load custom metadata fields')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.loadFailed'))
} finally {
loading.value = false
}
@@ -53,91 +60,94 @@ export function useCustomMetadataFields() {
const res = await api('/api/v1/custom-metadata/fields', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, displayOrder: fields.value.length }),
// After the last field, not at the active count: archiving leaves gaps, and a tie sorts by label.
body: JSON.stringify({ ...payload, displayOrder: Math.max(-1, ...fields.value.map((field) => field.displayOrder)) + 1 }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const created = (await res.json()) as CustomMetadataFieldDefinition
fields.value.push(created)
toast.success('Custom metadata field created')
fields.value = [...fields.value, created]
toast.success(i18n.global.t('settings.metadata.customFields.toasts.created', { label: created.label }))
void refreshActiveFields()
return created
} catch {
toast.error('Failed to create custom metadata field')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.createFailed', { label: payload.label }))
return null
} finally {
creating.value = false
}
}
async function saveField(fieldId: number, payload: UpdateFieldPayload): Promise<CustomMetadataFieldDefinition | null> {
savingId.value = fieldId
async function saveField(field: CustomMetadataFieldDefinition, payload: UpdateFieldPayload): Promise<CustomMetadataFieldDefinition | null> {
savingId.value = field.id
try {
const res = await api(`/api/v1/custom-metadata/fields/${fieldId}`, {
const res = await api(`/api/v1/custom-metadata/fields/${field.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated = (await res.json()) as CustomMetadataFieldDefinition
fields.value = fields.value.map((item) => (item.id === updated.id ? updated : item))
toast.success('Custom metadata field saved')
replace(updated)
toast.success(i18n.global.t('settings.metadata.customFields.toasts.saved', { label: updated.label }))
void refreshActiveFields()
return updated
} catch {
toast.error('Failed to save custom metadata field')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.saveFailed', { label: field.label }))
return null
} finally {
savingId.value = null
}
}
async function archiveField(fieldId: number): Promise<boolean> {
archivingId.value = fieldId
async function restoreField(field: CustomMetadataFieldDefinition, { quiet = false } = {}): Promise<boolean> {
restoringId.value = field.id
try {
const res = await api(`/api/v1/custom-metadata/fields/${fieldId}`, { method: 'DELETE' })
const res = await api(`/api/v1/custom-metadata/fields/${field.id}/restore`, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const now = new Date().toISOString()
fields.value = fields.value.map((item) => (item.id === fieldId ? { ...item, archivedAt: now } : item))
toast.success('Custom metadata field archived')
fields.value = fields.value.map((item) => (item.id === field.id ? { ...item, archivedAt: null } : item))
if (!quiet) toast.success(i18n.global.t('settings.metadata.customFields.toasts.restored', { label: field.label }))
void refreshActiveFields()
return true
} catch {
toast.error('Failed to archive custom metadata field')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.restoreFailed', { label: field.label }))
return false
} finally {
restoringId.value = null
}
}
/** Archiving is reversible, so it runs at once and offers Undo instead of asking first. */
async function archiveField(field: CustomMetadataFieldDefinition): Promise<boolean> {
archivingId.value = field.id
try {
const res = await api(`/api/v1/custom-metadata/fields/${field.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const now = new Date().toISOString()
fields.value = fields.value.map((item) => (item.id === field.id ? { ...item, archivedAt: now } : item))
toast.success(i18n.global.t('settings.metadata.customFields.toasts.archived', { label: field.label }), {
action: { label: i18n.global.t('common.undo'), onClick: () => void restoreField(field, { quiet: true }) },
})
void refreshActiveFields()
return true
} catch {
toast.error(i18n.global.t('settings.metadata.customFields.toasts.archiveFailed', { label: field.label }))
return false
} finally {
archivingId.value = null
}
}
async function restoreField(fieldId: number): Promise<CustomMetadataFieldDefinition | null> {
restoringId.value = fieldId
async function deleteFieldPermanently(field: CustomMetadataFieldDefinition): Promise<boolean> {
deletingPermanentlyId.value = field.id
try {
const res = await api(`/api/v1/custom-metadata/fields/${fieldId}/restore`, { method: 'POST' })
const res = await api(`/api/v1/custom-metadata/fields/${field.id}/permanent`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const restored = fields.value.find((item) => item.id === fieldId)
fields.value = fields.value.map((item) => (item.id === fieldId ? { ...item, archivedAt: null } : item))
toast.success('Custom metadata field restored')
void refreshActiveFields()
return restored ? { ...restored, archivedAt: null } : null
} catch {
toast.error('Failed to restore custom metadata field')
return null
} finally {
restoringId.value = null
}
}
async function deleteFieldPermanently(fieldId: number): Promise<boolean> {
deletingPermanentlyId.value = fieldId
try {
const res = await api(`/api/v1/custom-metadata/fields/${fieldId}/permanent`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
fields.value = fields.value.filter((item) => item.id !== fieldId)
toast.success('Custom metadata field permanently deleted')
fields.value = fields.value.filter((item) => item.id !== field.id)
toast.success(i18n.global.t('settings.metadata.customFields.toasts.deleted', { label: field.label }))
void refreshActiveFields()
return true
} catch {
toast.error('Failed to permanently delete custom metadata field')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.deleteFailed', { label: field.label }))
return false
} finally {
deletingPermanentlyId.value = null
@@ -159,7 +169,7 @@ export function useCustomMetadataFields() {
void refreshActiveFields()
return true
} catch {
toast.error('Failed to reorder custom metadata fields')
toast.error(i18n.global.t('settings.metadata.customFields.toasts.reorderFailed'))
await loadFields()
return false
} finally {
@@ -167,9 +177,20 @@ export function useCustomMetadataFields() {
}
}
/** The keyboard and menu path to reordering, alongside drag. */
async function moveField(field: CustomMetadataFieldDefinition, step: -1 | 1): Promise<boolean> {
const ids = activeFields.value.map((item) => item.id)
const from = ids.indexOf(field.id)
const to = from + step
if (from < 0 || to < 0 || to >= ids.length) return false
;[ids[from], ids[to]] = [ids[to]!, ids[from]!]
return reorderFields(ids)
}
return {
fields,
loading,
loaded,
creating,
savingId,
archivingId,
@@ -185,5 +206,6 @@ export function useCustomMetadataFields() {
restoreField,
deleteFieldPermanently,
reorderFields,
moveField,
}
}
@@ -0,0 +1,90 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { fieldCoverage, librariesHidingValues, previewFieldKey, sameLibraries } from '../custom-field-coverage'
import { filterOperatorsFor } from '../custom-field-types'
const libraries = [
{ id: 1, bookCount: 454 },
{ id: 6, bookCount: 10 },
{ id: 3, bookCount: 9 },
]
describe('fieldCoverage', () => {
it('counts filled books only in the libraries the field is on, and the rest as hidden', () => {
const field = { enabledLibraryIds: [1, 6], usageByLibrary: { 1: 207, 6: 10, 3: 4 } }
expect(fieldCoverage(field, libraries)).toEqual({ books: 464, filled: 217, hidden: 4, all: false, none: false })
})
it('reports a field on no library as none, with every stored value hidden', () => {
expect(fieldCoverage({ enabledLibraryIds: [], usageByLibrary: { 1: 2 } }, libraries)).toEqual({
books: 0,
filled: 0,
hidden: 2,
all: false,
none: true,
})
})
it('marks a field on every library as all', () => {
expect(fieldCoverage({ enabledLibraryIds: [1, 6, 3], usageByLibrary: {} }, libraries).all).toBe(true)
})
it('treats a missing usage map and book count as zero', () => {
expect(fieldCoverage({ enabledLibraryIds: [7], usageByLibrary: undefined as never }, [{ id: 7 }])).toEqual({
books: 0,
filled: 0,
hidden: 0,
all: true,
none: false,
})
})
it('can measure a draft selection instead of the saved one', () => {
const field = { enabledLibraryIds: [1], usageByLibrary: { 1: 5, 6: 2 } }
expect(fieldCoverage(field, libraries, [1, 6])).toMatchObject({ filled: 7, hidden: 0, books: 464 })
})
})
describe('librariesHidingValues', () => {
it('lists libraries that would be switched off while holding values', () => {
const field = { enabledLibraryIds: [1, 6], usageByLibrary: { 1: 3, 6: 2 } }
expect(librariesHidingValues(field, libraries, [1])).toEqual([{ library: { id: 6, bookCount: 10 }, count: 2 }])
})
it('ignores libraries without values', () => {
expect(librariesHidingValues({ enabledLibraryIds: [1, 3], usageByLibrary: { 1: 3 } }, libraries, [1])).toEqual([])
})
})
describe('previewFieldKey', () => {
it('slugifies like the server', () => {
expect(previewFieldKey(' Original Title (1st ed.) ', [])).toBe('original_title_1st_ed')
expect(previewFieldKey('Café Notes', [])).toBe('cafe_notes')
})
it('falls back when nothing is left of the label', () => {
expect(previewFieldKey('日本', [])).toBe('custom_field')
})
it('adds the server’s numeric suffix when the key is taken', () => {
expect(previewFieldKey('Edition Note', ['edition_note'])).toBe('edition_note_1')
expect(previewFieldKey('Edition Note', ['edition_note', 'edition_note_1'])).toBe('edition_note_2')
})
})
describe('sameLibraries', () => {
it('ignores order', () => {
expect(sameLibraries([1, 6], [6, 1])).toBe(true)
expect(sameLibraries([1], [1, 6])).toBe(false)
})
})
describe('filterOperatorsFor', () => {
it('lists the presence operators last, as the filter builder does', () => {
expect(filterOperatorsFor('boolean')).toEqual(['isTrue', 'isFalse', 'isEmpty', 'isNotEmpty'])
expect(filterOperatorsFor('number').slice(-2)).toEqual(['isEmpty', 'isNotEmpty'])
})
})
@@ -0,0 +1,71 @@
import type { CustomMetadataFieldDefinition, Library } from '@bookorbit/types'
type CoverageLibrary = Pick<Library, 'id' | 'bookCount'>
type CoverageField = Pick<CustomMetadataFieldDefinition, 'enabledLibraryIds' | 'usageByLibrary'>
export interface FieldCoverage {
/** Books in the libraries the field is on. */
books: number
/** Of those, books with a value. */
filled: number
/** Values kept in libraries the field is off for. They return when the library is turned back on. */
hidden: number
all: boolean
none: boolean
}
function usageIn(field: CoverageField, libraryId: number): number {
return field.usageByLibrary?.[libraryId] ?? 0
}
export function fieldCoverage(field: CoverageField, libraries: CoverageLibrary[], libraryIds: number[] = field.enabledLibraryIds): FieldCoverage {
const on = new Set(libraryIds)
let books = 0
let filled = 0
for (const library of libraries) {
if (!on.has(library.id)) continue
books += library.bookCount ?? 0
filled += usageIn(field, library.id)
}
let hidden = 0
for (const [id, count] of Object.entries(field.usageByLibrary ?? {})) {
if (!on.has(Number(id))) hidden += count
}
return { books, filled, hidden, all: libraries.length > 0 && libraries.every((library) => on.has(library.id)), none: on.size === 0 }
}
/** Libraries that hold values but would be off if `libraryIds` were saved, with how many values each keeps. */
export function librariesHidingValues<T extends CoverageLibrary>(
field: CoverageField,
libraries: T[],
libraryIds: number[],
): Array<{ library: T; count: number }> {
const on = new Set(libraryIds)
return libraries
.filter((library) => !on.has(library.id) && usageIn(field, library.id) > 0)
.map((library) => ({ library, count: usageIn(field, library.id) }))
}
/** The server's key for a new label (`slugifyLabel` plus its collision suffix), so the sheet can show it before saving. */
export function previewFieldKey(label: string, takenKeys: Iterable<string>): string {
const base =
label
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100) || 'custom_field'
const taken = new Set(takenKeys)
if (!taken.has(base)) return base
for (let suffix = 1; suffix < 1000; suffix++) {
const key = `${base.slice(0, Math.max(1, 97 - String(suffix).length))}_${suffix}`
if (!taken.has(key)) return key
}
return base
}
export function sameLibraries(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false
const set = new Set(a)
return b.every((id) => set.has(id))
}
@@ -1,75 +1,51 @@
import type { Component } from 'vue'
import { Calendar, Hash, Link, ToggleLeft, Type } from '@lucide/vue'
import type { CustomMetadataFieldType } from '@bookorbit/types'
import { formatDate, formatRelativeTime as formatLocaleRelativeTime } from '@/i18n/formatters'
import { Calendar, Hash, Link, SquareCheck, Type } from '@lucide/vue'
import type { CustomMetadataFieldType, RuleOperator } from '@bookorbit/types'
import { CUSTOM_FIELD_TYPE_OPERATORS, CUSTOM_METADATA_FIELD_TYPES } from '@bookorbit/types'
type CustomFieldTypeMeta = {
label: string
icon: Component
example: string
badgeClass: string
/** A tuned pill token, so the tint holds contrast in both themes. */
color: string
}
/** Yes / No draws a checkbox on Edit Metadata, so its icon is one too. */
export const CUSTOM_FIELD_TYPE_META: Record<CustomMetadataFieldType, CustomFieldTypeMeta> = {
text: {
label: 'Text',
icon: Type,
example: 'e.g. Original Title',
badgeClass: 'bg-sky-500/10 text-sky-600 dark:text-sky-400',
},
url: {
label: 'URL',
icon: Link,
example: 'e.g. https://example.com',
badgeClass: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
},
number: {
label: 'Number',
icon: Hash,
example: 'e.g. 42',
badgeClass: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
},
date: {
label: 'Date',
icon: Calendar,
example: 'e.g. 2024-01-31',
badgeClass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
},
boolean: {
label: 'Boolean',
icon: ToggleLeft,
example: 'Yes / No toggle',
badgeClass: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
},
text: { icon: Type, color: 'var(--pill-source-blue)' },
url: { icon: Link, color: 'var(--pill-source-indigo)' },
number: { icon: Hash, color: 'var(--pill-source-orange)' },
date: { icon: Calendar, color: 'var(--pill-source-teal)' },
boolean: { icon: SquareCheck, color: 'var(--pill-source-pink)' },
}
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: 'second' },
{ amount: 60, unit: 'minute' },
{ amount: 24, unit: 'hour' },
{ amount: 7, unit: 'day' },
{ amount: 4.34524, unit: 'week' },
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
]
export const CUSTOM_FIELD_TYPE_ORDER: readonly CustomMetadataFieldType[] = CUSTOM_METADATA_FIELD_TYPES
export function formatRelativeTime(isoString: string): string {
let duration = (new Date(isoString).getTime() - Date.now()) / 1000
for (const division of RELATIVE_TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatLocaleRelativeTime(Math.round(duration), division.unit)
}
duration /= division.amount
const PRESENCE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
/** The filter operators a field of this type offers, with the presence pair last as the filter builder shows them. */
export function filterOperatorsFor(type: CustomMetadataFieldType): RuleOperator[] {
const operators = CUSTOM_FIELD_TYPE_OPERATORS[type]
return [...operators.filter((operator) => !PRESENCE_OPERATORS.includes(operator)), ...PRESENCE_OPERATORS]
}
export function typeLabelKey(type: CustomMetadataFieldType): string {
return `settings.metadata.customFields.types.${type}.label`
}
export function typeHintKey(type: CustomMetadataFieldType): string {
return `settings.metadata.customFields.types.${type}.hint`
}
export function typeExampleKey(type: CustomMetadataFieldType): string {
return `settings.metadata.customFields.types.${type}.example`
}
/** Tint for a type chip or tile, on the DuplicateConfidenceChip recipe. */
export function typeTintStyle(type: CustomMetadataFieldType, fill = 13, ring = 32): Record<string, string> {
const color = CUSTOM_FIELD_TYPE_META[type].color
return {
color,
backgroundColor: `color-mix(in oklch, ${color} ${fill}%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklch, ${color} ${ring}%, transparent)`,
}
return formatLocaleRelativeTime(Math.round(duration), 'year')
}
export function formatAbsoluteDateTime(isoString: string): string {
return formatDate(new Date(isoString), {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
+139 -1
View File
@@ -2188,7 +2188,145 @@
"selectAll": "Select all",
"clear": "Clear",
"noLibraries": "No libraries available yet.",
"allLibraries": "All libraries"
"allLibraries": "All libraries",
"pageFacts": "{fields, plural, one {# field} other {# fields}} · {values, plural, one {# value stored} other {# values stored}}",
"newFieldAction": "New field",
"views": {
"label": "Show active or archived fields",
"active": "Active",
"archived": "Archived"
},
"columns": {
"order": "Order",
"field": "Field",
"type": "Type",
"libraries": "Libraries",
"filled": "Filled",
"valuesKept": "Values kept",
"archived": "Archived",
"actions": "Actions"
},
"orderHint": "Drag to set the order fields appear in on Edit Metadata, in table columns and in exports.",
"archivedHint": "Archived fields are hidden from books, filters and exports. Their values stay stored until the field is deleted.",
"coverage": "{filled} of {total}",
"coverageAria": "{filled} of {total} books have a value",
"onNoLibrary": "No libraries",
"onNoLibraryHint": "On no library, so this field appears nowhere",
"moreLibraries": "+{count}",
"noValues": "None",
"rowActions": {
"edit": "Edit",
"editField": "Edit field",
"moveUp": "Move up",
"moveDown": "Move down",
"archive": "Archive",
"restore": "Restore",
"deletePermanently": "Delete permanently…",
"more": "More actions for {label}"
},
"toasts": {
"loadFailed": "Couldn't load custom fields",
"created": "{label} created",
"createFailed": "Couldn't create {label}",
"saved": "{label} saved",
"saveFailed": "Couldn't save {label}",
"archived": "{label} archived",
"archiveFailed": "Couldn't archive {label}",
"restored": "{label} restored",
"restoreFailed": "Couldn't restore {label}",
"deleted": "{label} deleted",
"deleteFailed": "Couldn't delete {label}",
"reorderFailed": "Couldn't save the new order"
},
"empty": {
"body": "Track what BookOrbit has no field for, like where a book sits on your shelf or who has borrowed it. Fields appear on Edit Metadata, in table columns and in filters.",
"examples": "Start from an example",
"shelfNumber": "Shelf Number",
"acquiredOn": "Acquired On",
"signedCopy": "Signed Copy",
"loanedTo": "Loaned To"
},
"types": {
"text": {
"label": "Text",
"hint": "Any words",
"example": "First printing, signed bookplate"
},
"url": {
"label": "Link",
"hint": "Opens in a new tab",
"example": "https://example.com/book"
},
"number": {
"label": "Number",
"hint": "Sorts by value",
"example": "42"
},
"date": {
"label": "Date",
"hint": "A calendar day",
"example": "2024-03-09"
},
"boolean": {
"label": "Yes / No",
"hint": "A checkbox",
"example": "Yes"
}
},
"sheet": {
"newTitle": "New field",
"editDescription": "Edit a custom field's label and libraries, and see where it appears.",
"newDescription": "Name a new custom field, pick its type and choose its libraries.",
"archivedDescription": "An archived custom field. Restore it, or delete it with its values.",
"statusFilled": "{filled} of {total, plural, one {# book} other {# books}} filled",
"statusNone": "Not on any library",
"statusHidden": "{count} hidden",
"statusCreated": "created {date}",
"labelHint": "Shown on Edit Metadata, in table columns and in filters.",
"renameHint": "Renaming keeps every stored value and the file key {key}.",
"keyHint": "Saved in files and exports as {key}, which stays the same if you rename the field.",
"typeLocked": "Set when created",
"typeLockedHint": "The type cannot change later, so stored values always match it.",
"libraries": "Libraries",
"books": "{count, plural, one {# book} other {# books}}",
"filledOfBooks": "{filled} of {total, plural, one {# book} other {# books}}",
"hiddenCount": "{count} hidden",
"leavingNote": "{count, plural, one {# value in {libraries} stays stored but hidden while the field is off there.} other {# values in {libraries} stay stored but hidden while the field is off there.}} Turning it back on brings them back.",
"noLibraryWarning": "With no libraries this field appears nowhere: not on Edit Metadata, in filters or in exports.",
"appears": {
"title": "Where it appears",
"editMetadata": "Edit Metadata",
"details": "Details tab",
"filters": "Filters",
"table": "Table and sort",
"tableBody": "Available as a table column and a sort option",
"files": "Files",
"filesWritten": "Written into EPUB and FB2 files in {libraries}, where writing metadata to files is on.",
"filesNotWritten": "Not written into files: none of these libraries write metadata to EPUB or FB2 files.",
"exports": "Exports"
},
"archiveAction": "Archive field",
"archiveConfirm": "Archive {label}?",
"archiveConsequence": "{count, plural, =0 {It leaves Edit Metadata, filters and exports. Restore it from Archived at any time.} one {It leaves Edit Metadata, filters and exports. # value stays stored until the field is deleted.} other {It leaves Edit Metadata, filters and exports. # values stay stored until the field is deleted.}}",
"create": "Create field",
"onLibraries": "On {count} of {total} libraries",
"onAllLibraries": "On all {total} libraries",
"archivedBadge": "Archived",
"archivedTitle": "Archived {when}.",
"archivedBody": "{count, plural, =0 {This field is hidden from books, filters and exports. It holds no values.} one {This field is hidden from books, filters and exports. # stored value is kept and comes back on restore.} other {This field is hidden from books, filters and exports. # stored values are kept and come back on restore.}}",
"facts": {
"type": "Type",
"libraries": "Libraries",
"valuesKept": "Values kept",
"fileKey": "File key",
"created": "Created",
"archived": "Archived"
},
"deleteQuestion": "Delete {label} for good?",
"deleteConsequence": "{count, plural, =0 {It holds no values. This cannot be undone.} one {Its # stored value is removed from every book. Files already written keep their copy. This cannot be undone.} other {Its # stored values are removed from every book. Files already written keep their copy. This cannot be undone.}}",
"deleteConfirmLabel": "Type the field name to confirm",
"deleteConfirmPlaceholder": "Type {label} to confirm"
}
},
"tabs": {
"providers": "Providers",
+2 -1
View File
@@ -373,7 +373,8 @@ export const routes: RouteRecordRaw[] = [
path: 'metadata/custom-fields',
name: 'settings-metadata-custom-fields',
component: () => import('@/features/settings/metadata-preferences/CustomMetadataSettings.vue'),
meta: { maxWidth: 'max-w-4xl', title: () => t('titles.metadata.custom-fields') },
// The roster's five columns need the width Users gets; 4xl left half of a 1080p pane empty.
meta: { maxWidth: 'max-w-6xl', title: () => t('titles.metadata.custom-fields') },
},
{
path: 'metadata/score',