Files
bookorbit/client/src/features/settings/server-fonts/components/ServerFontRow.vue
T
neon e21a92b8a4 feat(settings): rebuild server fonts as a specimen ledger with a family sheet
Each family is a row with its specimen, a weight grid of the styles readers get and how readers
use it; its sheet previews the face on reader pages and inside the reader's own font list. Uploads
answer per file in one tray, removing a family confirms with its cost, and a rename is one request.
Server: additive GET /server-fonts/usage (counts only), PATCH /server-fonts/families, upload error codes.
2026-10-16 16:27:42 +00:00

136 lines
6.2 KiB
Vue

<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>