feat(settings): rebuild reader fonts on the shared font ledger

Reader › Fonts now uses the Server Fonts ledger, family sheet and upload tray for a reader's own
fonts, and lists the server's fonts with a show/hide switch and a preview sheet. The shared pieces
move to features/settings/fonts and the ledger columns narrow to give the specimen room.
Server: additive GET /fonts/usage and PATCH /fonts/families, upload error codes, and a 409 instead
of a 500 when a reader uploads a second file for the same face.
This commit is contained in:
neon
2026-10-16 18:54:38 +00:00
parent 096bcea5cf
commit c378354dac
45 changed files with 1699 additions and 1587 deletions
@@ -1,461 +0,0 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Pencil, Trash2, Type, Upload, X, Check, ChevronDown, ChevronRight } from '@lucide/vue'
import type { UserFont } from '@bookorbit/types'
import { isVariableFont } from '@bookorbit/types'
import type { FontScopeStore } from '@/features/reader/epub/composables/useCustomFonts'
import { FONT_WEIGHT_LABEL_KEYS, familyVariants } from '@/features/reader/shared/lib/font-variants'
import { formatBytes } from '@/lib/formatting'
const props = withDefaults(
defineProps<{
/** The collection this panel manages - per-user fonts or the server-wide set. */
store: FontScopeStore
/** Blocks every mutating control, e.g. for demo accounts. */
readonly?: boolean
}>(),
{
readonly: false,
},
)
const { t } = useI18n()
const WEIGHT_LABELS = computed<Record<number, string>>(() =>
Object.fromEntries(Object.entries(FONT_WEIGHT_LABEL_KEYS).map(([weight, key]) => [weight, t(key)])),
)
/**
* How one uploaded file describes itself. A variable file spans a range rather than
* sitting at one weight, so it reports the span and how many styles it carries.
*/
function variantLabel(variant: UserFont): string {
if (isVariableFont(variant)) {
return t('settings.reader.fonts.variableRange', { min: variant.weightMin, max: variant.weightMax })
}
const weight = WEIGHT_LABELS.value[variant.weight] ?? String(variant.weight)
return variant.style === 'italic' ? t('settings.reader.fonts.weightItalicOf', { weight }) : weight
}
function variantStyleCount(variant: UserFont): number {
return isVariableFont(variant) ? familyVariants([variant]).length : 0
}
const isServerScope = computed(() => props.store.scope === 'server')
const listLabel = computed(() => (isServerScope.value ? t('settings.admin.serverFonts.listLabel') : t('settings.reader.fonts.yourFonts')))
const emptyTitle = computed(() => (isServerScope.value ? t('settings.admin.serverFonts.noFontsYet') : t('settings.reader.fonts.noFontsYet')))
const emptyHint = computed(() => (isServerScope.value ? t('settings.admin.serverFonts.noFontsHint') : t('settings.reader.fonts.noFontsHint')))
const fonts = computed(() => props.store.fonts.value)
const families = computed(() => props.store.families.value)
const isDragging = ref(false)
const uploadSurfaceClass = computed(() => {
if (props.readonly) return 'cursor-not-allowed border-border bg-card opacity-50 shadow-xs'
if (isDragging.value) return 'cursor-pointer border-primary bg-primary/5 shadow-xs'
return 'cursor-pointer border-border bg-card shadow-xs hover:border-muted-foreground/40 hover:bg-muted/30'
})
const uploadErrors = ref<string[]>([])
const expandedFamilies = ref<Set<string>>(new Set())
const editingFamilyName = ref<string | null>(null)
const editingFamilyValue = ref('')
const editingVariantId = ref<number | null>(null)
const editingVariantWeight = ref<number>(400)
const editingVariantStyle = ref<'normal' | 'italic'>('normal')
const previewStyleEl = ref<HTMLStyleElement | null>(null)
function injectPreviewStyles(css: string) {
if (previewStyleEl.value) {
previewStyleEl.value.textContent = css
return
}
if (!css) return
const el = document.createElement('style')
el.setAttribute('data-font-settings-preview', props.store.scope)
el.textContent = css
document.head.appendChild(el)
previewStyleEl.value = el
}
function removePreviewStyles() {
previewStyleEl.value?.remove()
previewStyleEl.value = null
}
watch(
fonts,
() => {
injectPreviewStyles(props.store.generateFontFaceCSS())
},
{ immediate: true },
)
onMounted(() => props.store.fetchFonts())
onUnmounted(removePreviewStyles)
function toggleFamily(name: string) {
if (expandedFamilies.value.has(name)) {
expandedFamilies.value.delete(name)
} else {
expandedFamilies.value.add(name)
}
}
function startEditFamily(familyName: string) {
cancelEdits()
editingFamilyName.value = familyName
editingFamilyValue.value = familyName
}
function cancelEdits() {
editingFamilyName.value = null
editingVariantId.value = null
}
async function saveEditFamily() {
const oldName = editingFamilyName.value
const newName = editingFamilyValue.value.trim()
editingFamilyName.value = null
if (!oldName || !newName || newName === oldName) return
const variantsToUpdate = fonts.value.filter((f) => f.familyName === oldName)
const results = await Promise.allSettled(variantsToUpdate.map((f) => props.store.updateFont(f.id, { familyName: newName })))
const failed = results.some((r) => r.status === 'rejected' || r.value === null)
if (failed) {
toast.error(t('settings.reader.fonts.renameFamilyFailed'))
await props.store.fetchFonts()
} else {
toast.success(t('settings.reader.fonts.renamedTo', { name: newName }))
}
}
function handleFamilyEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') saveEditFamily()
else if (event.key === 'Escape') cancelEdits()
}
function startEditVariant(font: UserFont) {
cancelEdits()
editingVariantId.value = font.id
editingVariantWeight.value = font.weight
editingVariantStyle.value = font.style
}
async function saveEditVariant() {
const id = editingVariantId.value
editingVariantId.value = null
if (!id) return
const result = await props.store.updateFont(id, {
weight: editingVariantWeight.value,
style: editingVariantStyle.value,
})
if (!result) {
toast.error(t('settings.reader.fonts.updateVariantFailed'))
await props.store.fetchFonts()
}
}
function handleVariantEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') saveEditVariant()
else if (event.key === 'Escape') cancelEdits()
}
async function deleteVariant(font: UserFont) {
const snapshot = [...fonts.value]
props.store.setFonts(fonts.value.filter((f) => f.id !== font.id))
try {
const success = await props.store.deleteFont(font.id)
if (!success) {
props.store.setFonts(snapshot)
toast.error(t('settings.reader.fonts.deleteFontFailed'))
}
} catch {
props.store.setFonts(snapshot)
toast.error(t('settings.reader.fonts.deleteFontFailed'))
}
}
async function deleteFamily(familyName: string) {
const variants = families.value.find((f) => f.name === familyName)?.variants ?? []
if (variants.length === 0) return
props.store.setFonts(fonts.value.filter((f) => f.familyName !== familyName))
const results = await Promise.allSettled(variants.map((v) => props.store.deleteFont(v.id)))
const failed = results.some((r) => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value))
if (failed) {
await props.store.fetchFonts()
toast.error(t('settings.reader.fonts.deleteFamilyFailed'))
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragging.value = true
}
function handleDragLeave() {
isDragging.value = false
}
function handleDrop(event: DragEvent) {
event.preventDefault()
isDragging.value = false
const files = Array.from(event.dataTransfer?.files ?? [])
processFiles(files)
}
function handleFileInput(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
processFiles(files)
}
async function processFiles(files: File[]) {
if (props.readonly) {
toast.error(t('settings.reader.fonts.demoCannotManage'))
return
}
uploadErrors.value = []
for (const file of files) {
try {
await props.store.uploadFont(file)
toast.success(t('settings.reader.fonts.fileAdded', { name: file.name }))
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('settings.reader.fonts.uploadFailed')
uploadErrors.value = [...uploadErrors.value, `${file.name}: ${message}`]
}
}
}
function dismissError(index: number) {
uploadErrors.value = uploadErrors.value.filter((_, i) => i !== index)
}
</script>
<template>
<div>
<!-- Upload zone -->
<div class="mb-6">
<p class="settings-group-label">{{ t('settings.reader.fonts.uploadFonts') }}</p>
<label
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors"
:class="uploadSurfaceClass"
data-testid="font-upload-surface"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full transition-colors"
:class="isDragging && !readonly ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Upload :size="20" />
</div>
<div>
<p class="text-sm font-medium text-foreground">
<span v-if="readonly">{{ t('settings.reader.fonts.notAvailableDemo') }}</span>
<span v-else-if="store.uploading.value">{{ t('settings.reader.fonts.uploading') }}</span>
<span v-else-if="isDragging">{{ t('settings.reader.fonts.dropFilesHere') }}</span>
<span v-else
>{{ t('settings.reader.fonts.dragFontsPrefix') }}
<span class="text-primary underline underline-offset-2">{{ t('settings.reader.fonts.browse') }}</span></span
>
</p>
<p class="mt-1 text-xs text-muted-foreground">{{ t('settings.reader.fonts.fileTypesHint') }}</p>
</div>
<input
type="file"
accept=".ttf,.otf,.woff,.woff2"
multiple
class="absolute inset-0 opacity-0"
:class="readonly ? 'cursor-not-allowed' : 'cursor-pointer'"
:disabled="store.uploading.value || readonly"
@change="handleFileInput"
/>
</label>
<div v-if="uploadErrors.length > 0" class="mt-2 space-y-1">
<div
v-for="(err, i) in uploadErrors"
:key="i"
class="flex items-start justify-between gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
<span>{{ err }}</span>
<Button variant="destructive-ghost" size="icon-sm" class="mt-0.5 shrink-0" @click="dismissError(i)">
<X :size="12" />
</Button>
</div>
</div>
</div>
<!-- Font list -->
<div>
<div class="flex items-center justify-between mb-2">
<p class="settings-group-label mb-0">{{ listLabel }}</p>
<span class="text-xs text-muted-foreground">{{ t('settings.reader.fonts.fontsUsed', { count: fonts.length, max: store.maxFonts }) }}</span>
</div>
<div
v-if="store.loading.value"
class="flex items-center justify-center rounded-lg border border-border bg-card py-10 text-muted-foreground shadow-xs"
data-testid="font-loading-surface"
>
<span class="text-sm">{{ t('common.loading') }}</span>
</div>
<div
v-else-if="families.length === 0"
class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card px-6 py-12 text-center shadow-xs"
data-testid="font-empty-surface"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Type :size="22" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ emptyTitle }}</p>
<p class="text-xs text-muted-foreground mt-1">{{ emptyHint }}</p>
</div>
</div>
<div v-else class="settings-card" data-testid="font-list-surface">
<div v-for="family in families" :key="family.name" class="bg-card">
<!-- Family header row -->
<div class="flex items-center gap-3 px-4 py-3">
<button class="flex items-center gap-2 flex-1 min-w-0 text-left" @click="toggleFamily(family.name)">
<component :is="expandedFamilies.has(family.name) ? ChevronDown : ChevronRight" :size="15" class="shrink-0 text-muted-foreground" />
<span
v-if="editingFamilyName !== family.name"
class="text-sm font-medium truncate"
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif` }"
>
{{ family.name }}
</span>
</button>
<input
v-if="editingFamilyName === family.name"
v-model="editingFamilyValue"
class="flex-1 min-w-0 rounded-md border border-primary bg-background px-2 py-0.5 text-sm font-medium focus:outline-none"
autofocus
@blur="saveEditFamily"
@keydown="handleFamilyEditKeydown"
@click.stop
/>
<span class="shrink-0 text-xs text-muted-foreground">{{ t('settings.reader.fonts.fileCount', { count: family.variants.length }) }}</span>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
v-if="editingFamilyName !== family.name && !readonly"
:title="t('settings.reader.fonts.renameFamily')"
@click.stop="startEditFamily(family.name)"
>
<Pencil :size="13" />
</Button>
<Button
variant="secondary"
size="icon-sm"
v-if="editingFamilyName === family.name"
:title="t('common.save')"
@click.stop="saveEditFamily"
>
<Check :size="13" />
</Button>
<Button
variant="destructive-ghost"
size="icon-sm"
v-if="!readonly"
:title="t('settings.reader.fonts.deleteFamily')"
@click.stop="deleteFamily(family.name)"
>
<Trash2 :size="13" />
</Button>
</div>
</div>
<!-- Preview line -->
<div
class="px-4 pb-3 -mt-1 text-sm text-muted-foreground truncate"
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif`, fontSize: '16px' }"
>
{{ t('settings.reader.fonts.pangram') }}
</div>
<!-- Variant rows (expanded) -->
<div v-if="expandedFamilies.has(family.name)" class="border-t border-border/60 divide-y divide-border/60 bg-muted/20">
<div v-for="variant in family.variants" :key="variant.id" class="flex items-center gap-3 px-5 py-2.5">
<div class="flex-1 min-w-0">
<template v-if="editingVariantId === variant.id">
<div class="flex items-center gap-2 flex-wrap">
<select
v-model="editingVariantWeight"
class="h-7 rounded-xs border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
@keydown="handleVariantEditKeydown"
>
<option v-for="(label, w) in WEIGHT_LABELS" :key="w" :value="Number(w)">{{ label }} ({{ w }})</option>
</select>
<select
v-model="editingVariantStyle"
class="h-7 rounded-xs border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
@keydown="handleVariantEditKeydown"
>
<option value="normal">{{ t('settings.reader.fonts.styleNormal') }}</option>
<option value="italic">{{ t('settings.reader.fonts.styleItalic') }}</option>
</select>
</div>
</template>
<template v-else>
<span class="text-xs text-foreground">{{ variantLabel(variant) }}</span>
<span v-if="variantStyleCount(variant) > 0" class="ml-2 text-xs text-muted-foreground">
{{ t('settings.reader.fonts.variableStyles', { count: variantStyleCount(variant) }) }}
</span>
<span class="ml-2 text-xs text-muted-foreground uppercase">{{ variant.format }}</span>
<span class="ml-2 text-xs text-muted-foreground">{{ formatBytes(variant.fileSize) }}</span>
</template>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
v-if="editingVariantId !== variant.id && !readonly"
:title="t('settings.reader.fonts.editVariant')"
@click="startEditVariant(variant)"
>
<Pencil :size="12" />
</Button>
<Button variant="secondary" size="icon-sm" v-if="editingVariantId === variant.id" :title="t('common.save')" @click="saveEditVariant">
<Check :size="12" />
</Button>
<Button variant="ghost" size="icon-sm" v-if="editingVariantId === variant.id" :title="t('common.cancel')" @click="cancelEdits">
<X :size="12" />
</Button>
<Button
variant="destructive-ghost"
size="icon-sm"
v-if="editingVariantId !== variant.id && !readonly"
:title="t('settings.reader.fonts.deleteVariant')"
@click="deleteVariant(variant)"
>
<Trash2 :size="12" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+350 -13
View File
@@ -1,24 +1,361 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Type, Upload, Users } from '@lucide/vue'
import { MAX_FONTS_PER_USER, MAX_SERVER_FONTS } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useCustomFonts, type FontFamily } from '@/features/reader/epub/composables/useCustomFonts'
import { usePermissions } from '@/features/auth/composables/usePermissions' import { usePermissions } from '@/features/auth/composables/usePermissions'
import FontLibraryPanel from './FontLibraryPanel.vue' import { formatNumber } from '@/i18n/formatters'
import ServerFontVisibility from './ServerFontVisibility.vue' import FontDropOverlay from './fonts/components/FontDropOverlay.vue'
import FontFamilyMenu from './fonts/components/FontFamilyMenu.vue'
import FontFamilyRow from './fonts/components/FontFamilyRow.vue'
import FontFamilySheet from './fonts/components/FontFamilySheet.vue'
import FontLedgerHeader from './fonts/components/FontLedgerHeader.vue'
import FontLedgerToolbar from './fonts/components/FontLedgerToolbar.vue'
import FontUploadTray from './fonts/components/FontUploadTray.vue'
import FontUseFacts from './fonts/components/FontUseFacts.vue'
import { useFailedFontFaces } from './fonts/composables/useFailedFontFaces'
import { useFamilySheet } from './fonts/composables/useFamilySheet'
import { useFontDropTarget } from './fonts/composables/useFontDropTarget'
import { useFontLibrary } from './fonts/composables/useFontLibrary'
import { useInjectedFontFaces } from './fonts/composables/useInjectedFontFaces'
import { DEFAULT_PREVIEW_SIZE } from './fonts/lib/preview-sizes'
const HEADER_ACTIONS_TARGET = '#settings-header-actions'
const ACCEPT = '.ttf,.otf,.woff,.woff2'
const { t, locale } = useI18n()
const { isDemoRestrictedAccount: readonly } = usePermissions()
// One instance for both lists: hiding a server font here must show in the picker preview at once.
const customFonts = useCustomFonts() const customFonts = useCustomFonts()
const store = customFonts.scopeStore('user') const library = useFontLibrary('user', customFonts)
const { isDemoRestrictedAccount } = usePermissions() const { store, uploads, newFamilies } = library
const { failed: failedFaces } = useFailedFontFaces()
// The panel loads the user's own fonts; the shared collection and this reader's /**
// opt-outs are only needed for the visibility section below it. * The header renders the primary action beside the title. Falling back to rendering in place keeps
onMounted(async () => { * it reachable when this page is mounted outside the settings shell, including in tests.
await Promise.all([customFonts.fetchServerFonts(), customFonts.fetchServerFontVisibility()]) */
const headerSlotAvailable = ref(false)
const query = ref('')
const previewText = ref('')
const previewSize = ref(DEFAULT_PREVIEW_SIZE)
const sample = computed(() => previewText.value.trim() || t('settings.reader.fonts.pangram'))
const collator = computed(() => new Intl.Collator(locale.value, { sensitivity: 'base', numeric: true }))
const byName = (list: readonly FontFamily[]) => [...list].sort((a, b) => collator.value.compare(a.name, b.name))
const matches = (family: FontFamily) => {
const needle = query.value.trim().toLocaleLowerCase(locale.value)
return !needle || family.name.toLocaleLowerCase(locale.value).includes(needle)
}
const ownFamilies = computed(() => byName(store.families.value))
const serverFamilies = computed(() => byName(customFonts.serverFamilies.value))
const visibleOwn = computed(() => ownFamilies.value.filter(matches))
const visibleServer = computed(() => serverFamilies.value.filter(matches))
const shownServerCount = computed(() => customFonts.visibleServerFamilies.value.length)
const totalBytes = computed(() => store.fonts.value.reduce((sum, font) => sum + font.fileSize, 0))
const hasOwnFonts = computed(() => store.fonts.value.length > 0)
/** A refetch keeps the rows on screen; only a first load with nothing to show yet says so. */
const firstLoad = computed(() => store.loading.value && !hasOwnFonts.value)
/** The list the reader's font picker offers, for the copy of it in each sheet. */
const pickerServerFamilies = computed(() => byName(customFonts.visibleServerFamilies.value))
// ── Own family sheet ───────────────────────────────────────
const sheet = useFamilySheet(library, ownFamilies)
const { openFamily, openUse, armRemove, focusName, busy: sheetBusy } = sheet
function handleOpen(name: string) {
serverSheetName.value = null
sheet.open(name)
}
function handleRename(name: string) {
sheet.open(name, { rename: true })
}
function handleRemoveRequest(name: string) {
sheet.open(name, { remove: true })
}
function closeSheet() {
sheet.close()
}
function saveRename(newName: string) {
void sheet.rename(newName)
}
function removeOpenFamily() {
void sheet.removeFamily()
}
function updateFile(fontId: number, data: { weight: number; style: 'normal' | 'italic' }) {
void sheet.updateFile(fontId, data)
}
function removeFile(fontId: number) {
void sheet.removeFile(fontId)
}
// ── Server fonts: shown or hidden for this reader ─────────
const serverSheetName = ref<string | null>(null)
const serverSheetFamily = computed(() => serverFamilies.value.find((family) => family.name === serverSheetName.value) ?? null)
const pendingVisibility = ref(new Set<string>())
function isShown(family: FontFamily): boolean {
return !customFonts.isServerFamilyHidden(family.name)
}
async function setShown(name: string, shown: boolean) {
pendingVisibility.value = new Set([...pendingVisibility.value, name])
try {
const ok = await customFonts.setServerFamilyHidden(name, !shown)
if (!ok) toast.error(t('settings.reader.fonts.serverFontVisibilityFailed'))
} finally {
const next = new Set(pendingVisibility.value)
next.delete(name)
pendingVisibility.value = next
}
}
function openServerSheet(name: string) {
sheet.close()
serverSheetName.value = name
}
function closeServerSheet() {
serverSheetName.value = null
}
function handleServerSheetShown(shown: boolean) {
if (serverSheetName.value) void setShown(serverSheetName.value, shown)
}
// ── Uploads ────────────────────────────────────────────────
const fileInput = ref<HTMLInputElement | null>(null)
function openFilePicker() {
if (readonly.value) {
toast.error(t('settings.reader.fonts.demoCannotManage'))
return
}
fileInput.value?.click()
}
function handleFileInput(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
void library.addFiles(files)
}
function dismissUploads() {
library.dismissUploads()
}
const { dragCount } = useFontDropTarget(
() => !readonly.value,
(files) => void library.addFiles(files),
)
useInjectedFontFaces(() => [store.fonts.value, customFonts.serverFonts.value], customFonts.generateFontFaceCSS, 'data-reader-fonts-page')
onMounted(() => {
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
void Promise.all([library.load(), customFonts.fetchServerFonts(), customFonts.fetchServerFontVisibility()])
}) })
</script> </script>
<template> <template>
<div> <div class="@container flex flex-col gap-3">
<FontLibraryPanel :store="store" :readonly="isDemoRestrictedAccount" /> <Teleport :to="HEADER_ACTIONS_TARGET" defer :disabled="!headerSlotAvailable">
<ServerFontVisibility :custom-fonts="customFonts" /> <Button
v-if="!readonly"
size="sm"
type="button"
class="max-sm:size-8 max-sm:p-0"
:aria-label="t('settings.admin.serverFonts.addFonts')"
@click="openFilePicker"
>
<Upload :size="14" aria-hidden="true" />
<span class="max-sm:sr-only">{{ t('settings.admin.serverFonts.addFonts') }}</span>
</Button>
</Teleport>
<input
ref="fileInput"
type="file"
:accept="ACCEPT"
multiple
class="sr-only"
tabindex="-1"
aria-hidden="true"
data-testid="font-file-input"
@change="handleFileInput"
/>
<FontUploadTray v-if="uploads.length > 0" :entries="uploads" scope="user" @dismiss="dismissUploads" />
<FontLedgerToolbar
v-model:query="query"
v-model:preview-text="previewText"
v-model:preview-size="previewSize"
:families="ownFamilies.length"
:files="store.fonts.value.length"
:max-files="MAX_FONTS_PER_USER"
:bytes="totalBytes"
/>
<!-- Your fonts -->
<section class="flex flex-col gap-2" data-testid="own-fonts">
<h3 class="settings-group-label mb-0 pt-2">{{ t('settings.reader.fonts.yourFonts') }}</h3>
<div
v-if="firstLoad"
class="flex items-center justify-center rounded-xl border border-border bg-card py-10 text-sm text-muted-foreground shadow-xs"
data-testid="font-loading-surface"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="!hasOwnFonts"
class="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border border-dashed border-border bg-card px-5 py-4 shadow-xs"
data-testid="font-empty-surface"
>
<div class="grid size-10 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<Type :size="18" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ t('settings.reader.fonts.noFontsYet') }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">
<template v-if="readonly">{{ t('settings.reader.fonts.notAvailableDemo') }}</template>
<template v-else>{{ t('settings.reader.fonts.library.emptyHint') }} · {{ t('settings.reader.fonts.fileTypesHint') }}</template>
</p>
</div>
<Button v-if="!readonly" size="sm" variant="outline" type="button" @click="openFilePicker">
<Upload :size="14" aria-hidden="true" />
{{ t('settings.admin.serverFonts.addFonts') }}
</Button>
</div>
<template v-else>
<FontLedgerHeader :use-label="t('settings.reader.fonts.library.columns.use')" />
<div v-if="visibleOwn.length > 0" class="flex flex-col gap-2" data-testid="font-list-surface">
<FontFamilyRow
v-for="family in visibleOwn"
:key="family.cssFamilyName"
:family="family"
:preview-text="sample"
:preview-size="previewSize"
:is-new="newFamilies.has(family.name)"
:load-failed="failedFaces.has(family.cssFamilyName)"
@open="handleOpen"
>
<template #use>
<FontUseFacts :use="library.usageOf(family.cssFamilyName)" perspective="own" />
</template>
<template v-if="!readonly" #action>
<FontFamilyMenu :name="family.name" @rename="handleRename" @add-files="openFilePicker" @remove="handleRemoveRequest" />
</template>
</FontFamilyRow>
</div>
<p v-else class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
{{ t('settings.admin.serverFonts.noMatches', { query: query.trim() }) }}
</p>
</template>
</section>
<!-- Fonts shared by the server: shown or hidden for this reader -->
<section v-if="serverFamilies.length > 0" class="flex flex-col gap-2 pt-4" data-testid="server-fonts">
<div class="flex items-baseline justify-between gap-3">
<h3 class="settings-group-label mb-0">{{ t('settings.reader.fonts.serverFontsTitle') }}</h3>
<span class="shrink-0 text-xs whitespace-nowrap text-muted-foreground tabular-nums">{{
t('settings.reader.fonts.serverFontsShown', { count: formatNumber(shownServerCount), total: formatNumber(serverFamilies.length) })
}}</span>
</div>
<p class="text-xs text-muted-foreground">{{ t('settings.reader.fonts.serverFontsHint') }}</p>
<FontLedgerHeader :use-label="t('settings.reader.fonts.library.columns.use')" />
<div v-if="visibleServer.length > 0" class="flex flex-col gap-2" data-testid="server-font-list">
<FontFamilyRow
v-for="family in visibleServer"
:key="family.cssFamilyName"
:family="family"
:preview-text="sample"
:preview-size="previewSize"
:muted="!isShown(family)"
:load-failed="failedFaces.has(family.cssFamilyName)"
@open="openServerSheet"
>
<template #use>
<span
v-if="!isShown(family)"
class="inline-flex items-center rounded-full border border-border bg-muted px-2 text-[11.5px] leading-5 font-medium text-muted-foreground"
>{{ t('settings.reader.fonts.library.hiddenBadge') }}</span
>
<FontUseFacts :use="library.usageOf(family.cssFamilyName)" perspective="own" />
</template>
<template #action>
<ToggleSwitch
:model-value="isShown(family)"
:disabled="pendingVisibility.has(family.name)"
:aria-label="t('settings.reader.fonts.library.showFamily', { name: family.name })"
@update:model-value="setShown(family.name, $event)"
/>
</template>
</FontFamilyRow>
</div>
<p v-else class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
{{ t('settings.admin.serverFonts.noMatches', { query: query.trim() }) }}
</p>
<p v-if="shownServerCount === 0" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users :size="13" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fonts.serverFontsAllHidden') }}
</p>
</section>
<FontDropOverlay v-if="dragCount !== null" :count="dragCount" title-key="settings.reader.fonts.library.dropTitle" />
<FontFamilySheet
:open="openFamily !== null"
:family="openFamily"
scope="user"
perspective="own"
:usage="openUse"
:picker-server-families="pickerServerFamilies"
:picker-user-families="ownFamilies"
:total-files="store.fonts.value.length"
:max-files="MAX_FONTS_PER_USER"
:readonly="readonly"
:busy="sheetBusy"
:arm-remove="armRemove"
:focus-name="focusName"
:load-failed="openFamily !== null && failedFaces.has(openFamily.cssFamilyName)"
@close="closeSheet"
@save="saveRename"
@remove="removeOpenFamily"
@add-files="openFilePicker"
@update-file="updateFile"
@remove-file="removeFile"
/>
<FontFamilySheet
:open="serverSheetFamily !== null"
:family="serverSheetFamily"
scope="server"
perspective="own"
mode="view"
:usage="library.usageOf(serverSheetFamily?.cssFamilyName ?? '')"
:picker-server-families="pickerServerFamilies"
:picker-user-families="ownFamilies"
:total-files="0"
:max-files="MAX_SERVER_FONTS"
:readonly="false"
:busy="false"
:load-failed="serverSheetFamily !== null && failedFaces.has(serverSheetFamily.cssFamilyName)"
:shown="serverSheetFamily !== null && isShown(serverSheetFamily)"
:show-pending="serverSheetFamily !== null && pendingVisibility.has(serverSheetFamily.name)"
@close="closeServerSheet"
@update:shown="handleServerSheetShown"
/>
</div> </div>
</template> </template>
@@ -1,96 +0,0 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Users } from '@lucide/vue'
import type { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
const props = defineProps<{
customFonts: ReturnType<typeof useCustomFonts>
}>()
const { t } = useI18n()
const families = computed(() => props.customFonts.serverFamilies.value)
const visibleCount = computed(() => props.customFonts.visibleServerFamilies.value.length)
const pending = ref<Set<string>>(new Set())
const previewStyleEl = ref<HTMLStyleElement | null>(null)
// Server fonts are not part of this page's own collection, so their @font-face rules have
// to be injected here for the names below to render in their own typeface.
watch(
() => props.customFonts.serverFonts.value,
() => {
const css = props.customFonts.scopeStore('server').generateFontFaceCSS()
if (previewStyleEl.value) {
previewStyleEl.value.textContent = css
return
}
if (!css) return
const el = document.createElement('style')
el.setAttribute('data-server-font-visibility-preview', '')
el.textContent = css
document.head.appendChild(el)
previewStyleEl.value = el
},
{ immediate: true },
)
onUnmounted(() => {
previewStyleEl.value?.remove()
previewStyleEl.value = null
})
async function toggleFamily(familyName: string, shown: boolean) {
pending.value = new Set([...pending.value, familyName])
try {
const ok = await props.customFonts.setServerFamilyHidden(familyName, !shown)
if (!ok) toast.error(t('settings.reader.fonts.serverFontVisibilityFailed'))
} finally {
const next = new Set(pending.value)
next.delete(familyName)
pending.value = next
}
}
</script>
<template>
<div v-if="families.length > 0" class="mt-8">
<div class="flex items-center justify-between mb-2">
<p class="settings-group-label mb-0">{{ t('settings.reader.fonts.serverFontsTitle') }}</p>
<span class="text-xs text-muted-foreground">{{
t('settings.reader.fonts.serverFontsShown', { count: visibleCount, total: families.length })
}}</span>
</div>
<p class="mb-3 text-xs text-muted-foreground">{{ t('settings.reader.fonts.serverFontsHint') }}</p>
<div class="settings-card">
<div v-for="family in families" :key="family.cssFamilyName" class="flex items-center gap-3 bg-card px-4 py-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium" :style="{ fontFamily: `'${family.cssFamilyName}', sans-serif` }">
{{ family.name }}
</p>
<p class="mt-0.5 text-xs text-muted-foreground">
{{ t('settings.reader.fonts.fileCount', { count: family.variants.length }) }}
</p>
</div>
<ToggleSwitch
:model-value="!customFonts.isServerFamilyHidden(family.name)"
:disabled="pending.has(family.name)"
:aria-label="family.name"
@update:model-value="(shown: boolean) => toggleFamily(family.name, shown)"
/>
</div>
</div>
<p v-if="visibleCount === 0" class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Users :size="13" class="shrink-0" />
{{ t('settings.reader.fonts.serverFontsAllHidden') }}
</p>
</div>
</template>
@@ -1,30 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Info, Search, TextCursorInput, Type, Upload, X } from '@lucide/vue' import { Info, Type, Upload } from '@lucide/vue'
import { MAX_SERVER_FONTS } from '@bookorbit/types' import { MAX_SERVER_FONTS } from '@bookorbit/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { usePermissions } from '@/features/auth/composables/usePermissions' import { usePermissions } from '@/features/auth/composables/usePermissions'
import { formatNumber } from '@/i18n/formatters'
import { formatBytes } from '@/lib/formatting'
import SettingsPageHeader from './SettingsPageHeader.vue' import SettingsPageHeader from './SettingsPageHeader.vue'
import FontDropOverlay from './server-fonts/components/FontDropOverlay.vue' import FontDropOverlay from './fonts/components/FontDropOverlay.vue'
import FontUploadTray from './server-fonts/components/FontUploadTray.vue' import FontFamilyMenu from './fonts/components/FontFamilyMenu.vue'
import ServerFontFamilySheet from './server-fonts/components/ServerFontFamilySheet.vue' import FontFamilyRow from './fonts/components/FontFamilyRow.vue'
import ServerFontRow from './server-fonts/components/ServerFontRow.vue' import FontFamilySheet from './fonts/components/FontFamilySheet.vue'
import { useFailedFontFaces } from './server-fonts/composables/useFailedFontFaces' import FontLedgerHeader from './fonts/components/FontLedgerHeader.vue'
import { useServerFontLibrary } from './server-fonts/composables/useServerFontLibrary' import FontLedgerToolbar from './fonts/components/FontLedgerToolbar.vue'
import FontUploadTray from './fonts/components/FontUploadTray.vue'
import FontUseFacts from './fonts/components/FontUseFacts.vue'
import { useFailedFontFaces } from './fonts/composables/useFailedFontFaces'
import { useFamilySheet } from './fonts/composables/useFamilySheet'
import { useFontDropTarget } from './fonts/composables/useFontDropTarget'
import { useFontLibrary } from './fonts/composables/useFontLibrary'
import { useInjectedFontFaces } from './fonts/composables/useInjectedFontFaces'
import { DEFAULT_PREVIEW_SIZE } from './fonts/lib/preview-sizes'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const HEADER_ACTIONS_TARGET = '#settings-header-actions' const HEADER_ACTIONS_TARGET = '#settings-header-actions'
const PREVIEW_SIZES = [18, 24, 32] as const
const ACCEPT = '.ttf,.otf,.woff,.woff2' const ACCEPT = '.ttf,.otf,.woff,.woff2'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { isDemoRestrictedAccount: readonly } = usePermissions() const { isDemoRestrictedAccount: readonly } = usePermissions()
const library = useServerFontLibrary() const library = useFontLibrary('server')
const { store, uploads, newFamilies } = library const { store, uploads, newFamilies } = library
const { failed: failedFaces } = useFailedFontFaces() const { failed: failedFaces } = useFailedFontFaces()
@@ -36,7 +41,7 @@ const headerSlotAvailable = ref(false)
const query = ref('') const query = ref('')
const previewText = ref('') const previewText = ref('')
const previewSize = ref<number>(PREVIEW_SIZES[1]) const previewSize = ref(DEFAULT_PREVIEW_SIZE)
const sample = computed(() => previewText.value.trim() || t('settings.reader.fonts.pangram')) const sample = computed(() => previewText.value.trim() || t('settings.reader.fonts.pangram'))
const collator = computed(() => new Intl.Collator(locale.value, { sensitivity: 'base', numeric: true })) const collator = computed(() => new Intl.Collator(locale.value, { sensitivity: 'base', numeric: true }))
@@ -50,76 +55,32 @@ const hasFonts = computed(() => store.fonts.value.length > 0)
/** A refetch keeps the rows on screen; only a first load with nothing to show yet says so. */ /** A refetch keeps the rows on screen; only a first load with nothing to show yet says so. */
const firstLoad = computed(() => store.loading.value && !hasFonts.value) const firstLoad = computed(() => store.loading.value && !hasFonts.value)
// ── Family sheet ─────────────────────────────────────────── const sheet = useFamilySheet(library, families)
const openFamilyName = ref<string | null>(null) const { openFamily, openUse, armRemove, focusName, busy: sheetBusy } = sheet
const armRemove = ref(false)
const focusName = ref(false)
const sheetBusy = ref(false)
const openFamily = computed(() => families.value.find((family) => family.name === openFamilyName.value) ?? null)
const openUsage = computed(() => library.usageOf(openFamilyName.value ?? ''))
function openSheet(name: string, options: { remove?: boolean; rename?: boolean } = {}) {
armRemove.value = options.remove ?? false
focusName.value = options.rename ?? false
openFamilyName.value = name
}
function handleOpen(name: string) { function handleOpen(name: string) {
openSheet(name) sheet.open(name)
} }
function handleRename(name: string) { function handleRename(name: string) {
openSheet(name, { rename: true }) sheet.open(name, { rename: true })
} }
function handleRemoveRequest(name: string) { function handleRemoveRequest(name: string) {
openSheet(name, { remove: true }) sheet.open(name, { remove: true })
} }
function closeSheet() { function closeSheet() {
openFamilyName.value = null sheet.close()
armRemove.value = false
focusName.value = false
} }
function saveRename(newName: string) {
async function saveRename(newName: string) { void sheet.rename(newName)
const name = openFamilyName.value
if (!name) return
sheetBusy.value = true
const result = await library.renameFamily(name, newName)
sheetBusy.value = false
if (result.ok) {
toast.success(t('settings.reader.fonts.renamedTo', { name: newName }))
closeSheet()
} else if (result.code === 'FONT_FACE_TAKEN') {
toast.error(t('settings.admin.serverFonts.toasts.renameTaken', { name: newName }))
} else {
toast.error(t('settings.reader.fonts.renameFamilyFailed'))
}
} }
function removeOpenFamily() {
async function removeOpenFamily() { void sheet.removeFamily()
const name = openFamilyName.value
if (!name) return
sheetBusy.value = true
const { failed } = await library.removeFamily(name)
sheetBusy.value = false
if (failed === 0) {
toast.success(t('settings.admin.serverFonts.toasts.removed', { name }))
closeSheet()
} else {
toast.error(t('settings.admin.serverFonts.toasts.removePartial', { failed, name }))
}
} }
function updateFile(fontId: number, data: { weight: number; style: 'normal' | 'italic' }) {
async function updateFile(fontId: number, data: { weight: number; style: 'normal' | 'italic' }) { void sheet.updateFile(fontId, data)
if (!(await library.updateFile(fontId, data))) toast.error(t('settings.reader.fonts.updateVariantFailed'))
} }
function removeFile(fontId: number) {
async function removeFile(fontId: number) { void sheet.removeFile(fontId)
const ok = await store.deleteFont(fontId).catch(() => false)
if (!ok) {
toast.error(t('settings.reader.fonts.deleteFontFailed'))
return
}
if (!openFamily.value) closeSheet()
await library.fetchUsage()
} }
// ── Uploads ──────────────────────────────────────────────── // ── Uploads ────────────────────────────────────────────────
@@ -144,88 +105,17 @@ function dismissUploads() {
library.dismissUploads() library.dismissUploads()
} }
// The whole page is the drop target, since the button that opens a picker is far from most rows. const { dragCount } = useFontDropTarget(
const dragCount = ref<number | null>(null) () => !readonly.value,
let dragDepth = 0 (files) => void library.addFiles(files),
function carriesFiles(event: DragEvent): boolean {
return !readonly.value && (event.dataTransfer?.types ?? []).includes('Files')
}
function handleDragEnter(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
dragDepth++
dragCount.value = event.dataTransfer?.items.length ?? 0
}
function handleDragOver(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function handleDragLeave(event: DragEvent) {
if (!carriesFiles(event)) return
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) dragCount.value = null
}
function handleDrop(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
dragDepth = 0
dragCount.value = null
void library.addFiles(Array.from(event.dataTransfer?.files ?? []))
}
// ── Toolbar ────────────────────────────────────────────────
function handleQueryInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
}
function clearQuery() {
query.value = ''
}
function handlePreviewInput(event: Event) {
previewText.value = (event.target as HTMLInputElement).value
}
function selectSize(size: number) {
previewSize.value = size
}
// ── Font faces ─────────────────────────────────────────────
// The collection is not part of the app's own CSS, so its @font-face rules are injected for the
// names and specimens on this page to render in their own faces.
let fontFaceStyle: HTMLStyleElement | null = null
watch(
() => store.fonts.value,
() => {
const css = store.generateFontFaceCSS()
if (!fontFaceStyle) {
if (!css) return
fontFaceStyle = document.createElement('style')
fontFaceStyle.setAttribute('data-server-fonts-page', '')
document.head.appendChild(fontFaceStyle)
}
fontFaceStyle.textContent = css
},
{ immediate: true },
) )
useInjectedFontFaces(() => store.fonts.value, store.generateFontFaceCSS, 'data-server-fonts-page')
onMounted(() => { onMounted(() => {
headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null headerSlotAvailable.value = document.getElementById(HEADER_ACTIONS_TARGET.slice(1)) !== null
window.addEventListener('dragenter', handleDragEnter)
window.addEventListener('dragover', handleDragOver)
window.addEventListener('dragleave', handleDragLeave)
window.addEventListener('drop', handleDrop)
void library.load() void library.load()
}) })
onUnmounted(() => {
window.removeEventListener('dragenter', handleDragEnter)
window.removeEventListener('dragover', handleDragOver)
window.removeEventListener('dragleave', handleDragLeave)
window.removeEventListener('drop', handleDrop)
fontFaceStyle?.remove()
fontFaceStyle = null
})
</script> </script>
<template> <template>
@@ -298,112 +188,36 @@ onUnmounted(() => {
</div> </div>
<template v-else> <template v-else>
<div class="flex flex-wrap items-center gap-2.5"> <FontLedgerToolbar
<label class="relative block w-full min-w-0 @min-[48rem]:w-60"> v-model:query="query"
<span class="sr-only">{{ t('settings.admin.serverFonts.filterLabel') }}</span> v-model:preview-text="previewText"
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" /> v-model:preview-size="previewSize"
<input :families="families.length"
type="search" :files="store.fonts.value.length"
class="input-field h-9 w-full ps-9 pe-9" :max-files="MAX_SERVER_FONTS"
:value="query" :bytes="totalBytes"
:placeholder="t('settings.admin.serverFonts.filterPlaceholder')" />
@input="handleQueryInput"
/>
<button
v-if="query"
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('settings.admin.serverFonts.filterClear')"
@click="clearQuery"
>
<X :size="14" aria-hidden="true" />
</button>
</label>
<label class="relative block min-w-0 flex-1 @min-[48rem]:max-w-[22.5rem]">
<span class="sr-only">{{ t('settings.admin.serverFonts.previewLabel') }}</span>
<TextCursorInput
:size="15"
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
type="text"
class="input-field h-9 w-full ps-9"
:value="previewText"
:placeholder="t('settings.admin.serverFonts.previewPlaceholder')"
@input="handlePreviewInput"
/>
</label>
<div
role="group"
:aria-label="t('settings.admin.serverFonts.previewSize')"
class="flex shrink-0 gap-0.5 rounded-lg border border-border bg-muted p-0.5"
>
<button
v-for="(size, index) in PREVIEW_SIZES"
:key="size"
type="button"
class="grid h-[30px] w-[30px] place-items-center rounded-md font-serif leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="previewSize === size ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
:style="{ fontSize: `${11 + index * 3}px` }"
:aria-pressed="previewSize === size"
:aria-label="t('settings.admin.serverFonts.previewSizeOption', { size })"
:title="t('settings.admin.serverFonts.previewSizeOption', { size })"
@click="selectSize(size)"
>
A
</button>
</div>
<p
data-testid="server-fonts-totals"
class="flex w-full flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[13px] text-muted-foreground @min-[60rem]:ms-auto @min-[60rem]:w-auto"
>
<span
><span class="font-semibold text-foreground tabular-nums">{{ formatNumber(families.length) }}</span>
{{ t('settings.admin.serverFonts.familiesUnit', { count: families.length }) }}</span
>
<span class="opacity-50" aria-hidden="true">&middot;</span>
<i18n-t keypath="settings.admin.serverFonts.filesOfMax" tag="span" scope="global">
<template #count
><span class="font-semibold text-foreground tabular-nums">{{ formatNumber(store.fonts.value.length) }}</span></template
>
<template #max>{{ formatNumber(MAX_SERVER_FONTS) }}</template>
</i18n-t>
<span class="opacity-50" aria-hidden="true">&middot;</span>
<i18n-t keypath="settings.admin.serverFonts.onDisk" tag="span" scope="global">
<template #size
><span class="font-semibold text-foreground tabular-nums">{{ formatBytes(totalBytes) }}</span></template
>
</i18n-t>
</p>
</div>
<div <FontLedgerHeader :use-label="t('settings.admin.serverFonts.columns.readers')" />
aria-hidden="true"
class="hidden grid-cols-[15.75rem_minmax(0,1fr)_11rem_9.5rem_2rem] gap-7 ps-[25px] pe-[21px] pt-1.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase @min-[60rem]:grid"
>
<span>{{ t('settings.admin.serverFonts.columns.family') }}</span>
<span>{{ t('settings.admin.serverFonts.columns.specimen') }}</span>
<span>{{ t('settings.admin.serverFonts.columns.styles') }}</span>
<span>{{ t('settings.admin.serverFonts.columns.readers') }}</span>
</div>
<div v-if="visibleFamilies.length > 0" class="flex flex-col gap-2" data-testid="font-list-surface"> <div v-if="visibleFamilies.length > 0" class="flex flex-col gap-2" data-testid="font-list-surface">
<ServerFontRow <FontFamilyRow
v-for="family in visibleFamilies" v-for="family in visibleFamilies"
:key="family.cssFamilyName" :key="family.cssFamilyName"
:family="family" :family="family"
:usage="library.usageOf(family.name)"
:preview-text="sample" :preview-text="sample"
:preview-size="previewSize" :preview-size="previewSize"
:is-new="newFamilies.has(family.name)" :is-new="newFamilies.has(family.name)"
:readonly="readonly"
:load-failed="failedFaces.has(family.cssFamilyName)" :load-failed="failedFaces.has(family.cssFamilyName)"
@open="handleOpen" @open="handleOpen"
@rename="handleRename" >
@add-files="openFilePicker" <template #use>
@remove="handleRemoveRequest" <FontUseFacts :use="library.usageOf(family.cssFamilyName)" perspective="server" />
/> </template>
<template v-if="!readonly" #action>
<FontFamilyMenu :name="family.name" @rename="handleRename" @add-files="openFilePicker" @remove="handleRemoveRequest" />
</template>
</FontFamilyRow>
</div> </div>
<p v-else class="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground"> <p v-else class="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
{{ t('settings.admin.serverFonts.noMatches', { query: query.trim() }) }} {{ t('settings.admin.serverFonts.noMatches', { query: query.trim() }) }}
@@ -412,13 +226,15 @@ onUnmounted(() => {
<p class="px-1 pt-1 text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.footerHint') }}</p> <p class="px-1 pt-1 text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.footerHint') }}</p>
</template> </template>
<FontDropOverlay v-if="dragCount !== null" :count="dragCount" /> <FontDropOverlay v-if="dragCount !== null" :count="dragCount" title-key="settings.admin.serverFonts.drop.title" />
<ServerFontFamilySheet <FontFamilySheet
:open="openFamily !== null" :open="openFamily !== null"
:family="openFamily" :family="openFamily"
:usage="openUsage" scope="server"
:picker-families="families" perspective="server"
:usage="openUse"
:picker-server-families="families"
:total-files="store.fonts.value.length" :total-files="store.fonts.value.length"
:max-files="MAX_SERVER_FONTS" :max-files="MAX_SERVER_FONTS"
:readonly="readonly" :readonly="readonly"
@@ -1,16 +1,19 @@
import { describe, expect, it, vi, beforeEach } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { computed } from 'vue' import { computed } from 'vue'
import FontsSettings from '../FontsSettings.vue' import FontsSettings from '../FontsSettings.vue'
import { makeCustomFontsMock, makeFontStore, mockFont } from './font-store-fixture' import FontFamilyMenu from '../fonts/components/FontFamilyMenu.vue'
import { makeCustomFontsMock, makeFontStore, mockFont, type MockFontStore } from './font-store-fixture'
// --- Mocks --- const apiMock = vi.hoisted(() => vi.fn<(input: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
vi.mock('vue-sonner', () => ({ vi.mock('vue-sonner', () => ({
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() }, toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
})) }))
vi.mock('@/features/reader/epub/composables/useCustomFonts', () => ({ vi.mock('@/features/reader/epub/composables/useCustomFonts', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/reader/epub/composables/useCustomFonts')>()),
useCustomFonts: vi.fn<() => unknown>(), useCustomFonts: vi.fn<() => unknown>(),
})) }))
@@ -18,367 +21,272 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: vi.fn<() => unknown>(), usePermissions: vi.fn<() => unknown>(),
})) }))
const literataFonts = [mockFont(1, 'Literata', 400), mockFont(2, 'Literata', 700)] import { FontUploadError, useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
const georgiaPro = mockFont(3, 'Georgia Pro')
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
import { usePermissions } from '@/features/auth/composables/usePermissions' import { usePermissions } from '@/features/auth/composables/usePermissions'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
/** Builds the user-scope store and installs it as the composable's scopeStore('user'). */ /** The real menus open on a pointer gesture jsdom does not raise, so their content is rendered flat. */
function makeComposable(initialFonts: Parameters<typeof makeFontStore>[1] = []) { const DROPDOWN_STUBS = {
const store = makeFontStore('user', initialFonts) DropdownMenu: { template: '<div><slot /></div>' },
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ user: store }) as never) DropdownMenuTrigger: { template: '<div><slot /></div>' },
return store DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
DropdownMenuSeparator: { template: '<hr />' },
} }
function makePermissions(isDemo = false) { const georgia = [mockFont(1, 'Georgia'), mockFont(2, 'Georgia', 700)]
return { isDemoRestrictedAccount: computed(() => isDemo) } const inter = mockFont(4, 'Inter')
const courgette = mockFont(3, 'Courgette')
const bigCaslon = mockFont(5, 'Big Caslon', 500)
const USAGE = {
fonts: [
{ cssFamily: '__userfont_georgia', books: 2, isDefault: false },
{ cssFamily: '__serverfont_courgette', books: 0, isDefault: true },
],
}
function respond(body: unknown, ok = true) {
return Promise.resolve({ ok, json: () => Promise.resolve(body) })
}
let user: MockFontStore
let customFonts: ReturnType<typeof makeCustomFontsMock>
function mountPage(own = [...georgia, inter], server = [courgette, bigCaslon]): VueWrapper {
user = makeFontStore('user', own)
customFonts = makeCustomFontsMock({ user, server: makeFontStore('server', server) })
vi.mocked(useCustomFonts).mockReturnValue(customFonts as never)
return mount(FontsSettings, { attachTo: document.body, global: { stubs: DROPDOWN_STUBS } })
}
function rowsIn(wrapper: VueWrapper, section: string) {
return wrapper.get(`[data-testid="${section}"]`).findAll('[data-testid="font-family-row"]')
}
function rowNamed(wrapper: VueWrapper, section: string, name: string) {
const row = rowsIn(wrapper, section).find((candidate) => candidate.find('button').text() === name)
if (!row) throw new Error(`no ${section} row for ${name}`)
return row
}
function dialogText(): string {
return document.body.querySelector('[role="dialog"]')?.textContent ?? ''
}
function clickInDialog(text: string) {
const dialog = document.body.querySelector('[role="dialog"]')
const button = [...(dialog?.querySelectorAll('button') ?? [])].find((candidate) => (candidate.textContent ?? '').trim() === text)
if (!button) throw new Error(`no "${text}" button in the sheet`)
button.click()
}
/** jsdom has no DragEvent, so a drop is an Event carrying the one field the page reads. */
function dropFiles(files: File[]) {
const event = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], files, items: files } })
window.dispatchEvent(event)
} }
describe('FontsSettings', () => { describe('FontsSettings', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(usePermissions).mockReturnValue(makePermissions(false) as never) vi.mocked(usePermissions).mockReturnValue({ isDemoRestrictedAccount: computed(() => false) } as never)
apiMock.mockImplementation((url: string) => (url === '/api/v1/fonts/usage' ? respond(USAGE) : respond({}, false)))
}) })
describe('empty state', () => { afterEach(() => {
it('shows empty state message when no fonts are loaded', async () => { document.body.innerHTML = ''
makeComposable() })
const wrapper = mount(FontsSettings, { attachTo: document.body })
it("loads the reader's fonts, the server's, their opt-outs and how they use each", async () => {
mountPage()
await flushPromises()
expect(user.fetchFonts).toHaveBeenCalled()
expect(customFonts.fetchServerFonts).toHaveBeenCalled()
expect(customFonts.fetchServerFontVisibility).toHaveBeenCalled()
expect(apiMock).toHaveBeenCalledWith('/api/v1/fonts/usage')
})
describe('your fonts', () => {
it('lists one row per family, in name order, against the personal cap', async () => {
const wrapper = mountPage()
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('No fonts uploaded yet') expect(rowsIn(wrapper, 'own-fonts').map((row) => row.find('button').text())).toEqual(['Georgia', 'Inter'])
expect(rowNamed(wrapper, 'own-fonts', 'Georgia').text()).toContain('2 styles, no italics')
expect(wrapper.get('[data-testid="font-totals"]').text().replace(/\s+/g, ' ')).toContain('3 of 50 files')
}) })
it('shows quota counter as 0 / 50', async () => { it('says which of them the reader actually uses', async () => {
makeComposable() const wrapper = mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('0 / 50 used') expect(rowNamed(wrapper, 'own-fonts', 'Georgia').get('[data-testid="font-family-use"]').text()).toBe('On 2 books')
expect(rowNamed(wrapper, 'own-fonts', 'Inter').get('[data-testid="font-family-use"]').text()).toBe('')
})
it('keeps the empty state small, since the server list still follows', async () => {
const wrapper = mountPage([])
await flushPromises()
const empty = wrapper.get('[data-testid="font-empty-surface"]')
expect(empty.text()).toContain('No fonts uploaded yet')
expect(empty.text()).toContain('Add fonts')
expect(rowsIn(wrapper, 'server-fonts')).toHaveLength(2)
})
it('asks before removing a family, naming the books that lose it', async () => {
const wrapper = mountPage()
await flushPromises()
wrapper
.findAllComponents(FontFamilyMenu)
.find((menu) => menu.props('name') === 'Georgia')!
.vm.$emit('remove', 'Georgia')
await flushPromises()
const alert = document.body.querySelector('[role="dialog"] [role="alert"]')?.textContent ?? ''
expect(alert).toContain('Remove Georgia from your fonts?')
expect(alert).toContain('It is set on 2 of your books; they go back to their own fonts.')
expect(user.deleteFont).not.toHaveBeenCalled()
clickInDialog('Remove family')
await flushPromises()
expect(user.deleteFont.mock.calls).toEqual([[1], [2]])
})
it('renames a family through the personal route', async () => {
const wrapper = mountPage()
await flushPromises()
apiMock.mockImplementation((url: string) => (url === '/api/v1/fonts/families' ? respond([mockFont(4, 'Inter Display')]) : respond(USAGE)))
await rowNamed(wrapper, 'own-fonts', 'Inter').trigger('click')
await flushPromises()
const input = document.body.querySelector<HTMLInputElement>('#font-family-name')!
input.value = 'Inter Display'
input.dispatchEvent(new Event('input'))
await flushPromises()
clickInDialog('Save')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/v1/fonts/families', expect.objectContaining({ method: 'PATCH' }))
expect(toast.success).toHaveBeenCalledWith('Renamed to "Inter Display"')
})
it('uploads dropped files into the personal collection and explains the personal cap', async () => {
const wrapper = mountPage()
await flushPromises()
user.uploadFont.mockRejectedValue(new FontUploadError('Maximum of 50 fonts per user reached', 400, 'FONT_LIMIT_REACHED', { limit: 50 }))
dropFiles([new File(['x'], 'Lora-Regular.ttf')])
await flushPromises()
expect(user.uploadFont).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-testid="font-upload-entry"]').text()).toContain('You already have 50 font files.')
}) })
}) })
describe('with fonts', () => { describe('server fonts', () => {
it('renders font family names', async () => { it('lists every server family with a switch, counting how many are shown', async () => {
makeComposable([...literataFonts, georgiaPro]) const wrapper = mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Literata') const section = wrapper.get('[data-testid="server-fonts"]')
expect(wrapper.text()).toContain('Georgia Pro') expect(rowsIn(wrapper, 'server-fonts').map((row) => row.find('button').text())).toEqual(['Big Caslon', 'Courgette'])
expect(section.findAll('button[role="switch"]')).toHaveLength(2)
expect(section.text()).toContain('2 of 2 shown')
}) })
it('shows correct quota count', async () => { it("shows the reader's own use of a server font, such as being their default", async () => {
makeComposable([...literataFonts, georgiaPro]) const wrapper = mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('3 / 50 used') expect(rowNamed(wrapper, 'server-fonts', 'Courgette').get('[data-testid="font-family-use"]').text()).toBe('Your default')
}) })
it('shows file count per family', async () => { it('hides a family when its switch is turned off, and marks the row', async () => {
makeComposable([...literataFonts, georgiaPro]) const wrapper = mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('2 files') await rowNamed(wrapper, 'server-fonts', 'Big Caslon').get('button[role="switch"]').trigger('click')
expect(wrapper.text()).toContain('1 file')
})
})
describe('upload', () => {
it('calls uploadFont when a file is dropped', async () => {
const composable = makeComposable()
composable.uploadFont.mockResolvedValue({
font: literataFonts[0],
suggestedFamilyName: 'Literata',
suggestedWeight: 400,
suggestedStyle: 'normal',
})
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
const file = new File(['data'], 'Literata.ttf', { type: 'font/ttf' }) expect(customFonts.setServerFamilyHidden).toHaveBeenCalledWith('Big Caslon', true)
const label = wrapper.find('label') expect(rowNamed(wrapper, 'server-fonts', 'Big Caslon').text()).toContain('Hidden')
const dt = { files: [file] } expect(wrapper.get('[data-testid="server-fonts"]').text()).toContain('1 of 2 shown')
await label.trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: dt })
await flushPromises()
expect(composable.uploadFont).toHaveBeenCalledWith(file)
expect(toast.success).toHaveBeenCalledWith('"Literata.ttf" added')
}) })
it('shows error toast when upload fails', async () => { it('says so when every server font is hidden', async () => {
const composable = makeComposable() const wrapper = mountPage()
composable.uploadFont.mockRejectedValue(new Error('File too large'))
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
const file = new File(['data'], 'big.ttf') for (const name of ['Big Caslon', 'Courgette']) await rowNamed(wrapper, 'server-fonts', name).get('button[role="switch"]').trigger('click')
const label = wrapper.find('label')
await label.trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('big.ttf: File too large') expect(wrapper.text()).toContain('All server fonts are hidden.')
}) })
it('dismisses an error when X is clicked', async () => { it('opens a server family to preview, with only its switch to change', async () => {
const composable = makeComposable() const wrapper = mountPage()
composable.uploadFont.mockRejectedValue(new Error('Oops'))
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
const file = new File(['data'], 'test.ttf') await rowNamed(wrapper, 'server-fonts', 'Courgette').trigger('click')
await wrapper.find('label').trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('test.ttf: Oops') expect(dialogText()).toContain('Show in my reader')
expect(dialogText()).not.toContain('Family name')
expect(document.body.querySelector('#font-family-name')).toBeNull()
const dismissBtn = wrapper.findAll('button').find((b) => b.find('svg').exists() && b.classes().some((c) => c.includes('shrink-0'))) document.body.querySelector<HTMLButtonElement>('[role="dialog"] button[role="switch"]')!.click()
await dismissBtn?.trigger('click')
expect(wrapper.text()).not.toContain('test.ttf: Oops')
})
})
describe('delete', () => {
it('removes font family (optimistic) and completes silently', async () => {
const composable = makeComposable([...literataFonts, georgiaPro])
composable.deleteFont.mockResolvedValue(true)
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Georgia Pro') expect(customFonts.setServerFamilyHidden).toHaveBeenCalledWith('Courgette', true)
const allButtons = wrapper.findAll('button')
const deleteBtns = allButtons.filter((b) => b.attributes('title') === 'Delete family')
await deleteBtns[1]?.trigger('click')
await flushPromises()
expect(composable.deleteFont).toHaveBeenCalledWith(georgiaPro.id)
expect(composable.fonts.value.some((f) => f.familyName === 'Georgia Pro')).toBe(false)
}) })
it('refetches and shows error toast when family delete fails', async () => { it('reports a failed save', async () => {
const composable = makeComposable([georgiaPro]) const wrapper = mountPage()
composable.deleteFont.mockResolvedValue(false) await flushPromises()
customFonts.setServerFamilyHidden.mockResolvedValueOnce(false)
const wrapper = mount(FontsSettings, { attachTo: document.body }) await rowNamed(wrapper, 'server-fonts', 'Courgette').get('button[role="switch"]').trigger('click')
await flushPromises() await flushPromises()
const deleteBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete family') expect(toast.error).toHaveBeenCalledWith('Failed to update server font visibility')
await deleteBtn?.trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalledWith('Failed to delete font family')
expect(composable.fetchFonts).toHaveBeenCalled()
}) })
it('removes variant (optimistic) and completes silently', async () => { it('leaves the section out when the server has no fonts', async () => {
const composable = makeComposable([...literataFonts]) const wrapper = mountPage(undefined, [])
composable.deleteFont.mockResolvedValue(true)
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
// Expand the Literata family expect(wrapper.find('[data-testid="server-fonts"]').exists()).toBe(false)
const toggleBtn = wrapper.find('button')
await toggleBtn.trigger('click')
await flushPromises()
const deleteVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete variant')
await deleteVariantBtn?.trigger('click')
await flushPromises()
expect(composable.deleteFont).toHaveBeenCalledWith(literataFonts[0]!.id)
expect(composable.fonts.value.some((f) => f.id === literataFonts[0]!.id)).toBe(false)
})
it('restores variant and shows error toast when variant delete fails', async () => {
const composable = makeComposable([...literataFonts])
composable.deleteFont.mockResolvedValue(false)
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const toggleBtn = wrapper.find('button')
await toggleBtn.trigger('click')
await flushPromises()
const deleteVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete variant')
await deleteVariantBtn?.trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalledWith('Failed to delete font')
expect(composable.fonts.value.some((f) => f.id === literataFonts[0]!.id)).toBe(true)
})
})
describe('inline family rename', () => {
it('shows input when pencil is clicked and saves on Enter', async () => {
const composable = makeComposable([georgiaPro])
composable.updateFont.mockResolvedValue({ ...georgiaPro, familyName: 'New Name' })
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
await pencilBtn?.trigger('click')
await flushPromises()
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
expect(textInput?.exists()).toBe(true)
await textInput!.setValue('New Name')
await textInput!.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(composable.updateFont).toHaveBeenCalledWith(3, { familyName: 'New Name' })
expect(toast.success).toHaveBeenCalledWith('Renamed to "New Name"')
})
it('cancels edit on Escape', async () => {
const composable = makeComposable([georgiaPro])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
await pencilBtn?.trigger('click')
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
expect(textInput?.exists()).toBe(true)
await textInput!.trigger('keydown', { key: 'Escape' })
const afterInputs = wrapper.findAll('input').filter((i) => i.attributes('type') !== 'file')
expect(afterInputs.length).toBe(0)
expect(composable.updateFont).not.toHaveBeenCalled()
})
it('shows error and refetches if rename partially fails', async () => {
const composable = makeComposable([...literataFonts])
composable.updateFont.mockResolvedValueOnce({ ...literataFonts[0]!, familyName: 'New' }).mockResolvedValueOnce(null)
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const pencilBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
await pencilBtn?.trigger('click')
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
await textInput!.setValue('New')
await textInput!.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(toast.error).toHaveBeenCalledWith('Failed to rename font family')
expect(composable.fetchFonts).toHaveBeenCalled()
})
})
describe('variant expand', () => {
it('shows variant rows when family header is clicked', async () => {
makeComposable([...literataFonts])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
expect(wrapper.text()).not.toContain('Regular')
// Click the family toggle button
const toggleBtn = wrapper.find('button')
await toggleBtn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Regular')
expect(wrapper.text()).toContain('Bold')
}) })
}) })
describe('demo restriction', () => { describe('demo restriction', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(usePermissions).mockReturnValue(makePermissions(true) as never) vi.mocked(usePermissions).mockReturnValue({ isDemoRestrictedAccount: computed(() => true) } as never)
}) })
it('shows "Not available for demo accounts" text in upload zone', async () => { it('offers nothing that changes the collection but still lets the reader hide server fonts', async () => {
makeComposable() const wrapper = mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Not available for demo accounts') expect(wrapper.findAll('button').some((button) => button.text() === 'Add fonts')).toBe(false)
expect(wrapper.findAllComponents(FontFamilyMenu)).toHaveLength(0)
expect(wrapper.get('[data-testid="server-fonts"]').findAll('button[role="switch"]')).toHaveLength(2)
}) })
it('disables the file input', async () => { it('ignores dropped files', async () => {
makeComposable() mountPage()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
const fileInput = wrapper.find('input[type="file"]') dropFiles([new File(['x'], 'Test.ttf')])
expect(fileInput.attributes('disabled')).toBeDefined()
})
it('does not call uploadFont when file is dropped', async () => {
const composable = makeComposable()
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises() await flushPromises()
const file = new File(['data'], 'Test.ttf', { type: 'font/ttf' }) expect(user.uploadFont).not.toHaveBeenCalled()
const label = wrapper.find('label')
await label.trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
await flushPromises()
expect(composable.uploadFont).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Demo-restricted account cannot manage fonts')
})
it('hides Rename family and Delete family buttons', async () => {
makeComposable([georgiaPro])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const renameFamilyBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')
const deleteFamilyBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete family')
expect(renameFamilyBtn).toBeUndefined()
expect(deleteFamilyBtn).toBeUndefined()
})
it('hides Edit weight/style and Delete variant buttons when variants are expanded', async () => {
makeComposable([...literataFonts])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
const toggleBtn = wrapper.find('button')
await toggleBtn.trigger('click')
await flushPromises()
const editVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Edit weight/style')
const deleteVariantBtn = wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete variant')
expect(editVariantBtn).toBeUndefined()
expect(deleteVariantBtn).toBeUndefined()
})
it('still shows font families (read access is allowed)', async () => {
makeComposable([georgiaPro])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
expect(wrapper.text()).toContain('Georgia Pro')
})
it('still shows quota counter', async () => {
makeComposable([georgiaPro])
const wrapper = mount(FontsSettings, { attachTo: document.body })
await flushPromises()
expect(wrapper.text()).toContain('1 / 50 used')
}) })
}) })
}) })
@@ -1,190 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ServerFontVisibility from '../ServerFontVisibility.vue'
import { makeCustomFontsMock, makeFontStore, mockFont } from './font-store-fixture'
vi.mock('vue-sonner', () => ({
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
}))
import { toast } from 'vue-sonner'
const openDyslexic = [mockFont(1, 'OpenDyslexic', 400), mockFont(2, 'OpenDyslexic', 700)]
const atkinson = mockFont(3, 'Atkinson Hyperlegible')
function mountSection(serverFonts = [...openDyslexic, atkinson]) {
const customFonts = makeCustomFontsMock({ server: makeFontStore('server', serverFonts) })
const wrapper = mount(ServerFontVisibility, {
props: { customFonts: customFonts as never },
attachTo: document.body,
})
return { wrapper, customFonts }
}
function togglesOf(wrapper: ReturnType<typeof mount>) {
return wrapper.findAll('button[role="switch"]')
}
describe('ServerFontVisibility', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('lists one row per server family, not per variant', async () => {
const { wrapper } = mountSection()
await flushPromises()
expect(togglesOf(wrapper)).toHaveLength(2)
expect(wrapper.text()).toContain('OpenDyslexic')
expect(wrapper.text()).toContain('Atkinson Hyperlegible')
expect(wrapper.text()).toContain('2 files')
})
it('renders nothing at all when the server has no fonts', async () => {
const { wrapper } = mountSection([])
await flushPromises()
expect(wrapper.text()).toBe('')
expect(togglesOf(wrapper)).toHaveLength(0)
})
it('explains where the fonts come from', async () => {
const { wrapper } = mountSection()
await flushPromises()
expect(wrapper.classes()).toContain('mt-8')
expect(wrapper.text()).toContain('Server fonts (shared by admin)')
expect(wrapper.text()).toContain('come from your server administrator')
})
it('counts how many are shown', async () => {
const { wrapper } = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('2 of 2 shown')
})
it('previews each family in its own typeface', async () => {
const { wrapper } = mountSection([atkinson])
await flushPromises()
expect(wrapper.html()).toContain('__serverfont_atkinson_hyperlegible')
})
it('starts with everything enabled, since server fonts are opt-out', async () => {
const { wrapper } = mountSection()
await flushPromises()
for (const toggle of togglesOf(wrapper)) {
expect(toggle.attributes('aria-checked')).toBe('true')
}
})
})
describe('toggling', () => {
it('hides a family when its switch is turned off', async () => {
const { wrapper, customFonts } = mountSection()
await flushPromises()
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
expect(customFonts.setServerFamilyHidden).toHaveBeenCalledWith('OpenDyslexic', true)
expect(customFonts.hiddenServerFamilies.value).toEqual(['OpenDyslexic'])
})
it('restores a family when its switch is turned back on', async () => {
const { wrapper, customFonts } = mountSection()
await flushPromises()
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
expect(customFonts.setServerFamilyHidden).toHaveBeenLastCalledWith('OpenDyslexic', false)
expect(customFonts.hiddenServerFamilies.value).toEqual([])
})
it('leaves the hidden family listed so it can be restored', async () => {
const { wrapper } = mountSection()
await flushPromises()
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
expect(togglesOf(wrapper)).toHaveLength(2)
expect(togglesOf(wrapper)[0]!.attributes('aria-checked')).toBe('false')
expect(wrapper.text()).toContain('OpenDyslexic')
})
it('updates the shown count', async () => {
const { wrapper } = mountSection()
await flushPromises()
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('1 of 2 shown')
})
it('only affects the family that was toggled', async () => {
const { wrapper, customFonts } = mountSection()
await flushPromises()
await togglesOf(wrapper)[1]!.trigger('click')
await flushPromises()
expect(customFonts.hiddenServerFamilies.value).toEqual(['Atkinson Hyperlegible'])
expect(customFonts.visibleServerFamilies.value.map((f) => f.name)).toEqual(['OpenDyslexic'])
})
it('warns when everything has been hidden', async () => {
const { wrapper } = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('All server fonts are hidden')
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
await togglesOf(wrapper)[1]!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('All server fonts are hidden')
})
it('reports a failed save', async () => {
const { wrapper, customFonts } = mountSection()
await flushPromises()
customFonts.setServerFamilyHidden.mockResolvedValue(false)
await togglesOf(wrapper)[0]!.trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalledWith('Failed to update server font visibility')
})
it('disables the switch while its save is in flight', async () => {
const { wrapper, customFonts } = mountSection()
await flushPromises()
let release!: () => void
customFonts.setServerFamilyHidden.mockReturnValue(
new Promise<boolean>((resolve) => {
release = () => resolve(true)
}),
)
await togglesOf(wrapper)[0]!.trigger('click')
await wrapper.vm.$nextTick()
expect(togglesOf(wrapper)[0]!.attributes('disabled')).toBeDefined()
// The other rows stay usable.
expect(togglesOf(wrapper)[1]!.attributes('disabled')).toBeUndefined()
release()
await flushPromises()
expect(togglesOf(wrapper)[0]!.attributes('disabled')).toBeUndefined()
})
})
})
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { computed } from 'vue' import { computed } from 'vue'
import ServerFontsSettings from '../ServerFontsSettings.vue' import ServerFontsSettings from '../ServerFontsSettings.vue'
import ServerFontRow from '../server-fonts/components/ServerFontRow.vue' import FontFamilyMenu from '../fonts/components/FontFamilyMenu.vue'
import { makeCustomFontsMock, makeFontStore, mockFont, type MockFontStore } from './font-store-fixture' import { makeCustomFontsMock, makeFontStore, mockFont, type MockFontStore } from './font-store-fixture'
const apiMock = vi.hoisted(() => vi.fn<(input: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()) const apiMock = vi.hoisted(() => vi.fn<(input: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
@@ -63,7 +63,7 @@ function mountPage(fonts = [courgette, ...literata, bigCaslon]): VueWrapper {
} }
function rowNamed(wrapper: VueWrapper, name: string) { function rowNamed(wrapper: VueWrapper, name: string) {
const row = wrapper.findAll('[data-testid="server-font-row"]').find((candidate) => candidate.find('button').text() === name) const row = wrapper.findAll('[data-testid="font-family-row"]').find((candidate) => candidate.find('button').text() === name)
if (!row) throw new Error(`no row for ${name}`) if (!row) throw new Error(`no row for ${name}`)
return row return row
} }
@@ -123,7 +123,7 @@ describe('ServerFontsSettings', () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
const names = wrapper.findAll('[data-testid="server-font-row"]').map((row) => row.find('button').text()) const names = wrapper.findAll('[data-testid="font-family-row"]').map((row) => row.find('button').text())
expect(names).toEqual(['Big Caslon', 'Courgette', 'Literata']) expect(names).toEqual(['Big Caslon', 'Courgette', 'Literata'])
expect(rowNamed(wrapper, 'Literata').find('button').attributes('style')).toContain('__serverfont_literata') expect(rowNamed(wrapper, 'Literata').find('button').attributes('style')).toContain('__serverfont_literata')
}) })
@@ -140,16 +140,16 @@ describe('ServerFontsSettings', () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
expect(rowNamed(wrapper, 'Courgette').get('[data-testid="server-font-usage"]').text()).toBe('On 2 books') expect(rowNamed(wrapper, 'Courgette').get('[data-testid="font-family-use"]').text()).toBe('On 2 books')
expect(rowNamed(wrapper, 'Big Caslon').get('[data-testid="server-font-usage"]').text()).toBe('Hidden by 1 reader') expect(rowNamed(wrapper, 'Big Caslon').get('[data-testid="font-family-use"]').text()).toBe('Hidden by 1 reader')
expect(rowNamed(wrapper, 'Literata').get('[data-testid="server-font-usage"]').text()).toBe('') expect(rowNamed(wrapper, 'Literata').get('[data-testid="font-family-use"]').text()).toBe('')
}) })
it('counts families and files separately, against the server cap', async () => { it('counts families and files separately, against the server cap', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
const totals = wrapper.get('[data-testid="server-fonts-totals"]').text().replace(/\s+/g, ' ') const totals = wrapper.get('[data-testid="font-totals"]').text().replace(/\s+/g, ' ')
expect(totals).toContain('3 families') expect(totals).toContain('3 families')
expect(totals).toContain('4 of 200 files') expect(totals).toContain('4 of 200 files')
}) })
@@ -160,7 +160,7 @@ describe('ServerFontsSettings', () => {
await wrapper.get('input[type="text"]').setValue('Sphinx of black quartz') await wrapper.get('input[type="text"]').setValue('Sphinx of black quartz')
expect(wrapper.findAll('[data-testid="server-font-row"] p').every((p) => p.text() === 'Sphinx of black quartz')).toBe(true) expect(wrapper.findAll('[data-testid="font-family-row"] p').every((p) => p.text() === 'Sphinx of black quartz')).toBe(true)
}) })
it('filters families by name and says when none match', async () => { it('filters families by name and says when none match', async () => {
@@ -168,7 +168,7 @@ describe('ServerFontsSettings', () => {
await flushPromises() await flushPromises()
await wrapper.get('input[type="search"]').setValue('lit') await wrapper.get('input[type="search"]').setValue('lit')
expect(wrapper.findAll('[data-testid="server-font-row"]')).toHaveLength(1) expect(wrapper.findAll('[data-testid="font-family-row"]')).toHaveLength(1)
await wrapper.get('input[type="search"]').setValue('zzz') await wrapper.get('input[type="search"]').setValue('zzz')
expect(wrapper.text()).toContain('No families match “zzz”') expect(wrapper.text()).toContain('No families match “zzz”')
@@ -209,7 +209,7 @@ describe('ServerFontsSettings', () => {
expect(dialogText()).toContain('Literata') expect(dialogText()).toContain('Literata')
expect(dialogText()).toContain('In the reader') expect(dialogText()).toContain('In the reader')
expect(document.body.querySelectorAll('[data-testid="server-font-file"]')).toHaveLength(2) expect(document.body.querySelectorAll('[data-testid="font-family-file"]')).toHaveLength(2)
}) })
it('opens with the removal already asked when chosen from the row menu, naming what it costs', async () => { it('opens with the removal already asked when chosen from the row menu, naming what it costs', async () => {
@@ -217,8 +217,8 @@ describe('ServerFontsSettings', () => {
await flushPromises() await flushPromises()
wrapper wrapper
.findAllComponents(ServerFontRow) .findAllComponents(FontFamilyMenu)
.find((row) => row.props('family').name === 'Courgette')! .find((menu) => menu.props('name') === 'Courgette')!
.vm.$emit('remove', 'Courgette') .vm.$emit('remove', 'Courgette')
await flushPromises() await flushPromises()
@@ -232,8 +232,8 @@ describe('ServerFontsSettings', () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
wrapper wrapper
.findAllComponents(ServerFontRow) .findAllComponents(FontFamilyMenu)
.find((row) => row.props('family').name === 'Literata')! .find((menu) => menu.props('name') === 'Literata')!
.vm.$emit('remove', 'Literata') .vm.$emit('remove', 'Literata')
await flushPromises() await flushPromises()
@@ -251,7 +251,7 @@ describe('ServerFontsSettings', () => {
await rowNamed(wrapper, 'Courgette').trigger('click') await rowNamed(wrapper, 'Courgette').trigger('click')
await flushPromises() await flushPromises()
const input = document.body.querySelector<HTMLInputElement>('#server-font-family-name')! const input = document.body.querySelector<HTMLInputElement>('#font-family-name')!
input.value = 'Courgette Script' input.value = 'Courgette Script'
input.dispatchEvent(new Event('input')) input.dispatchEvent(new Event('input'))
await flushPromises() await flushPromises()
@@ -270,7 +270,7 @@ describe('ServerFontsSettings', () => {
await rowNamed(wrapper, 'Big Caslon').trigger('click') await rowNamed(wrapper, 'Big Caslon').trigger('click')
await flushPromises() await flushPromises()
const input = document.body.querySelector<HTMLInputElement>('#server-font-family-name')! const input = document.body.querySelector<HTMLInputElement>('#font-family-name')!
input.value = 'Caslon' input.value = 'Caslon'
input.dispatchEvent(new Event('input')) input.dispatchEvent(new Event('input'))
await flushPromises() await flushPromises()
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n'
import { Upload } from '@lucide/vue' import { Upload } from '@lucide/vue'
/** Covers the page while files are dragged over it. Browsers give the count, not the names, until the drop. */ /** Covers the page while files are dragged over it. Browsers give the count, not the names, until the drop. */
defineProps<{ count: number }>() defineProps<{ count: number; titleKey: string }>()
const { t } = useI18n() const { t } = useI18n()
</script> </script>
@@ -15,7 +15,7 @@ const { t } = useI18n()
<div class="mx-auto mb-3.5 grid size-14 place-items-center rounded-full bg-primary/15 text-primary"> <div class="mx-auto mb-3.5 grid size-14 place-items-center rounded-full bg-primary/15 text-primary">
<Upload :size="26" /> <Upload :size="26" />
</div> </div>
<p class="text-lg font-semibold text-foreground">{{ t('settings.admin.serverFonts.drop.title', { count }) }}</p> <p class="text-lg font-semibold text-foreground">{{ t(titleKey, { count }) }}</p>
<p class="mt-1 text-[13px] text-muted-foreground">{{ t('settings.reader.fonts.fileTypesHint') }}</p> <p class="mt-1 text-[13px] text-muted-foreground">{{ t('settings.reader.fonts.fileTypesHint') }}</p>
</div> </div>
</div> </div>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { FileUp, MoreHorizontal, Pencil, Trash2 } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
/** A family row's overflow menu. Removal is listed last and asks in the family sheet before it runs. */
const props = defineProps<{ name: string }>()
const emit = defineEmits<{ rename: [name: string]; addFiles: []; remove: [name: string] }>()
const { t } = useI18n()
function handleRename() {
emit('rename', props.name)
}
function handleAddFiles() {
emit('addFiles')
}
function handleRemove() {
emit('remove', props.name)
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon-sm" type="button" :aria-label="t('settings.admin.serverFonts.moreActions', { name })">
<MoreHorizontal :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52">
<DropdownMenuItem @click="handleRename">
<Pencil />
{{ t('settings.admin.serverFonts.menu.rename') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleAddFiles">
<FileUp />
{{ t('settings.admin.serverFonts.menu.addFiles') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" @click="handleRemove">
<Trash2 />
{{ t('settings.admin.serverFonts.menu.remove') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { TriangleAlert } from '@lucide/vue'
import type { FontFamily } from '@/features/reader/epub/composables/useCustomFonts'
import { useFontLabels } from '../composables/useFontLabels'
import FontStyleGrid from './FontStyleGrid.vue'
/**
* One family as a ledger row: its name in its own face, a specimen, the styles it really has, how it
* is used, and one trailing control. The page decides what the use column says and what the control is.
*/
const props = withDefaults(
defineProps<{
family: FontFamily
previewText: string
previewSize: number
isNew?: boolean
/** The browser refused the family's files, so readers see a fallback face. */
loadFailed?: boolean
/** Set in the muted colour, for a family the reader has hidden from their picker. */
muted?: boolean
}>(),
{ isNew: false, loadFailed: false, muted: false },
)
const emit = defineEmits<{ open: [name: string] }>()
const { t } = useI18n()
const { styleSummaryText, fileCountSummary } = useFontLabels()
const fontFamily = computed(() => `'${props.family.cssFamilyName}', sans-serif`)
const summary = computed(() => styleSummaryText(props.family.variants))
// Scales down with the row on narrow screens, so a 32 px preview does not dwarf a phone.
const specimenStyle = computed(() => ({ fontFamily: fontFamily.value, fontSize: `min(${props.previewSize}px, 6cqi)`, lineHeight: 1.35 }))
function handleOpen() {
emit('open', props.family.name)
}
</script>
<template>
<div
class="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2.5 rounded-xl border border-border bg-card px-4 py-3.5 shadow-xs transition-colors hover:border-foreground/15 @min-[60rem]:grid-cols-[12rem_minmax(0,1fr)_8.75rem_9rem_2.25rem] @min-[60rem]:items-center @min-[60rem]:gap-x-5 @min-[60rem]:py-[17px] @min-[60rem]:ps-6 @min-[60rem]:pe-5"
:class="isNew ? 'bg-[linear-gradient(90deg,color-mix(in_oklch,var(--success)_9%,transparent),transparent_40%)]' : ''"
data-testid="font-family-row"
@click="handleOpen"
>
<div class="order-1 flex min-w-0 flex-col gap-0.5">
<button
type="button"
class="block max-w-full truncate rounded-sm text-start text-[21px] leading-7 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="muted ? 'text-muted-foreground' : 'text-foreground'"
:style="{ fontFamily }"
>
{{ family.name }}
</button>
<span class="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<span v-if="isNew" class="rounded-full bg-success/15 px-1.5 text-[11px] leading-[18px] font-medium text-success">{{
t('settings.admin.serverFonts.newBadge')
}}</span>
<span class="tabular-nums">{{ fileCountSummary(family.variants) }}</span>
</span>
<span v-if="loadFailed" class="mt-0.5 inline-flex items-center gap-1.5 text-[12.5px] text-warning">
<TriangleAlert :size="13" class="shrink-0" aria-hidden="true" />
{{ t('settings.admin.serverFonts.loadFailed') }}
</span>
</div>
<p
class="order-3 col-span-2 min-w-0 overflow-hidden whitespace-nowrap [mask-image:linear-gradient(to_right,black_calc(100%-3.5rem),transparent)] rtl:[mask-image:linear-gradient(to_left,black_calc(100%-3.5rem),transparent)] @min-[60rem]:order-2 @min-[60rem]:col-span-1"
:class="muted ? 'text-muted-foreground' : 'text-foreground'"
:style="specimenStyle"
>
{{ previewText }}
</p>
<div
class="order-4 col-span-2 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 @min-[60rem]:order-3 @min-[60rem]:col-span-1 @min-[60rem]:flex-col @min-[60rem]:items-start"
>
<FontStyleGrid :fonts="family.variants" :label="summary" />
<span class="text-[12.5px] text-muted-foreground">{{ summary }}</span>
</div>
<div
class="order-5 col-span-2 flex flex-wrap gap-x-3 gap-y-1 empty:hidden @min-[60rem]:order-4 @min-[60rem]:col-span-1 @min-[60rem]:flex-col @min-[60rem]:empty:flex"
data-testid="font-family-use"
>
<slot name="use" />
</div>
<div class="order-2 flex items-center justify-end self-start @min-[60rem]:order-5 @min-[60rem]:self-center" @click.stop>
<slot name="action" />
</div>
</div>
</template>
@@ -2,7 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { EyeOff, FileUp, MoreHorizontal, Pencil, Trash2, TriangleAlert } from '@lucide/vue' import { EyeOff, FileUp, MoreHorizontal, Pencil, Trash2, TriangleAlert } from '@lucide/vue'
import type { FontStyle, ServerFontFamilyUsage, UserFont } from '@bookorbit/types' import type { FontScope, FontStyle, UserFont } from '@bookorbit/types'
import { FONT_WEIGHTS, fontCssFamilyGroupName, isVariableFont } from '@bookorbit/types' import { FONT_WEIGHTS, fontCssFamilyGroupName, isVariableFont } from '@bookorbit/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -10,28 +10,45 @@ import type { FontFamily } from '@/features/reader/epub/composables/useCustomFon
import { FONT_WEIGHT_LABEL_KEYS, familyVariants, fontVariantLabel } from '@/features/reader/shared/lib/font-variants' import { FONT_WEIGHT_LABEL_KEYS, familyVariants, fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
import { formatNumber } from '@/i18n/formatters' import { formatNumber } from '@/i18n/formatters'
import { formatBytes } from '@/lib/formatting' import { formatBytes } from '@/lib/formatting'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsEditorSheet from '../../components/SettingsEditorSheet.vue' import SettingsEditorSheet from '../../components/SettingsEditorSheet.vue'
import { useFontLabels } from '../composables/useFontLabels' import { useFontLabels } from '../composables/useFontLabels'
import type { FamilyUse } from '../composables/useFontLibrary'
import FontPagePreview, { type PreviewPaper } from './FontPagePreview.vue' import FontPagePreview, { type PreviewPaper } from './FontPagePreview.vue'
import ReaderFontPickerPreview from './ReaderFontPickerPreview.vue' import ReaderFontPickerPreview from './ReaderFontPickerPreview.vue'
const props = defineProps<{ /**
open: boolean * One family, previewed and managed. `server` is an administrator looking after the shared
family: FontFamily | null * collection; `own` is a reader looking after their own fonts, or (in `view` mode) looking at a
usage: ServerFontFamilyUsage * server font they can only show or hide.
/** Every server family, for the copy of the reader's font list. */ */
pickerFamilies: FontFamily[] const props = withDefaults(
totalFiles: number defineProps<{
maxFiles: number open: boolean
readonly: boolean family: FontFamily | null
busy: boolean scope: FontScope
/** Opens with "Remove family" already asked, for the row menu's entry of the same name. */ perspective: 'server' | 'own'
armRemove: boolean mode?: 'edit' | 'view'
/** Opens with the name field focused, for the row menu's "Rename family". */ usage: FamilyUse
focusName: boolean /** The families a reader's font list offers, for the copy of it. */
/** The browser refused the family's files, so every preview here is a fallback face. */ pickerServerFamilies: FontFamily[]
loadFailed: boolean pickerUserFamilies?: FontFamily[]
}>() totalFiles: number
maxFiles: number
readonly: boolean
busy: boolean
/** Opens with "Remove family" already asked, for the row menu's entry of the same name. */
armRemove?: boolean
/** Opens with the name field focused, for the row menu's "Rename family". */
focusName?: boolean
/** The browser refused the family's files, so every preview here is a fallback face. */
loadFailed?: boolean
/** View mode: whether this reader's font list offers the family. */
shown?: boolean
showPending?: boolean
}>(),
{ mode: 'edit', pickerUserFamilies: () => [], armRemove: false, focusName: false, loadFailed: false, shown: true, showPending: false },
)
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
@@ -40,6 +57,7 @@ const emit = defineEmits<{
addFiles: [] addFiles: []
updateFile: [fontId: number, data: { weight: number; style: FontStyle }] updateFile: [fontId: number, data: { weight: number; style: FontStyle }]
removeFile: [fontId: number] removeFile: [fontId: number]
'update:shown': [shown: boolean]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -85,18 +103,38 @@ const dirty = computed(() => props.family !== null && trimmedDraft.value !== ''
/** Saved reader settings name a family by its CSS name, so a rename that changes it strands them. */ /** Saved reader settings name a family by its CSS name, so a rename that changes it strands them. */
const renameStrandsBooks = computed( const renameStrandsBooks = computed(
() => dirty.value && props.usage.books > 0 && fontCssFamilyGroupName(trimmedDraft.value, 'server') !== fontCssFamilyGroupName(name.value, 'server'), () =>
dirty.value &&
(props.usage.books > 0 || props.usage.defaults > 0) &&
fontCssFamilyGroupName(trimmedDraft.value, props.scope) !== fontCssFamilyGroupName(name.value, props.scope),
) )
const editable = computed(() => props.mode === 'edit' && !props.readonly)
const removeConsequence = computed(() => { const removeConsequence = computed(() => {
const parts: string[] = [] const parts: string[] = []
if (props.usage.books > 0) { if (props.perspective === 'server') {
parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceBooks', { books: props.usage.books, readers: props.usage.readers })) if (props.usage.books > 0) {
parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceBooks', { books: props.usage.books, readers: props.usage.readers }))
}
} else {
if (props.usage.books > 0) parts.push(t('settings.reader.fonts.library.removeConsequenceBooks', { count: props.usage.books }))
if (props.usage.defaults > 0) parts.push(t('settings.reader.fonts.library.removeConsequenceDefault'))
} }
parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceFiles', { count: files.value.length })) parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceFiles', { count: files.value.length }))
return parts.join(' ') return parts.join(' ')
}) })
const filesUsed = computed(() =>
props.perspective === 'server'
? t('settings.admin.serverFonts.sheet.filesUsed', { count: formatNumber(props.totalFiles), max: formatNumber(props.maxFiles) })
: t('settings.reader.fonts.library.filesUsed', { count: formatNumber(props.totalFiles), max: formatNumber(props.maxFiles) }),
)
function handleShown(shown: boolean) {
emit('update:shown', shown)
}
const weightOptions = computed(() => FONT_WEIGHTS.map((weight) => ({ value: weight, label: `${t(FONT_WEIGHT_LABEL_KEYS[weight]!)} (${weight})` }))) const weightOptions = computed(() => FONT_WEIGHTS.map((weight) => ({ value: weight, label: `${t(FONT_WEIGHT_LABEL_KEYS[weight]!)} (${weight})` })))
function handleSave() { function handleSave() {
@@ -151,9 +189,13 @@ function confirmRemoveFile() {
:description="t('settings.admin.serverFonts.sheet.description', { name })" :description="t('settings.admin.serverFonts.sheet.description', { name })"
:dirty="dirty" :dirty="dirty"
:busy="busy" :busy="busy"
:removable="!readonly" :removable="editable"
:remove-label="t('settings.admin.serverFonts.sheet.removeLabel')" :remove-label="t('settings.admin.serverFonts.sheet.removeLabel')"
:remove-confirm="t('settings.admin.serverFonts.sheet.removeQuestion', { name })" :remove-confirm="
perspective === 'server'
? t('settings.admin.serverFonts.sheet.removeQuestion', { name })
: t('settings.reader.fonts.library.removeQuestion', { name })
"
:remove-consequence="removeConsequence" :remove-consequence="removeConsequence"
:armed-action="armRemove ? REMOVE_ACTION : null" :armed-action="armRemove ? REMOVE_ACTION : null"
@save="handleSave" @save="handleSave"
@@ -205,19 +247,23 @@ function confirmRemoveFile() {
<section class="flex flex-col gap-2.5"> <section class="flex flex-col gap-2.5">
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.inReader') }}</span> <span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.inReader') }}</span>
<ReaderFontPickerPreview :families="pickerFamilies" :selected="family" /> <ReaderFontPickerPreview :server-families="pickerServerFamilies" :user-families="pickerUserFamilies" :selected="family" />
<p class="text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.sheet.inReaderHint', { name: family.name }) }}</p> <p class="text-xs text-muted-foreground">
{{
perspective === 'server'
? t('settings.admin.serverFonts.sheet.inReaderHint', { name: family.name })
: t('settings.reader.fonts.library.inReaderHint', { name: family.name })
}}
</p>
</section> </section>
<section class="flex flex-col gap-2.5"> <section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between gap-3"> <div class="flex items-baseline justify-between gap-3">
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.files') }}</span> <span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.files') }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ <span v-if="mode === 'edit'" class="text-xs text-muted-foreground tabular-nums">{{ filesUsed }}</span>
t('settings.admin.serverFonts.sheet.filesUsed', { count: formatNumber(totalFiles), max: formatNumber(maxFiles) })
}}</span>
</div> </div>
<ul class="settings-card"> <ul class="settings-card">
<li v-for="font in files" :key="font.id" class="bg-card px-3.5 py-2.5" data-testid="server-font-file"> <li v-for="font in files" :key="font.id" class="bg-card px-3.5 py-2.5" data-testid="font-family-file">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-[9.5px] font-bold tracking-wide text-muted-foreground uppercase" class="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-[9.5px] font-bold tracking-wide text-muted-foreground uppercase"
@@ -229,7 +275,7 @@ function confirmRemoveFile() {
<p class="text-[12.5px] leading-[18px] text-muted-foreground">{{ fileDetail(font) }}</p> <p class="text-[12.5px] leading-[18px] text-muted-foreground">{{ fileDetail(font) }}</p>
</div> </div>
<span class="shrink-0 text-[12.5px] text-muted-foreground tabular-nums max-sm:hidden">{{ formatBytes(font.fileSize) }}</span> <span class="shrink-0 text-[12.5px] text-muted-foreground tabular-nums max-sm:hidden">{{ formatBytes(font.fileSize) }}</span>
<DropdownMenu v-if="!readonly"> <DropdownMenu v-if="editable">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button <Button
variant="ghost" variant="ghost"
@@ -287,11 +333,13 @@ function confirmRemoveFile() {
</ul> </ul>
</section> </section>
<section class="flex flex-col gap-2"> <section v-if="mode === 'edit'" class="flex flex-col gap-2">
<label for="server-font-family-name" class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.name') }}</label> <label for="font-family-name" class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.name') }}</label>
<input id="server-font-family-name" ref="nameInput" v-model="nameDraft" class="input-field h-9 w-full" :disabled="readonly" maxlength="200" /> <input id="font-family-name" ref="nameInput" v-model="nameDraft" class="input-field h-9 w-full" :disabled="readonly" maxlength="200" />
<p class="text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.sheet.nameHint') }}</p> <p class="text-xs text-muted-foreground">
<p v-if="dirty && usage.hidden > 0" class="flex items-start gap-1.5 text-xs text-muted-foreground"> {{ perspective === 'server' ? t('settings.admin.serverFonts.sheet.nameHint') : t('settings.reader.fonts.library.nameHint') }}
</p>
<p v-if="dirty && perspective === 'server' && usage.hidden > 0" class="flex items-start gap-1.5 text-xs text-muted-foreground">
<EyeOff :size="13" class="mt-0.5 shrink-0" aria-hidden="true" /> <EyeOff :size="13" class="mt-0.5 shrink-0" aria-hidden="true" />
{{ t('settings.admin.serverFonts.sheet.nameHiddenWarning', { count: usage.hidden, name: family.name }) }} {{ t('settings.admin.serverFonts.sheet.nameHiddenWarning', { count: usage.hidden, name: family.name }) }}
</p> </p>
@@ -301,7 +349,7 @@ function confirmRemoveFile() {
</p> </p>
</section> </section>
<section class="flex flex-col gap-2.5"> <section v-if="perspective === 'server'" class="flex flex-col gap-2.5">
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.readers') }}</span> <span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.readers') }}</span>
<dl class="grid grid-cols-3 overflow-hidden rounded-xl border border-border bg-card"> <dl class="grid grid-cols-3 overflow-hidden rounded-xl border border-border bg-card">
<div class="flex flex-col gap-0.5 px-4 py-3"> <div class="flex flex-col gap-0.5 px-4 py-3">
@@ -327,11 +375,27 @@ function confirmRemoveFile() {
</section> </section>
</template> </template>
<template v-if="!readonly" #actions> <template v-if="editable" #actions>
<Button size="sm" variant="outline" type="button" :disabled="busy" @click="handleAddFiles"> <Button size="sm" variant="outline" type="button" :disabled="busy" @click="handleAddFiles">
<FileUp :size="14" aria-hidden="true" /> <FileUp :size="14" aria-hidden="true" />
{{ t('settings.admin.serverFonts.menu.addFiles') }} {{ t('settings.admin.serverFonts.menu.addFiles') }}
</Button> </Button>
</template> </template>
<!-- A server font on a reader's own page: nothing to save, only whether their list offers it. -->
<template v-if="mode === 'view'" #footer="{ requestClose }">
<div class="flex flex-wrap items-center gap-3">
<label class="flex min-w-0 flex-1 items-center gap-3 text-sm text-foreground">
<ToggleSwitch
:model-value="shown"
:disabled="showPending || readonly"
:aria-label="t('settings.reader.fonts.library.showInReader')"
@update:model-value="handleShown"
/>
{{ t('settings.reader.fonts.library.showInReader') }}
</label>
<Button size="sm" variant="outline" type="button" @click="requestClose">{{ t('common.close') }}</Button>
</div>
</template>
</SettingsEditorSheet> </SettingsEditorSheet>
</template> </template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
/** Column labels over a family ledger, on the same grid as FontFamilyRow and only where it is a grid. */
defineProps<{ useLabel: string }>()
const { t } = useI18n()
</script>
<template>
<div
aria-hidden="true"
class="hidden grid-cols-[12rem_minmax(0,1fr)_8.75rem_9rem_2.25rem] gap-5 ps-[25px] pe-[21px] pt-1.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase @min-[60rem]:grid"
>
<span>{{ t('settings.admin.serverFonts.columns.family') }}</span>
<span>{{ t('settings.admin.serverFonts.columns.specimen') }}</span>
<span>{{ t('settings.admin.serverFonts.columns.styles') }}</span>
<span>{{ useLabel }}</span>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Search, TextCursorInput, X } from '@lucide/vue'
import { formatNumber } from '@/i18n/formatters'
import { formatBytes } from '@/lib/formatting'
import { PREVIEW_SIZES } from '../lib/preview-sizes'
/** Filter, the preview text and size every specimen follows, and the collection's totals against its cap. */
defineProps<{
families: number
files: number
maxFiles: number
bytes: number
}>()
const query = defineModel<string>('query', { required: true })
const previewText = defineModel<string>('previewText', { required: true })
const previewSize = defineModel<number>('previewSize', { required: true })
const { t } = useI18n()
function handleQueryInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
}
function clearQuery() {
query.value = ''
}
function handlePreviewInput(event: Event) {
previewText.value = (event.target as HTMLInputElement).value
}
function selectSize(size: number) {
previewSize.value = size
}
</script>
<template>
<div class="flex flex-wrap items-center gap-2.5">
<label class="relative block w-full min-w-0 @min-[48rem]:w-60">
<span class="sr-only">{{ t('settings.admin.serverFonts.filterLabel') }}</span>
<Search :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
class="input-field h-9 w-full ps-9 pe-9"
:value="query"
:placeholder="t('settings.admin.serverFonts.filterPlaceholder')"
@input="handleQueryInput"
/>
<button
v-if="query"
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('settings.admin.serverFonts.filterClear')"
@click="clearQuery"
>
<X :size="14" aria-hidden="true" />
</button>
</label>
<label class="relative block min-w-0 flex-1 @min-[48rem]:max-w-[22.5rem]">
<span class="sr-only">{{ t('settings.admin.serverFonts.previewLabel') }}</span>
<TextCursorInput :size="15" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="text"
class="input-field h-9 w-full ps-9"
:value="previewText"
:placeholder="t('settings.admin.serverFonts.previewPlaceholder')"
@input="handlePreviewInput"
/>
</label>
<div
role="group"
:aria-label="t('settings.admin.serverFonts.previewSize')"
class="flex shrink-0 gap-0.5 rounded-lg border border-border bg-muted p-0.5"
>
<button
v-for="(size, index) in PREVIEW_SIZES"
:key="size"
type="button"
class="grid h-[30px] w-[30px] place-items-center rounded-md font-serif leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="previewSize === size ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
:style="{ fontSize: `${11 + index * 3}px` }"
:aria-pressed="previewSize === size"
:aria-label="t('settings.admin.serverFonts.previewSizeOption', { size })"
:title="t('settings.admin.serverFonts.previewSizeOption', { size })"
@click="selectSize(size)"
>
A
</button>
</div>
<!-- The figures carry the weight; the units stay quiet beside them. -->
<p
data-testid="font-totals"
class="flex w-full flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[13px] text-muted-foreground @min-[60rem]:ms-auto @min-[60rem]:w-auto"
>
<span
><span class="font-semibold text-foreground tabular-nums">{{ formatNumber(families) }}</span>
{{ t('settings.admin.serverFonts.familiesUnit', { count: families }) }}</span
>
<span class="opacity-50" aria-hidden="true">&middot;</span>
<i18n-t keypath="settings.admin.serverFonts.filesOfMax" tag="span" scope="global">
<template #count
><span class="font-semibold text-foreground tabular-nums">{{ formatNumber(files) }}</span></template
>
<template #max>{{ formatNumber(maxFiles) }}</template>
</i18n-t>
<span class="opacity-50" aria-hidden="true">&middot;</span>
<i18n-t keypath="settings.admin.serverFonts.onDisk" tag="span" scope="global">
<template #size
><span class="font-semibold text-foreground tabular-nums">{{ formatBytes(bytes) }}</span></template
>
</i18n-t>
</p>
</div>
</template>
@@ -2,11 +2,12 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ChevronRight, CircleCheck, CircleDashed, CircleX, Clock, LoaderCircle, Upload } from '@lucide/vue' import { ChevronRight, CircleCheck, CircleDashed, CircleX, Clock, LoaderCircle, Upload } from '@lucide/vue'
import type { FontScope } from '@bookorbit/types'
import { fontCssFamilyGroupName } from '@bookorbit/types' import { fontCssFamilyGroupName } from '@bookorbit/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { fontVariantLabel } from '@/features/reader/shared/lib/font-variants' import { fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
import { formatBytes } from '@/lib/formatting' import { formatBytes } from '@/lib/formatting'
import type { UploadEntry } from '../composables/useServerFontLibrary' import type { UploadEntry } from '../composables/useFontLibrary'
import { refusalMessageKey } from '../lib/upload-outcome' import { refusalMessageKey } from '../lib/upload-outcome'
/** /**
@@ -14,7 +15,7 @@ import { refusalMessageKey } from '../lib/upload-outcome'
* already on the server, or why it was refused. Replaces a toast per file, which scrolled away before * already on the server, or why it was refused. Replaces a toast per file, which scrolled away before
* a ten-file family had finished. * a ten-file family had finished.
*/ */
const props = defineProps<{ entries: UploadEntry[] }>() const props = withDefaults(defineProps<{ entries: UploadEntry[]; scope?: FontScope }>(), { scope: 'server' })
const emit = defineEmits<{ dismiss: [] }>() const emit = defineEmits<{ dismiss: [] }>()
const { t } = useI18n() const { t } = useI18n()
@@ -40,7 +41,7 @@ function iconClass(entry: UploadEntry): string {
function faceStyle(entry: UploadEntry) { function faceStyle(entry: UploadEntry) {
if (!entry.face) return {} if (!entry.face) return {}
return { return {
fontFamily: `'${fontCssFamilyGroupName(entry.face.familyName, 'server')}', sans-serif`, fontFamily: `'${fontCssFamilyGroupName(entry.face.familyName, props.scope)}', sans-serif`,
fontWeight: entry.face.weight, fontWeight: entry.face.weight,
fontStyle: entry.face.style, fontStyle: entry.face.style,
} }
@@ -53,7 +54,7 @@ function faceLabel(entry: UploadEntry): string {
} }
function reason(entry: UploadEntry): string { function reason(entry: UploadEntry): string {
return t(refusalMessageKey(entry.code), entry.meta) return t(refusalMessageKey(entry.code, props.scope), entry.meta)
} }
function handleDismiss() { function handleDismiss() {
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { BookMarked, BookOpen, EyeOff } from '@lucide/vue'
import type { FamilyUse } from '../composables/useFontLibrary'
/**
* How a family is used, exceptions only: nothing is drawn for a family nobody has touched. `server`
* counts every reader, for an administrator; `own` speaks for the signed-in reader alone.
*/
const props = defineProps<{ use: FamilyUse; perspective: 'server' | 'own' }>()
const { t } = useI18n()
const fact = 'inline-flex items-center gap-1.5 text-[12.5px]'
function booksTitle(): string | undefined {
return props.perspective === 'server'
? t('settings.admin.serverFonts.facts.booksDetail', { books: props.use.books, readers: props.use.readers })
: undefined
}
</script>
<template>
<span v-if="use.books > 0" :class="fact" class="text-foreground" :title="booksTitle()">
<BookOpen :size="14" class="text-muted-foreground" aria-hidden="true" />
{{ t('settings.admin.serverFonts.facts.books', { count: use.books }) }}
</span>
<span v-if="use.defaults > 0" :class="fact" class="text-foreground">
<BookMarked :size="14" class="text-muted-foreground" aria-hidden="true" />
{{
perspective === 'server'
? t('settings.admin.serverFonts.facts.defaults', { count: use.defaults })
: t('settings.reader.fonts.library.yourDefault')
}}
</span>
<span v-if="perspective === 'server' && use.hidden > 0" :class="fact" class="text-muted-foreground">
<EyeOff :size="14" aria-hidden="true" />
{{ t('settings.admin.serverFonts.facts.hidden', { count: use.hidden }) }}
</span>
</template>
@@ -6,18 +6,30 @@ import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display' import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
import { closestVariant, familyVariants, fontVariantLabel, isSameVariant } from '@/features/reader/shared/lib/font-variants' import { closestVariant, familyVariants, fontVariantLabel, isSameVariant } from '@/features/reader/shared/lib/font-variants'
/**
* The Font section of the reader's settings panel, drawn with this server's families and one of them
* chosen, so an administrator sees exactly what readers will be offered. It mirrors
* ReaderSettingsPanel's markup and is display-only.
*/
const props = defineProps<{
families: FontFamily[]
selected: FontFamily | null
}>()
const { t } = useI18n() const { t } = useI18n()
/**
* The Font section of the reader's settings panel, drawn with real families and one of them chosen,
* so whoever manages fonts sees exactly what a reader's list offers. It mirrors ReaderSettingsPanel's
* markup and is display-only.
*/
const props = withDefaults(
defineProps<{
serverFamilies: FontFamily[]
userFamilies?: FontFamily[]
selected: FontFamily | null
}>(),
{ userFamilies: () => [] },
)
/** In the reader's own order: server fonts, then the reader's own. */
const sections = computed(() =>
[
{ key: 'server', label: t('reader.settings.fontServer'), families: props.serverFamilies },
{ key: 'user', label: t('reader.settings.fontYours'), families: props.userFamilies },
].filter((section) => section.families.length > 0),
)
const chips = computed(() => (props.selected ? familyVariants(props.selected.variants) : [])) const chips = computed(() => (props.selected ? familyVariants(props.selected.variants) : []))
/** A reader arriving with default settings lands on the style nearest Regular, as the reader picks it. */ /** A reader arriving with default settings lands on the style nearest Regular, as the reader picks it. */
const chosenChip = computed(() => closestVariant(chips.value, { weight: 400, style: 'normal' })) const chosenChip = computed(() => closestVariant(chips.value, { weight: 400, style: 'normal' }))
@@ -41,11 +53,11 @@ const tile = 'flex h-10 items-center truncate rounded-lg border px-2 text-sm'
> >
</div> </div>
<template v-if="families.length > 0"> <template v-for="section in sections" :key="section.key">
<p :class="groupLabel" class="mt-3">{{ t('reader.settings.fontServer') }}</p> <p :class="groupLabel" class="mt-3">{{ section.label }}</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<span <span
v-for="family in families" v-for="family in section.families"
:key="family.cssFamilyName" :key="family.cssFamilyName"
:class="[ :class="[
tile, tile,
@@ -40,16 +40,17 @@ describe('useFailedFontFaces', () => {
delete (document as { fonts?: unknown }).fonts delete (document as { fonts?: unknown }).fonts
}) })
it('collects the server families the browser refused, and only those', () => { it('collects the uploaded families the browser refused, and only those', () => {
installFontSet([ installFontSet([
{ family: '"__serverfont_big_caslon"', status: 'error' }, { family: '"__serverfont_big_caslon"', status: 'error' },
{ family: '__serverfont_courgette', status: 'loaded' }, { family: '__serverfont_courgette', status: 'loaded' },
{ family: '__userfont_georgia', status: 'error' }, { family: '__userfont_georgia', status: 'error' },
{ family: 'Inter Variable', status: 'error' },
]) ])
const { failed } = mountProbe() const { failed } = mountProbe()
expect([...failed.value]).toEqual(['__serverfont_big_caslon']) expect([...failed.value]).toEqual(['__serverfont_big_caslon', '__userfont_georgia'])
}) })
it('picks up a face that fails after the page has loaded', async () => { it('picks up a face that fails after the page has loaded', async () => {
@@ -11,7 +11,7 @@ vi.mock('@/features/reader/epub/composables/useCustomFonts', async (importOrigin
})) }))
import { FontUploadError, useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts' import { FontUploadError, useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
import { useServerFontLibrary } from '../useServerFontLibrary' import { useFontLibrary } from '../useFontLibrary'
const USAGE = { const USAGE = {
families: [ families: [
@@ -26,11 +26,11 @@ function respond(body: unknown, ok = true) {
function setup(fonts = [mockFont(3, 'Courgette'), mockFont(2, 'Big Caslon', 500)]): { function setup(fonts = [mockFont(3, 'Courgette'), mockFont(2, 'Big Caslon', 500)]): {
store: MockFontStore store: MockFontStore
library: ReturnType<typeof useServerFontLibrary> library: ReturnType<typeof useFontLibrary>
} { } {
const store = makeFontStore('server', fonts) const store = makeFontStore('server', fonts)
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ server: store }) as never) vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ server: store }) as never)
return { store, library: useServerFontLibrary() } return { store, library: useFontLibrary('server') }
} }
const fontFile = (name: string, size = 1000) => { const fontFile = (name: string, size = 1000) => {
@@ -39,7 +39,7 @@ const fontFile = (name: string, size = 1000) => {
return file return file
} }
describe('useServerFontLibrary', () => { describe('useFontLibrary', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
apiMock.mockImplementation((url: string) => (url.endsWith('/usage') ? respond(USAGE) : respond({}, false))) apiMock.mockImplementation((url: string) => (url.endsWith('/usage') ? respond(USAGE) : respond({}, false)))
@@ -53,14 +53,14 @@ describe('useServerFontLibrary', () => {
expect(store.fetchFonts).toHaveBeenCalled() expect(store.fetchFonts).toHaveBeenCalled()
expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/usage') expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/usage')
expect(library.usageOf('Courgette')).toMatchObject({ books: 2, readers: 1 }) expect(library.usageOf('__serverfont_courgette')).toMatchObject({ books: 2, readers: 1 })
}) })
it('answers zeros for a family the server did not count', async () => { it('answers zeros for a family the server did not count', async () => {
const { library } = setup() const { library } = setup()
await library.load() await library.load()
expect(library.usageOf('Literata')).toEqual({ familyName: 'Literata', books: 0, readers: 0, defaults: 0, hidden: 0 }) expect(library.usageOf('__serverfont_literata')).toEqual({ books: 0, readers: 0, defaults: 0, hidden: 0 })
}) })
it('keeps working when the counts cannot be loaded', async () => { it('keeps working when the counts cannot be loaded', async () => {
@@ -68,7 +68,7 @@ describe('useServerFontLibrary', () => {
const { library } = setup() const { library } = setup()
await expect(library.load()).resolves.toBeUndefined() await expect(library.load()).resolves.toBeUndefined()
expect(library.usageOf('Courgette').books).toBe(0) expect(library.usageOf('__serverfont_courgette').books).toBe(0)
}) })
}) })
@@ -237,3 +237,49 @@ describe('useServerFontLibrary', () => {
}) })
}) })
}) })
describe("useFontLibrary('user')", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("reads the reader's own counts, which cover their fonts and the server's, by CSS name", async () => {
apiMock.mockImplementation((url: string) =>
url === '/api/v1/fonts/usage'
? respond({
fonts: [
{ cssFamily: '__userfont_georgia', books: 2, isDefault: false },
{ cssFamily: '__serverfont_courgette', books: 0, isDefault: true },
],
})
: respond({}, false),
)
const store = makeFontStore('user', [mockFont(1, 'Georgia')])
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ user: store }) as never)
const library = useFontLibrary('user')
await library.load()
expect(store.fetchFonts).toHaveBeenCalled()
expect(library.usageOf('__userfont_georgia')).toEqual({ books: 2, readers: 1, defaults: 0, hidden: 0 })
expect(library.usageOf('__serverfont_courgette')).toEqual({ books: 0, readers: 0, defaults: 1, hidden: 0 })
})
it('renames a family through the personal route', async () => {
apiMock.mockImplementation((url: string) => (url === '/api/v1/fonts/families' ? respond([mockFont(1, 'Georgia Pro')]) : respond({ fonts: [] })))
const store = makeFontStore('user', [mockFont(1, 'Georgia')])
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ user: store }) as never)
const library = useFontLibrary('user')
await expect(library.renameFamily('Georgia', 'Georgia Pro')).resolves.toEqual({ ok: true })
expect(store.fonts.value.map((font) => font.familyName)).toEqual(['Georgia Pro'])
})
it("shares the page's useCustomFonts state when given one", () => {
const customFonts = makeCustomFontsMock()
useFontLibrary('user', customFonts as never)
expect(customFonts.scopeStore).toHaveBeenCalledWith('user')
expect(useCustomFonts).not.toHaveBeenCalled()
})
})
@@ -2,7 +2,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { FONT_CSS_FAMILY_PREFIXES } from '@bookorbit/types' import { FONT_CSS_FAMILY_PREFIXES } from '@bookorbit/types'
/** /**
* CSS family names of server fonts this browser refused to load. A file can pass the server's checks * CSS family names of uploaded fonts this browser refused to load. A file can pass the server's checks
* and still be rejected by the browser's font sanitizer (some system fonts carry tables it will not * and still be rejected by the browser's font sanitizer (some system fonts carry tables it will not
* accept), and then every reader silently gets a fallback face instead. * accept), and then every reader silently gets a fallback face instead.
*/ */
@@ -14,7 +14,7 @@ export function useFailedFontFaces() {
const next = new Set<string>() const next = new Set<string>()
document.fonts.forEach((face) => { document.fonts.forEach((face) => {
const family = face.family.replace(/^["']|["']$/g, '') const family = face.family.replace(/^["']|["']$/g, '')
if (face.status === 'error' && family.startsWith(FONT_CSS_FAMILY_PREFIXES.server)) next.add(family) if (face.status === 'error' && Object.values(FONT_CSS_FAMILY_PREFIXES).some((prefix) => family.startsWith(prefix))) next.add(family)
}) })
if (next.size !== failed.value.size || [...next].some((family) => !failed.value.has(family))) failed.value = next if (next.size !== failed.value.size || [...next].some((family) => !failed.value.has(family))) failed.value = next
} }
@@ -0,0 +1,78 @@
import { computed, ref, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import type { FontFamily } from '@/features/reader/epub/composables/useCustomFonts'
import type { useFontLibrary } from './useFontLibrary'
/**
* Which family's sheet is open, how it was opened, and what its Save, Remove and file actions do.
* A row's "Rename family" opens it with the name focused; "Remove family" opens it already asking.
*/
export function useFamilySheet(library: ReturnType<typeof useFontLibrary>, families: ComputedRef<FontFamily[]>) {
const { t } = useI18n()
const openName = ref<string | null>(null)
const armRemove = ref(false)
const focusName = ref(false)
const busy = ref(false)
const openFamily = computed(() => families.value.find((family) => family.name === openName.value) ?? null)
const openUse = computed(() => library.usageOf(openFamily.value?.cssFamilyName ?? ''))
function open(name: string, options: { remove?: boolean; rename?: boolean } = {}) {
armRemove.value = options.remove ?? false
focusName.value = options.rename ?? false
openName.value = name
}
function close() {
openName.value = null
armRemove.value = false
focusName.value = false
}
async function rename(newName: string) {
const name = openName.value
if (!name) return
busy.value = true
const result = await library.renameFamily(name, newName)
busy.value = false
if (result.ok) {
toast.success(t('settings.reader.fonts.renamedTo', { name: newName }))
close()
} else if (result.code === 'FONT_FACE_TAKEN') {
toast.error(t('settings.admin.serverFonts.toasts.renameTaken', { name: newName }))
} else {
toast.error(t('settings.reader.fonts.renameFamilyFailed'))
}
}
async function removeFamily() {
const name = openName.value
if (!name) return
busy.value = true
const { failed } = await library.removeFamily(name)
busy.value = false
if (failed === 0) {
toast.success(t('settings.admin.serverFonts.toasts.removed', { name }))
close()
} else {
toast.error(t('settings.admin.serverFonts.toasts.removePartial', { failed, name }))
}
}
async function updateFile(fontId: number, data: { weight: number; style: 'normal' | 'italic' }) {
if (!(await library.updateFile(fontId, data))) toast.error(t('settings.reader.fonts.updateVariantFailed'))
}
async function removeFile(fontId: number) {
const ok = await library.store.deleteFont(fontId).catch(() => false)
if (!ok) {
toast.error(t('settings.reader.fonts.deleteFontFailed'))
return
}
if (!openFamily.value) close()
await library.fetchUsage()
}
return { openName, openFamily, openUse, armRemove, focusName, busy, open, close, rename, removeFamily, updateFile, removeFile }
}
@@ -0,0 +1,53 @@
import { onMounted, onUnmounted, ref } from 'vue'
/**
* Makes the whole page a drop target for font files, since the button that opens a picker is far
* from most rows. `dragCount` is the number of items being dragged while files hover, else null:
* browsers give the count, not the names, until the drop.
*/
export function useFontDropTarget(enabled: () => boolean, onFiles: (files: File[]) => void) {
const dragCount = ref<number | null>(null)
let depth = 0
function carriesFiles(event: DragEvent): boolean {
return enabled() && (event.dataTransfer?.types ?? []).includes('Files')
}
function handleEnter(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
depth++
dragCount.value = event.dataTransfer?.items.length ?? 0
}
function handleOver(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function handleLeave(event: DragEvent) {
if (!carriesFiles(event)) return
depth = Math.max(0, depth - 1)
if (depth === 0) dragCount.value = null
}
function handleDrop(event: DragEvent) {
if (!carriesFiles(event)) return
event.preventDefault()
depth = 0
dragCount.value = null
onFiles(Array.from(event.dataTransfer?.files ?? []))
}
onMounted(() => {
window.addEventListener('dragenter', handleEnter)
window.addEventListener('dragover', handleOver)
window.addEventListener('dragleave', handleLeave)
window.addEventListener('drop', handleDrop)
})
onUnmounted(() => {
window.removeEventListener('dragenter', handleEnter)
window.removeEventListener('dragover', handleOver)
window.removeEventListener('dragleave', handleLeave)
window.removeEventListener('drop', handleDrop)
})
return { dragCount }
}
@@ -1,15 +1,28 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { ServerFontFamilyUsage, ServerFontUsageResponse, UserFont } from '@bookorbit/types' import type { FontScope, ServerFontUsageResponse, UserFont, UserFontUsageResponse } from '@bookorbit/types'
import { FONT_FORMAT_EXTENSIONS, MAX_FONT_FILE_SIZE } from '@bookorbit/types' import { FONT_FORMAT_EXTENSIONS, MAX_FONT_FILE_SIZE, fontCssFamilyGroupName } from '@bookorbit/types'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useCustomFonts, type FontUpdate } from '@/features/reader/epub/composables/useCustomFonts' import { useCustomFonts, type FontUpdate } from '@/features/reader/epub/composables/useCustomFonts'
import { familyVariants } from '@/features/reader/shared/lib/font-variants' import { familyVariants } from '@/features/reader/shared/lib/font-variants'
import { outcomeOfError, type UploadFace, type UploadState } from '../lib/upload-outcome' import { outcomeOfError, type UploadFace, type UploadState } from '../lib/upload-outcome'
import type { FontUploadErrorCode } from '@bookorbit/types' import type { FontUploadErrorCode } from '@bookorbit/types'
const ENDPOINT = '/api/v1/server-fonts' const ENDPOINTS: Record<FontScope, string> = { server: '/api/v1/server-fonts', user: '/api/v1/fonts' }
const ACCEPTED = Object.values(FONT_FORMAT_EXTENSIONS) const ACCEPTED = Object.values(FONT_FORMAT_EXTENSIONS)
/**
* How a family is used. On the Server Fonts page these count every reader; on a reader's own Fonts
* page they count that reader alone, so readers is 0 or 1 and defaults says whether it is their default.
*/
export interface FamilyUse {
books: number
readers: number
defaults: number
hidden: number
}
const NO_USE: FamilyUse = { books: 0, readers: 0, defaults: 0, hidden: 0 }
export interface UploadEntry { export interface UploadEntry {
id: number id: number
fileName: string fileName: string
@@ -18,7 +31,7 @@ export interface UploadEntry {
face: UploadFace | null face: UploadFace | null
/** The style's own name in the font ("Italic", "SemiBold"), when the font names it. */ /** The style's own name in the font ("Italic", "SemiBold"), when the font names it. */
styleName: string | null styleName: string | null
/** The file started a family the server did not have. */ /** The file started a family the collection did not have. */
newFamily: boolean newFamily: boolean
code: FontUploadErrorCode | null code: FontUploadErrorCode | null
meta: Record<string, string | number> meta: Record<string, string | number>
@@ -26,17 +39,19 @@ export interface UploadEntry {
export type RenameResult = { ok: true } | { ok: false; code: string | null } export type RenameResult = { ok: true } | { ok: false; code: string | null }
const NO_USAGE = (familyName: string): ServerFontFamilyUsage => ({ familyName, books: 0, readers: 0, defaults: 0, hidden: 0 })
/** /**
* The server collection as the Server Fonts page manages it: the files, how readers use each * One font collection as a settings page manages it: the files, how each family is used, and a
* family, and a queue that uploads dropped files one at a time and remembers how each one ended. * queue that uploads dropped files one at a time and remembers how each one ended.
*
* Pass the page's own useCustomFonts() when it also shows the other collection, so both read the
* same state.
*/ */
export function useServerFontLibrary() { export function useFontLibrary(scope: FontScope, customFonts: ReturnType<typeof useCustomFonts> = useCustomFonts()) {
const customFonts = useCustomFonts() const ENDPOINT = ENDPOINTS[scope]
const store = customFonts.scopeStore('server') const store = customFonts.scopeStore(scope)
const usage = ref(new Map<string, ServerFontFamilyUsage>()) /** Keyed by CSS family name, which is what readers' saved settings store. */
const usage = ref(new Map<string, FamilyUse>())
const uploads = ref<UploadEntry[]>([]) const uploads = ref<UploadEntry[]>([])
/** Families this visit's uploads created, marked until the tray is dismissed. */ /** Families this visit's uploads created, marked until the tray is dismissed. */
const newFamilies = ref(new Set<string>()) const newFamilies = ref(new Set<string>())
@@ -48,8 +63,19 @@ export function useServerFontLibrary() {
try { try {
const res = await api(`${ENDPOINT}/usage`) const res = await api(`${ENDPOINT}/usage`)
if (!res.ok) return if (!res.ok) return
const body: ServerFontUsageResponse = await res.json() if (scope === 'server') {
usage.value = new Map(body.families.map((family) => [family.familyName, family])) const body: ServerFontUsageResponse = await res.json()
usage.value = new Map(body.families.map(({ familyName, ...use }) => [fontCssFamilyGroupName(familyName, 'server'), use]))
} else {
// A reader's own counts cover their fonts and the server's, so one call serves both lists.
const body: UserFontUsageResponse = await res.json()
usage.value = new Map(
body.fonts.map((font) => [
font.cssFamily,
{ books: font.books, readers: font.books > 0 ? 1 : 0, defaults: font.isDefault ? 1 : 0, hidden: 0 },
]),
)
}
} catch { } catch {
// Usage is context for the decision, not the decision; the page works without it. // Usage is context for the decision, not the decision; the page works without it.
} }
@@ -59,11 +85,11 @@ export function useServerFontLibrary() {
await Promise.all([store.fetchFonts(), fetchUsage()]) await Promise.all([store.fetchFonts(), fetchUsage()])
} }
function usageOf(familyName: string): ServerFontFamilyUsage { function usageOf(cssFamilyName: string): FamilyUse {
return usage.value.get(familyName) ?? NO_USAGE(familyName) return usage.value.get(cssFamilyName) ?? NO_USE
} }
/** The name the reader's Style row will show for this face, taken from the family on the server. */ /** The name the reader's Style row will show for this face, taken from the family as stored. */
function styleNameOf(face: UploadFace | null): string | null { function styleNameOf(face: UploadFace | null): string | null {
if (!face) return null if (!face) return null
const variants = familyVariants(store.fonts.value.filter((font) => font.familyName === face.familyName)) const variants = familyVariants(store.fonts.value.filter((font) => font.familyName === face.familyName))
@@ -0,0 +1,29 @@
import { onUnmounted, watch, type WatchSource } from 'vue'
/**
* Uploaded fonts are not part of the app's own CSS, so a page that shows them injects their
* @font-face rules for names and specimens to render in their own faces, and removes them on leave.
*/
export function useInjectedFontFaces(source: WatchSource, css: () => string, marker: string) {
let style: HTMLStyleElement | null = null
watch(
source,
() => {
const text = css()
if (!style) {
if (!text) return
style = document.createElement('style')
style.setAttribute(marker, '')
document.head.appendChild(style)
}
style.textContent = text
},
{ immediate: true, deep: false },
)
onUnmounted(() => {
style?.remove()
style = null
})
}
@@ -0,0 +1,3 @@
/** Specimen sizes the Fonts pages offer, small to large; the middle one is the default. */
export const PREVIEW_SIZES = [18, 24, 32] as const
export const DEFAULT_PREVIEW_SIZE: number = PREVIEW_SIZES[1]
@@ -1,4 +1,4 @@
import type { FontStyle, FontUploadErrorCode } from '@bookorbit/types' import type { FontScope, FontStyle, FontUploadErrorCode } from '@bookorbit/types'
import { FONT_UPLOAD_ERROR_CODES } from '@bookorbit/types' import { FONT_UPLOAD_ERROR_CODES } from '@bookorbit/types'
import { FontUploadError } from '@/features/reader/epub/composables/useCustomFonts' import { FontUploadError } from '@/features/reader/epub/composables/useCustomFonts'
@@ -41,8 +41,8 @@ export function outcomeOfError(err: unknown): UploadOutcome {
return { state: 'failed', face: null, code: null, meta } return { state: 'failed', face: null, code: null, meta }
} }
/** Translation key for a refusal's reason. */ /** Translation key for a refusal's reason. The cap reads differently for a reader's own collection. */
export function refusalMessageKey(code: FontUploadErrorCode | null): string { export function refusalMessageKey(code: FontUploadErrorCode | null, scope: FontScope = 'server'): string {
switch (code) { switch (code) {
case 'FONT_TOO_LARGE': case 'FONT_TOO_LARGE':
return 'settings.admin.serverFonts.upload.reasons.tooLarge' return 'settings.admin.serverFonts.upload.reasons.tooLarge'
@@ -53,7 +53,7 @@ export function refusalMessageKey(code: FontUploadErrorCode | null): string {
case 'FONT_FACE_TAKEN': case 'FONT_FACE_TAKEN':
return 'settings.admin.serverFonts.upload.reasons.faceTaken' return 'settings.admin.serverFonts.upload.reasons.faceTaken'
case 'FONT_LIMIT_REACHED': case 'FONT_LIMIT_REACHED':
return 'settings.admin.serverFonts.upload.reasons.limitReached' return scope === 'user' ? 'settings.reader.fonts.library.limitReached' : 'settings.admin.serverFonts.upload.reasons.limitReached'
default: default:
return 'settings.admin.serverFonts.upload.reasons.failed' return 'settings.admin.serverFonts.upload.reasons.failed'
} }
@@ -1,135 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, BookMarked, EyeOff, FileUp, MoreHorizontal, Pencil, Trash2, TriangleAlert } from '@lucide/vue'
import type { ServerFontFamilyUsage } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import type { FontFamily } from '@/features/reader/epub/composables/useCustomFonts'
import { useFontLabels } from '../composables/useFontLabels'
import FontStyleGrid from './FontStyleGrid.vue'
const props = defineProps<{
family: FontFamily
usage: ServerFontFamilyUsage
previewText: string
previewSize: number
isNew: boolean
readonly: boolean
/** The browser refused the family's files, so readers see a fallback face. */
loadFailed: boolean
}>()
const emit = defineEmits<{ open: [name: string]; rename: [name: string]; addFiles: []; remove: [name: string] }>()
const { t } = useI18n()
const { styleSummaryText, fileCountSummary } = useFontLabels()
const fontFamily = computed(() => `'${props.family.cssFamilyName}', sans-serif`)
const summary = computed(() => styleSummaryText(props.family.variants))
// Scales down with the row on narrow screens, so a 32 px preview does not dwarf a phone.
const specimenStyle = computed(() => ({ fontFamily: fontFamily.value, fontSize: `min(${props.previewSize}px, 6cqi)`, lineHeight: 1.35 }))
function handleOpen() {
emit('open', props.family.name)
}
function handleRename() {
emit('rename', props.family.name)
}
function handleAddFiles() {
emit('addFiles')
}
function handleRemove() {
emit('remove', props.family.name)
}
</script>
<template>
<div
class="grid cursor-pointer grid-cols-[minmax(0,1fr)_2rem] gap-x-3 gap-y-2.5 rounded-xl border border-border bg-card px-4 py-3.5 shadow-xs transition-colors hover:border-foreground/15 @min-[60rem]:grid-cols-[15.75rem_minmax(0,1fr)_11rem_9.5rem_2rem] @min-[60rem]:items-center @min-[60rem]:gap-x-7 @min-[60rem]:py-[17px] @min-[60rem]:ps-6 @min-[60rem]:pe-5"
:class="isNew ? 'bg-[linear-gradient(90deg,color-mix(in_oklch,var(--success)_9%,transparent),transparent_40%)]' : ''"
data-testid="server-font-row"
@click="handleOpen"
>
<div class="order-1 flex min-w-0 flex-col gap-0.5">
<button
type="button"
class="block max-w-full truncate rounded-sm text-start text-[21px] leading-7 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:style="{ fontFamily }"
>
{{ family.name }}
</button>
<span class="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<span v-if="isNew" class="rounded-full bg-success/15 px-1.5 text-[11px] leading-[18px] font-medium text-success">{{
t('settings.admin.serverFonts.newBadge')
}}</span>
<span class="tabular-nums">{{ fileCountSummary(family.variants) }}</span>
</span>
<span v-if="loadFailed" class="mt-0.5 inline-flex items-center gap-1.5 text-[12.5px] text-warning">
<TriangleAlert :size="13" class="shrink-0" aria-hidden="true" />
{{ t('settings.admin.serverFonts.loadFailed') }}
</span>
</div>
<p
class="order-3 col-span-2 min-w-0 overflow-hidden whitespace-nowrap text-foreground [mask-image:linear-gradient(to_right,black_calc(100%-3.5rem),transparent)] rtl:[mask-image:linear-gradient(to_left,black_calc(100%-3.5rem),transparent)] @min-[60rem]:order-2 @min-[60rem]:col-span-1"
:style="specimenStyle"
>
{{ previewText }}
</p>
<div
class="order-4 col-span-2 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 @min-[60rem]:order-3 @min-[60rem]:col-span-1 @min-[60rem]:flex-col @min-[60rem]:items-start"
>
<FontStyleGrid :fonts="family.variants" :label="summary" />
<span class="text-[12.5px] text-muted-foreground">{{ summary }}</span>
</div>
<div
class="order-5 col-span-2 flex flex-wrap gap-x-3 gap-y-1 empty:hidden @min-[60rem]:order-4 @min-[60rem]:col-span-1 @min-[60rem]:flex-col @min-[60rem]:empty:flex"
data-testid="server-font-usage"
>
<span
v-if="usage.books > 0"
class="inline-flex items-center gap-1.5 text-[12.5px] text-foreground"
:title="t('settings.admin.serverFonts.facts.booksDetail', { books: usage.books, readers: usage.readers })"
>
<BookOpen :size="14" class="text-muted-foreground" aria-hidden="true" />
{{ t('settings.admin.serverFonts.facts.books', { count: usage.books }) }}
</span>
<span v-if="usage.defaults > 0" class="inline-flex items-center gap-1.5 text-[12.5px] text-foreground">
<BookMarked :size="14" class="text-muted-foreground" aria-hidden="true" />
{{ t('settings.admin.serverFonts.facts.defaults', { count: usage.defaults }) }}
</span>
<span v-if="usage.hidden > 0" class="inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<EyeOff :size="14" aria-hidden="true" />
{{ t('settings.admin.serverFonts.facts.hidden', { count: usage.hidden }) }}
</span>
</div>
<div class="order-2 flex justify-end self-start @min-[60rem]:order-5 @min-[60rem]:self-center" @click.stop>
<DropdownMenu v-if="!readonly">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon-sm" type="button" :aria-label="t('settings.admin.serverFonts.moreActions', { name: family.name })">
<MoreHorizontal :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52">
<DropdownMenuItem @click="handleRename">
<Pencil />
{{ t('settings.admin.serverFonts.menu.rename') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleAddFiles">
<FileUp />
{{ t('settings.admin.serverFonts.menu.addFiles') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" @click="handleRemove">
<Trash2 />
{{ t('settings.admin.serverFonts.menu.remove') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
+19 -1
View File
@@ -2697,7 +2697,25 @@
"serverFontsHint": "These fonts come from your server administrator. Turn one off to keep it out of your reader.", "serverFontsHint": "These fonts come from your server administrator. Turn one off to keep it out of your reader.",
"serverFontsShown": "{count} of {total} shown", "serverFontsShown": "{count} of {total} shown",
"serverFontsAllHidden": "All server fonts are hidden. Your reader will only offer built-in and your own fonts.", "serverFontsAllHidden": "All server fonts are hidden. Your reader will only offer built-in and your own fonts.",
"serverFontVisibilityFailed": "Failed to update server font visibility" "serverFontVisibilityFailed": "Failed to update server font visibility",
"library": {
"columns": {
"use": "In use"
},
"yourDefault": "Your default",
"hiddenBadge": "Hidden",
"emptyHint": "Add fonts above, or drop files anywhere on this page.",
"dropTitle": "{count, plural, one {Drop to add # file to your fonts} other {Drop to add # files to your fonts}}",
"limitReached": "You already have {limit} font files.",
"filesUsed": "{count} of your {max} used",
"nameHint": "This is the name in your reader’s font list.",
"inReaderHint": "Your font list in Reader › Settings, with {name} chosen.",
"showInReader": "Show in my reader",
"showFamily": "Show {name} in my reader",
"removeConsequenceBooks": "{count, plural, one {It is set on # of your books; that book goes back to its own font.} other {It is set on # of your books; they go back to their own fonts.}}",
"removeConsequenceDefault": "It is your default font; books without their own setting go back to the book default.",
"removeQuestion": "Remove {name} from your fonts?"
}
}, },
"bookDock": { "bookDock": {
"title": "Book Dock", "title": "Book Dock",
+1 -1
View File
@@ -327,7 +327,7 @@ export const routes: RouteRecordRaw[] = [
path: 'reader/fonts', path: 'reader/fonts',
name: 'settings-reader-fonts', name: 'settings-reader-fonts',
component: () => import('@/features/settings/FontsSettings.vue'), component: () => import('@/features/settings/FontsSettings.vue'),
meta: { title: () => t('titles.reader.fonts') }, meta: { maxWidth: 'max-w-[82rem]', title: () => t('titles.reader.fonts') },
}, },
{ {
path: 'reader/general', path: 'reader/general',
+19 -1
View File
@@ -92,12 +92,30 @@ export interface ServerFontUsageResponse {
families: ServerFontFamilyUsage[]; families: ServerFontFamilyUsage[];
} }
/**
* How the current reader uses one custom font, keyed by the CSS family name their saved settings
* store. Covers their own fonts and the server's, since both are offered in the same picker.
*/
export interface FontFamilyUseCount {
cssFamily: string;
/** This reader's books set to the family. */
books: number;
/** The family is this reader's default eBook font. */
isDefault: boolean;
}
export interface UserFontUsageResponse {
fonts: FontFamilyUseCount[];
}
/** Renames every file of a family together, so a failure cannot leave the family split in two. */ /** Renames every file of a family together, so a failure cannot leave the family split in two. */
export interface RenameServerFontFamilyRequest { export interface RenameFontFamilyRequest {
familyName: string; familyName: string;
newFamilyName: string; newFamilyName: string;
} }
export type RenameServerFontFamilyRequest = RenameFontFamilyRequest;
export const FONT_FORMATS: readonly FontFormat[] = ["ttf", "otf", "woff", "woff2"]; export const FONT_FORMATS: readonly FontFormat[] = ["ttf", "otf", "woff", "woff2"];
export const FONT_FORMAT_EXTENSIONS: Record<FontFormat, string> = { export const FONT_FORMAT_EXTENSIONS: Record<FontFormat, string> = {
@@ -1,7 +1,7 @@
import { IsString, MaxLength, MinLength } from 'class-validator'; import { IsString, MaxLength, MinLength } from 'class-validator';
import { FONT_FAMILY_NAME_MAX_LENGTH, type RenameServerFontFamilyRequest } from '@bookorbit/types'; import { FONT_FAMILY_NAME_MAX_LENGTH, type RenameFontFamilyRequest } from '@bookorbit/types';
export class RenameServerFontFamilyDto implements RenameServerFontFamilyRequest { export class RenameFontFamilyDto implements RenameFontFamilyRequest {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(FONT_FAMILY_NAME_MAX_LENGTH) @MaxLength(FONT_FAMILY_NAME_MAX_LENGTH)
+41
View File
@@ -0,0 +1,41 @@
import { ConflictException } from '@nestjs/common';
import type { FontUploadErrorCode } from '@bookorbit/types';
/** Postgres unique violation, reported either directly or as the cause of a driver error. */
export function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const directCode = (error as { code?: unknown }).code;
if (directCode === '23505') return true;
if (!(error instanceof Error)) return false;
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
return causeCode === '23505';
}
/** A file would take a family/weight/style slot another file already holds. */
export function faceTaken(familyName: string, weight: number, style: string, message: string): ConflictException {
return new ConflictException({
message,
errorCode: 'FONT_FACE_TAKEN' satisfies FontUploadErrorCode,
errorMeta: { familyName, weight, style },
});
}
/** The same bytes are already in the collection; names the face they became, so the client can say which. */
export function duplicateFont(existing: { familyName: string; weight: number; style: string }): ConflictException {
return new ConflictException({
message: 'This font file has already been uploaded',
errorCode: 'FONT_DUPLICATE' satisfies FontUploadErrorCode,
errorMeta: { familyName: existing.familyName, weight: existing.weight, style: existing.style },
});
}
/** A whole-family rename that would merge two files into the same weight and style. */
export function familyFaceTaken(newFamilyName: string): ConflictException {
return new ConflictException({
message: `"${newFamilyName}" already has a file at the same weight and style`,
errorCode: 'FONT_FACE_TAKEN' satisfies FontUploadErrorCode,
errorMeta: { familyName: newFamilyName },
});
}
@@ -41,6 +41,8 @@ describe('FontController', () => {
update: vi.fn(), update: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
getFileInfo: vi.fn(), getFileInfo: vi.fn(),
usage: vi.fn(),
renameFamily: vi.fn(),
}; };
controller = new FontController(fontService as unknown as FontService); controller = new FontController(fontService as unknown as FontService);
@@ -59,6 +61,27 @@ describe('FontController', () => {
}); });
}); });
describe('usage', () => {
it('answers for the signed-in reader only', async () => {
fontService.usage.mockResolvedValue({ fonts: [] });
await controller.usage(makeUser());
expect(fontService.usage).toHaveBeenCalledWith(1);
});
});
describe('renameFamily', () => {
it('forwards both names with the signed-in reader', async () => {
const user = makeUser();
fontService.renameFamily.mockResolvedValue([]);
await controller.renameFamily({ familyName: 'Georgia', newFamilyName: 'Georgia Pro' }, user);
expect(fontService.renameFamily).toHaveBeenCalledWith(user, 'Georgia', 'Georgia Pro');
});
});
describe('upload', () => { describe('upload', () => {
it('uploads a font file', async () => { it('uploads a font file', async () => {
const user = makeUser(); const user = makeUser();
@@ -187,6 +210,17 @@ describe('FontController', () => {
}); });
}); });
it('renameFamily has ForbidPermission DemoRestricted', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, FontController.prototype.renameFamily)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot manage fonts',
});
});
it('usage is a read open to demo accounts too', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, FontController.prototype.usage)).toBeUndefined();
});
it('list does not have ForbidPermission', () => { it('list does not have ForbidPermission', () => {
const meta = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, FontController.prototype.list); const meta = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, FontController.prototype.list);
expect(meta).toBeUndefined(); expect(meta).toBeUndefined();
@@ -8,6 +8,7 @@ import { ForbidPermission } from '../../common/decorators/forbid-permission.deco
import type { MultipartRequest } from '../../common/types/multipart-request'; import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { FontService } from './font.service'; import { FontService } from './font.service';
import { RenameFontFamilyDto } from './dto/rename-font-family.dto';
import { UpdateFontDto } from './dto/update-font.dto'; import { UpdateFontDto } from './dto/update-font.dto';
@Controller('fonts') @Controller('fonts')
@@ -19,6 +20,18 @@ export class FontController {
return this.fontService.list(user.id); return this.fontService.list(user.id);
} }
/** How this reader uses each custom font, so the Fonts page can say what removing or hiding one costs. */
@Get('usage')
async usage(@CurrentUser() user: RequestUser) {
return this.fontService.usage(user.id);
}
@Patch('families')
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot manage fonts')
async renameFamily(@Body() dto: RenameFontFamilyDto, @CurrentUser() user: RequestUser) {
return this.fontService.renameFamily(user, dto.familyName, dto.newFamilyName);
}
@Post('upload') @Post('upload')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot manage fonts') @ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot manage fonts')
+43 -1
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { and, eq, count } from 'drizzle-orm'; import { and, count, eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db'; import { DB } from '../../db';
@@ -46,6 +46,48 @@ export class FontRepository {
return row; return row;
} }
async findByUserAndFamily(userId: number, familyName: string): Promise<UserFontRow[]> {
return this.db.query.userFonts.findMany({
where: and(eq(schema.userFonts.userId, userId), eq(schema.userFonts.familyName, familyName)),
orderBy: [schema.userFonts.weight, schema.userFonts.style],
});
}
/** One statement, so a clash on any file leaves every file under its old name. */
async renameFamily(userId: number, familyName: string, newFamilyName: string): Promise<UserFontRow[]> {
return this.db
.update(schema.userFonts)
.set({ familyName: newFamilyName })
.where(and(eq(schema.userFonts.userId, userId), eq(schema.userFonts.familyName, familyName)))
.returning();
}
/**
* This reader's books whose saved settings name a custom font, grouped by the CSS family named.
* The prefix test uses `left()` rather than LIKE because the prefixes contain underscores.
*/
async countBooksByCssFamily(userId: number, prefixes: readonly string[]): Promise<Array<{ cssFamily: string; books: number }>> {
const cssFamily = sql<string>`${schema.readerPreferences.settings}->>'fontFamily'`;
const matchesPrefix = sql.join(
prefixes.map((prefix) => sql`left(${cssFamily}, ${prefix.length}) = ${prefix}`),
sql` or `,
);
return this.db
.select({ cssFamily, books: count() })
.from(schema.readerPreferences)
.where(and(eq(schema.readerPreferences.userId, userId), sql`(${matchesPrefix})`))
.groupBy(cssFamily);
}
/** The custom fonts this reader saved as a reader default, for any format group. */
async findDefaultCssFamilies(userId: number): Promise<string[]> {
const rows = await this.db
.select({ cssFamily: sql<string | null>`${schema.readerDefaultPreferences.settings}->>'fontFamily'` })
.from(schema.readerDefaultPreferences)
.where(eq(schema.readerDefaultPreferences.userId, userId));
return rows.flatMap((row) => (row.cssFamily ? [row.cssFamily] : []));
}
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
await this.db.delete(schema.userFonts).where(eq(schema.userFonts.id, id)); await this.db.delete(schema.userFonts).where(eq(schema.userFonts.id, id));
} }
+112 -1
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, ForbiddenException, HttpException, NotFoundException } from '@nestjs/common';
import { FontService } from './font.service'; import { FontService } from './font.service';
import type { FontRepository } from './font.repository'; import type { FontRepository } from './font.repository';
import type { FontStorageService } from './font.storage.service'; import type { FontStorageService } from './font.storage.service';
@@ -47,6 +47,21 @@ function makeFontRow(overrides: Partial<UserFontRow> = {}): UserFontRow {
}; };
} }
/** The body the global filter would send, so assertions see the error code the client reads. */
async function rejection(promise: Promise<unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
const err = await promise.then(
() => {
throw new Error('expected a rejection');
},
(e: unknown) => e,
);
if (!(err instanceof HttpException)) throw err;
const body = err.getResponse();
return { status: err.getStatus(), body: typeof body === 'string' ? { message: body } : (body as Record<string, unknown>) };
}
const uniqueViolation = () => Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' });
function makeTTFBuffer(): Buffer { function makeTTFBuffer(): Buffer {
return Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]); return Buffer.from([0x00, 0x01, 0x00, 0x00, ...Array(100).fill(0)]);
} }
@@ -67,6 +82,10 @@ describe('FontService', () => {
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
deleteAllByUser: vi.fn(), deleteAllByUser: vi.fn(),
findByUserAndFamily: vi.fn().mockResolvedValue([]),
renameFamily: vi.fn(),
countBooksByCssFamily: vi.fn().mockResolvedValue([]),
findDefaultCssFamilies: vi.fn().mockResolvedValue([]),
}; };
storage = { storage = {
@@ -181,12 +200,34 @@ describe('FontService', () => {
await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(ConflictException); await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(ConflictException);
}); });
it('names the font a duplicate already is, so the client can say which one', async () => {
repo.findByUserAndHash.mockResolvedValue(makeFontRow({ weight: 700, style: 'italic' }));
const { status, body } = await rejection(service.upload(makeUser(), makeTTFBuffer(), 'Literata.ttf'));
expect(status).toBe(409);
expect(body).toMatchObject({ errorCode: 'FONT_DUPLICATE', errorMeta: { familyName: 'Literata', weight: 700, style: 'italic' } });
});
it('rejects when user has reached font limit', async () => { it('rejects when user has reached font limit', async () => {
const user = makeUser(); const user = makeUser();
const buffer = makeTTFBuffer(); const buffer = makeTTFBuffer();
repo.countByUser.mockResolvedValue(50); repo.countByUser.mockResolvedValue(50);
await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(BadRequestException); await expect(service.upload(user, buffer, 'Literata.ttf')).rejects.toThrow(BadRequestException);
await expect(rejection(service.upload(user, buffer, 'Literata.ttf'))).resolves.toMatchObject({
body: { errorCode: 'FONT_LIMIT_REACHED', errorMeta: { limit: 50 } },
});
});
it('reports a second file for the same face as a coded conflict, not a server error', async () => {
repo.create.mockRejectedValue(uniqueViolation());
const { status, body } = await rejection(service.upload(makeUser(), makeTTFBuffer(), 'Literata-Copy.ttf'));
expect(status).toBe(409);
expect(body).toMatchObject({ errorCode: 'FONT_FACE_TAKEN', errorMeta: { familyName: 'Literata', weight: 400, style: 'normal' } });
expect(storage.delete).toHaveBeenCalledWith(1, 'stored-uuid.ttf');
}); });
it('cleans up stored file if DB insert fails', async () => { it('cleans up stored file if DB insert fails', async () => {
@@ -290,6 +331,76 @@ describe('FontService', () => {
}); });
}); });
describe('update conflicts', () => {
it('reports a weight/style edit onto a taken slot as a coded conflict', async () => {
repo.findById.mockResolvedValue(makeFontRow());
repo.update.mockRejectedValue(uniqueViolation());
const { status, body } = await rejection(service.update(makeUser(), 1, { weight: 700 }));
expect(status).toBe(409);
expect(body).toMatchObject({ errorCode: 'FONT_FACE_TAKEN', errorMeta: { familyName: 'Literata', weight: 700, style: 'normal' } });
});
});
describe('renameFamily', () => {
it("renames every file of the reader's family in one call", async () => {
const files = [makeFontRow({ id: 1 }), makeFontRow({ id: 2, weight: 700 })];
repo.findByUserAndFamily.mockResolvedValue(files);
repo.renameFamily.mockResolvedValue(files.map((f) => ({ ...f, familyName: 'Literata Text' })));
const result = await service.renameFamily(makeUser(), 'Literata', ' Literata Text ');
expect(repo.findByUserAndFamily).toHaveBeenCalledWith(1, 'Literata');
expect(repo.renameFamily).toHaveBeenCalledWith(1, 'Literata', 'Literata Text');
expect(result.map((f) => f.familyName)).toEqual(['Literata Text', 'Literata Text']);
});
it("cannot reach another reader's family", async () => {
await expect(service.renameFamily(makeUser({ id: 2 }), 'Literata', 'Mine')).rejects.toThrow(NotFoundException);
expect(repo.findByUserAndFamily).toHaveBeenCalledWith(2, 'Literata');
expect(repo.renameFamily).not.toHaveBeenCalled();
});
it('skips the write when the name does not change, and refuses a blank one', async () => {
repo.findByUserAndFamily.mockResolvedValue([makeFontRow()]);
await service.renameFamily(makeUser(), 'Literata', 'Literata');
await expect(service.renameFamily(makeUser(), 'Literata', ' ')).rejects.toThrow(BadRequestException);
expect(repo.renameFamily).not.toHaveBeenCalled();
});
it('reports a merge that would clash on weight and style as a coded conflict', async () => {
repo.findByUserAndFamily.mockResolvedValue([makeFontRow()]);
repo.renameFamily.mockRejectedValue(uniqueViolation());
await expect(rejection(service.renameFamily(makeUser(), 'Literata', 'Georgia'))).resolves.toMatchObject({
status: 409,
body: { errorCode: 'FONT_FACE_TAKEN', errorMeta: { familyName: 'Georgia' } },
});
});
});
describe('usage', () => {
it("counts the reader's books per custom font and marks their default, across both scopes", async () => {
repo.countBooksByCssFamily.mockResolvedValue([
{ cssFamily: '__userfont_georgia', books: 2 },
{ cssFamily: '__serverfont_courgette', books: 1 },
]);
repo.findDefaultCssFamilies.mockResolvedValue(['__serverfont_courgette', 'serif', '__userfont_inter']);
const { fonts } = await service.usage(7);
expect(repo.countBooksByCssFamily).toHaveBeenCalledWith(7, ['__userfont_', '__serverfont_']);
expect(repo.findDefaultCssFamilies).toHaveBeenCalledWith(7);
expect(fonts).toEqual([
{ cssFamily: '__userfont_georgia', books: 2, isDefault: false },
{ cssFamily: '__serverfont_courgette', books: 1, isDefault: true },
{ cssFamily: '__userfont_inter', books: 0, isDefault: true },
]);
});
});
describe('remove', () => { describe('remove', () => {
it('deletes font from DB and storage', async () => { it('deletes font from DB and storage', async () => {
const user = makeUser(); const user = makeUser();
+59 -10
View File
@@ -1,11 +1,13 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { MAX_FONTS_PER_USER } from '@bookorbit/types'; import { FONT_CSS_FAMILY_PREFIXES, MAX_FONTS_PER_USER, type FontUploadErrorCode, type UserFont, type UserFontUsageResponse } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { UserFontRow } from '../../db/schema'; import type { UserFontRow } from '../../db/schema';
import { FontRepository } from './font.repository'; import { FontRepository } from './font.repository';
import { FontStorageService } from './font.storage.service'; import { FontStorageService } from './font.storage.service';
import { FontValidationService } from './font.validation.service'; import { FontValidationService } from './font.validation.service';
import { duplicateFont, faceTaken, familyFaceTaken, isUniqueViolation } from './font-errors';
import { inspectFontUpload, resolveFontIdentity } from './font-upload.pipeline'; import { inspectFontUpload, resolveFontIdentity } from './font-upload.pipeline';
import { toFontResponse } from './dto/font-response.dto'; import { toFontResponse } from './dto/font-response.dto';
import type { UpdateFontDto } from './dto/update-font.dto'; import type { UpdateFontDto } from './dto/update-font.dto';
@@ -28,19 +30,23 @@ export class FontService {
async upload(user: RequestUser, buffer: Buffer, originalFilename: string) { async upload(user: RequestUser, buffer: Buffer, originalFilename: string) {
const event = 'font.upload'; const event = 'font.upload';
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} filename=${originalFilename} sizeBytes=${buffer.length} - font upload started`); this.logger.log(
`[${event}] [start] userId=${user.id} filename=${sanitizeLogValue(originalFilename)} sizeBytes=${buffer.length} - font upload started`,
);
try { try {
const { format, fileHash } = inspectFontUpload(this.validation, buffer, originalFilename); const { format, fileHash } = inspectFontUpload(this.validation, buffer, originalFilename);
const existing = await this.repo.findByUserAndHash(user.id, fileHash); const existing = await this.repo.findByUserAndHash(user.id, fileHash);
if (existing) { if (existing) throw duplicateFont(existing);
throw new ConflictException('This font file has already been uploaded');
}
const fontCount = await this.repo.countByUser(user.id); const fontCount = await this.repo.countByUser(user.id);
if (fontCount >= MAX_FONTS_PER_USER) { if (fontCount >= MAX_FONTS_PER_USER) {
throw new BadRequestException(`Maximum of ${MAX_FONTS_PER_USER} fonts per user reached`); throw new BadRequestException({
message: `Maximum of ${MAX_FONTS_PER_USER} fonts per user reached`,
errorCode: 'FONT_LIMIT_REACHED' satisfies FontUploadErrorCode,
errorMeta: { limit: MAX_FONTS_PER_USER },
});
} }
const { familyName, weight, style, weightMin, weightMax, instances, suggestedFamilyName } = resolveFontIdentity( const { familyName, weight, style, weightMin, weightMax, instances, suggestedFamilyName } = resolveFontIdentity(
@@ -69,11 +75,13 @@ export class FontService {
}); });
} catch (err) { } catch (err) {
await this.storage.delete(user.id, storedFileName); await this.storage.delete(user.id, storedFileName);
// The same face under a second filename is an easy mistake; say so instead of a 500.
if (isUniqueViolation(err)) throw faceTaken(familyName, weight, style, `You already have "${familyName}" at weight ${weight} (${style})`);
throw err; throw err;
} }
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} fontId=${row.id} durationMs=${Date.now() - startedAt} familyName=${familyName} format=${format} - font upload completed`, `[${event}] [end] userId=${user.id} fontId=${row.id} durationMs=${Date.now() - startedAt} familyName=${sanitizeLogValue(familyName)} format=${format} - font upload completed`,
); );
return { return {
@@ -86,7 +94,7 @@ export class FontService {
const errorClass = err instanceof Error ? err.name : 'Error'; const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 200); const errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 200);
this.logger.warn( this.logger.warn(
`[${event}] [fail] userId=${user.id} filename=${originalFilename} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - font upload failed`, `[${event}] [fail] userId=${user.id} filename=${sanitizeLogValue(originalFilename)} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(errorMessage)}" - font upload failed`,
); );
throw err; throw err;
} }
@@ -104,11 +112,52 @@ export class FontService {
return toFontResponse(font); return toFontResponse(font);
} }
const updated = await this.repo.update(fontId, data); let updated: UserFontRow | undefined;
try {
updated = await this.repo.update(fontId, data);
} catch (err) {
if (isUniqueViolation(err)) {
throw faceTaken(
data.familyName ?? font.familyName,
data.weight ?? font.weight,
data.style ?? font.style,
'Another of your fonts already uses that family, weight, and style',
);
}
throw err;
}
if (!updated) throw new NotFoundException('Font not found'); if (!updated) throw new NotFoundException('Font not found');
return toFontResponse(updated); return toFontResponse(updated);
} }
async renameFamily(user: RequestUser, familyName: string, newFamilyName: string): Promise<UserFont[]> {
const next = newFamilyName.trim();
const current = await this.repo.findByUserAndFamily(user.id, familyName);
if (current.length === 0) throw new NotFoundException('Font family not found');
if (!next) throw new BadRequestException('Family name cannot be empty');
if (next === familyName) return current.map(toFontResponse);
try {
const rows = await this.repo.renameFamily(user.id, familyName, next);
return rows.map(toFontResponse);
} catch (err) {
if (isUniqueViolation(err)) throw familyFaceTaken(next);
throw err;
}
}
/** How this reader uses each custom font, their own and the server's, by the CSS name their settings store. */
async usage(userId: number): Promise<UserFontUsageResponse> {
const prefixes = [FONT_CSS_FAMILY_PREFIXES.user, FONT_CSS_FAMILY_PREFIXES.server];
const [books, defaults] = await Promise.all([this.repo.countBooksByCssFamily(userId, prefixes), this.repo.findDefaultCssFamilies(userId)]);
const defaultSet = new Set(defaults.filter((cssFamily) => prefixes.some((prefix) => cssFamily.startsWith(prefix))));
const byCss = new Map(
books.map((row) => [row.cssFamily, { cssFamily: row.cssFamily, books: row.books, isDefault: defaultSet.has(row.cssFamily) }]),
);
for (const cssFamily of defaultSet) if (!byCss.has(cssFamily)) byCss.set(cssFamily, { cssFamily, books: 0, isDefault: true });
return { fonts: [...byCss.values()] };
}
async remove(user: RequestUser, fontId: number): Promise<void> { async remove(user: RequestUser, fontId: number): Promise<void> {
const font = await this.findOwnedFont(user, fontId); const font = await this.findOwnedFont(user, fontId);
await this.repo.delete(fontId); await this.repo.delete(fontId);
@@ -9,7 +9,7 @@ import { RequirePermission } from '../../common/decorators/require-permission.de
import type { MultipartRequest } from '../../common/types/multipart-request'; import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { UpdateFontDto } from '../font/dto/update-font.dto'; import { UpdateFontDto } from '../font/dto/update-font.dto';
import { RenameServerFontFamilyDto } from './dto/rename-server-font-family.dto'; import { RenameFontFamilyDto } from '../font/dto/rename-font-family.dto';
import { ServerFontService } from './server-font.service'; import { ServerFontService } from './server-font.service';
const CANNOT_MANAGE = 'Demo-restricted account cannot manage server fonts'; const CANNOT_MANAGE = 'Demo-restricted account cannot manage server fonts';
@@ -37,7 +37,7 @@ export class ServerFontController {
@Patch('families') @Patch('families')
@RequirePermission(Permission.ManageAppSettings) @RequirePermission(Permission.ManageAppSettings)
@ForbidPermission(Permission.DemoRestricted, CANNOT_MANAGE) @ForbidPermission(Permission.DemoRestricted, CANNOT_MANAGE)
async renameFamily(@Body() dto: RenameServerFontFamilyDto) { async renameFamily(@Body() dto: RenameFontFamilyDto) {
return this.serverFontService.renameFamily(dto.familyName, dto.newFamilyName); return this.serverFontService.renameFamily(dto.familyName, dto.newFamilyName);
} }
@@ -1,4 +1,4 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { import {
FONT_CSS_FAMILY_PREFIXES, FONT_CSS_FAMILY_PREFIXES,
MAX_SERVER_FONTS, MAX_SERVER_FONTS,
@@ -14,6 +14,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { ServerFontRow } from '../../db/schema'; import type { ServerFontRow } from '../../db/schema';
import { toFontResponse } from '../font/dto/font-response.dto'; import { toFontResponse } from '../font/dto/font-response.dto';
import type { UpdateFontDto } from '../font/dto/update-font.dto'; import type { UpdateFontDto } from '../font/dto/update-font.dto';
import { duplicateFont, faceTaken, familyFaceTaken, isUniqueViolation } from '../font/font-errors';
import { inspectFontUpload, resolveFontIdentity } from '../font/font-upload.pipeline'; import { inspectFontUpload, resolveFontIdentity } from '../font/font-upload.pipeline';
import { FontValidationService } from '../font/font.validation.service'; import { FontValidationService } from '../font/font.validation.service';
import { ServerFontRepository } from './server-font.repository'; import { ServerFontRepository } from './server-font.repository';
@@ -22,20 +23,6 @@ import { ServerFontStorageService } from './server-font.storage.service';
/** The user-preferences category holding each reader's hidden server families. */ /** The user-preferences category holding each reader's hidden server families. */
const HIDDEN_FAMILIES_CATEGORY = 'server-fonts'; const HIDDEN_FAMILIES_CATEGORY = 'server-fonts';
const faceTaken = (familyName: string, weight: number, style: string, message: string) =>
new ConflictException({ message, errorCode: 'FONT_FACE_TAKEN' satisfies FontUploadErrorCode, errorMeta: { familyName, weight, style } });
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const directCode = (error as { code?: unknown }).code;
if (directCode === '23505') return true;
if (!(error instanceof Error)) return false;
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
return causeCode === '23505';
}
/** /**
* Manages the administrator-curated font collection offered to every reader. Shares the * Manages the administrator-curated font collection offered to every reader. Shares the
* validation and metadata pipeline with per-user fonts but keeps its own table, storage * validation and metadata pipeline with per-user fonts but keeps its own table, storage
@@ -67,13 +54,7 @@ export class ServerFontService {
const { format, fileHash } = inspectFontUpload(this.validation, buffer, originalFilename); const { format, fileHash } = inspectFontUpload(this.validation, buffer, originalFilename);
const existing = await this.repo.findByHash(fileHash); const existing = await this.repo.findByHash(fileHash);
if (existing) { if (existing) throw duplicateFont(existing);
throw new ConflictException({
message: 'This font file has already been uploaded',
errorCode: 'FONT_DUPLICATE' satisfies FontUploadErrorCode,
errorMeta: { familyName: existing.familyName, weight: existing.weight, style: existing.style },
});
}
const fontCount = await this.repo.countAll(); const fontCount = await this.repo.countAll();
if (fontCount >= MAX_SERVER_FONTS) { if (fontCount >= MAX_SERVER_FONTS) {
@@ -189,13 +170,7 @@ export class ServerFontService {
this.logger.warn( this.logger.warn(
`[${event}] [fail] familyName=${sanitizeLogValue(familyName)} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message.slice(0, 200) : String(err))}" - server font family rename failed`, `[${event}] [fail] familyName=${sanitizeLogValue(familyName)} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message.slice(0, 200) : String(err))}" - server font family rename failed`,
); );
if (isUniqueViolation(err)) { if (isUniqueViolation(err)) throw familyFaceTaken(next);
throw new ConflictException({
message: `"${next}" already has a file at the same weight and style`,
errorCode: 'FONT_FACE_TAKEN' satisfies FontUploadErrorCode,
errorMeta: { familyName: next },
});
}
throw err; throw err;
} }
this.logger.log( this.logger.log(
@@ -83,6 +83,7 @@
"GET /dashboard/widgets/year-projection", "GET /dashboard/widgets/year-projection",
"GET /fonts", "GET /fonts",
"GET /fonts/:id/file", "GET /fonts/:id/file",
"GET /fonts/usage",
"GET /hardcover/books", "GET /hardcover/books",
"GET /hardcover/books/:bookId/editions", "GET /hardcover/books/:bookId/editions",
"GET /hardcover/books/:bookId/sync-state", "GET /hardcover/books/:bookId/sync-state",
@@ -162,6 +163,7 @@
"PATCH /custom-metadata/fields/:id", "PATCH /custom-metadata/fields/:id",
"PATCH /custom-metadata/fields/reorder", "PATCH /custom-metadata/fields/reorder",
"PATCH /fonts/:id", "PATCH /fonts/:id",
"PATCH /fonts/families",
"PATCH /hardcover/books/:bookId/edition", "PATCH /hardcover/books/:bookId/edition",
"PATCH /hardcover/books/:bookId/sync-state", "PATCH /hardcover/books/:bookId/sync-state",
"PATCH /hardcover/settings", "PATCH /hardcover/settings",