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