mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): rebuild server fonts as a specimen ledger with a family sheet
Each family is a row with its specimen, a weight grid of the styles readers get and how readers use it; its sheet previews the face on reader pages and inside the reader's own font list. Uploads answer per file in one tray, removing a family confirms with its cost, and a rename is one request. Server: additive GET /server-fonts/usage (counts only), PATCH /server-fonts/families, upload error codes.
This commit is contained in:
@@ -35,7 +35,7 @@ import { themes } from '../constants/themes'
|
||||
import { BUILTIN_READER_FONT_OPTIONS, type ReaderBuiltInFontOption } from '@/features/reader/shared/constants/font-options'
|
||||
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { FONT_WEIGHT_LABEL_KEYS, builtInVariants, closestVariant, familyVariants, isSameVariant } from '@/features/reader/shared/lib/font-variants'
|
||||
import { builtInVariants, closestVariant, familyVariants, fontVariantLabel, isSameVariant } from '@/features/reader/shared/lib/font-variants'
|
||||
import ReaderRangeField from '@/features/reader/shared/components/ReaderRangeField.vue'
|
||||
import ReaderSegmentedControl from '@/features/reader/shared/components/ReaderSegmentedControl.vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
@@ -367,11 +367,7 @@ function variantUpdateFor(variants: FontVariant[]): Partial<ReaderState> {
|
||||
}
|
||||
|
||||
function variantLabel(variant: FontNamedInstance): string {
|
||||
const weightKey = FONT_WEIGHT_LABEL_KEYS[variant.weight]
|
||||
const base = variant.name ?? (weightKey ? t(weightKey) : String(variant.weight))
|
||||
// A designer's own name usually says so already; only spell it out when it does not.
|
||||
if (variant.style !== 'italic' || /italic|oblique/i.test(base)) return base
|
||||
return t('reader.settings.fontStyleItalicOf', { style: base })
|
||||
return fontVariantLabel(variant, t)
|
||||
}
|
||||
|
||||
const groupLabelClass = 'mb-2 block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'
|
||||
|
||||
@@ -42,6 +42,22 @@ export interface FontScopeStore {
|
||||
generateFontFaceCSS: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused upload. Keeps the server's English message for older callers and adds the status and
|
||||
* error code, so a caller can say why in the reader's language.
|
||||
*/
|
||||
export class FontUploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
readonly meta?: Record<string, string | number>,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FontUploadError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface FontUpdate {
|
||||
familyName?: string
|
||||
weight?: number
|
||||
@@ -219,7 +235,7 @@ export function useCustomFonts() {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Upload failed' }))
|
||||
throw new Error(err.message || 'Upload failed')
|
||||
throw new FontUploadError(err.message || 'Upload failed', res.status, err.errorCode, err.errorMeta)
|
||||
}
|
||||
const result: FontUploadResult = await res.json()
|
||||
const list = listFor(scope)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FontNamedInstance, UserFont } from '@bookorbit/types'
|
||||
import { builtInVariants, closestVariant, familyVariants, isSameVariant, variantKey } from '../font-variants'
|
||||
import { builtInVariants, closestVariant, familyVariants, fontVariantLabel, isSameVariant, variantKey } from '../font-variants'
|
||||
|
||||
function makeFont(overrides: Partial<UserFont> = {}): UserFont {
|
||||
return {
|
||||
@@ -141,3 +141,24 @@ describe('isSameVariant', () => {
|
||||
expect(isSameVariant({ weight: 400, style: 'normal' }, { weight: 700, style: 'normal' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fontVariantLabel', () => {
|
||||
const t = (key: string, named?: Record<string, unknown>) => (named ? `${key}:${JSON.stringify(named)}` : key)
|
||||
|
||||
it("prefers the designer's own name", () => {
|
||||
expect(fontVariantLabel({ name: 'SemiBold', weight: 600, style: 'normal' }, t)).toBe('SemiBold')
|
||||
})
|
||||
|
||||
it('falls back to the standard weight name', () => {
|
||||
expect(fontVariantLabel({ name: null, weight: 500, style: 'normal' }, t)).toBe('settings.reader.fonts.weightMedium')
|
||||
})
|
||||
|
||||
it('adds italic only when the name does not already say so', () => {
|
||||
expect(fontVariantLabel({ name: 'Bold Italic', weight: 700, style: 'italic' }, t)).toBe('Bold Italic')
|
||||
expect(fontVariantLabel({ name: 'Bold', weight: 700, style: 'italic' }, t)).toBe('reader.settings.fontStyleItalicOf:{"style":"Bold"}')
|
||||
})
|
||||
|
||||
it('uses the raw weight when it has no standard name', () => {
|
||||
expect(fontVariantLabel({ name: null, weight: 350, style: 'normal' }, t)).toBe('350')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,19 @@ export function familyVariants(fonts: readonly UserFont[]): FontNamedInstance[]
|
||||
return [...byKey.values()].sort(compareFontVariants)
|
||||
}
|
||||
|
||||
type Translate = (key: string, named?: Record<string, unknown>) => string
|
||||
|
||||
/**
|
||||
* The name the reader's Style row gives a variant: the designer's own name when the font has one,
|
||||
* otherwise the standard weight name, with "Italic" added only when the name does not already say so.
|
||||
*/
|
||||
export function fontVariantLabel(variant: FontNamedInstance, t: Translate): string {
|
||||
const weightKey = FONT_WEIGHT_LABEL_KEYS[variant.weight]
|
||||
const base = variant.name ?? (weightKey ? t(weightKey) : String(variant.weight))
|
||||
if (variant.style !== 'italic' || /italic|oblique/i.test(base)) return base
|
||||
return t('reader.settings.fontStyleItalicOf', { style: base })
|
||||
}
|
||||
|
||||
/** The styles a built-in stack offers, in the same shape as an uploaded family's. */
|
||||
export function builtInVariants(): FontNamedInstance[] {
|
||||
return BUILT_IN_FONT_VARIANTS.map((variant) => ({ ...variant, name: null }))
|
||||
|
||||
@@ -1,21 +1,437 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Info, Search, TextCursorInput, Type, Upload, X } from '@lucide/vue'
|
||||
import { MAX_SERVER_FONTS } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import FontLibraryPanel from './FontLibraryPanel.vue'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import FontDropOverlay from './server-fonts/components/FontDropOverlay.vue'
|
||||
import FontUploadTray from './server-fonts/components/FontUploadTray.vue'
|
||||
import ServerFontFamilySheet from './server-fonts/components/ServerFontFamilySheet.vue'
|
||||
import ServerFontRow from './server-fonts/components/ServerFontRow.vue'
|
||||
import { useFailedFontFaces } from './server-fonts/composables/useFailedFontFaces'
|
||||
import { useServerFontLibrary } from './server-fonts/composables/useServerFontLibrary'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const HEADER_ACTIONS_TARGET = '#settings-header-actions'
|
||||
const PREVIEW_SIZES = [18, 24, 32] as const
|
||||
const ACCEPT = '.ttf,.otf,.woff,.woff2'
|
||||
|
||||
const store = useCustomFonts().scopeStore('server')
|
||||
const { isDemoRestrictedAccount } = usePermissions()
|
||||
const { t, locale } = useI18n()
|
||||
const { isDemoRestrictedAccount: readonly } = usePermissions()
|
||||
const library = useServerFontLibrary()
|
||||
const { store, uploads, newFamilies } = library
|
||||
const { failed: failedFaces } = useFailedFontFaces()
|
||||
|
||||
/**
|
||||
* The header renders the primary action beside the title. Falling back to rendering in place keeps
|
||||
* it reachable when this page is mounted outside the settings shell, including in tests.
|
||||
*/
|
||||
const headerSlotAvailable = ref(false)
|
||||
|
||||
const query = ref('')
|
||||
const previewText = ref('')
|
||||
const previewSize = ref<number>(PREVIEW_SIZES[1])
|
||||
const sample = computed(() => previewText.value.trim() || t('settings.reader.fonts.pangram'))
|
||||
|
||||
const collator = computed(() => new Intl.Collator(locale.value, { sensitivity: 'base', numeric: true }))
|
||||
const families = computed(() => [...store.families.value].sort((a, b) => collator.value.compare(a.name, b.name)))
|
||||
const visibleFamilies = computed(() => {
|
||||
const needle = query.value.trim().toLocaleLowerCase(locale.value)
|
||||
return needle ? families.value.filter((family) => family.name.toLocaleLowerCase(locale.value).includes(needle)) : families.value
|
||||
})
|
||||
const totalBytes = computed(() => store.fonts.value.reduce((sum, font) => sum + font.fileSize, 0))
|
||||
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. */
|
||||
const firstLoad = computed(() => store.loading.value && !hasFonts.value)
|
||||
|
||||
// ── Family sheet ───────────────────────────────────────────
|
||||
const openFamilyName = ref<string | null>(null)
|
||||
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) {
|
||||
openSheet(name)
|
||||
}
|
||||
function handleRename(name: string) {
|
||||
openSheet(name, { rename: true })
|
||||
}
|
||||
function handleRemoveRequest(name: string) {
|
||||
openSheet(name, { remove: true })
|
||||
}
|
||||
function closeSheet() {
|
||||
openFamilyName.value = null
|
||||
armRemove.value = false
|
||||
focusName.value = false
|
||||
}
|
||||
|
||||
async function saveRename(newName: string) {
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOpenFamily() {
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
|
||||
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 store.deleteFont(fontId).catch(() => false)
|
||||
if (!ok) {
|
||||
toast.error(t('settings.reader.fonts.deleteFontFailed'))
|
||||
return
|
||||
}
|
||||
if (!openFamily.value) closeSheet()
|
||||
await library.fetchUsage()
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
}
|
||||
|
||||
// The whole page is the drop target, since the button that opens a picker is far from most rows.
|
||||
const dragCount = ref<number | null>(null)
|
||||
let dragDepth = 0
|
||||
|
||||
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 },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
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()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dragenter', handleDragEnter)
|
||||
window.removeEventListener('dragover', handleDragOver)
|
||||
window.removeEventListener('dragleave', handleDragLeave)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
fontFaceStyle?.remove()
|
||||
fontFaceStyle = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="@container flex flex-col gap-3">
|
||||
<SettingsPageHeader v-if="!embedded" :title="t('settings.admin.serverFonts.title')" :subtitle="t('settings.admin.serverFonts.subtitle')" />
|
||||
<FontLibraryPanel :store="store" :readonly="isDemoRestrictedAccount" />
|
||||
|
||||
<Teleport :to="HEADER_ACTIONS_TARGET" defer :disabled="!headerSlotAvailable">
|
||||
<Button
|
||||
v-if="!readonly && hasFonts"
|
||||
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" @dismiss="dismissUploads" />
|
||||
|
||||
<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="!hasFonts"
|
||||
class="mx-auto mt-6 flex w-full max-w-[40rem] flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-card px-6 py-14 text-center shadow-xs"
|
||||
data-testid="font-empty-surface"
|
||||
>
|
||||
<div class="grid size-12 place-items-center rounded-full bg-muted text-muted-foreground">
|
||||
<Type :size="22" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.admin.serverFonts.noFontsYet') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.noFontsHint') }}</p>
|
||||
</div>
|
||||
<template v-if="!readonly">
|
||||
<Button size="sm" type="button" @click="openFilePicker">
|
||||
<Upload :size="14" aria-hidden="true" />
|
||||
{{ t('settings.admin.serverFonts.addFonts') }}
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.empty.dropHint') }} · {{ t('settings.reader.fonts.fileTypesHint') }}
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
class="mt-2 flex max-w-md items-start gap-2 rounded-lg bg-muted/60 px-3 py-2.5 text-start text-[12.5px] leading-[18px] text-muted-foreground"
|
||||
>
|
||||
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
{{ t('settings.admin.serverFonts.empty.tip') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<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>
|
||||
<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">·</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">·</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
|
||||
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">
|
||||
<ServerFontRow
|
||||
v-for="family in visibleFamilies"
|
||||
:key="family.cssFamilyName"
|
||||
:family="family"
|
||||
:usage="library.usageOf(family.name)"
|
||||
:preview-text="sample"
|
||||
:preview-size="previewSize"
|
||||
:is-new="newFamilies.has(family.name)"
|
||||
:readonly="readonly"
|
||||
:load-failed="failedFaces.has(family.cssFamilyName)"
|
||||
@open="handleOpen"
|
||||
@rename="handleRename"
|
||||
@add-files="openFilePicker"
|
||||
@remove="handleRemoveRequest"
|
||||
/>
|
||||
</div>
|
||||
<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() }) }}
|
||||
</p>
|
||||
|
||||
<p class="px-1 pt-1 text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.footerHint') }}</p>
|
||||
</template>
|
||||
|
||||
<FontDropOverlay v-if="dragCount !== null" :count="dragCount" />
|
||||
|
||||
<ServerFontFamilySheet
|
||||
:open="openFamily !== null"
|
||||
:family="openFamily"
|
||||
:usage="openUsage"
|
||||
:picker-families="families"
|
||||
:total-files="store.fonts.value.length"
|
||||
:max-files="MAX_SERVER_FONTS"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { computed } from 'vue'
|
||||
import { MAX_SERVER_FONTS } from '@bookorbit/types'
|
||||
import ServerFontsSettings from '../ServerFontsSettings.vue'
|
||||
import ServerFontRow from '../server-fonts/components/ServerFontRow.vue'
|
||||
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> }>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
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>(),
|
||||
}))
|
||||
|
||||
@@ -25,181 +29,253 @@ import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFont
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const openDyslexic = [mockFont(1, 'OpenDyslexic', 400), mockFont(2, 'OpenDyslexic', 700)]
|
||||
const atkinson = mockFont(3, 'Atkinson Hyperlegible')
|
||||
|
||||
function makeComposable(initialFonts = [] as Parameters<typeof makeFontStore>[1]): MockFontStore {
|
||||
const store = makeFontStore('server', initialFonts)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ server: store }) as never)
|
||||
return store
|
||||
/** The real menus open on a pointer gesture jsdom does not raise, so their content is rendered flat. */
|
||||
const DROPDOWN_STUBS = {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
}
|
||||
|
||||
function mountPage(props: { embedded?: boolean } = {}) {
|
||||
return mount(ServerFontsSettings, { props, attachTo: document.body })
|
||||
const bigCaslon = mockFont(2, 'Big Caslon', 500)
|
||||
const courgette = mockFont(3, 'Courgette')
|
||||
const literata = [mockFont(8, 'Literata'), mockFont(9, 'Literata', 700)]
|
||||
|
||||
const USAGE = {
|
||||
families: [
|
||||
{ familyName: 'Big Caslon', books: 0, readers: 0, defaults: 0, hidden: 1 },
|
||||
{ familyName: 'Courgette', books: 2, readers: 1, defaults: 0, hidden: 0 },
|
||||
{ familyName: 'Literata', books: 0, readers: 0, defaults: 0, hidden: 0 },
|
||||
],
|
||||
}
|
||||
|
||||
function respond(body: unknown, ok = true) {
|
||||
return Promise.resolve({ ok, json: () => Promise.resolve(body) })
|
||||
}
|
||||
|
||||
let store: MockFontStore
|
||||
|
||||
function mountPage(fonts = [courgette, ...literata, bigCaslon]): VueWrapper {
|
||||
store = makeFontStore('server', fonts)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ server: store }) as never)
|
||||
return mount(ServerFontsSettings, { props: { embedded: true }, attachTo: document.body, global: { stubs: DROPDOWN_STUBS } })
|
||||
}
|
||||
|
||||
function rowNamed(wrapper: VueWrapper, name: string) {
|
||||
const row = wrapper.findAll('[data-testid="server-font-row"]').find((candidate) => candidate.find('button').text() === name)
|
||||
if (!row) throw new Error(`no 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('ServerFontsSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(usePermissions).mockReturnValue({ isDemoRestrictedAccount: computed(() => false) } as never)
|
||||
apiMock.mockImplementation((url: string) => (url.endsWith('/usage') ? respond(USAGE) : respond({}, false)))
|
||||
})
|
||||
|
||||
describe('scope wiring', () => {
|
||||
it("manages the server collection, not the current user's fonts", async () => {
|
||||
const store = makeComposable()
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('loads the server collection and how readers use it', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(useCustomFonts).mock.results[0]!.value.scopeStore).toHaveBeenCalledWith('server')
|
||||
expect(store.scope).toBe('server')
|
||||
})
|
||||
|
||||
it('loads the collection on mount', async () => {
|
||||
const store = makeComposable()
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(store.fetchFonts).toHaveBeenCalled()
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/usage')
|
||||
})
|
||||
|
||||
it('uses the server cap rather than the per-user one', async () => {
|
||||
makeComposable([...openDyslexic])
|
||||
const wrapper = mountPage()
|
||||
it('shows the empty state with its own Add fonts button when the server has no fonts', async () => {
|
||||
const wrapper = mountPage([])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(`2 / ${MAX_SERVER_FONTS} used`)
|
||||
})
|
||||
|
||||
it('labels the list as server fonts', async () => {
|
||||
makeComposable([atkinson])
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Server fonts')
|
||||
expect(wrapper.text()).not.toContain('Your Fonts')
|
||||
})
|
||||
|
||||
it('shows a server-specific empty state', async () => {
|
||||
makeComposable()
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No server fonts yet')
|
||||
expect(wrapper.text()).toContain("Fonts you add here appear in every user's reader.")
|
||||
expect(wrapper.get('[data-testid="font-upload-surface"]').classes()).toContain('bg-card')
|
||||
expect(wrapper.get('[data-testid="font-empty-surface"]').classes()).toContain('bg-card')
|
||||
})
|
||||
|
||||
it('does not repeat the page subtitle when embedded in the settings shell', async () => {
|
||||
makeComposable()
|
||||
const wrapper = mountPage({ embedded: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Upload fonts that every user can pick in the eBook reader')
|
||||
const empty = wrapper.get('[data-testid="font-empty-surface"]')
|
||||
expect(empty.text()).toContain('No server fonts yet')
|
||||
expect(empty.text()).toContain('Add fonts')
|
||||
expect(wrapper.find('[data-testid="font-list-surface"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('font list', () => {
|
||||
it('renders families grouped by name', async () => {
|
||||
makeComposable([...openDyslexic, atkinson])
|
||||
describe('ledger', () => {
|
||||
it('lists one row per family, in name order, each set in its own face', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OpenDyslexic')
|
||||
expect(wrapper.text()).toContain('Atkinson Hyperlegible')
|
||||
expect(wrapper.text()).toContain('2 files')
|
||||
expect(wrapper.text()).toContain('1 file')
|
||||
expect(wrapper.get('[data-testid="font-list-surface"]').classes()).toContain('settings-card')
|
||||
const names = wrapper.findAll('[data-testid="server-font-row"]').map((row) => row.find('button').text())
|
||||
expect(names).toEqual(['Big Caslon', 'Courgette', 'Literata'])
|
||||
expect(rowNamed(wrapper, 'Literata').find('button').attributes('style')).toContain('__serverfont_literata')
|
||||
})
|
||||
|
||||
it('previews each family under a server-scoped CSS family name', async () => {
|
||||
makeComposable([atkinson])
|
||||
it('says what each family gives readers instead of how many files it has', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.html()).toContain('__serverfont_atkinson_hyperlegible')
|
||||
expect(wrapper.html()).not.toContain('__userfont_')
|
||||
expect(rowNamed(wrapper, 'Big Caslon').text()).toContain('Medium only')
|
||||
expect(rowNamed(wrapper, 'Literata').text()).toContain('2 styles, no italics')
|
||||
})
|
||||
|
||||
it('shows reader use only where there is some', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNamed(wrapper, 'Courgette').get('[data-testid="server-font-usage"]').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, 'Literata').get('[data-testid="server-font-usage"]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('counts families and files separately, against the server cap', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
const totals = wrapper.get('[data-testid="server-fonts-totals"]').text().replace(/\s+/g, ' ')
|
||||
expect(totals).toContain('3 families')
|
||||
expect(totals).toContain('4 of 200 files')
|
||||
})
|
||||
|
||||
it('sets the preview text in every row', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('filters families by name and says when none match', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[type="search"]').setValue('lit')
|
||||
expect(wrapper.findAll('[data-testid="server-font-row"]')).toHaveLength(1)
|
||||
|
||||
await wrapper.get('input[type="search"]').setValue('zzz')
|
||||
expect(wrapper.text()).toContain('No families match “zzz”')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutations', () => {
|
||||
it('uploads a dropped file to the server collection', async () => {
|
||||
const store = makeComposable()
|
||||
store.uploadFont.mockResolvedValue({ font: atkinson, suggestedFamilyName: 'Atkinson', suggestedWeight: 400, suggestedStyle: 'normal' })
|
||||
describe('uploads', () => {
|
||||
it('uploads files dropped anywhere on the page and lists where each one went', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
store.uploadFont.mockResolvedValue({
|
||||
font: mockFont(20, 'Caudex'),
|
||||
suggestedFamilyName: 'Caudex',
|
||||
suggestedWeight: 400,
|
||||
suggestedStyle: 'normal',
|
||||
})
|
||||
|
||||
dropFiles([new File(['x'], 'Caudex-Regular.ttf'), new File(['x'], 'reading-notes.pdf')])
|
||||
await flushPromises()
|
||||
|
||||
expect(store.uploadFont).toHaveBeenCalledTimes(1)
|
||||
const entries = wrapper.findAll('[data-testid="font-upload-entry"]').map((entry) => entry.text().replace(/\s+/g, ' '))
|
||||
expect(entries[0]).toContain('Caudex-Regular.ttf')
|
||||
expect(entries[0]).toContain('Regular · new family')
|
||||
expect(entries[0]).toContain('Added')
|
||||
expect(entries[1]).toContain('Not a font file. Use TTF, OTF, WOFF or WOFF2.')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('family sheet', () => {
|
||||
it('opens the family when its row is clicked', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'Atkinson.ttf', { type: 'font/ttf' })
|
||||
await wrapper.find('label').trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
|
||||
await rowNamed(wrapper, 'Literata').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.uploadFont).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('"Atkinson.ttf" added')
|
||||
expect(dialogText()).toContain('Literata')
|
||||
expect(dialogText()).toContain('In the reader')
|
||||
expect(document.body.querySelectorAll('[data-testid="server-font-file"]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('surfaces an upload rejection inline', async () => {
|
||||
const store = makeComposable()
|
||||
store.uploadFont.mockRejectedValue(new Error('This font file has already been uploaded'))
|
||||
it('opens with the removal already asked when chosen from the row menu, naming what it costs', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'Dupe.ttf')
|
||||
await wrapper.find('label').trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
|
||||
wrapper
|
||||
.findAllComponents(ServerFontRow)
|
||||
.find((row) => row.props('family').name === 'Courgette')!
|
||||
.vm.$emit('remove', 'Courgette')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Dupe.ttf: This font file has already been uploaded')
|
||||
const alert = document.body.querySelector('[role="dialog"] [role="alert"]')?.textContent ?? ''
|
||||
expect(alert).toContain('Remove Courgette for every reader?')
|
||||
expect(alert).toContain("It is set on 2 books by 1 reader; those books go back to the book's own font.")
|
||||
expect(store.deleteFont).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes a family optimistically', async () => {
|
||||
const store = makeComposable([...openDyslexic, atkinson])
|
||||
store.deleteFont.mockResolvedValue(true)
|
||||
it('removes every file of the family once confirmed', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
const deleteBtns = wrapper.findAll('button').filter((b) => b.attributes('title') === 'Delete family')
|
||||
await deleteBtns[1]?.trigger('click')
|
||||
wrapper
|
||||
.findAllComponents(ServerFontRow)
|
||||
.find((row) => row.props('family').name === 'Literata')!
|
||||
.vm.$emit('remove', 'Literata')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.deleteFont).toHaveBeenCalledWith(atkinson.id)
|
||||
expect(store.fonts.value.some((f) => f.familyName === 'Atkinson Hyperlegible')).toBe(false)
|
||||
clickInDialog('Remove family')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.deleteFont.mock.calls).toEqual([[8], [9]])
|
||||
expect(toast.success).toHaveBeenCalledWith('Literata removed')
|
||||
})
|
||||
|
||||
it('restores the list and reports failure when a delete is rejected', async () => {
|
||||
const store = makeComposable([atkinson])
|
||||
store.deleteFont.mockResolvedValue(false)
|
||||
it('renames the whole family in one request', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.attributes('title') === 'Delete family')
|
||||
?.trigger('click')
|
||||
apiMock.mockImplementation((url: string) => (url.endsWith('/families') ? respond([mockFont(3, 'Courgette Script')]) : respond(USAGE)))
|
||||
await rowNamed(wrapper, 'Courgette').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete font family')
|
||||
expect(store.fetchFonts).toHaveBeenCalledTimes(2)
|
||||
const input = document.body.querySelector<HTMLInputElement>('#server-font-family-name')!
|
||||
input.value = 'Courgette Script'
|
||||
input.dispatchEvent(new Event('input'))
|
||||
await flushPromises()
|
||||
expect(dialogText()).toContain('A new name sends the 2 books that use it back to the book’s own font.')
|
||||
|
||||
clickInDialog('Save')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/families', expect.objectContaining({ method: 'PATCH' }))
|
||||
expect(toast.success).toHaveBeenCalledWith('Renamed to "Courgette Script"')
|
||||
})
|
||||
|
||||
it('renames a family across all of its variants', async () => {
|
||||
const store = makeComposable([...openDyslexic])
|
||||
store.updateFont.mockResolvedValue({ ...openDyslexic[0]!, familyName: 'Open Dyslexic' })
|
||||
it('warns that a new name shows the family again to readers who hid it', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.attributes('title') === 'Rename family')
|
||||
?.trigger('click')
|
||||
await rowNamed(wrapper, 'Big Caslon').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const textInput = wrapper.findAll('input').find((i) => i.attributes('type') !== 'file')
|
||||
await textInput!.setValue('Open Dyslexic')
|
||||
await textInput!.trigger('keydown', { key: 'Enter' })
|
||||
const input = document.body.querySelector<HTMLInputElement>('#server-font-family-name')!
|
||||
input.value = 'Caslon'
|
||||
input.dispatchEvent(new Event('input'))
|
||||
await flushPromises()
|
||||
|
||||
expect(store.updateFont).toHaveBeenCalledTimes(2)
|
||||
expect(store.updateFont).toHaveBeenCalledWith(1, { familyName: 'Open Dyslexic' })
|
||||
expect(store.updateFont).toHaveBeenCalledWith(2, { familyName: 'Open Dyslexic' })
|
||||
expect(dialogText()).toContain('1 reader hid Big Caslon; a new name shows it to them again.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,27 +284,23 @@ describe('ServerFontsSettings', () => {
|
||||
vi.mocked(usePermissions).mockReturnValue({ isDemoRestrictedAccount: computed(() => true) } as never)
|
||||
})
|
||||
|
||||
it('blocks uploads', async () => {
|
||||
const store = makeComposable()
|
||||
it('keeps the list readable but offers nothing that changes it', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['data'], 'Test.ttf')
|
||||
await wrapper.find('label').trigger('drop', { preventDefault: vi.fn<() => void>(), dataTransfer: { files: [file] } })
|
||||
expect(wrapper.text()).toContain('Courgette')
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === 'Add fonts')).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('Remove family'))).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores dropped files', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
|
||||
dropFiles([new File(['x'], 'Test.ttf')])
|
||||
await flushPromises()
|
||||
|
||||
expect(store.uploadFont).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides the management controls but keeps the list readable', async () => {
|
||||
makeComposable([atkinson])
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('button').find((b) => b.attributes('title') === 'Rename family')).toBeUndefined()
|
||||
expect(wrapper.findAll('button').find((b) => b.attributes('title') === 'Delete family')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Atkinson Hyperlegible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,9 @@ const DROPDOWN_STUBS = {
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
}
|
||||
|
||||
function mountSheet(props: { dirty?: boolean; removable?: boolean; removeConfirm?: string; menuActions?: EditorMenuAction[] } = {}) {
|
||||
function mountSheet(
|
||||
props: { dirty?: boolean; removable?: boolean; removeConfirm?: string; menuActions?: EditorMenuAction[]; armedAction?: string | null } = {},
|
||||
) {
|
||||
return mount(SettingsEditorSheet, {
|
||||
props: { open: true, title: 'My Prowlarr', description: 'Settings for this indexer.', ...props },
|
||||
slots: { default: '<p>form body</p>' },
|
||||
@@ -103,6 +105,19 @@ describe('SettingsEditorSheet', () => {
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens with the question already asked when a list row armed it', async () => {
|
||||
const wrapper = mountSheet({ removable: true, removeConfirm: 'Delete this source?', armedAction: 'remove' })
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf('[role="alert"]')).toContain('Delete this source?')
|
||||
expect(wrapper.emitted('remove')).toBeUndefined()
|
||||
|
||||
clickByText('Delete', confirmation())
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes nothing when the question is dismissed', async () => {
|
||||
const wrapper = mountSheet({ removable: true })
|
||||
await flushPromises()
|
||||
|
||||
@@ -34,6 +34,11 @@ const props = defineProps<{
|
||||
removeConsequence?: string
|
||||
/** Anything acting on what the record is built on rather than on the record itself. */
|
||||
menuActions?: EditorMenuAction[]
|
||||
/**
|
||||
* A menu entry to arm as the sheet opens, for a list row whose own menu offers the same action:
|
||||
* the record opens with the question already asked, instead of a second click to find it.
|
||||
*/
|
||||
armedAction?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ save: []; cancel: []; remove: []; action: [id: string] }>()
|
||||
@@ -75,9 +80,10 @@ const confirmBar = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
() => {
|
||||
pendingId.value = null
|
||||
(open) => {
|
||||
pendingId.value = open ? (props.armedAction ?? null) : null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** The menu closes as it arms, so the answer to its question has to take the focus it left behind. */
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Upload } from '@lucide/vue'
|
||||
|
||||
/** Covers the page while files are dragged over it. Browsers give the count, not the names, until the drop. */
|
||||
defineProps<{ count: number }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none fixed inset-0 z-40 bg-scrim p-3 backdrop-blur-[3px]" aria-hidden="true" data-testid="font-drop-overlay">
|
||||
<div class="grid size-full place-items-center rounded-2xl border-2 border-dashed border-primary/70 bg-primary/6 text-center">
|
||||
<div class="rounded-xl border border-border bg-card px-8 py-6 shadow-lg">
|
||||
<div class="mx-auto mb-3.5 grid size-14 place-items-center rounded-full bg-primary/15 text-primary">
|
||||
<Upload :size="26" />
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-foreground">{{ t('settings.admin.serverFonts.drop.title', { count }) }}</p>
|
||||
<p class="mt-1 text-[13px] text-muted-foreground">{{ t('settings.reader.fonts.fileTypesHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { themes, type ThemeMode } from '@/features/reader/epub/constants/themes'
|
||||
|
||||
export type PreviewPaper = 'app' | 'white' | 'sepia' | 'night'
|
||||
|
||||
/**
|
||||
* A passage set the way the reader sets it, on the reader's own page colours. A script face that is
|
||||
* lovely on the app surface can fall apart on sepia, and this is where an administrator finds out.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
cssFamilyName: string
|
||||
paper: PreviewPaper
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:paper': [paper: PreviewPaper] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const themeNamed = (name: string) => themes.find((theme) => theme.name === name)!
|
||||
|
||||
/** The reader's Default page in light and dark, and its Sepia page. */
|
||||
const PAPER_COLOURS: Record<Exclude<PreviewPaper, 'app'>, ThemeMode> = {
|
||||
white: themeNamed('default').light,
|
||||
sepia: themeNamed('sepia').light,
|
||||
night: themeNamed('default').dark,
|
||||
}
|
||||
|
||||
const PAPERS: PreviewPaper[] = ['app', 'white', 'sepia', 'night']
|
||||
|
||||
function swatchStyle(paper: PreviewPaper) {
|
||||
if (paper === 'app') return {}
|
||||
const mode = PAPER_COLOURS[paper]
|
||||
return { backgroundColor: mode.bg, color: mode.fg }
|
||||
}
|
||||
|
||||
const pageStyle = computed(() => ({ ...swatchStyle(props.paper), fontFamily: `'${props.cssFamilyName}', serif` }))
|
||||
|
||||
function selectPaper(paper: PreviewPaper) {
|
||||
emit('update:paper', paper)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.preview') }}</span>
|
||||
<div class="flex items-center gap-1.5" role="group" :aria-label="t('settings.admin.serverFonts.sheet.pageColour')">
|
||||
<button
|
||||
v-for="paper in PAPERS"
|
||||
:key="paper"
|
||||
type="button"
|
||||
class="grid size-[26px] place-items-center rounded-full border border-foreground/15 font-serif text-xs font-semibold transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:outline-[1.5px] aria-pressed:outline-offset-2 aria-pressed:outline-primary aria-pressed:outline-solid"
|
||||
:class="paper === 'app' ? 'bg-card text-foreground' : ''"
|
||||
:style="swatchStyle(paper)"
|
||||
:aria-pressed="paper === props.paper"
|
||||
:aria-label="t(`settings.admin.serverFonts.sheet.papers.${paper}`)"
|
||||
:title="t(`settings.admin.serverFonts.sheet.papers.${paper}`)"
|
||||
@click="selectPaper(paper)"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
<div
|
||||
class="rounded-xl border border-border px-7 py-6 transition-colors"
|
||||
:class="paper === 'app' ? 'bg-card text-foreground' : ''"
|
||||
:style="pageStyle"
|
||||
>
|
||||
<p class="mb-3 text-[25px] leading-tight text-balance">{{ t('settings.admin.serverFonts.sheet.previewHeading') }}</p>
|
||||
<p class="text-lg leading-relaxed">{{ t('settings.admin.serverFonts.sheet.previewParagraph1') }}</p>
|
||||
<p class="mt-3.5 text-lg leading-relaxed">{{ t('settings.admin.serverFonts.sheet.previewParagraph2') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
|
||||
import { styleGrid, type StyleGridCell } from '../lib/font-style-grid'
|
||||
|
||||
/**
|
||||
* Which styles a family really has: nine weight columns, upright over italic. A square is a static
|
||||
* file, a bar is a variable axis with a dot for each style it offers, and an empty slot is a style
|
||||
* the browser would have to fake.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
fonts: readonly UserFont[]
|
||||
/** What the grid says, for assistive technology, which cannot read the shape. */
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const CELL = 10
|
||||
const GAP = 3
|
||||
|
||||
const rows = computed(() => styleGrid(props.fonts))
|
||||
|
||||
function barStyle(from: number, to: number) {
|
||||
const step = CELL + GAP
|
||||
return { insetInlineStart: `${from * step}px`, width: `${(to - from) * step + CELL}px` }
|
||||
}
|
||||
|
||||
function cellTitle(entry: StyleGridCell): string {
|
||||
return entry.variant ? `${fontVariantLabel(entry.variant, t)} · ${entry.weight}` : String(entry.weight)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="img" :aria-label="label" class="inline-grid grid-cols-[auto_auto] items-center gap-x-1.5 gap-y-1">
|
||||
<template v-for="row in rows" :key="row.style">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="w-4 text-center font-serif text-[11px] leading-none font-semibold text-muted-foreground"
|
||||
:class="row.style === 'italic' ? 'italic' : ''"
|
||||
>a</span
|
||||
>
|
||||
<span class="relative grid h-2.5 grid-cols-[repeat(9,10px)] gap-[3px]">
|
||||
<span
|
||||
v-for="range in row.ranges"
|
||||
:key="`${range.from}-${range.to}`"
|
||||
class="absolute top-0 h-full rounded-full bg-primary/20 ring-1 ring-primary/45 ring-inset"
|
||||
:style="barStyle(range.from, range.to)"
|
||||
/>
|
||||
<span v-for="entry in row.cells" :key="entry.weight" class="relative grid place-items-center" :title="cellTitle(entry)">
|
||||
<span v-if="entry.state === 'static'" class="size-full rounded-[2.5px] bg-primary" />
|
||||
<span v-else-if="entry.state === 'range'" class="size-1 rounded-full bg-primary" />
|
||||
<span v-else class="size-0.5 rounded-full bg-foreground/25" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronRight, CircleCheck, CircleDashed, CircleX, Clock, LoaderCircle, Upload } from '@lucide/vue'
|
||||
import { fontCssFamilyGroupName } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import type { UploadEntry } from '../composables/useServerFontLibrary'
|
||||
import { refusalMessageKey } from '../lib/upload-outcome'
|
||||
|
||||
/**
|
||||
* One card per batch of dropped files, with an answer for each: which family it joined, that it was
|
||||
* already on the server, or why it was refused. Replaces a toast per file, which scrolled away before
|
||||
* a ten-file family had finished.
|
||||
*/
|
||||
const props = defineProps<{ entries: UploadEntry[] }>()
|
||||
const emit = defineEmits<{ dismiss: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const finished = computed(() => props.entries.filter((entry) => entry.state !== 'queued' && entry.state !== 'uploading'))
|
||||
const busy = computed(() => finished.value.length < props.entries.length)
|
||||
const counts = computed(() => ({
|
||||
added: props.entries.filter((entry) => entry.state === 'added').length,
|
||||
duplicate: props.entries.filter((entry) => entry.state === 'duplicate').length,
|
||||
refused: props.entries.filter((entry) => entry.state === 'refused' || entry.state === 'failed').length,
|
||||
}))
|
||||
const progress = computed(() => (props.entries.length ? (finished.value.length / props.entries.length) * 100 : 0))
|
||||
|
||||
const STATE_ICONS = { queued: Clock, uploading: LoaderCircle, added: CircleCheck, duplicate: CircleDashed, refused: CircleX, failed: CircleX }
|
||||
|
||||
function iconClass(entry: UploadEntry): string {
|
||||
if (entry.state === 'added') return 'text-success'
|
||||
if (entry.state === 'refused' || entry.state === 'failed') return 'text-destructive'
|
||||
if (entry.state === 'uploading') return 'animate-spin text-primary motion-reduce:animate-none'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
function faceStyle(entry: UploadEntry) {
|
||||
if (!entry.face) return {}
|
||||
return {
|
||||
fontFamily: `'${fontCssFamilyGroupName(entry.face.familyName, 'server')}', sans-serif`,
|
||||
fontWeight: entry.face.weight,
|
||||
fontStyle: entry.face.style,
|
||||
}
|
||||
}
|
||||
|
||||
function faceLabel(entry: UploadEntry): string {
|
||||
if (!entry.face) return ''
|
||||
const style = fontVariantLabel({ name: entry.styleName, weight: entry.face.weight, style: entry.face.style }, t)
|
||||
return entry.newFamily ? `${style} · ${t('settings.admin.serverFonts.upload.newFamily')}` : style
|
||||
}
|
||||
|
||||
function reason(entry: UploadEntry): string {
|
||||
return t(refusalMessageKey(entry.code), entry.meta)
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
emit('dismiss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-border bg-card shadow-xs" :aria-label="t('settings.admin.serverFonts.upload.ariaLabel')">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2.5">
|
||||
<Upload :size="16" class="text-muted-foreground" aria-hidden="true" />
|
||||
<span class="text-sm font-semibold">{{ t('settings.admin.serverFonts.upload.title', { count: entries.length }) }}</span>
|
||||
<span class="text-[12.5px] text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{{ t('settings.admin.serverFonts.upload.progress', { done: finished.length, total: entries.length }) }}
|
||||
<template v-if="counts.added"> · {{ t('settings.admin.serverFonts.upload.addedCount', { count: counts.added }) }}</template>
|
||||
<template v-if="counts.duplicate"> · {{ t('settings.admin.serverFonts.upload.duplicateCount', { count: counts.duplicate }) }}</template>
|
||||
<template v-if="counts.refused"> · {{ t('settings.admin.serverFonts.upload.refusedCount', { count: counts.refused }) }}</template>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" type="button" class="ms-auto" :disabled="busy" @click="handleDismiss">
|
||||
{{ t('settings.admin.serverFonts.upload.dismiss') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="h-[3px] bg-foreground/8" aria-hidden="true">
|
||||
<div
|
||||
class="h-full rounded-e-full bg-primary transition-[width] duration-300 motion-reduce:transition-none"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<ul>
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 border-t border-border/60 px-4 py-2 text-[13px] leading-[19px] md:grid-cols-[1.25rem_minmax(0,1fr)_minmax(0,1.15fr)_9rem]"
|
||||
data-testid="font-upload-entry"
|
||||
>
|
||||
<component :is="STATE_ICONS[entry.state]" :size="16" :class="iconClass(entry)" aria-hidden="true" />
|
||||
<span class="truncate text-foreground" :title="entry.fileName">{{ entry.fileName }}</span>
|
||||
<span class="col-start-2 row-start-2 flex min-w-0 items-center gap-2 text-muted-foreground md:col-start-auto md:row-start-auto">
|
||||
<template v-if="entry.face && (entry.state === 'added' || entry.state === 'duplicate')">
|
||||
<ChevronRight :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span class="truncate text-base leading-5 text-foreground" :style="faceStyle(entry)">{{ entry.face.familyName }}</span>
|
||||
<span class="shrink-0">{{ faceLabel(entry) }}</span>
|
||||
</template>
|
||||
<span v-else-if="entry.state === 'refused' || entry.state === 'failed'">{{ reason(entry) }}</span>
|
||||
<span v-else class="tabular-nums">{{ formatBytes(entry.size) }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-end text-[12.5px] whitespace-nowrap"
|
||||
:class="{
|
||||
'text-success': entry.state === 'added',
|
||||
'text-destructive': entry.state === 'refused' || entry.state === 'failed',
|
||||
'text-muted-foreground': entry.state === 'queued' || entry.state === 'uploading' || entry.state === 'duplicate',
|
||||
}"
|
||||
>
|
||||
{{ t(`settings.admin.serverFonts.upload.states.${entry.state}`) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FontFamily } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/font-options'
|
||||
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
||||
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 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. */
|
||||
const chosenChip = computed(() => closestVariant(chips.value, { weight: 400, style: 'normal' }))
|
||||
|
||||
const groupLabel = 'mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground'
|
||||
const tile = 'flex h-10 items-center truncate rounded-lg border px-2 text-sm'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-border bg-card px-4 py-3.5" role="group" :aria-label="t('settings.admin.serverFonts.sheet.pickerLabel')">
|
||||
<p class="mb-2 block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('reader.settings.font') }}</p>
|
||||
<p :class="groupLabel">{{ t('reader.settings.fontBuiltIn') }}</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<span
|
||||
v-for="font in BUILTIN_READER_FONT_OPTIONS"
|
||||
:key="String(font.value)"
|
||||
:class="tile"
|
||||
class="border-border text-foreground"
|
||||
:style="font.value ? { fontFamily: font.value } : {}"
|
||||
>{{ t(font.labelKey) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<template v-if="families.length > 0">
|
||||
<p :class="groupLabel" class="mt-3">{{ t('reader.settings.fontServer') }}</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<span
|
||||
v-for="family in families"
|
||||
:key="family.cssFamilyName"
|
||||
:class="[
|
||||
tile,
|
||||
family.cssFamilyName === selected?.cssFamilyName ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-foreground',
|
||||
]"
|
||||
:style="{ fontFamily: `'${family.cssFamilyName}', sans-serif` }"
|
||||
>{{ formatFontFamilyLabel(family.name) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="selected && chips.length > 1">
|
||||
<p :class="groupLabel" class="mt-3">{{ t('reader.settings.fontStyle') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="variant in chips"
|
||||
:key="`${variant.weight}:${variant.style}`"
|
||||
class="flex h-9 items-center rounded-lg border px-3 text-sm"
|
||||
:class="
|
||||
chosenChip && isSameVariant(variant, chosenChip) ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-foreground'
|
||||
"
|
||||
:style="{ fontFamily: `'${selected.cssFamilyName}', serif`, fontWeight: variant.weight, fontStyle: variant.style }"
|
||||
>{{ fontVariantLabel(variant, t) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EyeOff, FileUp, MoreHorizontal, Pencil, Trash2, TriangleAlert } from '@lucide/vue'
|
||||
import type { FontStyle, ServerFontFamilyUsage, UserFont } from '@bookorbit/types'
|
||||
import { FONT_WEIGHTS, fontCssFamilyGroupName, isVariableFont } 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 { FONT_WEIGHT_LABEL_KEYS, familyVariants, fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import SettingsEditorSheet from '../../components/SettingsEditorSheet.vue'
|
||||
import { useFontLabels } from '../composables/useFontLabels'
|
||||
import FontPagePreview, { type PreviewPaper } from './FontPagePreview.vue'
|
||||
import ReaderFontPickerPreview from './ReaderFontPickerPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
family: FontFamily | null
|
||||
usage: ServerFontFamilyUsage
|
||||
/** Every server family, for the copy of the reader's font list. */
|
||||
pickerFamilies: 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
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [newName: string]
|
||||
remove: []
|
||||
addFiles: []
|
||||
updateFile: [fontId: number, data: { weight: number; style: FontStyle }]
|
||||
removeFile: [fontId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { styleSummaryText, fileCountSummary, fileDetail } = useFontLabels()
|
||||
|
||||
const REMOVE_ACTION = 'remove'
|
||||
|
||||
const nameDraft = ref('')
|
||||
const paper = ref<PreviewPaper>('app')
|
||||
const editingFileId = ref<number | null>(null)
|
||||
const editWeight = ref(400)
|
||||
const editStyle = ref<FontStyle>('normal')
|
||||
const confirmingFileId = ref<number | null>(null)
|
||||
|
||||
watch(
|
||||
() => [props.open, props.family?.name] as const,
|
||||
() => {
|
||||
nameDraft.value = props.family?.name ?? ''
|
||||
editingFileId.value = null
|
||||
confirmingFileId.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// The sheet focuses its first control as it opens; the name field takes focus after that settles.
|
||||
watch(
|
||||
() => props.open && props.focusName,
|
||||
async (focus) => {
|
||||
if (!focus) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => nameInput.value?.select()))
|
||||
},
|
||||
)
|
||||
|
||||
const name = computed(() => props.family?.name ?? '')
|
||||
const files = computed<UserFont[]>(() => props.family?.variants ?? [])
|
||||
const variants = computed(() => familyVariants(files.value))
|
||||
const isVariable = computed(() => files.value.some(isVariableFont))
|
||||
const trimmedDraft = computed(() => nameDraft.value.trim())
|
||||
const dirty = computed(() => props.family !== null && trimmedDraft.value !== '' && trimmedDraft.value !== props.family.name)
|
||||
|
||||
/** Saved reader settings name a family by its CSS name, so a rename that changes it strands them. */
|
||||
const renameStrandsBooks = computed(
|
||||
() => dirty.value && props.usage.books > 0 && fontCssFamilyGroupName(trimmedDraft.value, 'server') !== fontCssFamilyGroupName(name.value, 'server'),
|
||||
)
|
||||
|
||||
const removeConsequence = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (props.usage.books > 0) {
|
||||
parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceBooks', { books: props.usage.books, readers: props.usage.readers }))
|
||||
}
|
||||
parts.push(t('settings.admin.serverFonts.sheet.removeConsequenceFiles', { count: files.value.length }))
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const weightOptions = computed(() => FONT_WEIGHTS.map((weight) => ({ value: weight, label: `${t(FONT_WEIGHT_LABEL_KEYS[weight]!)} (${weight})` })))
|
||||
|
||||
function handleSave() {
|
||||
if (dirty.value) emit('save', trimmedDraft.value)
|
||||
else emit('close')
|
||||
}
|
||||
function handleCancel() {
|
||||
emit('close')
|
||||
}
|
||||
function handleRemove() {
|
||||
emit('remove')
|
||||
}
|
||||
function handleAddFiles() {
|
||||
emit('addFiles')
|
||||
}
|
||||
function handlePaper(next: PreviewPaper) {
|
||||
paper.value = next
|
||||
}
|
||||
|
||||
function startEditFile(font: UserFont) {
|
||||
confirmingFileId.value = null
|
||||
editingFileId.value = font.id
|
||||
editWeight.value = font.weight
|
||||
editStyle.value = font.style
|
||||
}
|
||||
function cancelEditFile() {
|
||||
editingFileId.value = null
|
||||
}
|
||||
function saveEditFile() {
|
||||
const id = editingFileId.value
|
||||
editingFileId.value = null
|
||||
if (id !== null) emit('updateFile', id, { weight: editWeight.value, style: editStyle.value })
|
||||
}
|
||||
function askRemoveFile(font: UserFont) {
|
||||
editingFileId.value = null
|
||||
confirmingFileId.value = font.id
|
||||
}
|
||||
function cancelRemoveFile() {
|
||||
confirmingFileId.value = null
|
||||
}
|
||||
function confirmRemoveFile() {
|
||||
const id = confirmingFileId.value
|
||||
confirmingFileId.value = null
|
||||
if (id !== null) emit('removeFile', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsEditorSheet
|
||||
:open="open && family !== null"
|
||||
:title="name"
|
||||
:description="t('settings.admin.serverFonts.sheet.description', { name })"
|
||||
:dirty="dirty"
|
||||
:busy="busy"
|
||||
:removable="!readonly"
|
||||
:remove-label="t('settings.admin.serverFonts.sheet.removeLabel')"
|
||||
:remove-confirm="t('settings.admin.serverFonts.sheet.removeQuestion', { name })"
|
||||
:remove-consequence="removeConsequence"
|
||||
:armed-action="armRemove ? REMOVE_ACTION : null"
|
||||
@save="handleSave"
|
||||
@cancel="handleCancel"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<template #status>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[12.5px] text-muted-foreground">
|
||||
<span v-if="isVariable" class="settings-state-pill">{{ t('settings.admin.serverFonts.sheet.variable') }}</span>
|
||||
<span>{{ styleSummaryText(files) }} · {{ fileCountSummary(files) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="family">
|
||||
<section>
|
||||
<FontPagePreview :css-family-name="family.cssFamilyName" :paper="paper" @update:paper="handlePaper">
|
||||
<p class="truncate text-[44px] leading-[52px] text-foreground" :style="{ fontFamily: `'${family.cssFamilyName}', serif` }">
|
||||
{{ family.name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/8 px-3 py-2.5 text-xs text-foreground"
|
||||
>
|
||||
<TriangleAlert :size="14" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
{{ t('settings.admin.serverFonts.sheet.loadFailedNote') }}
|
||||
</p>
|
||||
</FontPagePreview>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.styles') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.sheet.stylesHint') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="variant in variants"
|
||||
:key="`${variant.weight}:${variant.style}`"
|
||||
class="flex h-9 items-center rounded-lg border border-border px-3 text-sm text-foreground"
|
||||
:style="{ fontFamily: `'${family.cssFamilyName}', serif`, fontWeight: variant.weight, fontStyle: variant.style }"
|
||||
>{{ fontVariantLabel(variant, t) }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="variants.length === 1" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.singleStyleNote', { style: fontVariantLabel(variants[0]!, t) }) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<span class="settings-group-label mb-0">{{ t('settings.admin.serverFonts.sheet.inReader') }}</span>
|
||||
<ReaderFontPickerPreview :families="pickerFamilies" :selected="family" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.sheet.inReaderHint', { name: family.name }) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<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="text-xs text-muted-foreground tabular-nums">{{
|
||||
t('settings.admin.serverFonts.sheet.filesUsed', { count: formatNumber(totalFiles), max: formatNumber(maxFiles) })
|
||||
}}</span>
|
||||
</div>
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<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"
|
||||
aria-hidden="true"
|
||||
>{{ font.format }}</span
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13.5px] leading-[19px] font-medium break-all text-foreground">{{ font.originalFileName }}</p>
|
||||
<p class="text-[12.5px] leading-[18px] text-muted-foreground">{{ fileDetail(font) }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-[12.5px] text-muted-foreground tabular-nums max-sm:hidden">{{ formatBytes(font.fileSize) }}</span>
|
||||
<DropdownMenu v-if="!readonly">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
:aria-label="t('settings.admin.serverFonts.sheet.fileActions', { name: font.originalFileName })"
|
||||
>
|
||||
<MoreHorizontal :size="16" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<DropdownMenuItem @click="startEditFile(font)">
|
||||
<Pencil />
|
||||
{{ t('settings.admin.serverFonts.sheet.changeStyle') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" @click="askRemoveFile(font)">
|
||||
<Trash2 />
|
||||
{{ t('settings.admin.serverFonts.sheet.removeFile') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="editingFileId === font.id" class="mt-2.5 flex flex-wrap items-end gap-2 ps-11">
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.weight') }}
|
||||
<select v-model.number="editWeight" class="select-field h-8 text-sm">
|
||||
<option v-for="option in weightOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.style') }}
|
||||
<select v-model="editStyle" class="select-field h-8 text-sm">
|
||||
<option value="normal">{{ t('settings.reader.fonts.styleNormal') }}</option>
|
||||
<option value="italic">{{ t('settings.reader.fonts.styleItalic') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button size="sm" type="button" @click="saveEditFile">{{ t('common.save') }}</Button>
|
||||
<Button size="sm" variant="outline" type="button" @click="cancelEditFile">{{ t('common.cancel') }}</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="confirmingFileId === font.id"
|
||||
role="alert"
|
||||
class="mt-2.5 flex flex-wrap items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 ms-11"
|
||||
>
|
||||
<p class="min-w-0 flex-1 text-xs text-foreground">{{ t('settings.admin.serverFonts.sheet.removeFileQuestion') }}</p>
|
||||
<Button size="sm" variant="destructive" type="button" @click="confirmRemoveFile">{{
|
||||
t('settings.admin.serverFonts.sheet.removeFile')
|
||||
}}</Button>
|
||||
<Button size="sm" variant="outline" type="button" @click="cancelRemoveFile">{{ t('common.cancel') }}</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section 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>
|
||||
<input id="server-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 v-if="dirty && 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" />
|
||||
{{ t('settings.admin.serverFonts.sheet.nameHiddenWarning', { count: usage.hidden, name: family.name }) }}
|
||||
</p>
|
||||
<p v-if="renameStrandsBooks" class="flex items-start gap-1.5 text-xs text-warning">
|
||||
<TriangleAlert :size="13" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
{{ t('settings.admin.serverFonts.sheet.nameBooksWarning', { count: usage.books }) }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<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">
|
||||
<div class="flex flex-col gap-0.5 px-4 py-3">
|
||||
<dt class="order-2 text-[12.5px] text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.stats.books', { count: usage.books }) }}
|
||||
</dt>
|
||||
<dd class="order-1 text-[22px] leading-[26px] font-semibold tracking-tight tabular-nums">{{ formatNumber(usage.books) }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 border-s border-border px-4 py-3">
|
||||
<dt class="order-2 text-[12.5px] text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.stats.defaults', { count: usage.defaults }) }}
|
||||
</dt>
|
||||
<dd class="order-1 text-[22px] leading-[26px] font-semibold tracking-tight tabular-nums">{{ formatNumber(usage.defaults) }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 border-s border-border px-4 py-3">
|
||||
<dt class="order-2 text-[12.5px] text-muted-foreground">
|
||||
{{ t('settings.admin.serverFonts.sheet.stats.hidden', { count: usage.hidden }) }}
|
||||
</dt>
|
||||
<dd class="order-1 text-[22px] leading-[26px] font-semibold tracking-tight tabular-nums">{{ formatNumber(usage.hidden) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.admin.serverFonts.sheet.readersPrivacy') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="!readonly" #actions>
|
||||
<Button size="sm" variant="outline" type="button" :disabled="busy" @click="handleAddFiles">
|
||||
<FileUp :size="14" aria-hidden="true" />
|
||||
{{ t('settings.admin.serverFonts.menu.addFiles') }}
|
||||
</Button>
|
||||
</template>
|
||||
</SettingsEditorSheet>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<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>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useFailedFontFaces } from '../useFailedFontFaces'
|
||||
|
||||
type Face = { family: string; status: string }
|
||||
|
||||
/** jsdom has no FontFaceSet; this one holds faces and raises the two events the composable hears. */
|
||||
function installFontSet(faces: Face[]) {
|
||||
const listeners = new Map<string, Array<() => void>>()
|
||||
const set = {
|
||||
forEach: (callback: (face: Face) => void) => faces.forEach(callback),
|
||||
addEventListener: (type: string, listener: () => void) => listeners.set(type, [...(listeners.get(type) ?? []), listener]),
|
||||
removeEventListener: (type: string, listener: () => void) =>
|
||||
listeners.set(
|
||||
type,
|
||||
(listeners.get(type) ?? []).filter((entry) => entry !== listener),
|
||||
),
|
||||
fire: (type: string) => (listeners.get(type) ?? []).forEach((listener) => listener()),
|
||||
}
|
||||
Object.defineProperty(document, 'fonts', { value: set, configurable: true })
|
||||
return set
|
||||
}
|
||||
|
||||
function mountProbe() {
|
||||
let state: ReturnType<typeof useFailedFontFaces> | undefined
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
state = useFailedFontFaces()
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
)
|
||||
return state!
|
||||
}
|
||||
|
||||
describe('useFailedFontFaces', () => {
|
||||
afterEach(() => {
|
||||
delete (document as { fonts?: unknown }).fonts
|
||||
})
|
||||
|
||||
it('collects the server families the browser refused, and only those', () => {
|
||||
installFontSet([
|
||||
{ family: '"__serverfont_big_caslon"', status: 'error' },
|
||||
{ family: '__serverfont_courgette', status: 'loaded' },
|
||||
{ family: '__userfont_georgia', status: 'error' },
|
||||
])
|
||||
|
||||
const { failed } = mountProbe()
|
||||
|
||||
expect([...failed.value]).toEqual(['__serverfont_big_caslon'])
|
||||
})
|
||||
|
||||
it('picks up a face that fails after the page has loaded', async () => {
|
||||
const faces: Face[] = [{ family: '__serverfont_outfit', status: 'loading' }]
|
||||
const set = installFontSet(faces)
|
||||
const { failed } = mountProbe()
|
||||
expect(failed.value.size).toBe(0)
|
||||
|
||||
faces[0]!.status = 'error'
|
||||
set.fire('loadingerror')
|
||||
await nextTick()
|
||||
|
||||
expect([...failed.value]).toEqual(['__serverfont_outfit'])
|
||||
})
|
||||
})
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MAX_FONT_FILE_SIZE } from '@bookorbit/types'
|
||||
import { makeCustomFontsMock, makeFontStore, mockFont, type MockFontStore } from '../../../__tests__/font-store-fixture'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: string, init?: RequestInit) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
vi.mock('@/features/reader/epub/composables/useCustomFonts', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/features/reader/epub/composables/useCustomFonts')>()),
|
||||
useCustomFonts: vi.fn<() => unknown>(),
|
||||
}))
|
||||
|
||||
import { FontUploadError, useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { useServerFontLibrary } from '../useServerFontLibrary'
|
||||
|
||||
const USAGE = {
|
||||
families: [
|
||||
{ familyName: 'Courgette', books: 2, readers: 1, defaults: 0, hidden: 0 },
|
||||
{ familyName: 'Big Caslon', books: 0, readers: 0, defaults: 0, hidden: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
function respond(body: unknown, ok = true) {
|
||||
return Promise.resolve({ ok, json: () => Promise.resolve(body) })
|
||||
}
|
||||
|
||||
function setup(fonts = [mockFont(3, 'Courgette'), mockFont(2, 'Big Caslon', 500)]): {
|
||||
store: MockFontStore
|
||||
library: ReturnType<typeof useServerFontLibrary>
|
||||
} {
|
||||
const store = makeFontStore('server', fonts)
|
||||
vi.mocked(useCustomFonts).mockReturnValue(makeCustomFontsMock({ server: store }) as never)
|
||||
return { store, library: useServerFontLibrary() }
|
||||
}
|
||||
|
||||
const fontFile = (name: string, size = 1000) => {
|
||||
const file = new File(['x'], name)
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('useServerFontLibrary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMock.mockImplementation((url: string) => (url.endsWith('/usage') ? respond(USAGE) : respond({}, false)))
|
||||
})
|
||||
|
||||
describe('usage', () => {
|
||||
it('loads the files and the reader counts together', async () => {
|
||||
const { store, library } = setup()
|
||||
|
||||
await library.load()
|
||||
|
||||
expect(store.fetchFonts).toHaveBeenCalled()
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/usage')
|
||||
expect(library.usageOf('Courgette')).toMatchObject({ books: 2, readers: 1 })
|
||||
})
|
||||
|
||||
it('answers zeros for a family the server did not count', async () => {
|
||||
const { library } = setup()
|
||||
await library.load()
|
||||
|
||||
expect(library.usageOf('Literata')).toEqual({ familyName: 'Literata', books: 0, readers: 0, defaults: 0, hidden: 0 })
|
||||
})
|
||||
|
||||
it('keeps working when the counts cannot be loaded', async () => {
|
||||
apiMock.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) })
|
||||
const { library } = setup()
|
||||
|
||||
await expect(library.load()).resolves.toBeUndefined()
|
||||
expect(library.usageOf('Courgette').books).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addFiles', () => {
|
||||
it('uploads one file at a time and records the face each became', async () => {
|
||||
const { store, library } = setup()
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
store.uploadFont.mockImplementation(async (file: File) => {
|
||||
inFlight++
|
||||
peak = Math.max(peak, inFlight)
|
||||
await Promise.resolve()
|
||||
inFlight--
|
||||
const name = file.name.replace(/-.*$/, '')
|
||||
return { font: mockFont(10, name), suggestedFamilyName: name, suggestedWeight: 400, suggestedStyle: 'normal' }
|
||||
})
|
||||
|
||||
await library.addFiles([fontFile('Caudex-Regular.ttf'), fontFile('Forum-Regular.ttf')])
|
||||
|
||||
expect(peak).toBe(1)
|
||||
expect(library.uploads.value.map((entry) => [entry.state, entry.face?.familyName, entry.newFamily])).toEqual([
|
||||
['added', 'Caudex', true],
|
||||
['added', 'Forum', true],
|
||||
])
|
||||
expect([...library.newFamilies.value]).toEqual(['Caudex', 'Forum'])
|
||||
})
|
||||
|
||||
it('does not call a file that joins an existing family new', async () => {
|
||||
const { store, library } = setup()
|
||||
store.uploadFont.mockResolvedValue({
|
||||
font: mockFont(11, 'Courgette', 700),
|
||||
suggestedFamilyName: 'Courgette',
|
||||
suggestedWeight: 700,
|
||||
suggestedStyle: 'normal',
|
||||
})
|
||||
|
||||
await library.addFiles([fontFile('Courgette-Bold.ttf')])
|
||||
|
||||
expect(library.uploads.value[0]).toMatchObject({ state: 'added', newFamily: false })
|
||||
expect(library.newFamilies.value.size).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses the wrong type and oversized files without sending them', async () => {
|
||||
const { store, library } = setup()
|
||||
|
||||
await library.addFiles([fontFile('reading-notes.pdf'), fontFile('Huge.otf', MAX_FONT_FILE_SIZE + 1)])
|
||||
|
||||
expect(store.uploadFont).not.toHaveBeenCalled()
|
||||
expect(library.uploads.value.map((entry) => [entry.state, entry.code])).toEqual([
|
||||
['refused', 'FONT_UNSUPPORTED_TYPE'],
|
||||
['refused', 'FONT_TOO_LARGE'],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps going after a refusal and reports each file on its own', async () => {
|
||||
const { store, library } = setup()
|
||||
store.uploadFont
|
||||
.mockRejectedValueOnce(
|
||||
new FontUploadError('This font file has already been uploaded', 409, 'FONT_DUPLICATE', {
|
||||
familyName: 'Courgette',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ font: mockFont(12, 'Gabriela'), suggestedFamilyName: 'Gabriela', suggestedWeight: 400, suggestedStyle: 'normal' })
|
||||
|
||||
await library.addFiles([fontFile('Courgette-Copy.ttf'), fontFile('Gabriela-Regular.ttf')])
|
||||
|
||||
expect(library.uploads.value.map((entry) => entry.state)).toEqual(['duplicate', 'added'])
|
||||
expect(library.uploads.value[0]!.face).toEqual({ familyName: 'Courgette', weight: 400, style: 'normal' })
|
||||
})
|
||||
|
||||
it('names a duplicate by the style the font itself names', async () => {
|
||||
const italic = mockFont(9, 'Literata', 400, 'italic', {
|
||||
weightMin: 200,
|
||||
weightMax: 900,
|
||||
instances: [
|
||||
{ name: 'Italic', weight: 400, style: 'italic' },
|
||||
{ name: 'Bold Italic', weight: 700, style: 'italic' },
|
||||
],
|
||||
})
|
||||
const { store, library } = setup([italic])
|
||||
store.uploadFont.mockRejectedValue(
|
||||
new FontUploadError('This font file has already been uploaded', 409, 'FONT_DUPLICATE', {
|
||||
familyName: 'Literata',
|
||||
weight: 400,
|
||||
style: 'italic',
|
||||
}),
|
||||
)
|
||||
|
||||
await library.addFiles([fontFile('Literata-Italic.ttf')])
|
||||
|
||||
expect(library.uploads.value[0]).toMatchObject({ state: 'duplicate', styleName: 'Italic' })
|
||||
})
|
||||
|
||||
it('refreshes the reader counts once the batch is done', async () => {
|
||||
const { store, library } = setup()
|
||||
store.uploadFont.mockResolvedValue({
|
||||
font: mockFont(13, 'Forum'),
|
||||
suggestedFamilyName: 'Forum',
|
||||
suggestedWeight: 400,
|
||||
suggestedStyle: 'normal',
|
||||
})
|
||||
|
||||
await library.addFiles([fontFile('Forum-Regular.ttf')])
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/server-fonts/usage')
|
||||
})
|
||||
|
||||
it('clears the tray and the new marks on dismiss', async () => {
|
||||
const { store, library } = setup()
|
||||
store.uploadFont.mockResolvedValue({
|
||||
font: mockFont(14, 'Forum'),
|
||||
suggestedFamilyName: 'Forum',
|
||||
suggestedWeight: 400,
|
||||
suggestedStyle: 'normal',
|
||||
})
|
||||
await library.addFiles([fontFile('Forum-Regular.ttf')])
|
||||
|
||||
library.dismissUploads()
|
||||
|
||||
expect(library.uploads.value).toEqual([])
|
||||
expect(library.newFamilies.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameFamily', () => {
|
||||
it('renames the family in one request and swaps the returned files in', async () => {
|
||||
const { store, library } = setup([mockFont(8, 'Literata'), mockFont(9, 'Literata', 400, 'italic'), mockFont(3, 'Courgette')])
|
||||
apiMock.mockImplementation((url: string) =>
|
||||
url.endsWith('/families') ? respond([mockFont(8, 'Literata Text'), mockFont(9, 'Literata Text', 400, 'italic')]) : respond(USAGE),
|
||||
)
|
||||
|
||||
const result = await library.renameFamily('Literata', 'Literata Text')
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
'/api/v1/server-fonts/families',
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ familyName: 'Literata', newFamilyName: 'Literata Text' }) }),
|
||||
)
|
||||
expect(store.fonts.value.map((font) => font.familyName)).toEqual(['Literata Text', 'Literata Text', 'Courgette'])
|
||||
})
|
||||
|
||||
it('passes the error code back when the new name clashes', async () => {
|
||||
const { store, library } = setup()
|
||||
apiMock.mockImplementation((url: string) => (url.endsWith('/families') ? respond({ errorCode: 'FONT_FACE_TAKEN' }, false) : respond(USAGE)))
|
||||
|
||||
await expect(library.renameFamily('Courgette', 'Big Caslon')).resolves.toEqual({ ok: false, code: 'FONT_FACE_TAKEN' })
|
||||
expect(store.fonts.value.map((font) => font.familyName)).toEqual(['Courgette', 'Big Caslon'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFamily', () => {
|
||||
it('removes every file of the family', async () => {
|
||||
const { store, library } = setup([mockFont(8, 'Literata'), mockFont(9, 'Literata', 400, 'italic'), mockFont(3, 'Courgette')])
|
||||
|
||||
await expect(library.removeFamily('Literata')).resolves.toEqual({ removed: 2, failed: 0 })
|
||||
expect(store.deleteFont.mock.calls).toEqual([[8], [9]])
|
||||
})
|
||||
|
||||
it('counts the files it could not remove', async () => {
|
||||
const { store, library } = setup([mockFont(8, 'Literata'), mockFont(9, 'Literata', 400, 'italic')])
|
||||
store.deleteFont.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
|
||||
await expect(library.removeFamily('Literata')).resolves.toEqual({ removed: 1, failed: 1 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
export function useFailedFontFaces() {
|
||||
const failed = ref(new Set<string>())
|
||||
|
||||
function collect() {
|
||||
if (typeof document === 'undefined' || !document.fonts) return
|
||||
const next = new Set<string>()
|
||||
document.fonts.forEach((face) => {
|
||||
const family = face.family.replace(/^["']|["']$/g, '')
|
||||
if (face.status === 'error' && family.startsWith(FONT_CSS_FAMILY_PREFIXES.server)) next.add(family)
|
||||
})
|
||||
if (next.size !== failed.value.size || [...next].some((family) => !failed.value.has(family))) failed.value = next
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.fonts?.addEventListener('loadingerror', collect)
|
||||
document.fonts?.addEventListener('loadingdone', collect)
|
||||
collect()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.fonts?.removeEventListener('loadingerror', collect)
|
||||
document.fonts?.removeEventListener('loadingdone', collect)
|
||||
})
|
||||
|
||||
return { failed, collect }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserFont } from '@bookorbit/types'
|
||||
import { isVariableFont } from '@bookorbit/types'
|
||||
import { FONT_WEIGHT_LABEL_KEYS, fontVariantLabel } from '@/features/reader/shared/lib/font-variants'
|
||||
import { formatDate } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { styleSummary } from '../lib/font-style-grid'
|
||||
|
||||
/** The wording the Server Fonts page uses for families and files, in one place. */
|
||||
export function useFontLabels() {
|
||||
const { t } = useI18n()
|
||||
|
||||
/** "Medium only", "16 styles", "9 styles, no italics". */
|
||||
function styleSummaryText(fonts: readonly UserFont[]): string {
|
||||
const summary = styleSummary(fonts)
|
||||
if (summary.kind === 'single') return t('settings.admin.serverFonts.styles.single', { style: fontVariantLabel(summary.variant, t) })
|
||||
if (summary.kind === 'many') {
|
||||
return t(summary.italics ? 'settings.admin.serverFonts.styles.many' : 'settings.admin.serverFonts.styles.manyNoItalics', {
|
||||
count: summary.count,
|
||||
})
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function familySize(fonts: readonly UserFont[]): string {
|
||||
return formatBytes(fonts.reduce((sum, font) => sum + font.fileSize, 0))
|
||||
}
|
||||
|
||||
function fileCountSummary(fonts: readonly UserFont[]): string {
|
||||
return t('settings.admin.serverFonts.fileSummary', { count: fonts.length, size: familySize(fonts) })
|
||||
}
|
||||
|
||||
/** What one file contributes: its weight or variable range, and whether it is upright or italic. */
|
||||
function fileFace(font: UserFont): string {
|
||||
const weight = isVariableFont(font)
|
||||
? t('settings.reader.fonts.variableRange', { min: font.weightMin, max: font.weightMax })
|
||||
: FONT_WEIGHT_LABEL_KEYS[font.weight]
|
||||
? t(FONT_WEIGHT_LABEL_KEYS[font.weight]!)
|
||||
: String(font.weight)
|
||||
const slant = t(font.style === 'italic' ? 'settings.admin.serverFonts.sheet.italic' : 'settings.admin.serverFonts.sheet.upright')
|
||||
return `${weight} · ${slant}`
|
||||
}
|
||||
|
||||
function fileDetail(font: UserFont): string {
|
||||
return t('settings.admin.serverFonts.sheet.fileDetail', {
|
||||
summary: fileFace(font),
|
||||
date: formatDate(new Date(font.createdAt), { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
})
|
||||
}
|
||||
|
||||
return { styleSummaryText, familySize, fileCountSummary, fileFace, fileDetail }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ServerFontFamilyUsage, ServerFontUsageResponse, UserFont } from '@bookorbit/types'
|
||||
import { FONT_FORMAT_EXTENSIONS, MAX_FONT_FILE_SIZE } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useCustomFonts, type FontUpdate } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { familyVariants } from '@/features/reader/shared/lib/font-variants'
|
||||
import { outcomeOfError, type UploadFace, type UploadState } from '../lib/upload-outcome'
|
||||
import type { FontUploadErrorCode } from '@bookorbit/types'
|
||||
|
||||
const ENDPOINT = '/api/v1/server-fonts'
|
||||
const ACCEPTED = Object.values(FONT_FORMAT_EXTENSIONS)
|
||||
|
||||
export interface UploadEntry {
|
||||
id: number
|
||||
fileName: string
|
||||
size: number
|
||||
state: UploadState
|
||||
face: UploadFace | null
|
||||
/** The style's own name in the font ("Italic", "SemiBold"), when the font names it. */
|
||||
styleName: string | null
|
||||
/** The file started a family the server did not have. */
|
||||
newFamily: boolean
|
||||
code: FontUploadErrorCode | null
|
||||
meta: Record<string, string | number>
|
||||
}
|
||||
|
||||
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
|
||||
* family, and a queue that uploads dropped files one at a time and remembers how each one ended.
|
||||
*/
|
||||
export function useServerFontLibrary() {
|
||||
const customFonts = useCustomFonts()
|
||||
const store = customFonts.scopeStore('server')
|
||||
|
||||
const usage = ref(new Map<string, ServerFontFamilyUsage>())
|
||||
const uploads = ref<UploadEntry[]>([])
|
||||
/** Families this visit's uploads created, marked until the tray is dismissed. */
|
||||
const newFamilies = ref(new Set<string>())
|
||||
let nextUploadId = 1
|
||||
|
||||
const uploading = computed(() => uploads.value.some((entry) => entry.state === 'queued' || entry.state === 'uploading'))
|
||||
|
||||
async function fetchUsage(): Promise<void> {
|
||||
try {
|
||||
const res = await api(`${ENDPOINT}/usage`)
|
||||
if (!res.ok) return
|
||||
const body: ServerFontUsageResponse = await res.json()
|
||||
usage.value = new Map(body.families.map((family) => [family.familyName, family]))
|
||||
} catch {
|
||||
// Usage is context for the decision, not the decision; the page works without it.
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await Promise.all([store.fetchFonts(), fetchUsage()])
|
||||
}
|
||||
|
||||
function usageOf(familyName: string): ServerFontFamilyUsage {
|
||||
return usage.value.get(familyName) ?? NO_USAGE(familyName)
|
||||
}
|
||||
|
||||
/** The name the reader's Style row will show for this face, taken from the family on the server. */
|
||||
function styleNameOf(face: UploadFace | null): string | null {
|
||||
if (!face) return null
|
||||
const variants = familyVariants(store.fonts.value.filter((font) => font.familyName === face.familyName))
|
||||
return variants.find((variant) => variant.weight === face.weight && variant.style === face.style)?.name ?? null
|
||||
}
|
||||
|
||||
function patchEntry(id: number, patch: Partial<UploadEntry>) {
|
||||
uploads.value = uploads.value.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry))
|
||||
}
|
||||
|
||||
/** Answers the checks a browser can make without sending the bytes: the extension and the size. */
|
||||
function precheck(file: File): FontUploadErrorCode | null {
|
||||
const lower = file.name.toLowerCase()
|
||||
if (!ACCEPTED.some((extension) => lower.endsWith(extension))) return 'FONT_UNSUPPORTED_TYPE'
|
||||
if (file.size > MAX_FONT_FILE_SIZE) return 'FONT_TOO_LARGE'
|
||||
return null
|
||||
}
|
||||
|
||||
async function addFiles(files: File[]): Promise<void> {
|
||||
if (files.length === 0) return
|
||||
const queued = files.map((file) => ({
|
||||
file,
|
||||
entry: {
|
||||
id: nextUploadId++,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
state: 'queued' as UploadState,
|
||||
face: null,
|
||||
styleName: null,
|
||||
newFamily: false,
|
||||
code: null,
|
||||
meta: {},
|
||||
},
|
||||
}))
|
||||
uploads.value = [...uploads.value, ...queued.map(({ entry }) => entry)]
|
||||
|
||||
// One at a time: a font can be 50 MB and the queue can be a whole family's worth of files.
|
||||
for (const { file, entry } of queued) {
|
||||
const refused = precheck(file)
|
||||
if (refused) {
|
||||
patchEntry(entry.id, {
|
||||
state: 'refused',
|
||||
code: refused,
|
||||
meta: refused === 'FONT_TOO_LARGE' ? { maxMb: MAX_FONT_FILE_SIZE / 1024 / 1024 } : {},
|
||||
})
|
||||
continue
|
||||
}
|
||||
patchEntry(entry.id, { state: 'uploading' })
|
||||
const known = new Set(store.fonts.value.map((font) => font.familyName))
|
||||
try {
|
||||
const result = await store.uploadFont(file)
|
||||
if (!result) throw new Error('Upload failed')
|
||||
const { familyName, weight, style } = result.font
|
||||
const newFamily = !known.has(familyName)
|
||||
if (newFamily) newFamilies.value = new Set([...newFamilies.value, familyName])
|
||||
const face = { familyName, weight, style }
|
||||
patchEntry(entry.id, { state: 'added', face, styleName: styleNameOf(face), newFamily })
|
||||
} catch (err) {
|
||||
const outcome = outcomeOfError(err)
|
||||
patchEntry(entry.id, {
|
||||
state: outcome.state,
|
||||
face: outcome.face,
|
||||
styleName: styleNameOf(outcome.face),
|
||||
code: outcome.code,
|
||||
meta: outcome.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
await fetchUsage()
|
||||
}
|
||||
|
||||
function dismissUploads() {
|
||||
if (uploading.value) return
|
||||
uploads.value = []
|
||||
newFamilies.value = new Set()
|
||||
}
|
||||
|
||||
async function renameFamily(familyName: string, newFamilyName: string): Promise<RenameResult> {
|
||||
try {
|
||||
const res = await api(`${ENDPOINT}/families`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ familyName, newFamilyName }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
return { ok: false, code: typeof body.errorCode === 'string' ? body.errorCode : null }
|
||||
}
|
||||
const renamed: UserFont[] = await res.json()
|
||||
const byId = new Map(renamed.map((font) => [font.id, font]))
|
||||
store.setFonts(store.fonts.value.map((font) => byId.get(font.id) ?? font))
|
||||
if (newFamilies.value.has(familyName))
|
||||
newFamilies.value = new Set([...newFamilies.value].map((name) => (name === familyName ? newFamilyName : name)))
|
||||
await fetchUsage()
|
||||
return { ok: true }
|
||||
} catch {
|
||||
return { ok: false, code: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a family file by file. A file that fails stays listed, so a partial removal shows as a
|
||||
* smaller family rather than as a family that looks gone but still sits in readers' pickers.
|
||||
*/
|
||||
async function removeFamily(familyName: string): Promise<{ removed: number; failed: number }> {
|
||||
const files = store.fonts.value.filter((font) => font.familyName === familyName)
|
||||
let removed = 0
|
||||
for (const font of files) {
|
||||
const ok = await store.deleteFont(font.id).catch(() => false)
|
||||
if (ok) removed++
|
||||
}
|
||||
await fetchUsage()
|
||||
return { removed, failed: files.length - removed }
|
||||
}
|
||||
|
||||
async function updateFile(fontId: number, data: FontUpdate): Promise<boolean> {
|
||||
return (await store.updateFont(fontId, data)) !== null
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
usage,
|
||||
uploads,
|
||||
uploading,
|
||||
newFamilies,
|
||||
load,
|
||||
fetchUsage,
|
||||
usageOf,
|
||||
addFiles,
|
||||
dismissUploads,
|
||||
renameFamily,
|
||||
removeFamily,
|
||||
updateFile,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FontNamedInstance, UserFont } from '@bookorbit/types'
|
||||
import { styleGrid, styleSummary } from '../font-style-grid'
|
||||
|
||||
function font(overrides: Partial<UserFont> = {}): UserFont {
|
||||
return {
|
||||
id: 1,
|
||||
familyName: 'Family',
|
||||
originalFileName: 'Family.ttf',
|
||||
format: 'ttf',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
weightMin: null,
|
||||
weightMax: null,
|
||||
instances: null,
|
||||
fileSize: 1000,
|
||||
createdAt: '2026-09-21T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const named = (style: 'normal' | 'italic', names: string[], start: number): FontNamedInstance[] =>
|
||||
names.map((name, index) => ({ name, weight: start + index * 100, style }))
|
||||
|
||||
/** Literata as the dev server holds it: a variable upright and a variable italic, 200-900 each. */
|
||||
const literata = [
|
||||
font({
|
||||
id: 8,
|
||||
weightMin: 200,
|
||||
weightMax: 900,
|
||||
instances: named('normal', ['ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black'], 200),
|
||||
}),
|
||||
font({
|
||||
id: 9,
|
||||
style: 'italic',
|
||||
weightMin: 200,
|
||||
weightMax: 900,
|
||||
instances: named(
|
||||
'italic',
|
||||
['ExtraLight Italic', 'Light Italic', 'Italic', 'Medium Italic', 'SemiBold Italic', 'Bold Italic', 'ExtraBold Italic', 'Black Italic'],
|
||||
200,
|
||||
),
|
||||
}),
|
||||
]
|
||||
|
||||
const states = (rows: ReturnType<typeof styleGrid>, style: 'normal' | 'italic') =>
|
||||
rows.find((row) => row.style === style)!.cells.map((cell) => cell.state)
|
||||
|
||||
describe('styleGrid', () => {
|
||||
it('marks a lone static face in its own column and nothing else', () => {
|
||||
const rows = styleGrid([font({ weight: 500 })])
|
||||
|
||||
expect(states(rows, 'normal')).toEqual(['none', 'none', 'none', 'none', 'static', 'none', 'none', 'none', 'none'])
|
||||
expect(states(rows, 'italic').every((state) => state === 'none')).toBe(true)
|
||||
expect(rows[0]!.ranges).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a variable axis as a span with a mark for each style it offers', () => {
|
||||
const rows = styleGrid(literata)
|
||||
|
||||
for (const style of ['normal', 'italic'] as const) {
|
||||
expect(states(rows, style)).toEqual(['none', 'range', 'range', 'range', 'range', 'range', 'range', 'range', 'range'])
|
||||
expect(rows.find((row) => row.style === style)!.ranges).toEqual([{ from: 1, to: 8 }])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a static italic beside a variable upright apart', () => {
|
||||
const rows = styleGrid([
|
||||
font({ weightMin: 100, weightMax: 900, weight: 100, instances: named('normal', ['Thin', 'Regular'], 100) }),
|
||||
font({ id: 2, style: 'italic' }),
|
||||
])
|
||||
|
||||
expect(rows[0]!.ranges).toEqual([{ from: 0, to: 8 }])
|
||||
expect(states(rows, 'italic')[3]).toBe('static')
|
||||
expect(rows[1]!.ranges).toEqual([])
|
||||
})
|
||||
|
||||
it('puts an off-grid weight in the nearest column', () => {
|
||||
const rows = styleGrid([font({ weight: 350 }), font({ id: 2, weight: 640 })])
|
||||
|
||||
expect(states(rows, 'normal')[3]).toBe('static')
|
||||
expect(states(rows, 'normal')[5]).toBe('static')
|
||||
})
|
||||
})
|
||||
|
||||
describe('styleSummary', () => {
|
||||
it('names the single style a one-file family offers', () => {
|
||||
expect(styleSummary([font({ weight: 500 })])).toEqual({ kind: 'single', variant: { name: null, weight: 500, style: 'normal' } })
|
||||
})
|
||||
|
||||
it('counts the styles of a variable family and notes the italics', () => {
|
||||
expect(styleSummary(literata)).toEqual({ kind: 'many', count: 16, italics: true })
|
||||
})
|
||||
|
||||
it('says when a family has no italics at all', () => {
|
||||
expect(styleSummary([font({ weight: 400 }), font({ id: 2, weight: 700 })])).toEqual({ kind: 'many', count: 2, italics: false })
|
||||
})
|
||||
|
||||
it('is empty for an empty family', () => {
|
||||
expect(styleSummary([])).toEqual({ kind: 'empty' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FontUploadError } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
import { outcomeOfError, refusalMessageKey } from '../upload-outcome'
|
||||
|
||||
describe('outcomeOfError', () => {
|
||||
it('reads a duplicate as already on the server, naming the face it duplicates', () => {
|
||||
const err = new FontUploadError('This font file has already been uploaded', 409, 'FONT_DUPLICATE', {
|
||||
familyName: 'Literata',
|
||||
weight: 400,
|
||||
style: 'italic',
|
||||
})
|
||||
|
||||
expect(outcomeOfError(err)).toEqual({
|
||||
state: 'duplicate',
|
||||
face: { familyName: 'Literata', weight: 400, style: 'italic' },
|
||||
code: 'FONT_DUPLICATE',
|
||||
meta: { familyName: 'Literata', weight: 400, style: 'italic' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a coded refusal as refused, keeping its parameters for the message', () => {
|
||||
const err = new FontUploadError('Maximum of 200 server fonts reached', 400, 'FONT_LIMIT_REACHED', { limit: 200 })
|
||||
|
||||
expect(outcomeOfError(err)).toMatchObject({ state: 'refused', code: 'FONT_LIMIT_REACHED', meta: { limit: 200 } })
|
||||
})
|
||||
|
||||
it('reads a bare 413 from the HTTP layer as too large', () => {
|
||||
expect(outcomeOfError(new FontUploadError('Request file too large', 413))).toMatchObject({ state: 'refused', code: 'FONT_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('treats an uncoded refusal from an older server as a plain failure', () => {
|
||||
expect(outcomeOfError(new FontUploadError('This font file has already been uploaded', 409))).toMatchObject({ state: 'failed', code: null })
|
||||
})
|
||||
|
||||
it('ignores a code it does not know rather than trusting it', () => {
|
||||
expect(outcomeOfError(new FontUploadError('Nope', 400, 'SOMETHING_NEW'))).toMatchObject({ state: 'failed', code: null })
|
||||
})
|
||||
|
||||
it('treats a network error as a failure', () => {
|
||||
expect(outcomeOfError(new TypeError('Failed to fetch'))).toMatchObject({ state: 'failed', face: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('refusalMessageKey', () => {
|
||||
it('gives every code its own reason and falls back for the rest', () => {
|
||||
expect(refusalMessageKey('FONT_INVALID_FILE')).toBe('settings.admin.serverFonts.upload.reasons.invalidFile')
|
||||
expect(refusalMessageKey(null)).toBe('settings.admin.serverFonts.upload.reasons.failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FontNamedInstance, FontStyle, UserFont } from '@bookorbit/types'
|
||||
import { isVariableFont } from '@bookorbit/types'
|
||||
import { familyVariants } from '@/features/reader/shared/lib/font-variants'
|
||||
|
||||
/** The nine standard weights, one grid column each. */
|
||||
export const GRID_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const
|
||||
|
||||
/**
|
||||
* `static`: a file renders exactly this weight. `range`: a variable file offers this weight as one
|
||||
* of its styles. `none`: nothing on the server renders it; the browser would fake it.
|
||||
*/
|
||||
export type StyleGridCellState = 'none' | 'static' | 'range'
|
||||
|
||||
export interface StyleGridCell {
|
||||
weight: number
|
||||
state: StyleGridCellState
|
||||
/** The style the reader offers here, when it offers one. */
|
||||
variant: FontNamedInstance | null
|
||||
}
|
||||
|
||||
export interface StyleGridRow {
|
||||
style: FontStyle
|
||||
cells: StyleGridCell[]
|
||||
/** Column spans of variable axes, as indexes into GRID_WEIGHTS. */
|
||||
ranges: Array<{ from: number; to: number }>
|
||||
}
|
||||
|
||||
/** Off-grid weights (350, 450) sit in the nearest column, because that is the face they look like. */
|
||||
function columnOf(weight: number): number {
|
||||
return Math.min(GRID_WEIGHTS.length - 1, Math.max(0, Math.round(weight / 100) - 1))
|
||||
}
|
||||
|
||||
/** Upright over italic, each with nine weight cells and the spans of any variable axes. */
|
||||
export function styleGrid(fonts: readonly UserFont[]): StyleGridRow[] {
|
||||
const variants = familyVariants(fonts)
|
||||
|
||||
return (['normal', 'italic'] as const).map((style) => {
|
||||
const files = fonts.filter((font) => font.style === style)
|
||||
const variable = files.filter(isVariableFont)
|
||||
const ranges = variable.map((font) => ({ from: columnOf(font.weightMin!), to: columnOf(font.weightMax!) }))
|
||||
const offered = variants.filter((variant) => variant.style === style)
|
||||
|
||||
const cells = GRID_WEIGHTS.map((weight, column): StyleGridCell => {
|
||||
const variant = offered.find((candidate) => columnOf(candidate.weight) === column) ?? null
|
||||
if (!variant) return { weight, state: 'none', variant: null }
|
||||
const fromAxis = variable.some((font) => variant.weight >= font.weightMin! && variant.weight <= font.weightMax!)
|
||||
return { weight, state: fromAxis ? 'range' : 'static', variant }
|
||||
})
|
||||
|
||||
return { style, cells, ranges }
|
||||
})
|
||||
}
|
||||
|
||||
export type StyleSummary = { kind: 'single'; variant: FontNamedInstance } | { kind: 'many'; count: number; italics: boolean } | { kind: 'empty' }
|
||||
|
||||
/**
|
||||
* What a family gives readers, in the reader's own terms: one named style, or how many styles and
|
||||
* whether any of them is italic. A family without italics still gets slanted text, faked by the browser.
|
||||
*/
|
||||
export function styleSummary(fonts: readonly UserFont[]): StyleSummary {
|
||||
const variants = familyVariants(fonts)
|
||||
if (variants.length === 0) return { kind: 'empty' }
|
||||
if (variants.length === 1) return { kind: 'single', variant: variants[0]! }
|
||||
return { kind: 'many', count: variants.length, italics: variants.some((variant) => variant.style === 'italic') }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { FontStyle, FontUploadErrorCode } from '@bookorbit/types'
|
||||
import { FONT_UPLOAD_ERROR_CODES } from '@bookorbit/types'
|
||||
import { FontUploadError } from '@/features/reader/epub/composables/useCustomFonts'
|
||||
|
||||
/** Where one dropped file ended up. */
|
||||
export type UploadState = 'queued' | 'uploading' | 'added' | 'duplicate' | 'refused' | 'failed'
|
||||
|
||||
export interface UploadFace {
|
||||
familyName: string
|
||||
weight: number
|
||||
style: FontStyle
|
||||
}
|
||||
|
||||
export interface UploadOutcome {
|
||||
state: Exclude<UploadState, 'queued' | 'uploading'>
|
||||
/** The face the file became, or the face already on the server that it duplicates. */
|
||||
face: UploadFace | null
|
||||
code: FontUploadErrorCode | null
|
||||
meta: Record<string, string | number>
|
||||
}
|
||||
|
||||
const isUploadCode = (code: unknown): code is FontUploadErrorCode => (FONT_UPLOAD_ERROR_CODES as readonly unknown[]).includes(code)
|
||||
|
||||
function faceFromMeta(meta: Record<string, string | number> | undefined): UploadFace | null {
|
||||
if (!meta || typeof meta.familyName !== 'string') return null
|
||||
const weight = typeof meta.weight === 'number' ? meta.weight : 400
|
||||
return { familyName: meta.familyName, weight, style: meta.style === 'italic' ? 'italic' : 'normal' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a refused upload. Servers older than the error codes send only an English message, and a
|
||||
* file over the multipart limit is cut off by the HTTP layer with a bare 413; both still land in a
|
||||
* state the tray can describe.
|
||||
*/
|
||||
export function outcomeOfError(err: unknown): UploadOutcome {
|
||||
if (!(err instanceof FontUploadError)) return { state: 'failed', face: null, code: null, meta: {} }
|
||||
const meta = err.meta ?? {}
|
||||
const code = isUploadCode(err.code) ? err.code : err.status === 413 ? 'FONT_TOO_LARGE' : null
|
||||
if (code === 'FONT_DUPLICATE') return { state: 'duplicate', face: faceFromMeta(meta), code, meta }
|
||||
if (code) return { state: 'refused', face: faceFromMeta(meta), code, meta }
|
||||
return { state: 'failed', face: null, code: null, meta }
|
||||
}
|
||||
|
||||
/** Translation key for a refusal's reason. */
|
||||
export function refusalMessageKey(code: FontUploadErrorCode | null): string {
|
||||
switch (code) {
|
||||
case 'FONT_TOO_LARGE':
|
||||
return 'settings.admin.serverFonts.upload.reasons.tooLarge'
|
||||
case 'FONT_UNSUPPORTED_TYPE':
|
||||
return 'settings.admin.serverFonts.upload.reasons.unsupportedType'
|
||||
case 'FONT_INVALID_FILE':
|
||||
return 'settings.admin.serverFonts.upload.reasons.invalidFile'
|
||||
case 'FONT_FACE_TAKEN':
|
||||
return 'settings.admin.serverFonts.upload.reasons.faceTaken'
|
||||
case 'FONT_LIMIT_REACHED':
|
||||
return 'settings.admin.serverFonts.upload.reasons.limitReached'
|
||||
default:
|
||||
return 'settings.admin.serverFonts.upload.reasons.failed'
|
||||
}
|
||||
}
|
||||
+126
-1
@@ -1587,7 +1587,132 @@
|
||||
"subtitle": "Upload fonts that every user can pick in the eBook reader, without uploading their own.",
|
||||
"listLabel": "Server fonts",
|
||||
"noFontsYet": "No server fonts yet",
|
||||
"noFontsHint": "Fonts you add here appear in every user's reader."
|
||||
"noFontsHint": "Fonts you add here appear in every user's reader.",
|
||||
"addFonts": "Add fonts",
|
||||
"filterLabel": "Filter families",
|
||||
"filterPlaceholder": "Filter families",
|
||||
"filterClear": "Clear filter",
|
||||
"noMatches": "No families match “{query}”",
|
||||
"previewLabel": "Preview text",
|
||||
"previewPlaceholder": "Type to preview it in every family",
|
||||
"previewSize": "Preview size",
|
||||
"previewSizeOption": "{size} px",
|
||||
"familiesUnit": "{count, plural, one {family} other {families}}",
|
||||
"filesOfMax": "{count} of {max} files",
|
||||
"onDisk": "{size} on disk",
|
||||
"columns": {
|
||||
"family": "Family",
|
||||
"specimen": "Specimen",
|
||||
"styles": "Styles",
|
||||
"readers": "Readers"
|
||||
},
|
||||
"fileSummary": "{count, plural, one {# file} other {# files}} · {size}",
|
||||
"newBadge": "New",
|
||||
"styles": {
|
||||
"single": "{style} only",
|
||||
"many": "{count} styles",
|
||||
"manyNoItalics": "{count} styles, no italics"
|
||||
},
|
||||
"facts": {
|
||||
"books": "{count, plural, one {On # book} other {On # books}}",
|
||||
"booksDetail": "{books, plural, one {Set on # book} other {Set on # books}} by {readers, plural, one {# reader} other {# readers}}",
|
||||
"defaults": "{count, plural, one {Default for # reader} other {Default for # readers}}",
|
||||
"hidden": "{count, plural, one {Hidden by # reader} other {Hidden by # readers}}"
|
||||
},
|
||||
"moreActions": "More for {name}",
|
||||
"menu": {
|
||||
"rename": "Rename family",
|
||||
"addFiles": "Add files",
|
||||
"remove": "Remove family…"
|
||||
},
|
||||
"footerHint": "Every reader sees these in the reader's font list and can hide any of them under Reader › Fonts.",
|
||||
"empty": {
|
||||
"dropHint": "or drop files anywhere on this page",
|
||||
"tip": "A family reads best as four files: Regular, Italic, Bold and Bold Italic. One variable file can stand in for every weight."
|
||||
},
|
||||
"drop": {
|
||||
"title": "{count, plural, one {Drop to add # file for every reader} other {Drop to add # files for every reader}}"
|
||||
},
|
||||
"upload": {
|
||||
"ariaLabel": "Upload progress",
|
||||
"title": "{count, plural, one {Adding # file} other {Adding # files}}",
|
||||
"progress": "{done} of {total} done",
|
||||
"addedCount": "{count} added",
|
||||
"duplicateCount": "{count} already here",
|
||||
"refusedCount": "{count} not added",
|
||||
"dismiss": "Dismiss",
|
||||
"newFamily": "new family",
|
||||
"states": {
|
||||
"queued": "Waiting",
|
||||
"uploading": "Uploading",
|
||||
"added": "Added",
|
||||
"duplicate": "Already on the server",
|
||||
"refused": "Not added",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"reasons": {
|
||||
"tooLarge": "Larger than {maxMb} MB.",
|
||||
"unsupportedType": "Not a font file. Use TTF, OTF, WOFF or WOFF2.",
|
||||
"invalidFile": "The file is damaged or is not really a font.",
|
||||
"faceTaken": "{familyName} already has a file at this weight and style.",
|
||||
"limitReached": "The server already holds {limit} font files.",
|
||||
"failed": "Upload failed. Try again."
|
||||
}
|
||||
},
|
||||
"sheet": {
|
||||
"description": "Preview, rename and manage the files of {name}",
|
||||
"variable": "Variable",
|
||||
"preview": "Preview",
|
||||
"pageColour": "Page colour",
|
||||
"papers": {
|
||||
"app": "App surface",
|
||||
"white": "White page",
|
||||
"sepia": "Sepia page",
|
||||
"night": "Night page"
|
||||
},
|
||||
"previewHeading": "The Garden of Live Flowers",
|
||||
"previewParagraph1": "Alice looked round her in great surprise. “Why, I do believe we’ve been under this tree the whole time! Everything’s just as it was!”",
|
||||
"previewParagraph2": "“Now, here, you see, it takes all the running you can do, to keep in the same place. If you want to get somewhere else, you must run at least twice as fast as that!”",
|
||||
"styles": "Styles",
|
||||
"stylesHint": "What the reader's Style row offers",
|
||||
"singleStyleNote": "One style, so readers get no Style row. Bold and italic text in books is faked by the browser from {style}.",
|
||||
"inReader": "In the reader",
|
||||
"inReaderHint": "The font list every reader gets in Reader › Settings, with {name} chosen.",
|
||||
"pickerLabel": "Preview of the reader's font list",
|
||||
"files": "Files",
|
||||
"filesUsed": "{count} of {max} used on this server",
|
||||
"fileDetail": "{summary} · Added {date}",
|
||||
"upright": "Upright",
|
||||
"italic": "Italic",
|
||||
"fileActions": "More for {name}",
|
||||
"changeStyle": "Change weight and style",
|
||||
"removeFile": "Remove file",
|
||||
"removeFileQuestion": "Remove this file for every reader?",
|
||||
"weight": "Weight",
|
||||
"style": "Style",
|
||||
"name": "Family name",
|
||||
"nameHint": "Readers pick fonts by this name.",
|
||||
"nameHiddenWarning": "{count, plural, one {# reader hid {name}} other {# readers hid {name}}}; a new name shows it to them again.",
|
||||
"nameBooksWarning": "A new name sends {count, plural, one {the # book that uses it} other {the # books that use it}} back to the book’s own font.",
|
||||
"readers": "Readers",
|
||||
"stats": {
|
||||
"books": "{count, plural, one {book uses it} other {books use it}}",
|
||||
"defaults": "{count, plural, one {reader's default} other {readers' default}}",
|
||||
"hidden": "{count, plural, one {reader hid it} other {readers hid it}}"
|
||||
},
|
||||
"readersPrivacy": "Counts only. Which books and which readers stay private to them.",
|
||||
"removeLabel": "Remove family",
|
||||
"removeQuestion": "Remove {name} for every reader?",
|
||||
"removeConsequenceBooks": "{books, plural, one {It is set on # book} other {It is set on # books}} by {readers, plural, one {# reader} other {# readers}}; those books go back to the book's own font.",
|
||||
"removeConsequenceFiles": "{count, plural, one {Its file is deleted from the server.} other {All # files are deleted from the server.}}",
|
||||
"loadFailedNote": "This browser could not load the font file, so the previews show a fallback face. Readers' browsers will most likely refuse it too; try a different copy of the font."
|
||||
},
|
||||
"toasts": {
|
||||
"removed": "{name} removed",
|
||||
"removePartial": "{failed, plural, one {# file} other {# files}} of {name} could not be removed",
|
||||
"renameTaken": "{name} already has a file at the same weight and style"
|
||||
},
|
||||
"loadFailed": "This browser can't load it"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -521,7 +521,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
name: 'settings-admin-server-fonts',
|
||||
component: () => import('@/features/settings/ServerFontsSettings.vue'),
|
||||
props: { embedded: true },
|
||||
meta: { maxWidth: 'max-w-3xl', title: () => t('titles.admin.server-fonts') },
|
||||
meta: { maxWidth: 'max-w-[82rem]', title: () => t('titles.admin.server-fonts') },
|
||||
},
|
||||
{
|
||||
path: 'admin/tts',
|
||||
|
||||
Reference in New Issue
Block a user