feat(authors): rebuild the page as an index and a gallery on one view toggle

The two layouts are the viewMode the app already persists, so search, filters
and sort carry across them. Only 19% of a library's authors have a portrait,
so the gallery leans on monograms and the index treats the name as the content;
a sort name now prints only when it says something the display name does not.
Jump buckets, a cover fallback and two filters come from the server so the A-Z
rail and the chips cover the whole result set rather than the loaded pages.

Reconciles authors.has_photo against the image store on boot: the list resolves
portraits from disk while the filter queried the column, so "no portrait" was
over-reporting by 64.
This commit is contained in:
neon
2026-08-27 04:04:18 +00:00
parent 95239ba701
commit b41675c608
51 changed files with 2131 additions and 1108 deletions
+40 -3
View File
@@ -6,6 +6,7 @@ import { CheckSquare, LayoutGrid, List, SlidersHorizontal, Square, Table2 } from
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import type { TableDensity } from '@bookorbit/types'
import type { BookViewMode } from '@/composables/useDisplaySettings'
import ViewHeaderDesktopSearch from '@/components/view-header/ViewHeaderDesktopSearch.vue'
import ViewHeaderDisplayControls from '@/components/view-header/ViewHeaderDisplayControls.vue'
@@ -23,6 +24,10 @@ withDefaults(
gridGap: number
showJumpRailToggle?: boolean
showJumpRails?: boolean
jumpRailModes?: BookViewMode[]
rowDensity?: TableDensity
showCoverFallbackToggle?: boolean
coverFallback?: boolean
viewMode: BookViewMode
selectionMode?: boolean
showSelection?: boolean
@@ -36,9 +41,13 @@ withDefaults(
gridGapStep?: number
searchable?: boolean
searchQuery?: string
/** Views search different things; the default placeholder describes books. */
searchPlaceholder?: string
allowedViewModes?: BookViewMode[]
mobileSearchInMenu?: boolean
mobileDisplayInMenu?: boolean
/** Views with no covers and no grid have nothing for the cover-size and gap sliders to do. */
showDisplayControls?: boolean
}>(),
{
coverSizeMin: 100,
@@ -52,6 +61,7 @@ withDefaults(
allowedViewModes: () => ['grid', 'list', 'table'] as BookViewMode[],
mobileSearchInMenu: true,
mobileDisplayInMenu: true,
showDisplayControls: true,
},
)
@@ -59,6 +69,8 @@ const emit = defineEmits<{
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:showJumpRails': [value: boolean]
'update:rowDensity': [value: TableDensity]
'update:coverFallback': [value: boolean]
'update:viewMode': [value: BookViewMode]
'toggle-selection': []
'update:coverShape': [value: 'square' | 'circle']
@@ -84,7 +96,12 @@ function handleShowJumpRailsUpdate(value: boolean) {
</div>
<div class="flex shrink-0 items-center gap-2">
<ViewHeaderDesktopSearch v-if="searchable" :search-query="searchQuery" @update:search-query="emit('update:searchQuery', $event)" />
<ViewHeaderDesktopSearch
v-if="searchable"
:search-query="searchQuery"
:placeholder="searchPlaceholder"
@update:search-query="emit('update:searchQuery', $event)"
/>
<slot name="toolbar" />
<slot name="actions" />
@@ -115,6 +132,8 @@ function handleShowJumpRailsUpdate(value: boolean) {
size="icon"
class="h-8 w-8 rounded-lg"
:class="viewMode === 'grid' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
:aria-label="t('components.viewHeader.viewModes.grid')"
:aria-pressed="viewMode === 'grid'"
@click="emit('update:viewMode', 'grid')"
>
<LayoutGrid :size="14" />
@@ -125,6 +144,8 @@ function handleShowJumpRailsUpdate(value: boolean) {
size="icon"
class="h-8 w-8 rounded-lg"
:class="viewMode === 'list' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
:aria-label="t('components.viewHeader.viewModes.list')"
:aria-pressed="viewMode === 'list'"
@click="emit('update:viewMode', 'list')"
>
<List :size="14" />
@@ -135,18 +156,21 @@ function handleShowJumpRailsUpdate(value: boolean) {
size="icon"
class="h-8 w-8 rounded-lg"
:class="viewMode === 'table' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-primary/5'"
:aria-label="t('components.viewHeader.viewModes.table')"
:aria-pressed="viewMode === 'table'"
@click="emit('update:viewMode', 'table')"
>
<Table2 :size="14" />
</Button>
</div>
<Popover>
<Popover v-if="showDisplayControls">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="hidden h-8 w-8 rounded-lg text-muted-foreground hover:bg-primary/5 hover:text-foreground md:flex"
:aria-label="t('components.viewHeader.display')"
>
<SlidersHorizontal :size="14" />
</Button>
@@ -158,6 +182,10 @@ function handleShowJumpRailsUpdate(value: boolean) {
:grid-gap="gridGap"
:show-jump-rail-toggle="showJumpRailToggle"
:show-jump-rails="showJumpRails"
:jump-rail-modes="jumpRailModes"
:row-density="rowDensity"
:show-cover-fallback-toggle="showCoverFallbackToggle"
:cover-fallback="coverFallback"
:cover-shape="coverShape"
:cover-size-min="coverSizeMin"
:cover-size-max="coverSizeMax"
@@ -168,6 +196,8 @@ function handleShowJumpRailsUpdate(value: boolean) {
@update:cover-size="emit('update:coverSize', $event)"
@update:grid-gap="emit('update:gridGap', $event)"
@update:show-jump-rails="handleShowJumpRailsUpdate"
@update:row-density="emit('update:rowDensity', $event)"
@update:cover-fallback="emit('update:coverFallback', $event)"
@update:cover-shape="emit('update:coverShape', $event)"
>
<template #columns>
@@ -186,7 +216,7 @@ function handleShowJumpRailsUpdate(value: boolean) {
:show-view-mode-toggle="showViewModeToggle"
:searchable="searchable"
:mobile-search-in-menu="mobileSearchInMenu"
:show-display-action="mobileDisplayInMenu"
:show-display-action="mobileDisplayInMenu && showDisplayControls"
:allowed-view-modes="allowedViewModes"
@update:view-mode="emit('update:viewMode', $event)"
@open-display="mobileDisplayOpen = true"
@@ -213,6 +243,10 @@ function handleShowJumpRailsUpdate(value: boolean) {
:grid-gap="gridGap"
:show-jump-rail-toggle="showJumpRailToggle"
:show-jump-rails="showJumpRails"
:jump-rail-modes="jumpRailModes"
:row-density="rowDensity"
:show-cover-fallback-toggle="showCoverFallbackToggle"
:cover-fallback="coverFallback"
:cover-shape="coverShape"
:cover-size-min="coverSizeMin"
:cover-size-max="coverSizeMax"
@@ -223,6 +257,8 @@ function handleShowJumpRailsUpdate(value: boolean) {
@update:cover-size="emit('update:coverSize', $event)"
@update:grid-gap="emit('update:gridGap', $event)"
@update:show-jump-rails="handleShowJumpRailsUpdate"
@update:row-density="emit('update:rowDensity', $event)"
@update:cover-fallback="emit('update:coverFallback', $event)"
@update:cover-shape="emit('update:coverShape', $event)"
>
<template #columns>
@@ -239,6 +275,7 @@ function handleShowJumpRailsUpdate(value: boolean) {
v-if="searchable && mobileSearchInMenu"
:open="mobileSearchOpen"
:search-query="searchQuery"
:placeholder="searchPlaceholder"
@update:open="mobileSearchOpen = $event"
@update:search-query="emit('update:searchQuery', $event)"
/>
@@ -26,6 +26,19 @@ describe('ViewHeaderDisplayControls', () => {
expect(wrapper.emitted('update:showJumpRails')).toEqual([[false]])
})
it('renders the cover fallback checkbox only for views that offer it', async () => {
// A declared Boolean prop is false, not undefined, so presence must be its own
// flag or this control leaks into every view that renders these settings.
expect(mountControls({ coverFallback: true }).find('input[type="checkbox"]').exists()).toBe(false)
const wrapper = mountControls({ showCoverFallbackToggle: true, coverFallback: true })
const checkbox = wrapper.get('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
await checkbox.setValue(false)
expect(wrapper.emitted('update:coverFallback')).toEqual([[false]])
})
it('omits the jump rail checkbox when unsupported or outside grid mode', () => {
expect(mountControls().find('input[type="checkbox"]').exists()).toBe(false)
expect(mountControls({ viewMode: 'list', showJumpRailToggle: true, showJumpRails: true }).find('input[type="checkbox"]').exists()).toBe(false)
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Circle, Square } from '@lucide/vue'
import { TABLE_DENSITIES, type TableDensity } from '@bookorbit/types'
import type { BookViewMode } from '@/composables/useDisplaySettings'
const { t } = useI18n()
@@ -12,7 +13,18 @@ withDefaults(
gridGap: number
showJumpRailToggle?: boolean
showJumpRails?: boolean
/** View modes the rail actually applies to. Books only rail in grid; authors do both. */
jumpRailModes?: BookViewMode[]
coverShape?: 'square' | 'circle'
/** Row height for list mode. Omitted by views whose list rows are not adjustable. */
rowDensity?: TableDensity
/**
* Only the authors views offer this. A declared Boolean prop defaults to false
* rather than undefined in Vue, so presence needs its own flag - the same shape
* the jump-rail toggle above uses.
*/
showCoverFallbackToggle?: boolean
coverFallback?: boolean
coverSizeMin?: number
coverSizeMax?: number
coverSizeStep?: number
@@ -21,6 +33,7 @@ withDefaults(
gridGapStep?: number
}>(),
{
jumpRailModes: () => ['grid'] as BookViewMode[],
coverSizeMin: 100,
coverSizeMax: 280,
coverSizeStep: 10,
@@ -35,8 +48,20 @@ const emit = defineEmits<{
'update:gridGap': [value: number]
'update:showJumpRails': [value: boolean]
'update:coverShape': [value: 'square' | 'circle']
'update:rowDensity': [value: TableDensity]
'update:coverFallback': [value: boolean]
}>()
function handleCoverFallbackChange(event: Event) {
emit('update:coverFallback', (event.target as HTMLInputElement).checked)
}
const DENSITY_LABELS: Record<TableDensity, string> = {
compact: 'components.viewHeader.displayControls.densityCompact',
comfortable: 'components.viewHeader.displayControls.densityComfortable',
roomy: 'components.viewHeader.displayControls.densityRoomy',
}
function handleShowJumpRailsChange(event: Event) {
emit('update:showJumpRails', (event.target as HTMLInputElement).checked)
}
@@ -46,6 +71,26 @@ function handleShowJumpRailsChange(event: Event) {
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">{{ t('components.viewHeader.displayControls.display') }}</p>
<div v-if="rowDensity !== undefined && viewMode === 'list'" class="space-y-1.5">
<span class="text-xs text-muted-foreground">{{ t('components.viewHeader.displayControls.rowDensity') }}</span>
<div class="mt-1.5 flex items-center gap-1">
<button
v-for="density in TABLE_DENSITIES"
:key="density"
class="flex flex-1 items-center justify-center rounded-md border py-1.5 text-xs font-medium transition-colors"
:class="
rowDensity === density
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40'
"
:aria-pressed="rowDensity === density"
@click="emit('update:rowDensity', density)"
>
{{ t(DENSITY_LABELS[density]) }}
</button>
</div>
</div>
<template v-if="viewMode !== 'table'">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
@@ -79,7 +124,17 @@ function handleShowJumpRailsChange(event: Event) {
/>
</div>
<label v-if="showJumpRailToggle && viewMode === 'grid'" class="flex cursor-pointer items-center justify-between gap-3 pt-1">
<label v-if="showCoverFallbackToggle" class="flex cursor-pointer items-center justify-between gap-3 pt-1">
<span class="text-xs text-muted-foreground">{{ t('components.viewHeader.displayControls.coverFallback') }}</span>
<input
type="checkbox"
:checked="coverFallback"
class="size-4 shrink-0 cursor-pointer rounded border-input accent-primary"
@change="handleCoverFallbackChange"
/>
</label>
<label v-if="showJumpRailToggle && jumpRailModes.includes(viewMode)" class="flex cursor-pointer items-center justify-between gap-3 pt-1">
<span class="text-xs text-muted-foreground">{{ t('components.viewHeader.displayControls.showJumpRails') }}</span>
<input
type="checkbox"
@@ -16,6 +16,8 @@ function validDisplayPreferences(overrides: Partial<DisplayPreferences> = {}): D
smartScopeFilterExpanded: true,
authorCoverSize: 140,
authorCoverShape: 'circle',
authorRowDensity: 'comfortable',
authorCoverFallback: false,
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
@@ -156,6 +156,9 @@ const authorCoverSize = ref(normalizePositiveInteger(storage.get('authorCoverSiz
const authorCoverShape = ref<AuthorCoverShape>(normalizeAuthorCoverShape(storage.get('authorCoverShape', 'circle')))
const tableZebraStriping = ref(storage.get<boolean>('tableZebraStriping', false) === true)
const tableDensity = ref<TableDensity>(normalizeTableDensity(storage.get('tableDensity', 'comfortable')))
const authorRowDensity = ref<TableDensity>(normalizeTableDensity(storage.get('authorRowDensity', 'comfortable')))
// Off by default: a book cover under a person's name reads as their photograph.
const authorCoverFallback = ref(storage.get<boolean>('authorCoverFallback', false) === true)
const bookSpineOverlay = ref<BookSpineOverlay>(normalizeBookSpineOverlay(storage.get('bookSpineOverlay', DEFAULT_BOOK_SPINE_OVERLAY)))
const showSpineOnComics = ref(storage.get<boolean>('showSpineOnComics', false) === true)
const bookShadowStrength = ref<BookShadowStrength>(normalizeBookShadowStrength(storage.get('bookShadowStrength', DEFAULT_BOOK_SHADOW_STRENGTH)))
@@ -185,6 +188,8 @@ watch(authorCoverSize, (v) => storage.set('authorCoverSize', v))
watch(authorCoverShape, (v) => storage.set('authorCoverShape', normalizeAuthorCoverShape(v)))
watch(tableZebraStriping, (v) => storage.set('tableZebraStriping', v))
watch(tableDensity, (v) => storage.set('tableDensity', normalizeTableDensity(v)))
watch(authorRowDensity, (v) => storage.set('authorRowDensity', normalizeTableDensity(v)))
watch(authorCoverFallback, (v) => storage.set('authorCoverFallback', v))
watch(bookSpineOverlay, (value) => storage.set('bookSpineOverlay', normalizeBookSpineOverlay(value)))
watch(showSpineOnComics, (v) => storage.set('showSpineOnComics', v))
watch(bookShadowStrength, (value) => storage.set('bookShadowStrength', normalizeBookShadowStrength(value)))
@@ -210,6 +215,8 @@ export function getDisplayPreferencesSnapshot(): DisplayPreferences {
smartScopeFilterExpanded: smartScopeFilterExpanded.value === true,
authorCoverSize: normalizePositiveInteger(authorCoverSize.value, 120, 100, 400),
authorCoverShape: normalizeAuthorCoverShape(authorCoverShape.value),
authorRowDensity: normalizeTableDensity(authorRowDensity.value),
authorCoverFallback: authorCoverFallback.value === true,
tableZebraStriping: tableZebraStriping.value === true,
tableDensity: normalizeTableDensity(tableDensity.value),
bookSpineOverlay: normalizeBookSpineOverlay(bookSpineOverlay.value),
@@ -244,6 +251,8 @@ export function sanitizeDisplayPreferences(raw: unknown): Partial<DisplayPrefere
if (typeof obj.smartScopeFilterExpanded === 'boolean') out.smartScopeFilterExpanded = obj.smartScopeFilterExpanded
if (typeof obj.authorCoverSize === 'number') out.authorCoverSize = normalizePositiveInteger(obj.authorCoverSize, 120, 100, 400)
if (AUTHOR_COVER_SHAPES.includes(obj.authorCoverShape as AuthorCoverShape)) out.authorCoverShape = obj.authorCoverShape as AuthorCoverShape
if (TABLE_DENSITIES.includes(obj.authorRowDensity as TableDensity)) out.authorRowDensity = obj.authorRowDensity as TableDensity
if (typeof obj.authorCoverFallback === 'boolean') out.authorCoverFallback = obj.authorCoverFallback
if (typeof obj.tableZebraStriping === 'boolean') out.tableZebraStriping = obj.tableZebraStriping
if (TABLE_DENSITIES.includes(obj.tableDensity as TableDensity)) out.tableDensity = obj.tableDensity as TableDensity
if (BOOK_SPINE_OVERLAYS.includes(obj.bookSpineOverlay as BookSpineOverlay)) out.bookSpineOverlay = obj.bookSpineOverlay as BookSpineOverlay
@@ -289,6 +298,8 @@ export function applyDisplayPreferences(raw: unknown): void {
if (prefs.smartScopeFilterExpanded !== undefined) smartScopeFilterExpanded.value = prefs.smartScopeFilterExpanded
if (prefs.authorCoverSize !== undefined) authorCoverSize.value = prefs.authorCoverSize
if (prefs.authorCoverShape !== undefined) authorCoverShape.value = prefs.authorCoverShape
if (prefs.authorRowDensity !== undefined) authorRowDensity.value = prefs.authorRowDensity
if (prefs.authorCoverFallback !== undefined) authorCoverFallback.value = prefs.authorCoverFallback
if (prefs.tableZebraStriping !== undefined) tableZebraStriping.value = prefs.tableZebraStriping
if (prefs.tableDensity !== undefined) tableDensity.value = prefs.tableDensity
if (prefs.bookSpineOverlay !== undefined) bookSpineOverlay.value = prefs.bookSpineOverlay
@@ -317,6 +328,8 @@ export function useDisplaySettings() {
smartScopeFilterExpanded,
authorCoverSize,
authorCoverShape,
authorRowDensity,
authorCoverFallback,
tableZebraStriping,
tableDensity,
bookSpineOverlay,
+44 -9
View File
@@ -1,18 +1,31 @@
import { api } from '@/lib/api'
import type { AuthorsPage, AuthorDetail, AuthorMetadataCandidate, AuthorMetadataProviderKey, BooksPage, MergeAuthorsResult } from '@bookorbit/types'
import type {
AuthorsPage,
AuthorDetail,
AuthorMetadataCandidate,
AuthorMetadataProviderKey,
BooksPage,
JumpBucketsResponse,
MergeAuthorsResult,
} from '@bookorbit/types'
import type { AuthorBookSort, AuthorListSort, SortDirection } from '../types/author'
export const MAX_AUTHOR_IMAGE_BYTES = 20 * 1024 * 1024
type ListAuthorsParams = {
export type AuthorFilterParams = {
q?: string
libraryId?: number | null
hasPhoto?: boolean | null
hasSortName?: boolean | null
addedWithinDays?: number | null
minBookCount?: number | null
}
type ListAuthorsParams = AuthorFilterParams & {
page: number
size: number
sort: AuthorListSort
order: SortDirection
libraryId?: number | null
hasPhoto?: boolean | null
minBookCount?: number | null
}
type ListAuthorBooksParams = {
@@ -84,14 +97,11 @@ function toQuery(params: Record<string, string | number | (string | number)[] |
export async function fetchAuthors(params: ListAuthorsParams): Promise<AuthorsPage> {
const query = toQuery({
q: params.q,
page: params.page,
size: params.size,
sort: params.sort,
order: params.order,
libraryId: params.libraryId ?? undefined,
hasPhoto: params.hasPhoto != null ? String(params.hasPhoto) : undefined,
minBookCount: params.minBookCount ?? undefined,
...authorFilterQuery(params),
})
const res = await api(`/api/v1/authors${query}`)
@@ -99,6 +109,31 @@ export async function fetchAuthors(params: ListAuthorsParams): Promise<AuthorsPa
return res.json()
}
function authorFilterQuery(params: AuthorFilterParams) {
return {
q: params.q,
libraryId: params.libraryId ?? undefined,
hasPhoto: params.hasPhoto != null ? String(params.hasPhoto) : undefined,
hasSortName: params.hasSortName != null ? String(params.hasSortName) : undefined,
addedWithinDays: params.addedWithinDays ?? undefined,
minBookCount: params.minBookCount ?? undefined,
}
}
/**
* A-Z buckets for the jump rail, under the same filters as the list. Server-computed
* so the rail covers the whole result set rather than the pages already scrolled to.
*/
export async function fetchAuthorJumpBuckets(
params: AuthorFilterParams & { sort: 'name' | 'sortName'; order: SortDirection },
): Promise<JumpBucketsResponse> {
const query = toQuery({ sort: params.sort, order: params.order, ...authorFilterQuery(params) })
const res = await api(`/api/v1/authors/jump-buckets${query}`)
if (!res.ok) throw new Error('Failed to load author jump buckets')
return res.json()
}
export async function fetchAuthor(id: number): Promise<AuthorDetail | null> {
const res = await api(`/api/v1/authors/${id}`)
if (res.status === 404) return null
@@ -1,253 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters'
import type { AuthorSummary } from '@bookorbit/types'
import { BookCopy, Check, ExternalLink, Loader2, MoreHorizontal, RefreshCw, Trash2 } from '@lucide/vue'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { toDisplayCoverUrl } from '@/features/book/lib/metadata-fetch'
const props = defineProps<{
author: AuthorSummary
selectionMode?: boolean
selected?: boolean
canRefresh?: boolean
canDelete?: boolean
refreshing?: boolean
deleting?: boolean
shape?: 'square' | 'circle'
}>()
const emit = defineEmits<{
open: [authorId: number]
select: [event: MouseEvent]
refresh: [authorId: number]
delete: [authorId: number]
}>()
const { t } = useI18n()
const initial = computed(() => props.author.name.trim().charAt(0).toUpperCase() || '?')
const fallbackStyle = computed(() => bookCoverStyle(props.author.name || String(props.author.id)))
const imageSrc = computed(() => {
const display = toDisplayCoverUrl(props.author.imageUrl)
return display || undefined
})
const hasImage = computed(() => Boolean(imageSrc.value) && !imageFailed.value)
const imageFailed = ref(false)
watch(
() => props.author.imageUrl,
() => {
imageFailed.value = false
},
)
function handleClick(event: MouseEvent) {
if (props.selectionMode || event.metaKey || event.ctrlKey) {
emit('select', event)
return
}
emit('open', props.author.id)
}
function handleRefresh() {
if (!props.canRefresh || props.refreshing) return
emit('refresh', props.author.id)
}
function handleDelete() {
if (!props.canDelete || props.deleting) return
emit('delete', props.author.id)
}
</script>
<template>
<!-- Square layout (default) -->
<div
v-if="shape !== 'circle'"
class="group flex flex-col @container"
:class="[selectionMode ? 'select-none' : '', 'cursor-pointer']"
@click="handleClick($event as MouseEvent)"
>
<div
class="relative w-full rounded-sm overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="[selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]', selected ? 'ring-2 ring-primary' : '']"
style="aspect-ratio: 2/3"
:style="hasImage ? undefined : fallbackStyle"
>
<img
v-if="hasImage"
:src="imageSrc"
:alt="t('author.card.portraitAlt', { name: author.name })"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
loading="lazy"
decoding="async"
fetchpriority="low"
@error="imageFailed = true"
/>
<div v-else class="absolute inset-0 flex items-center justify-center text-3xl font-semibold" :style="{ color: fallbackStyle.color }">
{{ initial }}
</div>
<div v-if="!selectionMode" class="absolute left-1.5 top-1.5 z-10">
<span class="inline-flex items-center gap-1 rounded border border-white/20 bg-black/55 px-1.5 py-0.5 text-[10px] font-semibold text-white">
<BookCopy :size="11" />
{{ formatNumber(author.bookCount) }}
</span>
</div>
<div v-if="selectionMode" class="absolute inset-0 z-30 pointer-events-none" :class="selected ? 'bg-primary/20' : ''">
<div
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<div
class="absolute inset-x-0 bottom-0 z-10 p-2"
:class="hasImage ? 'bg-linear-to-t from-black/80 via-black/35 to-transparent' : 'bg-linear-to-t from-black/65 via-black/30 to-transparent'"
>
<div class="flex items-end justify-between gap-2">
<div class="min-w-0">
<h3 class="line-clamp-2 text-xs leading-tight" :style="hasImage ? undefined : { color: fallbackStyle.color }">
{{ author.name }}
</h3>
</div>
<DropdownMenu v-if="!selectionMode">
<DropdownMenuTrigger as-child>
<button
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-black/35 text-white/90 transition-colors hover:bg-black/55 hover:text-white"
@click.stop
>
<MoreHorizontal :size="14" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('open', author.id)">
<ExternalLink class="mr-2 h-4 w-4" />
{{ t('author.card.viewDetails') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canRefresh || refreshing" @click="handleRefresh">
<Loader2 v-if="refreshing" class="mr-2 h-4 w-4 animate-spin" />
<RefreshCw v-else class="mr-2 h-4 w-4" />
{{ t('author.card.refreshMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!canDelete || deleting"
:class="canDelete ? 'text-destructive focus:text-destructive' : ''"
@click="handleDelete"
>
<Loader2 v-if="deleting" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ t('author.card.deleteAuthor') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Transition name="fade">
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
</div>
</div>
<!-- Circle layout -->
<div
v-else
class="group flex flex-col items-center gap-2 py-2 @container"
:class="[selectionMode ? 'select-none' : '', 'cursor-pointer']"
@click="handleClick($event as MouseEvent)"
>
<div class="relative w-full aspect-square">
<div
class="w-full h-full rounded-full overflow-hidden shadow-md transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="[
selectionMode ? '' : 'group-hover:shadow-xl group-hover:scale-[1.02]',
selected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
]"
:style="hasImage ? undefined : fallbackStyle"
>
<img
v-if="hasImage"
:src="imageSrc"
:alt="t('author.card.portraitAlt', { name: author.name })"
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-out"
loading="lazy"
decoding="async"
fetchpriority="low"
@error="imageFailed = true"
/>
<div v-else class="absolute inset-0 flex items-center justify-center text-3xl font-semibold" :style="{ color: fallbackStyle.color }">
{{ initial }}
</div>
<div v-if="selectionMode" class="absolute inset-0 z-30 pointer-events-none rounded-full" :class="selected ? 'bg-primary/20' : ''" />
<Transition name="fade">
<div v-if="refreshing" class="absolute inset-0 z-40 flex items-center justify-center bg-black/50 rounded-full">
<Loader2 class="size-[32cqi] animate-spin text-white drop-shadow-lg" />
</div>
</Transition>
</div>
<div v-if="selectionMode" class="absolute top-1 left-1 z-30">
<div
class="h-5 w-5 rounded flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
</div>
<span
v-if="!selectionMode"
class="absolute bottom-0.5 left-0.5 z-10 h-6 w-6 rounded-full border border-border bg-card shadow-sm flex items-center justify-center text-[10px] font-semibold text-foreground"
>
{{ formatNumber(author.bookCount) }}
</span>
<DropdownMenu v-if="!selectionMode">
<DropdownMenuTrigger as-child>
<button
class="absolute bottom-0.5 right-0.5 z-10 h-6 w-6 rounded-full border border-border bg-card shadow-sm flex items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click.stop
>
<MoreHorizontal :size="12" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('open', author.id)">
<ExternalLink class="mr-2 h-4 w-4" />
{{ t('author.card.viewDetails') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canRefresh || refreshing" @click="handleRefresh">
<Loader2 v-if="refreshing" class="mr-2 h-4 w-4 animate-spin" />
<RefreshCw v-else class="mr-2 h-4 w-4" />
{{ t('author.card.refreshMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!canDelete || deleting"
:class="canDelete ? 'text-destructive focus:text-destructive' : ''"
@click="handleDelete"
>
<Loader2 v-if="deleting" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ t('author.card.deleteAuthor') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="flex flex-col items-center gap-0.5 w-full min-w-0 px-1 text-center">
<h3 class="w-full truncate text-xs font-semibold text-foreground leading-tight">{{ author.name }}</h3>
</div>
</div>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Library } from '@lucide/vue'
import { formatNumber } from '@/i18n/formatters'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import type { AuthorQuickFilter, AuthorQuickFilterCounts, LibraryFilterOption } from '../types/author'
const props = defineProps<{
quickFilter: AuthorQuickFilter
counts?: AuthorQuickFilterCounts | null
libraries: LibraryFilterOption[]
libraryId: number | null
}>()
const emit = defineEmits<{
'update:quickFilter': [value: AuthorQuickFilter]
'update:libraryId': [value: number | null]
}>()
const { t } = useI18n()
const CHIPS: { key: AuthorQuickFilter; label: string }[] = [
{ key: 'all', label: 'author.filters.chips.all' },
{ key: 'noPortrait', label: 'author.filters.chips.noPortrait' },
{ key: 'multipleBooks', label: 'author.filters.chips.multipleBooks' },
{ key: 'recentlyAdded', label: 'author.filters.chips.recentlyAdded' },
{ key: 'noSortName', label: 'author.filters.chips.noSortName' },
]
const activeLibrary = computed(() => props.libraries.find((library) => library.id === props.libraryId) ?? null)
function countFor(key: AuthorQuickFilter): string | null {
const counts = props.counts
if (!counts) return null
const value = counts[key]
return typeof value === 'number' ? formatNumber(value) : null
}
function selectChip(key: AuthorQuickFilter) {
emit('update:quickFilter', key)
}
function selectAllLibraries() {
emit('update:libraryId', null)
}
</script>
<template>
<div
class="-mx-1 flex shrink-0 items-center gap-1.5 overflow-x-auto px-1 py-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
:aria-label="t('author.filters.chips.groupLabel')"
>
<button
v-for="chip in CHIPS"
:key="chip.key"
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors sm:h-[26px] sm:px-2.5 sm:text-[11.5px]"
:class="
quickFilter === chip.key
? 'border-primary/55 bg-primary/12 text-primary'
: 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-pressed="quickFilter === chip.key"
@click="selectChip(chip.key)"
>
{{ t(chip.label) }}
<span
v-if="countFor(chip.key)"
class="text-[11px] font-bold tabular-nums"
:class="quickFilter === chip.key ? 'text-primary' : 'text-muted-foreground'"
>
{{ countFor(chip.key) }}
</span>
</button>
<span v-if="libraries.length > 1" class="mx-0.5 h-4 w-px shrink-0 bg-border" aria-hidden="true" />
<DropdownMenu v-if="libraries.length > 1">
<DropdownMenuTrigger as-child>
<button
type="button"
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors sm:h-[26px] sm:px-2.5 sm:text-[11.5px]"
:class="
activeLibrary
? 'border-primary/55 bg-primary/12 text-primary'
: 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
"
>
<Library :size="12" />
<span class="max-w-[8rem] truncate">{{ activeLibrary ? activeLibrary.name : t('author.filters.allLibraries') }}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="max-h-72 overflow-y-auto">
<DropdownMenuItem @click="selectAllLibraries">
<Check class="mr-2 h-4 w-4" :class="libraryId === null ? '' : 'opacity-0'" />
{{ t('author.filters.allLibraries') }}
</DropdownMenuItem>
<DropdownMenuItem v-for="library in libraries" :key="library.id" @click="emit('update:libraryId', library.id)">
<Check class="mr-2 h-4 w-4" :class="libraryId === library.id ? '' : 'opacity-0'" />
<span class="truncate">{{ library.name }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -1,107 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { LibraryFilterOption } from '../types/author'
defineProps<{
libraryId: number | null
libraries: LibraryFilterOption[]
hasPhoto: boolean | null
minBookCount: number | null
activeCount?: number
closable?: boolean
embedded?: boolean
}>()
const emit = defineEmits<{
'update:libraryId': [value: number | null]
'update:hasPhoto': [value: boolean | null]
'update:minBookCount': [value: number | null]
clear: []
close: []
}>()
const { t } = useI18n()
function onClear() {
emit('clear')
}
function onClose() {
emit('close')
}
function onLibraryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
emit('update:libraryId', value ? Number(value) : null)
}
function onHasPhotoChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'true') emit('update:hasPhoto', true)
else if (value === 'false') emit('update:hasPhoto', false)
else emit('update:hasPhoto', null)
}
function onMinBookCountChange(event: Event) {
const raw = (event.target as HTMLInputElement).value
const num = parseInt(raw, 10)
emit('update:minBookCount', Number.isInteger(num) && num >= 1 ? num : null)
}
</script>
<template>
<section :class="embedded ? 'rounded-md border border-border bg-card p-3' : 'mb-4 rounded-md border border-border bg-card p-3'">
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">{{ t('author.filters.title') }}</span>
<div class="flex items-center gap-2">
<button
v-if="(activeCount ?? 0) > 0"
class="h-7 rounded-md border border-input px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="onClear"
>
{{ t('author.filters.clearAll') }}
</button>
<button
v-if="closable"
class="h-7 rounded-md border border-input px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="onClose"
>
{{ t('common.close') }}
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
:value="libraryId ?? ''"
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="onLibraryChange"
>
<option value="">{{ t('author.filters.allLibraries') }}</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
<select
:value="hasPhoto === true ? 'true' : hasPhoto === false ? 'false' : ''"
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
@change="onHasPhotoChange"
>
<option value="">{{ t('author.filters.allAuthors') }}</option>
<option value="true">{{ t('author.filters.hasPhoto') }}</option>
<option value="false">{{ t('author.filters.missingPhoto') }}</option>
</select>
<div class="flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-2.5">
<span class="text-sm text-muted-foreground">{{ t('author.filters.minBooks') }}</span>
<input
type="number"
:value="minBookCount ?? ''"
min="1"
:placeholder="t('author.filters.anyPlaceholder')"
class="w-14 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
@change="onMinBookCountChange"
/>
</div>
</div>
</section>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ExternalLink, Loader2, MoreHorizontal, RefreshCw, Trash2 } from '@lucide/vue'
import type { AuthorSummary, TableDensity } from '@bookorbit/types'
import { formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import AuthorPortrait from './AuthorPortrait.vue'
import { hasInformativeSortName } from '../lib/author-identity'
const props = withDefaults(
defineProps<{
author: AuthorSummary
density?: TableDensity
maxBookCount?: number
selectionMode?: boolean
selected?: boolean
canRefresh?: boolean
canDelete?: boolean
refreshing?: boolean
deleting?: boolean
coverFallback?: boolean
}>(),
{ density: 'comfortable', maxBookCount: 1, coverFallback: true },
)
const emit = defineEmits<{
open: [authorId: number]
select: [event: MouseEvent]
refresh: [authorId: number]
delete: [authorId: number]
}>()
const { t } = useI18n()
/** Row height and portrait scale move together, so the portrait never crowds the row. */
const METRICS: Record<TableDensity, { row: number; portrait: number }> = {
compact: { row: 32, portrait: 24 },
comfortable: { row: 38, portrait: 30 },
roomy: { row: 46, portrait: 34 },
}
const metrics = computed(() => METRICS[props.density] ?? METRICS.comfortable)
const busySpinnerSize = computed(() => Math.round(metrics.value.portrait * 0.5))
const showSortName = computed(() => hasInformativeSortName(props.author))
const lastAdded = computed(() => (props.author.lastAddedAt ? formatRelativeFromNow(new Date(props.author.lastAddedAt)) : ''))
const bookCountLabel = computed(() => formatNumber(props.author.bookCount))
/**
* The bar only appears above one book. In a real library the overwhelming majority of
* authors have exactly one, and a stub bar on every one of those rows reads as a
* rendering fault rather than as data. Square-rooted so the long tail stays visible
* next to an outlier with a hundred titles.
*/
const barWidth = computed(() => {
if (props.author.bookCount <= 1) return 0
const max = Math.max(props.maxBookCount, props.author.bookCount, 2)
return Math.max(12, Math.round(Math.sqrt(props.author.bookCount / max) * 100))
})
const secondaryLine = computed(() => {
const parts: string[] = []
if (showSortName.value && props.author.sortName) parts.push(props.author.sortName)
parts.push(t('author.index.bookCount', { count: props.author.bookCount }))
if (lastAdded.value) parts.push(lastAdded.value)
return parts.join(' · ')
})
const accessibleLabel = computed(() => t('author.index.rowLabel', { name: props.author.name, count: props.author.bookCount }))
const menuOpen = ref(false)
const busy = computed(() => Boolean(props.refreshing || props.deleting))
const busyLabel = computed(() =>
props.deleting ? t('author.index.deleting', { name: props.author.name }) : t('author.index.refreshing', { name: props.author.name }),
)
function handleActivate(event: MouseEvent) {
if (props.selectionMode || event.metaKey || event.ctrlKey || event.shiftKey) {
emit('select', event)
return
}
emit('open', props.author.id)
}
function handleRefresh() {
if (!props.canRefresh || props.refreshing) return
emit('refresh', props.author.id)
}
function handleDelete() {
if (!props.canDelete || props.deleting) return
emit('delete', props.author.id)
}
</script>
<template>
<div
class="group/row relative isolate flex items-center rounded-lg transition-colors"
:class="[selected ? 'bg-primary/10' : 'hover:bg-muted/60', busy ? 'opacity-70' : '']"
:aria-busy="busy ? 'true' : undefined"
:style="{ minHeight: `${metrics.row}px` }"
>
<!-- Stretched primary action: keeps the whole row clickable without nesting the
menu button inside another button, which is invalid and breaks keyboard order. -->
<button
type="button"
class="absolute inset-0 z-0 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
:aria-label="accessibleLabel"
:aria-pressed="selectionMode ? selected : undefined"
@click="handleActivate"
/>
<div
class="pointer-events-none relative z-10 flex w-full min-w-0 items-center gap-2.5 px-2 py-1 @container/row sm:gap-3"
:style="{ minHeight: `${metrics.row}px` }"
>
<span
v-if="selectionMode"
class="flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border transition-colors"
:class="selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'"
>
<Check :size="11" />
</span>
<span class="relative shrink-0 overflow-hidden rounded-lg" :style="{ width: `${metrics.portrait}px`, height: `${metrics.portrait}px` }">
<AuthorPortrait :author="author" :show-source-badge="false" :cover-fallback="coverFallback" />
<span v-if="busy" class="absolute inset-0 grid place-items-center bg-black/55" role="status" :aria-label="busyLabel">
<Loader2 :size="busySpinnerSize" class="animate-spin text-white" />
</span>
</span>
<span class="flex min-w-0 flex-1 flex-col gap-px @[34rem]/page:flex-row @[34rem]/page:items-baseline @[34rem]/page:gap-2">
<span class="truncate text-sm font-semibold text-foreground @[34rem]/page:shrink @[34rem]/page:text-[13.5px]">
{{ author.name }}
</span>
<span v-if="showSortName && author.sortName" class="hidden min-w-0 shrink-[60] truncate text-xs text-muted-foreground @[34rem]/page:inline">
{{ author.sortName }}
</span>
<span class="truncate text-xs text-muted-foreground @[34rem]/page:hidden">{{ secondaryLine }}</span>
</span>
<span class="hidden shrink-0 items-center gap-3 @[34rem]/page:flex">
<!-- nowrap and wide enough for the longest relative form: "19 hours ago"
wrapping to a second line silently grows the row and breaks the
baseline every other row in the column is aligned to. -->
<span
v-if="lastAdded"
class="hidden w-[5.5rem] shrink-0 truncate whitespace-nowrap text-right text-xs text-muted-foreground @[24rem]/row:inline"
>
{{ lastAdded }}
</span>
<span class="flex shrink-0 items-center gap-2">
<span class="hidden h-[3px] w-[26px] overflow-hidden rounded-full @[17rem]/row:block" :class="barWidth ? 'bg-border' : 'bg-transparent'">
<span v-if="barWidth" class="block h-full rounded-full bg-primary" :style="{ width: `${barWidth}%` }" />
</span>
<span
class="min-w-[1.1rem] text-right text-xs font-semibold tabular-nums"
:class="author.bookCount > 1 ? 'text-foreground' : 'text-muted-foreground'"
>
{{ bookCountLabel }}
</span>
</span>
</span>
</div>
<!-- Overlaid rather than given a column of its own: the menu costs the name no
width, and it never appears in selection mode where the row click owns the tap. -->
<DropdownMenu v-if="!selectionMode" v-model:open="menuOpen">
<DropdownMenuTrigger as-child>
<button
type="button"
class="absolute right-1.5 top-1/2 z-20 size-6 -translate-y-1/2 place-items-center rounded-md border border-border bg-card text-muted-foreground shadow-xs transition-colors hover:text-foreground focus-visible:grid group-focus-within/row:grid group-hover/row:grid"
:class="busy || menuOpen ? 'grid' : 'hidden'"
:aria-label="t('author.index.actionsFor', { name: author.name })"
@click.stop
>
<MoreHorizontal :size="13" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('open', author.id)">
<ExternalLink class="mr-2 h-4 w-4" />
{{ t('author.card.viewDetails') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canRefresh || refreshing" @click="handleRefresh">
<Loader2 v-if="refreshing" class="mr-2 h-4 w-4 animate-spin" />
<RefreshCw v-else class="mr-2 h-4 w-4" />
{{ t('author.card.refreshMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!canDelete || deleting"
:class="canDelete ? 'text-destructive focus:text-destructive' : ''"
@click="handleDelete"
>
<Loader2 v-if="deleting" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ t('author.card.deleteAuthor') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDate, formatNumber } from '@/i18n/formatters'
import type { AuthorSummary } from '@bookorbit/types'
import { ArrowRight, BookCopy, Check, Clock3, Loader2 } from '@lucide/vue'
import { toDisplayCoverUrl } from '@/features/book/lib/metadata-fetch'
const props = defineProps<{
author: AuthorSummary
selectionMode?: boolean
selected?: boolean
refreshing?: boolean
}>()
const emit = defineEmits<{
open: [authorId: number]
select: [event: MouseEvent]
}>()
const { t } = useI18n()
const lastAddedLabel = computed(() => {
if (!props.author.lastAddedAt) return t('author.listRow.noRecentAdditions')
const date = new Date(props.author.lastAddedAt)
return Number.isNaN(date.getTime()) ? t('author.listRow.noRecentAdditions') : formatDate(date)
})
const imageFailed = ref(false)
const initial = computed(() => props.author.name.trim().charAt(0).toUpperCase() || '?')
const imageSrc = computed(() => {
const display = toDisplayCoverUrl(props.author.imageUrl)
return display || undefined
})
watch(
() => props.author.imageUrl,
() => {
imageFailed.value = false
},
)
function handleClick(event: MouseEvent) {
if (props.selectionMode || event.metaKey || event.ctrlKey) {
emit('select', event)
return
}
emit('open', props.author.id)
}
</script>
<template>
<button
class="w-full rounded-lg border border-transparent px-1 sm:px-3 py-2 text-left transition-colors hover:border-border hover:bg-muted/40 [content-visibility:auto] [contain-intrinsic-size:56px_1000px]"
:class="[selectionMode ? 'cursor-pointer select-none' : '', selected ? 'border-primary/45 bg-primary/5' : '']"
@click="handleClick($event as MouseEvent)"
>
<div class="flex items-center gap-3">
<span
v-if="selectionMode"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border text-[10px]"
:class="selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'"
>
<Check :size="11" />
</span>
<div class="h-8 w-8 shrink-0 overflow-hidden rounded-md border border-border/60 bg-muted/30">
<img
v-if="imageSrc && !imageFailed"
:src="imageSrc"
:alt="t('author.listRow.portraitAlt', { name: author.name })"
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
fetchpriority="low"
@error="imageFailed = true"
/>
<div v-else class="flex h-full w-full items-center justify-center text-xs font-semibold text-primary">
{{ initial }}
</div>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ author.name }}</p>
<p v-if="author.sortName && author.sortName !== author.name" class="truncate text-xs text-muted-foreground">{{ author.sortName }}</p>
</div>
<div class="hidden md:flex items-center gap-5 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<BookCopy :size="12" />
{{ formatNumber(author.bookCount) }}
</span>
<span class="inline-flex items-center gap-1.5">
<Clock3 :size="12" />
{{ lastAddedLabel }}
</span>
</div>
<Loader2 v-if="!selectionMode && refreshing" :size="14" class="animate-spin text-muted-foreground" />
<ArrowRight v-else-if="!selectionMode" :size="14" class="text-muted-foreground" />
</div>
</button>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookImage } from '@lucide/vue'
import type { AuthorSummary } from '@bookorbit/types'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { toDisplayCoverUrl } from '@/features/book/lib/metadata-fetch'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { authorInitials } from '../lib/author-identity'
/**
* The one place the author artwork rule lives: a portrait if we have one, else the
* cover of a book they wrote, else a monogram. Only a small minority of a real
* library's authors have a portrait, so without the middle step the grid is a wall
* of near-identical letter tiles.
*
* The monogram always renders underneath, so an image that is slow, missing, or
* broken degrades to letters instead of an empty rectangle. Fills its container;
* the caller owns the size.
*/
const props = withDefaults(
defineProps<{
author: AuthorSummary
shape?: 'square' | 'circle'
/** Marks cover-derived artwork so it never reads as a photograph of the person. */
showSourceBadge?: boolean
/**
* Turn off while filtering for authors that have no portrait: standing in a book
* cover there fills every tile and hides the very gap the filter exists to show.
*/
coverFallback?: boolean
}>(),
{ shape: 'square', showSourceBadge: true, coverFallback: true },
)
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const portraitFailed = ref(false)
const coverFailed = ref(false)
watch(
() => props.author.imageUrl,
() => {
portraitFailed.value = false
},
)
watch(
() => props.author.coverBookId,
() => {
coverFailed.value = false
},
)
const portraitSrc = computed(() => {
if (portraitFailed.value) return ''
return toDisplayCoverUrl(props.author.imageUrl)
})
const coverSrc = computed(() => {
if (!props.coverFallback || portraitSrc.value || coverFailed.value) return ''
const bookId = props.author.coverBookId
return bookId ? coverUrl(bookId) : ''
})
const source = computed<'portrait' | 'cover' | 'monogram'>(() => {
if (portraitSrc.value) return 'portrait'
if (coverSrc.value) return 'cover'
return 'monogram'
})
const initials = computed(() => authorInitials(props.author.name))
const monogramStyle = computed(() => bookCoverStyle(props.author.name || String(props.author.id)))
/** Same rule as the tile badges: a circle clips whatever sits in the square's corner. */
const badgeInset = computed(() => (props.shape === 'circle' ? 'bottom-[9%] left-[9%]' : 'bottom-1 left-1'))
function handlePortraitError() {
portraitFailed.value = true
}
function handleCoverError() {
coverFailed.value = true
}
</script>
<template>
<span
class="@container relative flex h-full w-full items-center justify-center overflow-hidden"
:class="shape === 'circle' ? 'rounded-full' : 'rounded-[inherit]'"
:style="monogramStyle"
>
<span
class="select-none font-serif font-semibold leading-none tracking-tight text-[length:clamp(0.55rem,36cqw,2.25rem)]"
:style="{ color: monogramStyle.color }"
aria-hidden="true"
>
{{ initials }}
</span>
<img
v-if="portraitSrc"
:src="portraitSrc"
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
fetchpriority="low"
class="absolute inset-0 h-full w-full object-cover object-[50%_26%]"
@error="handlePortraitError"
/>
<img
v-else-if="coverSrc"
:src="coverSrc"
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
fetchpriority="low"
class="absolute inset-0 h-full w-full object-cover object-[50%_18%]"
@error="handleCoverError"
/>
<span v-if="source === 'cover'" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/35 to-transparent to-45%" />
<span
v-if="source === 'cover' && showSourceBadge"
class="pointer-events-none absolute hidden size-4 place-items-center rounded bg-black/45 text-white/85 backdrop-blur-[2px] @[3.5rem]:grid"
:class="badgeInset"
:title="t('author.portrait.fromTheirBook')"
>
<BookImage :size="9" />
</span>
</span>
</template>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ExternalLink, Loader2, MoreHorizontal, RefreshCw, Trash2 } from '@lucide/vue'
import type { AuthorCoverShape, AuthorSummary } from '@bookorbit/types'
import { formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import AuthorPortrait from './AuthorPortrait.vue'
const props = withDefaults(
defineProps<{
author: AuthorSummary
shape?: AuthorCoverShape
selectionMode?: boolean
selected?: boolean
canRefresh?: boolean
canDelete?: boolean
refreshing?: boolean
deleting?: boolean
coverFallback?: boolean
}>(),
{ shape: 'square', coverFallback: true },
)
const emit = defineEmits<{
open: [authorId: number]
select: [event: MouseEvent]
refresh: [authorId: number]
delete: [authorId: number]
}>()
const { t } = useI18n()
const bookCountLabel = computed(() => formatNumber(props.author.bookCount))
const lastAdded = computed(() => (props.author.lastAddedAt ? formatRelativeFromNow(new Date(props.author.lastAddedAt)) : ''))
const countLine = computed(() => t('author.index.bookCount', { count: props.author.bookCount }))
const accessibleLabel = computed(() => t('author.index.rowLabel', { name: props.author.name, count: props.author.bookCount }))
const menuOpen = ref(false)
/**
* A circle inscribed in the tile meets its corner diagonal at ~14.6% of the width,
* so on a circular tile the badges move inwards to sit on the arc rather than
* floating in the empty corner beside it.
*/
const badgeInset = computed(() =>
props.shape === 'circle'
? { topLeft: 'left-[7%] top-[7%]', topRight: 'right-[7%] top-[7%]', bottomRight: 'bottom-[7%] right-[7%]' }
: { topLeft: 'left-1.5 top-1.5', topRight: 'right-1.5 top-1.5', bottomRight: 'bottom-1.5 right-1.5' },
)
const busy = computed(() => Boolean(props.refreshing || props.deleting))
const busyLabel = computed(() =>
props.deleting ? t('author.index.deleting', { name: props.author.name }) : t('author.index.refreshing', { name: props.author.name }),
)
function handleActivate(event: MouseEvent) {
if (props.selectionMode || event.metaKey || event.ctrlKey || event.shiftKey) {
emit('select', event)
return
}
emit('open', props.author.id)
}
function handleRefresh() {
if (!props.canRefresh || props.refreshing) return
emit('refresh', props.author.id)
}
function handleDelete() {
if (!props.canDelete || props.deleting) return
emit('delete', props.author.id)
}
</script>
<template>
<div class="group/tile relative isolate flex h-full min-w-0 flex-col gap-1.5">
<!-- The hit target is the whole tile, name included. Sizing it to the artwork
instead leaves every corner of a circular tile dead, and never lets anyone
click the name: 26 of 63 sampled points across a tile did nothing. -->
<button
type="button"
class="absolute inset-0 z-10 rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
:aria-label="accessibleLabel"
:aria-pressed="selectionMode ? selected : undefined"
@click="handleActivate"
/>
<!-- Two boxes, deliberately: the outer one is the badge positioning context and
does not clip, the inner one clips the artwork to its shape. Badges hung on
the tile itself would measure from below the name; badges inside the clipped
box get eaten by the circle. -->
<div class="@container relative aspect-square w-full">
<div
class="absolute inset-0 overflow-hidden shadow-xs ring-1 ring-border/70 transition-[box-shadow,transform] duration-150"
:class="[
shape === 'circle' ? 'rounded-full' : 'rounded-[10px]',
selectionMode ? '' : 'group-hover/tile:-translate-y-px group-hover/tile:shadow-md',
selected ? 'ring-2 ring-primary' : '',
]"
>
<AuthorPortrait :author="author" :shape="shape" :cover-fallback="coverFallback" />
<!-- Resting state stays quiet; the numbers are one hover away rather than
printed over every tile in the grid. -->
<span
v-if="!selectionMode"
class="pointer-events-none absolute inset-0 z-20 hidden flex-col justify-end gap-0.5 bg-linear-to-t from-black/75 via-black/30 to-transparent p-2 opacity-0 transition-opacity duration-150 group-focus-within/tile:opacity-100 group-hover/tile:opacity-100 @[4.5rem]:flex"
:class="shape === 'circle' ? 'rounded-full' : 'rounded-[10px]'"
>
<span class="truncate text-[11px] font-bold leading-tight text-white">{{ countLine }}</span>
<span v-if="lastAdded" class="hidden truncate text-[10px] leading-tight text-white/75 @[5.5rem]:block">{{ lastAdded }}</span>
</span>
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<span
v-if="busy"
class="pointer-events-none absolute inset-0 z-40 grid place-items-center bg-black/55 backdrop-blur-[1px]"
:class="shape === 'circle' ? 'rounded-full' : 'rounded-[10px]'"
role="status"
:aria-label="busyLabel"
>
<Loader2 class="size-[34cqi] animate-spin text-white drop-shadow-lg" />
</span>
</Transition>
</div>
<!-- Lives on the tile rather than inside the artwork: the artwork lifts on hover,
and that transform makes it a stacking context, which trapped this menu
beneath the tile's own select button. It only shows on hover, so it was
never clickable at all. -->
<!-- Badges live on the tile, never inside the artwork: a circular tile is
overflow-hidden, and anything sitting in the square's corner is clipped
away by the circle. Their inset follows the shape instead. -->
<span
v-if="selectionMode"
class="pointer-events-none absolute z-20 grid size-[18px] place-items-center rounded-md backdrop-blur-[2px] transition-colors"
:class="[badgeInset.topLeft, selected ? 'bg-primary text-primary-foreground' : 'bg-black/45 text-transparent ring-1 ring-white/55']"
>
<Check :size="11" />
</span>
<span
v-else-if="author.bookCount > 1"
class="pointer-events-none absolute z-20 hidden h-[17px] min-w-[17px] place-items-center rounded-full bg-background/80 px-1.5 text-[10px] font-bold tabular-nums text-foreground ring-1 ring-border backdrop-blur-[2px] transition-opacity group-hover/tile:opacity-0 sm:grid"
:class="badgeInset.bottomRight"
>
{{ bookCountLabel }}
</span>
<DropdownMenu v-if="!selectionMode" v-model:open="menuOpen">
<DropdownMenuTrigger as-child>
<button
type="button"
class="absolute z-30 size-[22px] place-items-center rounded-md bg-black/45 text-white backdrop-blur-[2px] transition-colors hover:bg-black/65 focus-visible:grid group-focus-within/tile:grid group-hover/tile:grid"
:class="[badgeInset.topRight, menuOpen ? 'grid' : 'hidden']"
:aria-label="t('author.index.actionsFor', { name: author.name })"
@click.stop
>
<MoreHorizontal :size="12" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="emit('open', author.id)">
<ExternalLink class="mr-2 h-4 w-4" />
{{ t('author.card.viewDetails') }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canRefresh || refreshing" @click="handleRefresh">
<Loader2 v-if="refreshing" class="mr-2 h-4 w-4 animate-spin" />
<RefreshCw v-else class="mr-2 h-4 w-4" />
{{ t('author.card.refreshMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!canDelete || deleting"
:class="canDelete ? 'text-destructive focus:text-destructive' : ''"
@click="handleDelete"
>
<Loader2 v-if="deleting" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ t('author.card.deleteAuthor') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<!-- mt-auto pins the names to the bottom of the grid row. A 1fr track can be a
fractional width, so aspect-square rounds two tiles in a row a pixel shorter
than the rest; letting the gap absorb that keeps the text baselines aligned. -->
<p
class="mt-auto line-clamp-2 min-h-[2.6em] text-[11.5px] font-semibold leading-[1.3] tracking-[-0.005em] text-foreground [overflow-wrap:anywhere]"
:class="shape === 'circle' ? 'text-center' : ''"
>
{{ author.name }}
</p>
</div>
</template>
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { AuthorSummary } from '@bookorbit/types'
import AuthorTile from '../AuthorTile.vue'
function author(overrides: Partial<AuthorSummary> = {}): AuthorSummary {
return { id: 1, name: 'Blake Crouch', sortName: null, bookCount: 3, lastAddedAt: null, coverBookId: null, ...overrides }
}
function mountTile(props: Record<string, unknown> = {}) {
return mount(AuthorTile, {
props: { author: author(), ...props },
global: {
mocks: { $t: (key: string) => key },
stubs: {
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' },
AuthorPortrait: { template: '<span class="portrait-stub" />' },
},
},
})
}
describe('AuthorTile', () => {
it('stretches the activate button over the whole tile, not just the artwork', () => {
const wrapper = mountTile()
const root = wrapper.element as HTMLElement
const activate = wrapper.get('button.inset-0')
// Sizing the target to the artwork leaves every corner of a circular tile dead
// and never lets anyone click the name.
expect(activate.element.parentElement).toBe(root)
expect(activate.classes()).toContain('inset-0')
})
it('keeps the actions menu out of the clipped artwork so the hover transform cannot bury it', () => {
const wrapper = mountTile()
const root = wrapper.element as HTMLElement
const menuButton = wrapper.get('button[aria-label^="Actions for"]')
// The artwork lifts on hover, and a transform makes that element a stacking
// context; a menu nested inside it renders beneath the tile's activate button.
const clipped = root.querySelector('.overflow-hidden')
expect(clipped).not.toBeNull()
expect(clipped!.contains(menuButton.element)).toBe(false)
})
it('keeps the badges out of the clipped artwork, which a circle would crop away', () => {
const wrapper = mountTile({ selectionMode: true, selected: true, shape: 'circle' })
const clipped = wrapper.element.querySelector('.overflow-hidden')!
const checkbox = [...wrapper.element.querySelectorAll('span')].find((s) => s.querySelector('svg') && /rounded-md/.test(s.className))
expect(checkbox).toBeDefined()
expect(clipped.contains(checkbox!)).toBe(false)
})
it('anchors the badges to the artwork box, not to the tile below the name', () => {
const wrapper = mountTile({ shape: 'circle' })
const frame = wrapper.element.querySelector('.aspect-square')!
const badge = [...frame.querySelectorAll('span')].find((s) => /tabular-nums/.test(s.className))
// Hanging them on the tile measures from under the name, which parks the count
// badge beside the author's name instead of on their portrait.
expect(badge).toBeDefined()
expect(frame.contains(badge!)).toBe(true)
})
it('never lets anything stacked above the activate button swallow a click', () => {
const wrapper = mountTile({ selectionMode: true, selected: true })
const above = [...wrapper.element.querySelectorAll('.absolute')].filter((el) => {
if (el.tagName === 'BUTTON' || el.querySelector('button')) return false
return /z-(2|3|4)0/.test(el.className)
})
expect(above.length).toBeGreaterThan(0)
for (const el of above) expect(el.className).toContain('pointer-events-none')
})
it('shows a busy overlay while refreshing without needing a hover', () => {
const wrapper = mountTile({ refreshing: true })
const status = wrapper.get('[role="status"]')
expect(status.find('.animate-spin').exists()).toBe(true)
// A spinner tucked inside the hover-revealed menu button is invisible in practice.
expect(status.element.className).not.toContain('group-hover')
})
it('emits select rather than open while in selection mode', async () => {
const wrapper = mountTile({ selectionMode: true })
await wrapper.get('button.inset-0').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('open')).toBeUndefined()
})
it('opens the author on a plain click', async () => {
const wrapper = mountTile()
await wrapper.get('button.inset-0').trigger('click')
expect(wrapper.emitted('open')?.[0]).toEqual([1])
})
})
@@ -0,0 +1,142 @@
import { computed, nextTick, ref, watch, type Ref } from 'vue'
import type { JumpBucket } from '@bookorbit/types'
import { useJumpRailGutter } from '@/features/book/composables/useJumpRailGutter'
import { fetchAuthorJumpBuckets, type AuthorFilterParams } from '../api/author'
import { isLetterSort } from '../lib/author-identity'
import type { AuthorListSort, SortDirection } from '../types/author'
const LETTER_TEMPLATE = ['#', ...Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index))]
/** Below this the list is short enough to scroll, and a rail is just clutter. */
const MIN_TOTAL_FOR_RAIL = 50
/**
* A-Z rail for the authors page, reusing the book views' `JumpRail` presentation and
* gutter. Buckets come from the server under the same filters as the list, so the rail
* spans the whole result set rather than the pages fetched so far; jumping loads
* forward to the target index before scrolling to it.
*/
export function useAuthorJumpRail(options: {
enabled: Ref<boolean>
sort: Ref<AuthorListSort>
order: Ref<SortDirection>
total: Ref<number>
filterParams: () => AuthorFilterParams
loadThrough: (index: number) => Promise<boolean>
scroller: Ref<HTMLElement | null>
}) {
const buckets = ref<JumpBucket[]>([])
const activeKey = ref<string | null>(null)
const jumping = ref(false)
const supported = computed(() => isLetterSort(options.sort.value))
const visible = computed(() => options.enabled.value && supported.value && options.total.value >= MIN_TOTAL_FOR_RAIL)
const template = computed(() => (options.order.value === 'desc' ? [...LETTER_TEMPLATE].reverse() : LETTER_TEMPLATE))
const { gutterReserved, releaseGutter } = useJumpRailGutter(visible)
// Keyed so a filter, sort or search change refetches exactly once.
const queryKey = computed(() => {
if (!visible.value) return ''
return JSON.stringify([options.sort.value, options.order.value, options.filterParams()])
})
let requestToken = 0
watch(
queryKey,
async (key) => {
if (!key) {
buckets.value = []
activeKey.value = null
return
}
const token = ++requestToken
try {
const sort = options.sort.value === 'sortName' ? 'sortName' : 'name'
const response = await fetchAuthorJumpBuckets({ ...options.filterParams(), sort, order: options.order.value })
if (token !== requestToken) return
buckets.value = response.buckets
syncActiveKey()
} catch {
if (token !== requestToken) return
// A rail that failed to load is simply absent; the list is unaffected.
buckets.value = []
}
},
{ immediate: true },
)
/**
* The heading currently under the top edge of the scroller. Read from the rendered
* headings rather than from scroll maths, so it stays right whatever the section
* heights are in either view.
*/
function syncActiveKey() {
const root = options.scroller.value
if (!root || buckets.value.length === 0) return
const top = root.getBoundingClientRect().top
const headings = root.querySelectorAll<HTMLElement>('[data-letter]')
let current: string | null = headings.length > 0 ? (headings[0]?.dataset.letter ?? null) : null
for (const heading of headings) {
if (heading.getBoundingClientRect().top - top <= 8) current = heading.dataset.letter ?? current
else break
}
activeKey.value = current
}
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
function offsetOf(root: HTMLElement, key: string): number | null {
const heading = root.querySelector<HTMLElement>(`[data-letter="${CSS.escape(key)}"]`)
if (!heading) return null
return heading.getBoundingClientRect().top - root.getBoundingClientRect().top
}
/**
* One scroll is not enough in an infinite list. Pages that land after the jump extend
* the section they belong to, and because the index lays rows out in CSS columns a
* part-filled section grows by a whole row when it re-balances - measured at 376px of
* drift, which put the reader two letters short of where they asked for. So the
* position is re-asserted until it holds still, and abandoned the moment the reader
* scrolls themselves rather than fighting them for the viewport.
*/
async function settleOnLetter(root: HTMLElement, key: string) {
const deadline = Date.now() + 3000
let expected = root.scrollTop
let stable = 0
while (Date.now() < deadline && stable < 3) {
if (Math.abs(root.scrollTop - expected) > 2) return
const offset = offsetOf(root, key)
if (offset === null) return
if (Math.abs(offset) < 1) {
stable += 1
} else {
stable = 0
root.scrollTop += offset
expected = root.scrollTop
}
await nextFrame()
}
}
async function handleJump(bucket: JumpBucket) {
if (jumping.value) return
jumping.value = true
try {
await options.loadThrough(bucket.index)
await nextTick()
const root = options.scroller.value
if (!root || offsetOf(root, bucket.key) === null) return
activeKey.value = bucket.key
await settleOnLetter(root, bucket.key)
} finally {
jumping.value = false
}
}
return { buckets, visible, template, activeKey, gutterReserved, releaseGutter, syncActiveKey, handleJump }
}
@@ -17,11 +17,24 @@ export function useAuthorsList() {
const order = ref<SortDirection>('asc')
const libraryId = ref<number | null>(null)
const hasPhoto = ref<boolean | null>(null)
const hasSortName = ref<boolean | null>(null)
const addedWithinDays = ref<number | null>(null)
const minBookCount = ref<number | null>(null)
const page = ref(0)
const hasMore = computed(() => items.value.length < total.value)
function filterParams() {
return {
q: q.value.trim() || undefined,
libraryId: libraryId.value,
hasPhoto: hasPhoto.value,
hasSortName: hasSortName.value,
addedWithinDays: addedWithinDays.value,
minBookCount: minBookCount.value,
}
}
async function load(reset = false): Promise<void> {
if (loading.value) return
if (!reset && !hasMore.value) return
@@ -36,14 +49,11 @@ export function useAuthorsList() {
try {
const data = await fetchAuthors({
q: q.value.trim() || undefined,
...filterParams(),
page: page.value,
size: PAGE_SIZE,
sort: sort.value,
order: order.value,
libraryId: libraryId.value,
hasPhoto: hasPhoto.value,
minBookCount: minBookCount.value,
})
items.value = reset ? data.items : [...items.value, ...data.items]
@@ -56,6 +66,22 @@ export function useAuthorsList() {
}
}
/**
* Loads forward until `index` is materialised, so a jump-rail target that sits
* beyond the pages fetched so far can still be scrolled to. Bounded by `total`
* and by any page that comes back short, so it cannot spin on a stale count.
*/
async function loadThrough(index: number): Promise<boolean> {
let guard = 0
while (items.value.length <= index && hasMore.value && guard < 200) {
const before = items.value.length
await load()
if (items.value.length === before) return false
guard += 1
}
return items.value.length > index
}
return {
items,
total,
@@ -67,7 +93,11 @@ export function useAuthorsList() {
order,
libraryId,
hasPhoto,
hasSortName,
addedWithinDays,
minBookCount,
filterParams,
load,
loadThrough,
}
}
@@ -0,0 +1,92 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { authorInitials, authorLetterKey, hasInformativeSortName, isLetterSort } from '../author-identity'
describe('authorInitials', () => {
it('takes the first and last name', () => {
expect(authorInitials('Blake Crouch')).toBe('BC')
})
it('reads an inverted name the right way round', () => {
expect(authorInitials('Aurelius, Marcus')).toBe('MA')
expect(authorInitials('Marcus Aurelius')).toBe('MA')
})
it('ignores a parenthesised alias', () => {
expect(authorInitials('Abbot George Burke (Swami Nirmalananda Giri)')).toBe('AB')
})
it('handles initials-only and single-word names', () => {
expect(authorInitials('J.K. Rowling')).toBe('JR')
expect(authorInitials('qntm')).toBe('Q')
expect(authorInitials('SLMN')).toBe('S')
})
it('keeps diacritics out of the way', () => {
expect(authorInitials('Stjepan Šejić')).toBe('SŠ')
expect(authorInitials('Sjón')).toBe('S')
})
it('never returns an empty monogram', () => {
expect(authorInitials('')).toBe('?')
expect(authorInitials(' ')).toBe('?')
expect(authorInitials('!!!')).toBe('?')
})
})
describe('authorLetterKey', () => {
const author = (name: string, sortName: string | null = null) => ({ name, sortName })
it('buckets by display name when sorting by name', () => {
expect(authorLetterKey(author('Ben Aaronovitch', 'Aaronovitch, Ben'), 'name')).toBe('B')
})
it('buckets by sort name when sorting by sort name', () => {
expect(authorLetterKey(author('Ben Aaronovitch', 'Aaronovitch, Ben'), 'sortName')).toBe('A')
})
it('falls back to the display name when the sort name is blank', () => {
expect(authorLetterKey(author('Ben Aaronovitch', ' '), 'sortName')).toBe('B')
expect(authorLetterKey(author('Ben Aaronovitch', null), 'sortName')).toBe('B')
})
it('folds accents so a name lands in the bucket the rail shows', () => {
expect(authorLetterKey(author('Élise Fontenaille'), 'name')).toBe('E')
expect(authorLetterKey(author('Šejić, Stjepan'), 'name')).toBe('S')
})
it('buckets anything non-alphabetic under #', () => {
expect(authorLetterKey(author('47 Ronin'), 'name')).toBe('#')
expect(authorLetterKey(author('村上春樹'), 'name')).toBe('#')
expect(authorLetterKey(author(''), 'name')).toBe('#')
})
})
describe('hasInformativeSortName', () => {
it('hides a sort name that is only the display name reordered', () => {
expect(hasInformativeSortName({ name: 'Ben Aaronovitch', sortName: 'Aaronovitch, Ben' })).toBe(false)
expect(hasInformativeSortName({ name: 'James Islington', sortName: 'ISLINGTON, JAMES' })).toBe(false)
expect(hasInformativeSortName({ name: 'Welch, J', sortName: 'J, Welch,' })).toBe(false)
})
it('shows a sort name that carries something new', () => {
expect(hasInformativeSortName({ name: 'Douglas Corleone', sortName: 'Unknown' })).toBe(true)
expect(hasInformativeSortName({ name: 'Dr. Seuss', sortName: 'Seuss' })).toBe(true)
expect(hasInformativeSortName({ name: 'Ellen Fein', sortName: 'Fein, Ellen & Schneider, Sherrie' })).toBe(true)
})
it('treats a missing or blank sort name as nothing to show', () => {
expect(hasInformativeSortName({ name: 'Blake Crouch', sortName: null })).toBe(false)
expect(hasInformativeSortName({ name: 'Blake Crouch', sortName: ' ' })).toBe(false)
})
})
describe('isLetterSort', () => {
it('is true only for the two alphabetical sorts', () => {
expect(isLetterSort('name')).toBe(true)
expect(isLetterSort('sortName')).toBe(true)
expect(isLetterSort('bookCount')).toBe(false)
expect(isLetterSort('lastAddedAt')).toBe(false)
expect(isLetterSort('lastEnrichedAt')).toBe(false)
})
})
@@ -0,0 +1,73 @@
import type { AuthorSummary } from '@bookorbit/types'
import type { AuthorListSort } from '../types/author'
/**
* Initials for the monogram an author falls back to when there is no portrait and
* none of their books carries cover art. Parenthesised suffixes and punctuation are
* dropped, and an inverted "Surname, Forename" is read back the right way round so
* "Aurelius, Marcus" and "Marcus Aurelius" produce the same pair.
*/
export function authorInitials(name: string): string {
const withoutAlias = name.replace(/\(.*?\)/g, ' ')
// The comma decides the reading order, so the swap has to happen before
// punctuation is stripped - otherwise "Aurelius, Marcus" reads as AM, not MA.
const strip = (value: string) => value.replace(/[^\p{L}\p{N}\s.'-]/gu, ' ').trim()
const ordered = strip(withoutAlias.includes(',') ? withoutAlias.split(',').reverse().join(' ') : withoutAlias)
if (!ordered) return '?'
const letters = ordered
.split(/\s+/)
.filter(Boolean)
.map((part) => part.match(/\p{L}/u)?.[0] ?? '')
.filter(Boolean)
if (letters.length === 0) return '?'
const first = letters[0] ?? ''
const last = letters.length > 1 ? (letters[letters.length - 1] ?? '') : ''
return (first + last).toUpperCase()
}
/**
* The A-Z bucket an author belongs to, mirroring the server's bucket expression in
* `AuthorsRepository.letterBucketExpr`. Both sides fold accents and bucket anything
* non-alphabetic to "#", so a section heading always agrees with the jump rail.
*/
export function authorLetterKey(author: Pick<AuthorSummary, 'name' | 'sortName'>, sort: AuthorListSort): string {
const key = sort === 'sortName' ? author.sortName?.trim() || author.name : author.name
const initial = key
.trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.charAt(0)
.toUpperCase()
return initial >= 'A' && initial <= 'Z' ? initial : '#'
}
/** Only the alphabetical sorts have letter buckets; the rest have no rail. */
export function isLetterSort(sort: AuthorListSort): sort is 'name' | 'sortName' {
return sort === 'name' || sort === 'sortName'
}
/**
* A sort name earns its own line only when it says something the display name does
* not. In a real library the overwhelming majority are the display name reordered
* ("Ben Aaronovitch" / "Aaronovitch, Ben"), and printing those beside the name is
* noise on every row. Comparing letter multisets catches every reordering, while
* still surfacing the ones that carry new information ("Unknown", a co-author).
*/
export function hasInformativeSortName(author: Pick<AuthorSummary, 'name' | 'sortName'>): boolean {
const sortName = author.sortName?.trim()
if (!sortName) return false
return letterSignature(sortName) !== letterSignature(author.name)
}
function letterSignature(value: string): string {
return value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z]/g, '')
.split('')
.sort()
.join('')
}
@@ -9,4 +9,12 @@ export type LibraryFilterOption = {
name: string
}
/** The quick filters offered as chips. Mutually exclusive; the library filter is separate. */
export type AuthorQuickFilter = 'all' | 'noPortrait' | 'multipleBooks' | 'recentlyAdded' | 'noSortName'
export type AuthorQuickFilterCounts = Record<AuthorQuickFilter, number>
/** Window used by the "recently added" chip, shared by the client and the query it sends. */
export const RECENTLY_ADDED_DAYS = 7
export type { AuthorSummary, AuthorDetail }
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { flushPromises, shallowMount } from '@vue/test-utils'
import type { AuthorSummary } from '@bookorbit/types'
import type { AuthorListSort, SortDirection } from '../types/author'
import AuthorsView from './AuthorsView.vue'
import AuthorFilters from '../components/AuthorFilters.vue'
import AuthorFilterChips from '../components/AuthorFilterChips.vue'
class MockIntersectionObserver {
observe = vi.fn<(target: Element) => void>()
@@ -12,18 +13,27 @@ class MockIntersectionObserver {
takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => [])
}
function author(id: number, name: string, extra: Partial<AuthorSummary> = {}): AuthorSummary {
return { id, name, sortName: null, bookCount: 1, lastAddedAt: null, coverBookId: null, ...extra }
}
const mocks = vi.hoisted(() => ({
route: { params: {} as Record<string, string>, query: {} as Record<string, unknown>, fullPath: '/authors' },
routerPush: vi.fn<(to: unknown) => Promise<void>>(),
routerReplace: vi.fn<(to: unknown) => Promise<void>>(),
fetchLibraries: vi.fn<() => Promise<void>>(),
load: vi.fn<(reset?: boolean) => Promise<void>>(),
loadThrough: vi.fn<(index: number) => Promise<boolean>>(),
items: null as unknown as { value: AuthorSummary[] },
q: null as unknown as { value: string },
sort: null as unknown as { value: AuthorListSort },
order: null as unknown as { value: SortDirection },
libraryId: null as unknown as { value: number | null },
hasPhoto: null as unknown as { value: boolean | null },
hasSortName: null as unknown as { value: boolean | null },
addedWithinDays: null as unknown as { value: number | null },
minBookCount: null as unknown as { value: number | null },
viewMode: null as unknown as { value: string },
}))
vi.mock('vue-router', () => ({
@@ -38,9 +48,12 @@ vi.mock('vue-sonner', () => ({
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({
gridGap: ref(16),
viewMode: ref('grid'),
authorCoverSize: ref(160),
viewMode: mocks.viewMode,
authorCoverSize: ref(120),
authorCoverShape: ref('circle'),
authorRowDensity: ref('comfortable'),
authorCoverFallback: ref(false),
showJumpRails: ref(true),
}),
}))
@@ -52,9 +65,22 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true, isDemoRestrictedAccount: ref(false), isSuperuser: ref(false) }),
}))
vi.mock('../composables/useAuthorJumpRail', () => ({
useAuthorJumpRail: () => ({
buckets: ref([]),
visible: ref(false),
template: ref([]),
activeKey: ref(null),
gutterReserved: ref(false),
releaseGutter: vi.fn<() => void>(),
syncActiveKey: vi.fn<() => void>(),
handleJump: vi.fn<() => Promise<void>>(),
}),
}))
vi.mock('../composables/useAuthorsList', () => ({
useAuthorsList: () => ({
items: ref([]),
items: mocks.items,
total: ref(0),
loading: ref(false),
error: ref(null),
@@ -64,8 +90,12 @@ vi.mock('../composables/useAuthorsList', () => ({
order: mocks.order,
libraryId: mocks.libraryId,
hasPhoto: mocks.hasPhoto,
hasSortName: mocks.hasSortName,
addedWithinDays: mocks.addedWithinDays,
minBookCount: mocks.minBookCount,
filterParams: () => ({}),
load: mocks.load,
loadThrough: mocks.loadThrough,
}),
}))
@@ -97,7 +127,7 @@ async function mountView() {
return wrapper
}
describe('AuthorsView filters panel placement', () => {
describe('AuthorsView', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
mocks.route.query = {}
@@ -105,12 +135,17 @@ describe('AuthorsView filters panel placement', () => {
mocks.routerReplace.mockResolvedValue()
mocks.fetchLibraries.mockResolvedValue()
mocks.load.mockResolvedValue()
mocks.loadThrough.mockResolvedValue(true)
mocks.items = ref([])
mocks.q = ref('')
mocks.sort = ref('name')
mocks.order = ref('asc')
mocks.libraryId = ref(null)
mocks.hasPhoto = ref(null)
mocks.hasSortName = ref(null)
mocks.addedWithinDays = ref(null)
mocks.minBookCount = ref(null)
mocks.viewMode = ref('list')
})
afterEach(() => {
@@ -118,23 +153,53 @@ describe('AuthorsView filters panel placement', () => {
vi.clearAllMocks()
})
it('renders the desktop filters panel outside the scrolling <main> so it stays anchored when scrolled', async () => {
mocks.route.query = { libraryId: '5' }
it('keeps the filter chips outside the scrolling <main> so they stay anchored', async () => {
const wrapper = await mountView()
const main = wrapper.find('main')
expect(main.exists()).toBe(true)
const filters = wrapper.findComponent(AuthorFilters)
expect(filters.exists()).toBe(true)
// Regression guard for #326: the panel must NOT live inside the scroll container.
expect(filters.element.closest('main')).toBeNull()
expect(wrapper.find('main').exists()).toBe(true)
const chips = wrapper.findComponent(AuthorFilterChips)
expect(chips.exists()).toBe(true)
// Regression guard for #326: filters must NOT live inside the scroll container.
expect(chips.element.closest('main')).toBeNull()
})
it('does not render the desktop filters panel when no filter is active', async () => {
mocks.route.query = {}
it('hydrates a quick filter from the route into the matching list filters', async () => {
mocks.route.query = { filter: 'noPortrait' }
await mountView()
expect(mocks.hasPhoto.value).toBe(false)
expect(mocks.minBookCount.value).toBeNull()
expect(mocks.addedWithinDays.value).toBeNull()
expect(mocks.hasSortName.value).toBeNull()
})
it('treats the quick filters as mutually exclusive', async () => {
mocks.route.query = { filter: 'multipleBooks' }
const wrapper = await mountView()
expect(wrapper.findComponent(AuthorFilters).exists()).toBe(false)
expect(mocks.minBookCount.value).toBe(2)
wrapper.findComponent(AuthorFilterChips).vm.$emit('update:quickFilter', 'recentlyAdded')
await flushPromises()
expect(mocks.minBookCount.value).toBeNull()
expect(mocks.addedWithinDays.value).toBe(7)
})
it('groups authors into one section per letter when sorted alphabetically', async () => {
mocks.items = ref([author(1, 'Alan Glynn'), author(2, 'Amy Tan'), author(3, 'Blake Crouch')])
const wrapper = await mountView()
const headings = wrapper.findAll('[data-letter]')
expect(headings.map((heading) => heading.attributes('data-letter'))).toEqual(['A', 'B'])
})
it('drops the letter sections when the sort is not alphabetical', async () => {
mocks.items = ref([author(1, 'Alan Glynn'), author(2, 'Blake Crouch')])
// The view hydrates sort from the route on mount, so drive it from there.
mocks.route.query = { sort: 'bookCount' }
const wrapper = await mountView()
expect(wrapper.findAll('[data-letter]')).toHaveLength(0)
})
})
+332 -370
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, CheckCheck, Filter, RefreshCcw, Search, SlidersHorizontal, Trash2, X } from '@lucide/vue'
import { ArrowUpDown, CheckCheck, RefreshCcw, Trash2, X } from '@lucide/vue'
import { toast } from 'vue-sonner'
import SelectionActionBar from '@/components/SelectionActionBar.vue'
@@ -14,15 +14,18 @@ import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
import { useLibraries } from '@/features/library/composables/useLibraries'
import JumpRail from '@/features/book/components/JumpRail.vue'
import { bulkRefreshAuthorsMetadata, deleteAuthors, refreshAuthorMetadata } from '../api/author'
import AuthorCard from '../components/AuthorCard.vue'
import AuthorConfirmDialog from '../components/AuthorConfirmDialog.vue'
import AuthorFilters from '../components/AuthorFilters.vue'
import AuthorListRow from '../components/AuthorListRow.vue'
import AuthorFilterChips from '../components/AuthorFilterChips.vue'
import AuthorIndexRow from '../components/AuthorIndexRow.vue'
import AuthorTile from '../components/AuthorTile.vue'
import { useAuthorJumpRail } from '../composables/useAuthorJumpRail'
import { useAuthorSelection } from '../composables/useAuthorSelection'
import { useAuthorsList } from '../composables/useAuthorsList'
import { useRefreshingAuthors } from '../composables/useRefreshingAuthors'
import type { AuthorListSort, SortDirection } from '../types/author'
import { authorLetterKey, isLetterSort } from '../lib/author-identity'
import { RECENTLY_ADDED_DAYS, type AuthorListSort, type AuthorQuickFilter, type AuthorSummary, type SortDirection } from '../types/author'
import type { BookViewMode } from '@/composables/useDisplaySettings'
const { t } = useI18n()
@@ -30,23 +33,21 @@ const router = useRouter()
const route = useRoute()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings()
const { gridGap, viewMode, authorCoverSize, authorCoverShape, authorRowDensity, authorCoverFallback, showJumpRails } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { hasPermission, isDemoRestrictedAccount, isSuperuser } = usePermissions()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, minBookCount, load } = useAuthorsList()
const list = useAuthorsList()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, hasSortName, addedWithinDays, minBookCount, load } = list
const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors()
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleAuthor, rangeSelectTo, selectAll, isSelected } =
useAuthorSelection()
const hydrating = ref(true)
const suppressAutoReload = ref(false)
const filtersOpen = ref(false)
const mobileControlsExpanded = ref(false)
const initialLoadComplete = ref(false)
const INITIAL_SKELETON_COUNT = 18
const INITIAL_SKELETON_COUNT = 24
const sentinel = ref<HTMLElement | null>(null)
const mobileSearchInput = ref<HTMLInputElement | null>(null)
let observer: IntersectionObserver | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -60,12 +61,15 @@ const deleteDialogOpen = ref(false)
const canRefreshMetadata = computed(() => hasPermission('library_edit_metadata') && !isDemoRestrictedAccount.value)
const canDeleteAuthors = computed(() => isSuperuser.value)
/** The page offers exactly the two views the design has; `table` is not one of them. */
const authorViewMode = computed<BookViewMode>({
get: () => (viewMode.value === 'table' ? 'grid' : viewMode.value),
set: (next) => {
viewMode.value = next === 'table' ? 'grid' : next
},
})
const isGallery = computed(() => authorViewMode.value === 'grid')
const sortLabels = computed<Record<AuthorListSort, string>>(() => ({
name: t('author.list.sort.name'),
@@ -76,18 +80,90 @@ const sortLabels = computed<Record<AuthorListSort, string>>(() => ({
}))
const isDefaultSort = computed(() => sort.value === 'name' && order.value === 'asc')
const sortSummary = computed(() => `${sortLabels.value[sort.value]} ${order.value === 'asc' ? '↑' : '↓'}`)
/* ── quick filters ──────────────────────────────────────────────────────────
Chips are mutually exclusive, so the filter refs are always derived from one
value rather than drifting apart. The library filter is orthogonal to them. */
const quickFilter = computed<AuthorQuickFilter>(() => {
if (hasPhoto.value === false) return 'noPortrait'
if (minBookCount.value !== null && minBookCount.value >= 2) return 'multipleBooks'
if (addedWithinDays.value !== null) return 'recentlyAdded'
if (hasSortName.value === false) return 'noSortName'
return 'all'
})
function applyQuickFilter(next: AuthorQuickFilter) {
hasPhoto.value = next === 'noPortrait' ? false : null
minBookCount.value = next === 'multipleBooks' ? 2 : null
addedWithinDays.value = next === 'recentlyAdded' ? RECENTLY_ADDED_DAYS : null
hasSortName.value = next === 'noSortName' ? false : null
}
const activeFilterCount = computed(() => {
let count = 0
if (q.value.trim()) count += 1
if (libraryId.value !== null) count += 1
if (hasPhoto.value !== null) count += 1
if (minBookCount.value !== null) count += 1
if (quickFilter.value !== 'all') count += 1
return count
})
const mobileControlsBadgeCount = computed(() => activeFilterCount.value + (!isDefaultSort.value ? 1 : 0))
const libraryOptions = computed(() => libraries.value.map((library) => ({ id: library.id, name: library.name })))
/**
* A book cover under a person's name reads as a photograph of that person, so this
* is off unless asked for. Even when it is on it stays off while filtering for
* authors with no portrait, where filling every tile hides the very gap being
* looked for.
*/
const coverFallback = computed(() => authorCoverFallback.value && quickFilter.value !== 'noPortrait')
/* ── sectioning ─────────────────────────────────────────────────────────────
Only the two alphabetical sorts have letter sections, and each one is its own
<section> so the sticky heading is bounded by it. Sharing a containing block
stacks every heading at the top of the scroller instead of replacing it. */
const sectioned = computed(() => isLetterSort(sort.value))
const sections = computed(() => {
if (!sectioned.value) return [{ letter: '', authors: items.value }]
const groups: { letter: string; authors: AuthorSummary[] }[] = []
for (const author of items.value) {
const letter = authorLetterKey(author, sort.value)
const current = groups[groups.length - 1]
if (current && current.letter === letter) current.authors.push(author)
else groups.push({ letter, authors: [author] })
}
return groups
})
const maxBookCount = computed(() => items.value.reduce((max, author) => Math.max(max, author.bookCount), 1))
/**
* The stored tile size and gap govern wherever they fit, but both are capped against
* the page's own width so a phone never drops to two enormous tiles with a 28px
* trench between them. The cqw units resolve against the page container, which
* already excludes the rail gutter; above roughly 700px of content neither cap binds
* and the sliders are honoured exactly.
*/
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(auto-fill, minmax(min(${authorCoverSize.value}px, 30cqw), 1fr))`,
gap: `min(${gridGap.value}px, 4cqw)`,
}))
const showSkeleton = computed(() => !initialLoadComplete.value && loading.value)
const showEmpty = computed(() => initialLoadComplete.value && !loading.value && items.value.length === 0)
/* ── jump rail ────────────────────────────────────────────────────────────── */
const rail = useAuthorJumpRail({
enabled: showJumpRails,
sort,
order,
total,
filterParams: list.filterParams,
loadThrough: list.loadThrough,
scroller: mainRef,
})
const deleteDialogLoading = computed(() => {
if (bulkDeleting.value) return true
@@ -100,12 +176,9 @@ const deleteDialogTitle = computed(() => {
return t('author.list.deleteDialog.titleOne')
})
const deleteDialogDescription = computed(() => {
if (pendingDeleteIds.value.length > 1) {
return t('author.list.deleteDialog.descriptionMany')
}
return t('author.list.deleteDialog.descriptionOne')
})
const deleteDialogDescription = computed(() =>
pendingDeleteIds.value.length > 1 ? t('author.list.deleteDialog.descriptionMany') : t('author.list.deleteDialog.descriptionOne'),
)
function showRefreshResultToast(updated: { imageUrl?: string | null }) {
if (!updated.imageUrl) {
@@ -128,15 +201,8 @@ function parseLibraryId(value: unknown): number | null {
return Number.isInteger(raw) && raw > 0 ? raw : null
}
function parseHasPhoto(value: unknown): boolean | null {
if (value === 'true') return true
if (value === 'false') return false
return null
}
function parseMinBookCount(value: unknown): number | null {
const raw = typeof value === 'string' ? parseInt(value, 10) : NaN
return Number.isInteger(raw) && raw >= 1 ? raw : null
function parseQuickFilter(value: unknown): AuthorQuickFilter {
return value === 'noPortrait' || value === 'multipleBooks' || value === 'recentlyAdded' || value === 'noSortName' ? value : 'all'
}
function syncRouteQuery() {
@@ -147,8 +213,7 @@ function syncRouteQuery() {
sort: sort.value !== 'name' ? sort.value : undefined,
order: order.value !== 'asc' ? order.value : undefined,
libraryId: libraryId.value ? String(libraryId.value) : undefined,
hasPhoto: hasPhoto.value !== null ? String(hasPhoto.value) : undefined,
minBookCount: minBookCount.value !== null ? String(minBookCount.value) : undefined,
filter: quickFilter.value !== 'all' ? quickFilter.value : undefined,
},
})
}
@@ -171,42 +236,20 @@ function resetSort() {
order.value = 'asc'
}
function clearSearchQuery() {
q.value = ''
function handleSearchUpdate(value: string) {
q.value = value
}
function onMobileSortChange(event: Event) {
const value = parseSort((event.target as HTMLSelectElement).value)
setSortField(value)
function handleQuickFilterUpdate(value: AuthorQuickFilter) {
applyQuickFilter(value)
}
function toggleFiltersOpen() {
filtersOpen.value = !filtersOpen.value
}
function closeFiltersPanel() {
filtersOpen.value = false
}
function closeMobileControls() {
mobileControlsExpanded.value = false
}
function toggleMobileControls() {
if (mobileControlsExpanded.value) {
closeMobileControls()
return
}
mobileControlsExpanded.value = true
void nextTick(() => {
mobileSearchInput.value?.focus()
})
function handleLibraryUpdate(value: number | null) {
libraryId.value = value
}
function handleAuthorSelect(authorId: number, event: MouseEvent) {
if (!selectionMode.value) {
enterSelectionMode()
}
if (!selectionMode.value) enterSelectionMode()
if (event.shiftKey) {
rangeSelectTo(
@@ -236,12 +279,10 @@ async function clearFilters() {
sort.value = 'name'
order.value = 'asc'
libraryId.value = null
hasPhoto.value = null
minBookCount.value = null
applyQuickFilter('all')
syncRouteQuery()
await load(true)
filtersOpen.value = false
await nextTick()
suppressAutoReload.value = false
@@ -261,10 +302,7 @@ async function refreshSelectedAuthorsMetadata() {
const next = [...items.value]
const current = next[index]
if (!current) return
next[index] = {
...current,
imageUrl: event.imageUrl ?? null,
}
next[index] = { ...current, imageUrl: event.imageUrl ?? null }
items.value = next
}
}
@@ -325,6 +363,14 @@ function cancelDeleteAuthors() {
pendingDeleteIds.value = []
}
function startBulkDeleteConfirm() {
confirmingBulkDelete.value = true
}
function cancelBulkDeleteConfirm() {
confirmingBulkDelete.value = false
}
async function confirmDeleteAuthors() {
const ids = [...pendingDeleteIds.value]
if (!canDeleteAuthors.value || ids.length === 0) return
@@ -332,40 +378,31 @@ async function confirmDeleteAuthors() {
const singleAuthorId: number | null = ids.length === 1 ? (ids[0] ?? null) : null
deleteDialogOpen.value = false
if (singleAuthorId !== null) {
deletingAuthorId.value = singleAuthorId
} else {
bulkDeleting.value = true
}
if (singleAuthorId !== null) deletingAuthorId.value = singleAuthorId
else bulkDeleting.value = true
try {
const result = await deleteAuthors({ authorIds: ids })
await load(true)
if (ids.length > 1) exitSelectionMode()
toast.success(
t('author.list.toast.deleteSuccess', {
count: result.deletedAuthorIds.length,
books: result.affectedBookCount,
}),
)
toast.success(t('author.list.toast.deleteSuccess', { count: result.deletedAuthorIds.length, books: result.affectedBookCount }))
} catch (actionError) {
toast.error(actionError instanceof Error ? actionError.message : t('author.list.toast.deleteFailed'))
} finally {
if (singleAuthorId !== null) {
deletingAuthorId.value = null
} else {
bulkDeleting.value = false
}
if (singleAuthorId !== null) deletingAuthorId.value = null
else bulkDeleting.value = false
pendingDeleteIds.value = []
}
}
function loadIfSentinelVisible() {
if (loading.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
void load()
}
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) void load()
}
function handleScroll() {
rail.syncActiveKey()
}
onMounted(async () => {
@@ -373,27 +410,24 @@ onMounted(async () => {
sort.value = parseSort(route.query.sort)
order.value = parseOrder(route.query.order)
libraryId.value = parseLibraryId(route.query.libraryId)
hasPhoto.value = parseHasPhoto(route.query.hasPhoto)
minBookCount.value = parseMinBookCount(route.query.minBookCount)
applyQuickFilter(parseQuickFilter(route.query.filter))
await fetchLibraries()
hydrating.value = false
filtersOpen.value = activeFilterCount.value > 0
await load(true)
initialLoadComplete.value = true
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) {
void load()
}
if (entries[0]?.isIntersecting && !loading.value) void load()
},
{ rootMargin: '280px' },
)
await nextTick()
if (sentinel.value) observer.observe(sentinel.value)
rail.syncActiveKey()
})
onUnmounted(() => {
@@ -401,7 +435,7 @@ onUnmounted(() => {
if (searchTimer) clearTimeout(searchTimer)
})
watch([sort, order, libraryId, hasPhoto, minBookCount], () => {
watch([sort, order, libraryId, hasPhoto, hasSortName, addedWithinDays, minBookCount], () => {
if (hydrating.value || suppressAutoReload.value) return
if (selectionMode.value) exitSelectionMode()
syncRouteQuery()
@@ -421,7 +455,10 @@ watch(q, () => {
watch(
loading,
(isLoading) => {
if (!isLoading) loadIfSentinelVisible()
if (!isLoading) {
loadIfSentinelVisible()
rail.syncActiveKey()
}
},
{ flush: 'post' },
)
@@ -439,272 +476,204 @@ defineOptions({ name: 'AuthorsView' })
<template>
<div class="flex h-full flex-col">
<section class="flex flex-1 flex-col min-h-0">
<section class="flex min-h-0 flex-1 flex-col">
<ViewHeader
:title="t('author.list.title')"
icon="Users"
fallback-icon="Users"
:total="total"
searchable
:search-query="q"
:search-placeholder="t('author.list.searchPlaceholder')"
v-model:coverSize="authorCoverSize"
v-model:gridGap="gridGap"
v-model:viewMode="authorViewMode"
v-model:coverShape="authorCoverShape"
v-model:rowDensity="authorRowDensity"
v-model:coverFallback="authorCoverFallback"
show-cover-fallback-toggle
v-model:showJumpRails="showJumpRails"
show-jump-rail-toggle
:jump-rail-modes="['grid', 'list']"
:allowed-view-modes="['grid', 'list']"
:cover-size-min="72"
:cover-size-max="220"
:cover-size-step="4"
:selection-mode="selectionMode"
@update:search-query="handleSearchUpdate"
@toggle-selection="toggleSelectionMode"
>
<template #toolbar>
<div class="hidden lg:flex h-8 w-64 items-center rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="mr-1.5 shrink-0 text-muted-foreground" />
<input
v-model="q"
type="search"
:placeholder="t('author.list.searchPlaceholder')"
class="author-search-input h-full w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<button v-if="q.trim()" class="ml-1 text-muted-foreground transition-colors hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
</div>
<div class="hidden lg:block h-5 w-px shrink-0 bg-border" />
<div class="hidden sm:flex items-center gap-1">
<Popover>
<PopoverTrigger as-child>
<Popover>
<PopoverTrigger as-child>
<button
class="hidden h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:flex"
:class="
!isDefaultSort
? 'border-primary/55 bg-primary/10 text-primary'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden">{{ t('author.list.sortButton') }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-56 p-2">
<div class="mb-2 px-1 text-xs font-medium text-muted-foreground">{{ t('author.list.sortBy') }}</div>
<div class="flex flex-col gap-0.5">
<button
class="flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
v-for="field in ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] as const"
:key="field"
class="flex items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="sort === field ? 'font-medium text-foreground' : 'text-muted-foreground'"
@click="setSortField(field)"
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden">{{ t('author.list.sortButton') }}</span>
{{ sortLabels[field] }}
<span v-if="sort === field" class="text-xs text-primary">{{ order === 'asc' ? '↑' : '↓' }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-56 p-2">
<div class="mb-2 px-1 text-xs font-medium text-muted-foreground">{{ t('author.list.sortBy') }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="field in ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] as const"
:key="field"
class="flex items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="sort === field ? 'text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortField(field)"
>
{{ sortLabels[field] }}
<span v-if="sort === field" class="text-xs text-primary">{{ order === 'asc' ? '↑' : '↓' }}</span>
</button>
</div>
<div class="my-2 border-t border-border" />
<div class="flex gap-1">
<button
v-for="dir in ['asc', 'desc'] as const"
:key="dir"
class="flex-1 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="order === dir ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortOrder(dir)"
>
{{ dir === 'asc' ? t('author.list.ascending') : t('author.list.descending') }}
</button>
</div>
</PopoverContent>
</Popover>
<button
v-if="!isDefaultSort"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-destructive hover:bg-muted"
:aria-label="t('common.resetSortAria')"
@click="resetSort"
>
<X :size="13" />
</button>
</div>
<div class="hidden sm:block h-5 w-px shrink-0 bg-border" />
</div>
<div class="my-2 border-t border-border" />
<div class="flex gap-1">
<button
v-for="dir in ['asc', 'desc'] as const"
:key="dir"
class="flex-1 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="order === dir ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground'"
@click="setSortOrder(dir)"
>
{{ dir === 'asc' ? t('author.list.ascending') : t('author.list.descending') }}
</button>
</div>
</PopoverContent>
</Popover>
<button
@click="toggleFiltersOpen"
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
activeFilterCount > 0
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<Filter :size="13" />
<span>{{ t('author.list.filters') }}</span>
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
</button>
<button
v-if="activeFilterCount > 0 || !isDefaultSort"
@click="clearFilters"
class="hidden sm:flex h-8 items-center gap-1 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-destructive"
v-if="!isDefaultSort"
class="hidden size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-destructive md:flex"
:aria-label="t('common.resetSortAria')"
@click="resetSort"
>
<X :size="13" />
{{ t('author.list.clear') }}
</button>
<button
class="sm:hidden relative flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
mobileControlsExpanded
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
:aria-label="t('author.list.showControlsAria')"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
<span
v-if="mobileControlsBadgeCount > 0"
class="absolute -right-1 -top-1 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
>
{{ mobileControlsBadgeCount }}
</span>
</button>
</template>
</ViewHeader>
<section v-if="mobileControlsExpanded" class="mb-3 space-y-2 rounded-lg border border-border/70 bg-card/70 p-2 sm:hidden">
<div class="flex items-center gap-1 rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="shrink-0 text-muted-foreground" />
<input
ref="mobileSearchInput"
v-model="q"
type="search"
:placeholder="t('author.list.searchPlaceholder')"
class="mobile-search-input h-9 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<button v-if="q.trim()" class="text-muted-foreground transition-colors hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
<AuthorFilterChips
class="mb-2"
:quick-filter="quickFilter"
:libraries="libraryOptions"
:library-id="libraryId"
@update:quick-filter="handleQuickFilterUpdate"
@update:library-id="handleLibraryUpdate"
/>
<!-- The selection bar floats over the bottom of the scroller, so the list is
padded while it is up. Without this the last rows sit under the bar and
cannot be selected - in the one mode whose whole purpose is selecting. -->
<main
ref="mainRef"
class="@container/page min-h-0 flex-1 overflow-y-auto transition-[padding] duration-200"
:class="[rail.gutterReserved.value ? 'pr-10' : 'pr-2', selectionMode ? 'pb-24' : '']"
@scroll.passive="handleScroll"
>
<div v-if="error" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive" role="alert">
{{ error }}
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-0 flex-1">
<select
:value="sort"
class="h-8 w-full appearance-none rounded-md border border-input bg-background px-2.5 pr-8 text-sm text-foreground outline-none transition-colors focus:border-primary/60"
@change="onMobileSortChange"
>
<option v-for="field in ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] as const" :key="field" :value="field">
{{ sortLabels[field] }}
</option>
</select>
<ArrowUpDown :size="13" class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<!-- Skeleton only before the first page ever lands. A filter, a sort or a
search keystroke refetches constantly, and swapping rows for a skeleton
on each one is a flicker, not a loading state. -->
<div v-if="showSkeleton" class="pt-1" aria-hidden="true">
<div v-if="isGallery" class="grid" :style="gridStyle">
<div v-for="index in INITIAL_SKELETON_COUNT" :key="`author-skeleton-${index}`" class="flex flex-col gap-1.5">
<div
class="aspect-square w-full animate-pulse bg-muted/60"
:class="authorCoverShape === 'circle' ? 'rounded-full' : 'rounded-[10px]'"
/>
<div class="h-3 w-3/4 animate-pulse rounded bg-muted/50" />
</div>
</div>
<div v-else class="flex flex-col gap-1">
<div v-for="index in INITIAL_SKELETON_COUNT" :key="`author-skeleton-row-${index}`" class="flex items-center gap-3 px-2 py-1">
<div class="size-[30px] shrink-0 animate-pulse rounded-lg bg-muted/60" />
<div class="h-3 w-40 max-w-[45%] animate-pulse rounded bg-muted/50" />
</div>
</div>
</div>
<div v-else-if="showEmpty" class="flex flex-col items-center justify-center gap-2 py-24 text-center">
<p class="text-sm font-medium text-foreground">{{ t('author.list.empty.title') }}</p>
<p class="max-w-[34ch] text-xs text-muted-foreground">{{ t('author.list.empty.hint') }}</p>
<button
class="flex h-8 items-center rounded-md border px-2.5 text-sm transition-colors"
:class="
order === 'asc'
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
@click="setSortOrder(order === 'asc' ? 'desc' : 'asc')"
>
{{ order === 'asc' ? t('author.list.asc') : t('author.list.desc') }}
</button>
<button
v-if="activeFilterCount > 0 || !isDefaultSort"
class="h-8 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-destructive"
v-if="activeFilterCount > 0"
class="mt-1 h-8 rounded-lg border border-input px-3 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="clearFilters"
>
{{ t('author.list.clear') }}
</button>
</div>
<AuthorFilters
v-model:library-id="libraryId"
v-model:has-photo="hasPhoto"
v-model:min-book-count="minBookCount"
:active-count="activeFilterCount"
embedded
:libraries="libraries.map((library) => ({ id: library.id, name: library.name }))"
@clear="clearFilters"
/>
</section>
<template v-else>
<section v-for="section in sections" :key="section.letter || 'all'" class="mt-3 first:mt-0">
<h2
v-if="sectioned && section.letter"
:data-letter="section.letter"
class="sticky top-0 z-10 mb-1 flex h-7 items-center gap-2.5 bg-linear-to-b from-background from-65% to-transparent"
>
<span class="min-w-[0.9rem] font-serif text-[15px] font-semibold leading-none text-foreground">{{ section.letter }}</span>
<span class="h-px flex-1 bg-border" />
<span class="text-[10.5px] font-bold tabular-nums text-muted-foreground">{{ formatNumber(section.authors.length) }}</span>
</h2>
<!-- Desktop filters panel rendered outside <main> so it stays anchored when the list is scrolled -->
<div v-if="filtersOpen && !mobileControlsExpanded" class="pr-2">
<AuthorFilters
v-model:library-id="libraryId"
v-model:has-photo="hasPhoto"
v-model:min-book-count="minBookCount"
:active-count="activeFilterCount"
closable
:libraries="libraries.map((library) => ({ id: library.id, name: library.name }))"
@clear="clearFilters"
@close="closeFiltersPanel"
/>
</div>
<div v-if="isGallery" class="grid" :style="gridStyle">
<AuthorTile
v-for="author in section.authors"
:key="author.id"
:author="author"
:shape="authorCoverShape"
:cover-fallback="coverFallback"
:selection-mode="selectionMode"
:selected="isSelected(author.id)"
:can-refresh="canRefreshMetadata"
:can-delete="canDeleteAuthors"
:refreshing="isRefreshing(author.id)"
:deleting="deletingAuthorId === author.id"
@open="openAuthor"
@select="handleAuthorSelect(author.id, $event)"
@refresh="refreshSingleAuthorMetadata"
@delete="promptDeleteSingleAuthor"
/>
</div>
<main ref="mainRef" class="flex-1 min-h-0 overflow-y-auto pr-2">
<div v-if="error" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{{ error }}
</div>
<div
v-if="!initialLoadComplete && loading"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${authorCoverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<div
v-for="index in INITIAL_SKELETON_COUNT"
:key="`author-skeleton-${index}`"
class="w-full animate-pulse bg-muted/50"
:class="authorCoverShape === 'circle' ? 'rounded-full aspect-square' : 'rounded-sm aspect-[2/3]'"
/>
</div>
<div v-if="!loading && items.length === 0" class="flex flex-col items-center justify-center gap-2 py-24 text-center">
<p class="text-sm font-medium text-foreground">{{ t('author.list.empty.title') }}</p>
<p class="text-xs text-muted-foreground">{{ t('author.list.empty.hint') }}</p>
</div>
<div
v-show="authorViewMode === 'grid' && items.length > 0"
class="grid"
:style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${authorCoverSize}px, 1fr))`, gap: `${gridGap}px` }"
>
<AuthorCard
v-for="author in items"
:key="author.id"
:author="author"
:shape="authorCoverShape"
:selection-mode="selectionMode"
:selected="isSelected(author.id)"
:can-refresh="canRefreshMetadata"
:can-delete="canDeleteAuthors"
:refreshing="isRefreshing(author.id)"
:deleting="deletingAuthorId === author.id"
@open="openAuthor"
@select="handleAuthorSelect(author.id, $event)"
@refresh="refreshSingleAuthorMetadata"
@delete="promptDeleteSingleAuthor"
/>
</div>
<div v-show="authorViewMode === 'list' && items.length > 0" class="flex flex-col divide-y divide-border">
<AuthorListRow
v-for="author in items"
:key="author.id"
:author="author"
:selection-mode="selectionMode"
:selected="isSelected(author.id)"
:refreshing="isRefreshing(author.id)"
@open="openAuthor"
@select="handleAuthorSelect(author.id, $event)"
/>
</div>
<!-- Column-major: an index is read down a column, so multi-column rows use
CSS columns. A grid would run A, B, C, D across the first row. -->
<div v-else class="[column-gap:0.875rem] @[40rem]/page:columns-2 @[68rem]/page:columns-3 @[94rem]/page:columns-4">
<AuthorIndexRow
v-for="author in section.authors"
:key="author.id"
class="[break-inside:avoid]"
:author="author"
:density="authorRowDensity"
:cover-fallback="coverFallback"
:max-book-count="maxBookCount"
:selection-mode="selectionMode"
:selected="isSelected(author.id)"
:can-refresh="canRefreshMetadata"
:can-delete="canDeleteAuthors"
:refreshing="isRefreshing(author.id)"
:deleting="deletingAuthorId === author.id"
@open="openAuthor"
@select="handleAuthorSelect(author.id, $event)"
@refresh="refreshSingleAuthorMetadata"
@delete="promptDeleteSingleAuthor"
/>
</div>
</section>
</template>
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loading" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
<span v-if="loading && initialLoadComplete" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
<span v-else-if="initialLoadComplete && !hasMore && items.length > 0" class="text-xs text-muted-foreground">
{{ t('author.list.allLoaded', { total: formatNumber(total) }) }}
</span>
@@ -712,22 +681,35 @@ defineOptions({ name: 'AuthorsView' })
</main>
</section>
<JumpRail
:visible="rail.visible.value"
:buckets="rail.buckets.value"
kind="letter"
field="author"
:template="rail.template.value"
:active-key="rail.activeKey.value"
:viewport="mainRef"
@jump="rail.handleJump"
@after-leave="rail.releaseGutter"
/>
<SelectionActionBar :visible="selectionMode" :count="selectedCount" @exit="exitSelectionMode">
<template #content>
<template v-if="!confirmingBulkDelete">
<span class="px-2.5 py-0.5 text-sm font-semibold tabular-nums whitespace-nowrap rounded-full bg-primary/10 text-primary">{{
selectedCount
}}</span>
<span class="whitespace-nowrap rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-semibold tabular-nums text-primary">
{{ selectedCount }}
</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="mx-1 h-5 w-px shrink-0 bg-border" />
<Tooltip>
<TooltipTrigger as-child>
<button
:disabled="selectedCount === 0"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:aria-label="t('author.list.selection.selectVisible')"
class="flex size-9 items-center justify-center rounded-full transition-colors"
:class="
selectedCount > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'text-muted-foreground cursor-not-allowed'
selectedCount > 0 ? 'text-foreground hover:bg-primary hover:text-primary-foreground' : 'cursor-not-allowed text-muted-foreground'
"
@click="selectAllVisible"
>
@@ -741,44 +723,47 @@ defineOptions({ name: 'AuthorsView' })
<TooltipTrigger as-child>
<button
:disabled="selectedCount === 0 || bulkRefreshing"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:class="selectedCount > 0 ? 'text-foreground hover:bg-muted' : 'text-muted-foreground cursor-not-allowed'"
:aria-label="t('author.list.selection.refreshMetadata')"
class="flex size-9 items-center justify-center rounded-full transition-colors"
:class="selectedCount > 0 ? 'text-foreground hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
@click="refreshSelectedAuthorsMetadata"
>
<RefreshCcw :size="17" :class="bulkRefreshing ? 'animate-spin' : ''" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{
bulkRefreshing ? t('author.list.selection.refreshingMetadata') : t('author.list.selection.refreshMetadata')
}}</TooltipContent>
<TooltipContent side="top">
{{ bulkRefreshing ? t('author.list.selection.refreshingMetadata') : t('author.list.selection.refreshMetadata') }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="canDeleteAuthors">
<TooltipTrigger as-child>
<button
:disabled="selectedCount === 0 || bulkDeleting"
class="h-9 w-9 flex items-center justify-center rounded-full transition-colors"
:aria-label="t('author.list.selection.deleteSelected')"
class="flex size-9 items-center justify-center rounded-full transition-colors"
:class="
selectedCount > 0
? 'text-destructive hover:bg-destructive hover:text-destructive-foreground'
: 'text-muted-foreground cursor-not-allowed'
: 'cursor-not-allowed text-muted-foreground'
"
@click="confirmingBulkDelete = true"
@click="startBulkDeleteConfirm"
>
<Trash2 :size="17" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{
bulkDeleting ? t('author.list.selection.deleting') : t('author.list.selection.deleteSelected')
}}</TooltipContent>
<TooltipContent side="top">
{{ bulkDeleting ? t('author.list.selection.deleting') : t('author.list.selection.deleteSelected') }}
</TooltipContent>
</Tooltip>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="mx-1 h-5 w-px shrink-0 bg-border" />
<Tooltip>
<TooltipTrigger as-child>
<button
class="h-9 w-9 flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:aria-label="t('author.list.selection.exitSelection')"
class="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@click="exitSelectionMode"
>
<X :size="17" />
@@ -789,14 +774,14 @@ defineOptions({ name: 'AuthorsView' })
</template>
<template v-else>
<span class="px-3 text-sm font-semibold text-destructive whitespace-nowrap">{{
t('author.list.selection.confirmDeleteCount', { count: selectedCount })
}}</span>
<span class="whitespace-nowrap px-3 text-sm font-semibold text-destructive">
{{ t('author.list.selection.confirmDeleteCount', { count: selectedCount }) }}
</span>
<div class="w-px h-5 bg-border mx-1 shrink-0" />
<div class="mx-1 h-5 w-px shrink-0 bg-border" />
<button
class="h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
class="h-8 rounded-full px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive hover:text-destructive-foreground"
:disabled="bulkDeleting"
@click="confirmDeleteSelectedFromPopover"
>
@@ -804,9 +789,9 @@ defineOptions({ name: 'AuthorsView' })
</button>
<button
class="h-8 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
class="h-8 rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:disabled="bulkDeleting"
@click="confirmingBulkDelete = false"
@click="cancelBulkDeleteConfirm"
>
{{ t('common.cancel') }}
</button>
@@ -826,26 +811,3 @@ defineOptions({ name: 'AuthorsView' })
/>
</div>
</template>
<style scoped>
.author-search-input::-webkit-search-decoration,
.author-search-input::-webkit-search-cancel-button,
.author-search-input::-webkit-search-results-button,
.author-search-input::-webkit-search-results-decoration,
.mobile-search-input::-webkit-search-decoration,
.mobile-search-input::-webkit-search-cancel-button,
.mobile-search-input::-webkit-search-results-button,
.mobile-search-input::-webkit-search-results-decoration {
-webkit-appearance: none;
appearance: none;
}
.author-search-input::-ms-clear,
.author-search-input::-ms-reveal,
.mobile-search-input::-ms-clear,
.mobile-search-input::-ms-reveal {
display: none;
width: 0;
height: 0;
}
</style>
+1 -12
View File
@@ -2985,19 +2985,8 @@
"refreshMetadata": "Obnovit metadata",
"deleteAuthor": "Odstranit autora"
},
"listRow": {
"noRecentAdditions": "Žádné nedávné příspěvky",
"portraitAlt": "Portrét {name}"
},
"filters": {
"title": "Filtry autorů",
"clearAll": "Vymazat vše",
"allLibraries": "Všechny knihovny",
"allAuthors": "Všichni autoři",
"hasPhoto": "Má fotku",
"missingPhoto": "Chybějící fotka",
"minBooks": "Minerální knihy",
"anyPlaceholder": "Jakýkoliv"
"allLibraries": "Všechny knihovny"
},
"header": {
"never": "Nikdy",
+1 -12
View File
@@ -3002,19 +3002,8 @@
"refreshMetadata": "Opdater Metadata",
"deleteAuthor": "Slet Forfatter"
},
"listRow": {
"noRecentAdditions": "Ingen seneste tilføjelser",
"portraitAlt": "{name} portræt"
},
"filters": {
"title": "Forfatter Filtre",
"clearAll": "Ryd alle",
"allLibraries": "Alle Biblioteker",
"allAuthors": "Alle forfattere",
"hasPhoto": "Har foto",
"missingPhoto": "Manglende foto",
"minBooks": "Min. bøger",
"anyPlaceholder": "Alle"
"allLibraries": "Alle Biblioteker"
},
"header": {
"never": "Aldrig",
+1 -12
View File
@@ -2704,19 +2704,8 @@
"refreshMetadata": "Metadaten aktualisieren",
"deleteAuthor": "Autor löschen"
},
"listRow": {
"noRecentAdditions": "Keine kürzlichen Ergänzungen",
"portraitAlt": "Porträt von {name}"
},
"filters": {
"title": "Autorenfilter",
"clearAll": "Alle löschen",
"allLibraries": "Alle Bibliotheken",
"allAuthors": "Alle Autoren",
"hasPhoto": "Hat Foto",
"missingPhoto": "Foto fehlt",
"minBooks": "Min. Bücher",
"anyPlaceholder": "Beliebig"
"allLibraries": "Alle Bibliotheken"
},
"header": {
"never": "Nie",
+1 -11
View File
@@ -3096,18 +3096,8 @@
"refreshMetadata": "Ανανέωση Μεταδεδομένων",
"deleteAuthor": "Διαγραφή Συγγραφέα"
},
"listRow": {
"noRecentAdditions": "Καμία πρόσφατη προσθήκη"
},
"filters": {
"title": "Φίλτρα Συγγραφέων",
"clearAll": "Εκκαθάριση όλων",
"allLibraries": "Όλες Οι Βιβλιοθήκες",
"allAuthors": "Όλοι οι συγγραφείς",
"hasPhoto": "Έχει φωτογραφία",
"missingPhoto": "Λείπει φωτογραφία",
"minBooks": "Ελάχιστα βιβλία",
"anyPlaceholder": "Οποιαδήποτε"
"allLibraries": "Όλες Οι Βιβλιοθήκες"
},
"header": {
"never": "Ποτέ",
+29 -12
View File
@@ -3776,19 +3776,16 @@
"refreshMetadata": "Refresh Metadata",
"deleteAuthor": "Delete Author"
},
"listRow": {
"noRecentAdditions": "No recent additions",
"portraitAlt": "{name} portrait"
},
"filters": {
"title": "Author Filters",
"clearAll": "Clear all",
"allLibraries": "All Libraries",
"allAuthors": "All authors",
"hasPhoto": "Has photo",
"missingPhoto": "Missing photo",
"minBooks": "Min books",
"anyPlaceholder": "Any"
"chips": {
"groupLabel": "Filter authors",
"all": "All",
"noPortrait": "No portrait",
"multipleBooks": "2+ books",
"recentlyAdded": "Added this week",
"noSortName": "No sort name"
}
},
"header": {
"never": "Never",
@@ -3935,6 +3932,16 @@
"deleteSuccess": "Deleted author; affected {books} books",
"deleteFailed": "Failed to delete author"
}
},
"portrait": {
"fromTheirBook": "Cover of one of their books; no portrait on file"
},
"index": {
"bookCount": "{count, plural, =0 {no books} one {# book} other {# books}}",
"rowLabel": "{name}, {count, plural, =0 {no books} one {# book} other {# books}}",
"actionsFor": "Actions for {name}",
"refreshing": "Refreshing metadata for {name}",
"deleting": "Deleting {name}"
}
},
"book": {
@@ -5844,7 +5851,17 @@
"columnsHint": "Use the column visibility panel in the table header to show or hide columns.",
"coverShape": "Cover shape",
"circle": "Circle",
"square": "Square"
"square": "Square",
"rowDensity": "Row height",
"densityCompact": "Compact",
"densityComfortable": "Cosy",
"densityRoomy": "Roomy",
"coverFallback": "Use book covers for missing portraits"
},
"viewModes": {
"grid": "Grid view",
"list": "List view",
"table": "Table view"
}
},
"iconPicker": {
+1 -12
View File
@@ -3341,19 +3341,8 @@
"refreshMetadata": "Actualizar metadatos",
"deleteAuthor": "Eliminar autor"
},
"listRow": {
"noRecentAdditions": "No hay adiciones recientes",
"portraitAlt": "{name} retrato"
},
"filters": {
"title": "Filtros de autor",
"clearAll": "Borrar todo",
"allLibraries": "Todas las bibliotecas",
"allAuthors": "Todos los autores",
"hasPhoto": "tiene foto",
"missingPhoto": "foto faltante",
"minBooks": "libros mínimos",
"anyPlaceholder": "Cualquiera"
"allLibraries": "Todas las bibliotecas"
},
"header": {
"never": "nunca",
+1 -12
View File
@@ -2950,19 +2950,8 @@
"refreshMetadata": "Päivitä Metatiedot",
"deleteAuthor": "Poista Tekijä"
},
"listRow": {
"noRecentAdditions": "Ei viimeaikaisia lisäyksiä",
"portraitAlt": "{name} muotokuva"
},
"filters": {
"title": "Kirjoittajan Suodattimet",
"clearAll": "Tyhjennä kaikki",
"allLibraries": "Kaikki Kirjastot",
"allAuthors": "Kaikki kirjoittajat",
"hasPhoto": "On kuva",
"missingPhoto": "Puuttuva kuva",
"minBooks": "Minimi kirjat",
"anyPlaceholder": "Kaikki"
"allLibraries": "Kaikki Kirjastot"
},
"header": {
"never": "Ei Koskaan",
+1 -12
View File
@@ -3179,19 +3179,8 @@
"refreshMetadata": "Actualiser les métadonnées",
"deleteAuthor": "Supprimer l'auteur"
},
"listRow": {
"noRecentAdditions": "Aucun ajout récent",
"portraitAlt": "{name}portrait"
},
"filters": {
"title": "Filtres d'auteur",
"clearAll": "Tout effacer",
"allLibraries": "Toutes les bibliothèques",
"allAuthors": "Tous les auteurs",
"hasPhoto": "A une photo",
"missingPhoto": "Photo manquante",
"minBooks": "Min. livres",
"anyPlaceholder": "N'importe lequel"
"allLibraries": "Toutes les bibliothèques"
},
"header": {
"never": "Jamais",
+1 -12
View File
@@ -3134,19 +3134,8 @@
"refreshMetadata": "Aggiorna metadati",
"deleteAuthor": "Elimina autore"
},
"listRow": {
"noRecentAdditions": "Nessuna aggiunta recente",
"portraitAlt": "{name} ritratto"
},
"filters": {
"title": "Filtri dell'autore",
"clearAll": "Cancella tutto",
"allLibraries": "Tutte le biblioteche",
"allAuthors": "Tutti gli autori",
"hasPhoto": "Ha una foto",
"missingPhoto": "Foto mancante",
"minBooks": "Libri minimi",
"anyPlaceholder": "Qualunque"
"allLibraries": "Tutte le biblioteche"
},
"header": {
"never": "Mai",
+1 -12
View File
@@ -3080,19 +3080,8 @@
"refreshMetadata": "メタデータを更新",
"deleteAuthor": "投稿者を削除"
},
"listRow": {
"noRecentAdditions": "最近の追加はありません",
"portraitAlt": "{name} 縦向き画像"
},
"filters": {
"title": "投稿者フィルター",
"clearAll": "すべてクリア",
"allLibraries": "すべてのライブラリ",
"allAuthors": "すべての著者",
"hasPhoto": "写真あり",
"missingPhoto": "不足している写真",
"minBooks": "最小の本",
"anyPlaceholder": "任意の"
"allLibraries": "すべてのライブラリ"
},
"header": {
"never": "一切なし",
+1 -12
View File
@@ -3695,19 +3695,8 @@
"refreshMetadata": "메타데이터 갱신",
"deleteAuthor": "저자 삭제"
},
"listRow": {
"noRecentAdditions": "최근 추가된 항목 없음",
"portraitAlt": "{name} 초상화"
},
"filters": {
"title": "저자 필터",
"clearAll": "전체 초기화",
"allLibraries": "모든 라이브러리",
"allAuthors": "모든 저자",
"hasPhoto": "이미지 있음",
"missingPhoto": "이미지 없음",
"minBooks": "최소 권수",
"anyPlaceholder": "없음"
"allLibraries": "모든 라이브러리"
},
"header": {
"never": "없음",
+1 -12
View File
@@ -2688,19 +2688,8 @@
"refreshMetadata": "Metadata vernieuwen",
"deleteAuthor": "Auteur verwijderen"
},
"listRow": {
"noRecentAdditions": "Geen recente toevoegingen",
"portraitAlt": "Portret van {name}"
},
"filters": {
"title": "Auteurfilters",
"clearAll": "Alles wissen",
"allLibraries": "Alle bibliotheken",
"allAuthors": "Alle auteurs",
"hasPhoto": "Met foto",
"missingPhoto": "Zonder foto",
"minBooks": "Min. boeken",
"anyPlaceholder": "Alle"
"allLibraries": "Alle bibliotheken"
},
"header": {
"never": "Nooit",
+1 -12
View File
@@ -3011,19 +3011,8 @@
"refreshMetadata": "Odśwież metadane",
"deleteAuthor": "Usuń autora"
},
"listRow": {
"noRecentAdditions": "Brak nowych dodatków",
"portraitAlt": "{name} portret"
},
"filters": {
"title": "Filtry autorskie",
"clearAll": "Wyczyść wszystko",
"allLibraries": "Wszystkie biblioteki",
"allAuthors": "Wszyscy autorzy",
"hasPhoto": "Ma zdjęcie",
"missingPhoto": "Brakujące zdjęcie",
"minBooks": "Min. książki",
"anyPlaceholder": "Dowolny"
"allLibraries": "Wszystkie biblioteki"
},
"header": {
"never": "Nigdy",
+1 -12
View File
@@ -3233,19 +3233,8 @@
"refreshMetadata": "Atualizar Metadados",
"deleteAuthor": "Excluir Autor"
},
"listRow": {
"noRecentAdditions": "Nenhuma adição recente",
"portraitAlt": "Retrato de {name}"
},
"filters": {
"title": "Filtros de Autores",
"clearAll": "Limpar tudo",
"allLibraries": "Todas as Bibliotecas",
"allAuthors": "Todos os autores",
"hasPhoto": "Com foto",
"missingPhoto": "Sem foto",
"minBooks": "Mínimo de livros",
"anyPlaceholder": "Qualquer"
"allLibraries": "Todas as Bibliotecas"
},
"header": {
"never": "Nunca",
+1 -11
View File
@@ -3143,18 +3143,8 @@
"refreshMetadata": "Reîmprospătare metadate",
"deleteAuthor": "Șterge Autor"
},
"listRow": {
"noRecentAdditions": "Nici o completare recentă"
},
"filters": {
"title": "Filtre autor",
"clearAll": "Șterge tot",
"allLibraries": "Toate bibliotecile",
"allAuthors": "Toți autorii",
"hasPhoto": "Are fotografie",
"missingPhoto": "Fotografie lipsă",
"minBooks": "Cărți minime",
"anyPlaceholder": "Oricare"
"allLibraries": "Toate bibliotecile"
},
"header": {
"never": "Niciodată",
+1 -12
View File
@@ -3053,19 +3053,8 @@
"refreshMetadata": "Обновить метаданные",
"deleteAuthor": "Удалить автора"
},
"listRow": {
"noRecentAdditions": "Нет недавних дополнений",
"portraitAlt": "{name} портрет"
},
"filters": {
"title": "Фильтры автора",
"clearAll": "Очистить все",
"allLibraries": "Все библиотеки",
"allAuthors": "Все авторы",
"hasPhoto": "Есть фото",
"missingPhoto": "Отсутствует фото",
"minBooks": "Мин. книг",
"anyPlaceholder": "Любая"
"allLibraries": "Все библиотеки"
},
"header": {
"never": "Никогда",
+1 -12
View File
@@ -3227,19 +3227,8 @@
"refreshMetadata": "Osveži metapodatke",
"deleteAuthor": "Izbriši avtorja"
},
"listRow": {
"noRecentAdditions": "Ni nedavnih dodatkov",
"portraitAlt": "Portret avtorja {name}"
},
"filters": {
"title": "Filtri avtorjev",
"clearAll": "Počisti vse",
"allLibraries": "Vse knjižnice",
"allAuthors": "Vsi avtorji",
"hasPhoto": "Ima fotografijo",
"missingPhoto": "Manjka fotografija",
"minBooks": "Najm. knjig",
"anyPlaceholder": "Katerakoli"
"allLibraries": "Vse knjižnice"
},
"header": {
"never": "Nikoli",
+1 -12
View File
@@ -3002,19 +3002,8 @@
"refreshMetadata": "Uppdatera metadata",
"deleteAuthor": "Ta bort författare"
},
"listRow": {
"noRecentAdditions": "Inga senaste tillägg",
"portraitAlt": "{name} porträtt"
},
"filters": {
"title": "Författare Filter",
"clearAll": "Rensa alla",
"allLibraries": "Alla bibliotek",
"allAuthors": "Alla författare",
"hasPhoto": "Har foto",
"missingPhoto": "Fotot saknas",
"minBooks": "Minsta böcker",
"anyPlaceholder": "Valfri"
"allLibraries": "Alla bibliotek"
},
"header": {
"never": "Aldrig",
+1 -12
View File
@@ -3061,19 +3061,8 @@
"refreshMetadata": "Оновити метадані",
"deleteAuthor": "Видалити автора"
},
"listRow": {
"noRecentAdditions": "Немає останніх доповнень",
"portraitAlt": "Портрет {name}"
},
"filters": {
"title": "Фільтри автора",
"clearAll": "Очистити все",
"allLibraries": "Всі бібліотеки",
"allAuthors": "Усі автори",
"hasPhoto": "Має фото",
"missingPhoto": "Відсутнє фото",
"minBooks": "Мін книг",
"anyPlaceholder": "Будь-яке"
"allLibraries": "Всі бібліотеки"
},
"header": {
"never": "Ніколи",
+1 -12
View File
@@ -3224,19 +3224,8 @@
"refreshMetadata": "刷新元数据",
"deleteAuthor": "删除作者"
},
"listRow": {
"noRecentAdditions": "没有最近的添加",
"portraitAlt": "{name} 的头像"
},
"filters": {
"title": "作者过滤器",
"clearAll": "全部清除",
"allLibraries": "所有库",
"allAuthors": "所有作者",
"hasPhoto": "有照片",
"missingPhoto": "缺少照片",
"minBooks": "最小书",
"anyPlaceholder": "任何"
"allLibraries": "所有库"
},
"header": {
"never": "从不使用",
+12
View File
@@ -9,6 +9,18 @@ export type AuthorSummary = {
imageUrl?: string | null;
bookCount: number;
lastAddedAt: string | null;
/**
* A book of theirs that has cover art, most recently added first. Only 19% of a
* typical library's authors have a portrait, so the grid falls back to this
* before it falls back to a monogram. Null when none of their books has a cover.
*/
coverBookId?: number | null;
};
/** One bucket of the authors A-Z rail. `letter` is "#" for anything non-alphabetic. */
export type AuthorLetterCount = {
letter: string;
count: number;
};
export type AuthorDetail = AuthorSummary & {
@@ -50,6 +50,9 @@ export interface DisplayPreferences {
smartScopeFilterExpanded: boolean;
authorCoverSize: number;
authorCoverShape: AuthorCoverShape;
authorRowDensity: TableDensity;
/** Stand a book's cover in for a missing author portrait. Off: monogram instead. */
authorCoverFallback: boolean;
tableZebraStriping: boolean;
tableDensity: TableDensity;
bookSpineOverlay: BookSpineOverlay;
@@ -159,6 +159,25 @@ export class AuthorImageStorageService {
return true;
}
/**
* Every author id that has a stored thumbnail, read in one directory listing.
* The stored image is the truth the list already renders from, so this is what
* `authors.has_photo` gets reconciled against.
*/
async listAuthorIdsWithStoredImage(): Promise<number[]> {
const root = join(this.appDataPath, 'authors');
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
const ids: number[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const id = Number(entry.name);
if (!Number.isInteger(id) || id <= 0) continue;
if (await this.isReadable(join(root, entry.name, 'thumbnail.jpg'))) ids.push(id);
}
return ids;
}
private authorDir(authorId: number): string {
return join(this.appDataPath, 'authors', String(authorId));
}
@@ -0,0 +1,48 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AuthorImageStorageService } from './author-image-storage.service';
import { AuthorsRepository } from './authors.repository';
/**
* Reconciles `authors.has_photo` with the author image store.
*
* The list API resolves an author's portrait from disk, but the "has photo" filter
* queries the column, so any drift between the two makes the filter quietly lie:
* authors that plainly show a portrait still come back under "no portrait". Drift is
* expected after a database reset, a restore, or an image written by an older build.
*
* One directory listing plus at most two statements, so the cost does not grow with
* the library and this can run on every boot. A failure is logged and swallowed:
* a stale flag is a wrong filter, not a reason to refuse to start.
*/
@Injectable()
export class AuthorPhotoFlagBackfillService implements OnApplicationBootstrap {
private readonly logger = new Logger(AuthorPhotoFlagBackfillService.name);
constructor(
private readonly authorsRepo: AuthorsRepository,
private readonly authorImageStorage: AuthorImageStorageService,
) {}
async onApplicationBootstrap(): Promise<void> {
const event = 'author.photo_flag_backfill';
const startedAt = Date.now();
try {
const idsWithImage = await this.authorImageStorage.listAuthorIdsWithStoredImage();
const { marked, cleared } = await this.authorsRepo.reconcileHasPhoto(idsWithImage);
if (marked === 0 && cleared === 0) return;
this.logger.log(
`[${event}] [end] durationMs=${Date.now() - startedAt} stored=${idsWithImage.length} marked=${marked} cleared=${cleared} - author photo flags reconciled with the image store`,
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - author photo flag reconciliation failed`,
);
}
}
}
@@ -40,7 +40,7 @@ import { BulkAuthorIdsDto } from './dto/bulk-author-ids.dto';
import { DeleteAuthorsDto } from './dto/delete-authors.dto';
import { ListAuthorBooksDto } from './dto/list-author-books.dto';
import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
import { ListAuthorsDto } from './dto/list-authors.dto';
import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { PreviewAuthorEnrichmentCountDto } from './dto/preview-author-enrichment-count.dto';
@@ -64,6 +64,12 @@ export class AuthorsController {
return this.authorsService.findAll(user, dto);
}
// Declared before ':id' so the literal path is not captured as an author id.
@Get('jump-buckets')
findJumpBuckets(@CurrentUser() user: RequestUser, @Query() dto: ListAuthorLettersDto) {
return this.authorsService.findJumpBuckets(user, dto);
}
@Get('metadata/providers')
listMetadataProviders() {
return this.authorsService.listMetadataProviders();
@@ -11,6 +11,7 @@ import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service';
import { AuthorPhotoFlagBackfillService } from './author-photo-flag-backfill.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
import { AuthorEnrichmentGateway } from './author-enrichment.gateway';
@@ -71,6 +72,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider, GoodreadsAuthor
AuthorEnrichmentRepository,
AuthorsService,
AuthorsRepository,
AuthorPhotoFlagBackfillService,
],
exports: [AuthorsService, AuthorsRepository, AuthorImageStorageService, AuthorEnrichmentOrchestratorService, AuthorMetadataPreferencesService],
})
@@ -23,6 +23,16 @@ type AuthorSummaryRow = {
lastAddedAt: Date | null;
};
/** The list query alone resolves a cover fallback; detail and enrichment do not need one. */
export type AuthorListItemRow = AuthorSummaryRow & {
coverBookId: number | null;
};
export type AuthorLetterCountRow = {
letter: string;
count: number;
};
type AuthorBookIdRow = {
id: number;
};
@@ -62,13 +72,17 @@ export class AuthorsRepository {
order: SortDirection;
libraryIds: number[];
hasPhoto?: boolean;
hasSortName?: boolean;
addedWithinDays?: number;
minBookCount?: number;
contentFilters?: ContentFilterRules;
}): Promise<{ items: AuthorSummaryRow[]; total: number; page: number; size: number }> {
}): Promise<{ items: AuthorListItemRow[]; total: number; page: number; size: number }> {
const where = this.buildAuthorWhere({
q: params.q,
libraryIds: params.libraryIds,
hasPhoto: params.hasPhoto,
hasSortName: params.hasSortName,
addedWithinDays: params.addedWithinDays,
contentFilters: params.contentFilters,
});
const bookCountExpr = sql<number>`count(distinct ${books.id})`;
@@ -99,6 +113,7 @@ export class AuthorsRepository {
description: authors.description,
bookCount: sql<number>`count(distinct ${books.id})::int`,
lastAddedAt: lastAddedExpr,
coverBookId: this.coverBookIdExpr(params.libraryIds),
})
.from(authors)
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
@@ -406,7 +421,124 @@ export class AuthorsRepository {
});
}
private buildAuthorWhere(params: { q?: string; libraryIds: number[]; hasPhoto?: boolean; contentFilters?: ContentFilterRules }): SQL {
/**
* The most recently added book of theirs that actually carries cover art, scoped to
* the libraries the caller can see. Correlated per row rather than joined, so it costs
* one index lookup per author on the page instead of widening the grouped join.
*/
private coverBookIdExpr(libraryIds: number[]): SQL<number | null> {
return sql<number | null>`(
SELECT cb.id
FROM ${bookAuthors} cba
JOIN ${books} cb ON cb.id = cba.book_id
JOIN ${bookMetadata} cm ON cm.book_id = cb.id
WHERE cba.author_id = ${authors.id}
AND cb.library_id IN (${sql.join(
libraryIds.map((id) => sql`${id}`),
sql`, `,
)})
AND cm.cover_source IS NOT NULL
ORDER BY cb.added_at DESC, cb.id DESC
LIMIT 1
)`;
}
/**
* Counts per A-Z bucket for the jump rail, under the same filters as the list. One
* grouped query, so the rail stays correct for a library the page has not scrolled
* through yet. Anything not starting A-Z buckets to "#".
*/
async findLetterCounts(params: {
q?: string;
/** Must match the field the list is ordered by, or the rail points at the wrong rows. */
sort: Extract<AuthorListSort, 'name' | 'sortName'>;
order: SortDirection;
libraryIds: number[];
hasPhoto?: boolean;
hasSortName?: boolean;
addedWithinDays?: number;
minBookCount?: number;
contentFilters?: ContentFilterRules;
}): Promise<AuthorLetterCountRow[]> {
if (params.libraryIds.length === 0) return [];
const where = this.buildAuthorWhere({
q: params.q,
libraryIds: params.libraryIds,
hasPhoto: params.hasPhoto,
hasSortName: params.hasSortName,
addedWithinDays: params.addedWithinDays,
contentFilters: params.contentFilters,
});
const having = params.minBookCount !== undefined ? sql`count(distinct ${books.id}) >= ${params.minBookCount}` : undefined;
const perAuthor = this.db
.select({ letter: this.letterBucketExpr(params.sort).as('letter') })
.from(authors)
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.where(where)
.groupBy(authors.id, authors.sortName, authors.name)
.having(having)
.as('per_author');
const rows = await this.db
.select({ letter: perAuthor.letter, count: sql<number>`count(*)::int` })
.from(perAuthor)
.groupBy(perAuthor.letter)
.orderBy(params.order === 'asc' ? asc(perAuthor.letter) : desc(perAuthor.letter));
return rows.map((row) => ({ letter: row.letter, count: Number(row.count) }));
}
/**
* First character of whichever key the list is ordered by, folded to unaccented
* uppercase so "Sjon" and "Sjón" share a bucket. Sorting by display name and
* bucketing by sort name would put "Ben Aaronovitch" under A in a list ordered by B.
*/
private letterBucketExpr(sort: 'name' | 'sortName'): SQL<string> {
const key = sort === 'sortName' ? sql`COALESCE(NULLIF(BTRIM(${authors.sortName}), ''), ${authors.name})` : sql`${authors.name}`;
const initial = sql`UPPER(LEFT(public.bookorbit_unaccent(BTRIM(${key})), 1))`;
return sql<string>`CASE WHEN ${initial} BETWEEN 'A' AND 'Z' THEN ${initial} ELSE '#' END`;
}
/**
* Points `has_photo` back at the image store. Two statements whatever the library
* size, so it is cheap enough to run on every boot. Returns how many rows moved in
* each direction, which is what makes the drift visible in the log.
*
* `sql.param` is required: interpolating the array directly expands it to
* `ANY(($1, $2, ...))`, which Postgres rejects, and the failure is invisible here
* because a stale flag must not stop the app from booting.
*/
async reconcileHasPhoto(idsWithImage: number[]): Promise<{ marked: number; cleared: number }> {
const marked = idsWithImage.length
? await this.db
.update(authors)
.set({ hasPhoto: true })
.where(and(eq(authors.hasPhoto, false), sql`${authors.id} = ANY(${sql.param(idsWithImage)}::int[])`))
.returning({ id: authors.id })
: [];
const cleared = idsWithImage.length
? await this.db
.update(authors)
.set({ hasPhoto: false })
.where(and(eq(authors.hasPhoto, true), sql`NOT (${authors.id} = ANY(${sql.param(idsWithImage)}::int[]))`))
.returning({ id: authors.id })
: await this.db.update(authors).set({ hasPhoto: false }).where(eq(authors.hasPhoto, true)).returning({ id: authors.id });
return { marked: marked.length, cleared: cleared.length };
}
private buildAuthorWhere(params: {
q?: string;
libraryIds: number[];
hasPhoto?: boolean;
hasSortName?: boolean;
addedWithinDays?: number;
contentFilters?: ContentFilterRules;
}): SQL {
const clauses: SQL[] = [inArray(books.libraryId, params.libraryIds)];
if (params.contentFilters) {
clauses.push(...buildContentFilterClauses(params.contentFilters, this.db));
@@ -418,6 +550,14 @@ export class AuthorsRepository {
if (params.hasPhoto !== undefined) {
clauses.push(eq(authors.hasPhoto, params.hasPhoto));
}
if (params.hasSortName !== undefined) {
// An empty string is a missing sort name as far as the UI is concerned.
const missing = sql`(${authors.sortName} IS NULL OR BTRIM(${authors.sortName}) = '')`;
clauses.push(params.hasSortName ? (sql`NOT ${missing}` as SQL) : (missing as SQL));
}
if (params.addedWithinDays !== undefined) {
clauses.push(sql`${books.addedAt} >= NOW() - MAKE_INTERVAL(days => ${params.addedWithinDays})` as SQL);
}
return and(...clauses)!;
}
@@ -52,6 +52,7 @@ describe('AuthorsService', () => {
findByIdForEnrichment: vi.fn(),
updateAuthorDescriptionIfEmpty: vi.fn(),
countAuthors: vi.fn(),
findLetterCounts: vi.fn(),
};
const bookReadService = {
@@ -723,4 +724,73 @@ describe('AuthorsService', () => {
expect(result).toEqual({ processed: 2, failed: 1, updated: 1 });
expect(progress).toHaveBeenCalledTimes(2);
});
describe('findJumpBuckets', () => {
it('turns letter counts into running offsets the rail can scroll to', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([
{ letter: 'A', count: 42 },
{ letter: 'B', count: 14 },
{ letter: 'C', count: 35 },
]);
const response = await service.findJumpBuckets(reqUser(), {});
expect(response.kind).toBe('letter');
expect(response.granularity).toBeNull();
expect(response.total).toBe(91);
expect(response.buckets).toEqual([
{ key: 'A', label: 'A', index: 0, isUnknown: false },
{ key: 'B', label: 'B', index: 42, isUnknown: false },
{ key: 'C', label: 'C', index: 56, isUnknown: false },
]);
});
it('marks the non-alphabetic bucket as unknown', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([
{ letter: '#', count: 3 },
{ letter: 'A', count: 5 },
]);
const response = await service.findJumpBuckets(reqUser(), {});
expect(response.buckets[0]).toEqual({ key: '#', label: '#', index: 0, isUnknown: true });
expect(response.buckets[1]?.index).toBe(3);
});
it('buckets by the same field the list is ordered by', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([]);
await service.findJumpBuckets(reqUser(), { sort: 'sortName', order: 'desc' });
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'sortName', order: 'desc' }));
});
it('falls back to the display-name bucketing when no sort is given', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([]);
await service.findJumpBuckets(reqUser(), {});
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'name', order: 'asc' }));
});
it('returns an empty rail when the user has no accessible libraries', async () => {
libraryService.findAll.mockResolvedValue([]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
const response = await service.findJumpBuckets(reqUser(), {});
expect(response).toEqual({ buckets: [], total: 0, kind: 'letter', granularity: null });
expect(authorsRepo.findLetterCounts).not.toHaveBeenCalled();
});
it('passes the list filters through so the rail matches what is on screen', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([]);
await service.findJumpBuckets(reqUser(), { q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 });
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(
expect.objectContaining({ q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 }),
);
});
});
});
+39 -1
View File
@@ -7,6 +7,7 @@ import type {
AuthorMetadataProviderInfo,
AuthorSummary,
AuthorsPage,
JumpBucketsResponse,
BooksPage,
MergeAuthorsResult,
} from '@bookorbit/types';
@@ -27,7 +28,7 @@ import { AuthorDetailRow, AuthorsRepository } from './authors.repository';
import { ListAuthorBooksDto } from './dto/list-author-books.dto';
import { DeleteAuthorsDto } from './dto/delete-authors.dto';
import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
import { ListAuthorsDto } from './dto/list-authors.dto';
import { ListAuthorLettersDto, ListAuthorsDto } from './dto/list-authors.dto';
import { LookupAuthorMetadataDto } from './dto/lookup-author-metadata.dto';
import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { UpdateAuthorDto } from './dto/update-author.dto';
@@ -73,6 +74,8 @@ export class AuthorsService {
order: dto.order ?? 'asc',
libraryIds,
hasPhoto: dto.hasPhoto,
hasSortName: dto.hasSortName,
addedWithinDays: dto.addedWithinDays,
minBookCount: dto.minBookCount,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
});
@@ -84,6 +87,39 @@ export class AuthorsService {
};
}
/**
* A-Z jump buckets under the current filters, in the same shape the book views use so
* the existing rail renders them unchanged. `index` is the offset of the bucket's first
* author in the sorted list, which is what the rail jumps to. One grouped query, so the
* rail is correct for the whole filtered set and not only the pages already loaded.
*/
async findJumpBuckets(user: RequestUser, dto: ListAuthorLettersDto): Promise<JumpBucketsResponse> {
const empty: JumpBucketsResponse = { buckets: [], total: 0, kind: 'letter', granularity: null };
const libraryIds = await this.resolveLibraryIds(user, dto.libraryId);
if (libraryIds.length === 0) return empty;
const counts = await this.authorsRepo.findLetterCounts({
q: dto.q,
sort: dto.sort === 'sortName' ? 'sortName' : 'name',
order: dto.order ?? 'asc',
libraryIds,
hasPhoto: dto.hasPhoto,
hasSortName: dto.hasSortName,
addedWithinDays: dto.addedWithinDays,
minBookCount: dto.minBookCount,
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
});
let offset = 0;
const buckets = counts.map((row) => {
const bucket = { key: row.letter, label: row.letter, index: offset, isUnknown: row.letter === '#' };
offset += row.count;
return bucket;
});
return { buckets, total: offset, kind: 'letter', granularity: null };
}
/** Total authors the user can browse; matches the unfiltered total of {@link findAll}. */
async countAll(user: RequestUser): Promise<number> {
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
@@ -573,6 +609,7 @@ export class AuthorsService {
description: string | null;
bookCount: number;
lastAddedAt: Date | null;
coverBookId?: number | null;
}): AuthorSummary {
return {
id: row.id,
@@ -581,6 +618,7 @@ export class AuthorsService {
description: row.description,
bookCount: row.bookCount,
lastAddedAt: row.lastAddedAt ? row.lastAddedAt.toISOString() : null,
coverBookId: row.coverBookId ?? null,
};
}
@@ -45,6 +45,66 @@ export class ListAuthorsDto {
@IsBoolean()
hasPhoto?: boolean;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
hasSortName?: boolean;
/** "Added recently" filter, expressed in days so the client picks the window. */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
addedWithinDays?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
minBookCount?: number;
}
/** Query for the A-Z rail. Same filters as the list, minus paging and sorting. */
export class ListAuthorLettersDto {
@IsOptional()
@IsString()
@MaxLength(500)
q?: string;
/** Only the two alphabetical sorts have letter buckets; anything else is rejected. */
@IsOptional()
@IsIn(['name', 'sortName'])
sort?: 'name' | 'sortName' = 'name';
/** Bucket order has to match the list, or every jump index is off. */
@IsOptional()
@IsIn(SORT_DIRECTIONS)
order?: SortDirection = 'asc';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
libraryId?: number;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
hasPhoto?: boolean;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
hasSortName?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
addedWithinDays?: number;
@IsOptional()
@Type(() => Number)
@IsInt()