mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3096,18 +3096,8 @@
|
||||
"refreshMetadata": "Ανανέωση Μεταδεδομένων",
|
||||
"deleteAuthor": "Διαγραφή Συγγραφέα"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "Καμία πρόσφατη προσθήκη"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Φίλτρα Συγγραφέων",
|
||||
"clearAll": "Εκκαθάριση όλων",
|
||||
"allLibraries": "Όλες Οι Βιβλιοθήκες",
|
||||
"allAuthors": "Όλοι οι συγγραφείς",
|
||||
"hasPhoto": "Έχει φωτογραφία",
|
||||
"missingPhoto": "Λείπει φωτογραφία",
|
||||
"minBooks": "Ελάχιστα βιβλία",
|
||||
"anyPlaceholder": "Οποιαδήποτε"
|
||||
"allLibraries": "Όλες Οι Βιβλιοθήκες"
|
||||
},
|
||||
"header": {
|
||||
"never": "Ποτέ",
|
||||
|
||||
+29
-12
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3080,19 +3080,8 @@
|
||||
"refreshMetadata": "メタデータを更新",
|
||||
"deleteAuthor": "投稿者を削除"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "最近の追加はありません",
|
||||
"portraitAlt": "{name} 縦向き画像"
|
||||
},
|
||||
"filters": {
|
||||
"title": "投稿者フィルター",
|
||||
"clearAll": "すべてクリア",
|
||||
"allLibraries": "すべてのライブラリ",
|
||||
"allAuthors": "すべての著者",
|
||||
"hasPhoto": "写真あり",
|
||||
"missingPhoto": "不足している写真",
|
||||
"minBooks": "最小の本",
|
||||
"anyPlaceholder": "任意の"
|
||||
"allLibraries": "すべてのライブラリ"
|
||||
},
|
||||
"header": {
|
||||
"never": "一切なし",
|
||||
|
||||
@@ -3695,19 +3695,8 @@
|
||||
"refreshMetadata": "메타데이터 갱신",
|
||||
"deleteAuthor": "저자 삭제"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "최근 추가된 항목 없음",
|
||||
"portraitAlt": "{name} 초상화"
|
||||
},
|
||||
"filters": {
|
||||
"title": "저자 필터",
|
||||
"clearAll": "전체 초기화",
|
||||
"allLibraries": "모든 라이브러리",
|
||||
"allAuthors": "모든 저자",
|
||||
"hasPhoto": "이미지 있음",
|
||||
"missingPhoto": "이미지 없음",
|
||||
"minBooks": "최소 권수",
|
||||
"anyPlaceholder": "없음"
|
||||
"allLibraries": "모든 라이브러리"
|
||||
},
|
||||
"header": {
|
||||
"never": "없음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -3053,19 +3053,8 @@
|
||||
"refreshMetadata": "Обновить метаданные",
|
||||
"deleteAuthor": "Удалить автора"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "Нет недавних дополнений",
|
||||
"portraitAlt": "{name} портрет"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фильтры автора",
|
||||
"clearAll": "Очистить все",
|
||||
"allLibraries": "Все библиотеки",
|
||||
"allAuthors": "Все авторы",
|
||||
"hasPhoto": "Есть фото",
|
||||
"missingPhoto": "Отсутствует фото",
|
||||
"minBooks": "Мин. книг",
|
||||
"anyPlaceholder": "Любая"
|
||||
"allLibraries": "Все библиотеки"
|
||||
},
|
||||
"header": {
|
||||
"never": "Никогда",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3061,19 +3061,8 @@
|
||||
"refreshMetadata": "Оновити метадані",
|
||||
"deleteAuthor": "Видалити автора"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "Немає останніх доповнень",
|
||||
"portraitAlt": "Портрет {name}"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Фільтри автора",
|
||||
"clearAll": "Очистити все",
|
||||
"allLibraries": "Всі бібліотеки",
|
||||
"allAuthors": "Усі автори",
|
||||
"hasPhoto": "Має фото",
|
||||
"missingPhoto": "Відсутнє фото",
|
||||
"minBooks": "Мін книг",
|
||||
"anyPlaceholder": "Будь-яке"
|
||||
"allLibraries": "Всі бібліотеки"
|
||||
},
|
||||
"header": {
|
||||
"never": "Ніколи",
|
||||
|
||||
@@ -3224,19 +3224,8 @@
|
||||
"refreshMetadata": "刷新元数据",
|
||||
"deleteAuthor": "删除作者"
|
||||
},
|
||||
"listRow": {
|
||||
"noRecentAdditions": "没有最近的添加",
|
||||
"portraitAlt": "{name} 的头像"
|
||||
},
|
||||
"filters": {
|
||||
"title": "作者过滤器",
|
||||
"clearAll": "全部清除",
|
||||
"allLibraries": "所有库",
|
||||
"allAuthors": "所有作者",
|
||||
"hasPhoto": "有照片",
|
||||
"missingPhoto": "缺少照片",
|
||||
"minBooks": "最小书",
|
||||
"anyPlaceholder": "任何"
|
||||
"allLibraries": "所有库"
|
||||
},
|
||||
"header": {
|
||||
"never": "从不使用",
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user