mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add sort toolbar and extended filters to authors page
This commit is contained in:
@@ -18,6 +18,8 @@ type ListAuthorsParams = {
|
||||
sort: AuthorListSort
|
||||
order: SortDirection
|
||||
libraryId?: number | null
|
||||
hasPhoto?: boolean | null
|
||||
minBookCount?: number | null
|
||||
}
|
||||
|
||||
type ListAuthorBooksParams = {
|
||||
@@ -100,6 +102,8 @@ export async function fetchAuthors(params: ListAuthorsParams): Promise<AuthorsPa
|
||||
sort: params.sort,
|
||||
order: params.order,
|
||||
libraryId: params.libraryId ?? undefined,
|
||||
hasPhoto: params.hasPhoto != null ? String(params.hasPhoto) : undefined,
|
||||
minBookCount: params.minBookCount ?? undefined,
|
||||
})
|
||||
|
||||
const res = await api(`/api/v1/authors${query}`)
|
||||
|
||||
@@ -1,44 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LibraryFilterOption } from '../types/author'
|
||||
|
||||
import type { AuthorListSort, LibraryFilterOption, SortDirection } from '../types/author'
|
||||
|
||||
const props = defineProps<{
|
||||
search: string
|
||||
sort: AuthorListSort
|
||||
order: SortDirection
|
||||
defineProps<{
|
||||
libraryId: number | null
|
||||
libraries: LibraryFilterOption[]
|
||||
hasPhoto: boolean | null
|
||||
minBookCount: number | null
|
||||
activeCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:sort': [value: AuthorListSort]
|
||||
'update:order': [value: SortDirection]
|
||||
'update:libraryId': [value: number | null]
|
||||
'update:hasPhoto': [value: boolean | null]
|
||||
'update:minBookCount': [value: number | null]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.search,
|
||||
set: (value: string) => emit('update:search', value),
|
||||
})
|
||||
|
||||
const sortModel = computed({
|
||||
get: () => props.sort,
|
||||
set: (value: AuthorListSort) => emit('update:sort', value),
|
||||
})
|
||||
|
||||
const orderModel = computed({
|
||||
get: () => props.order,
|
||||
set: (value: SortDirection) => emit('update:order', value),
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -54,40 +48,36 @@ function onLibraryChange(event: Event) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 md:flex-row md:items-center">
|
||||
<input
|
||||
v-model="searchModel"
|
||||
type="search"
|
||||
placeholder="Search authors"
|
||||
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-primary/60 md:max-w-sm"
|
||||
/>
|
||||
<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="">All Libraries</option>
|
||||
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
v-model="sortModel"
|
||||
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="bookCount">Book Count</option>
|
||||
<option value="lastAddedAt">Recent Additions</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="">All authors</option>
|
||||
<option value="true">Has photo</option>
|
||||
<option value="false">Missing photo</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="orderModel"
|
||||
class="h-9 rounded-md border border-input bg-background px-2.5 text-sm outline-none transition-colors focus:border-primary/60"
|
||||
>
|
||||
<option value="asc">Ascending</option>
|
||||
<option value="desc">Descending</option>
|
||||
</select>
|
||||
|
||||
<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="">All Libraries</option>
|
||||
<option v-for="library in libraries" :key="library.id" :value="library.id">{{ library.name }}</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">Min books</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="minBookCount ?? ''"
|
||||
min="1"
|
||||
placeholder="Any"
|
||||
class="w-14 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
|
||||
@change="onMinBookCountChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -16,6 +16,8 @@ export function useAuthorsList() {
|
||||
const sort = ref<AuthorListSort>('name')
|
||||
const order = ref<SortDirection>('asc')
|
||||
const libraryId = ref<number | null>(null)
|
||||
const hasPhoto = ref<boolean | null>(null)
|
||||
const minBookCount = ref<number | null>(null)
|
||||
|
||||
const page = ref(0)
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
@@ -40,6 +42,8 @@ export function useAuthorsList() {
|
||||
sort: sort.value,
|
||||
order: order.value,
|
||||
libraryId: libraryId.value,
|
||||
hasPhoto: hasPhoto.value,
|
||||
minBookCount: minBookCount.value,
|
||||
})
|
||||
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
@@ -62,6 +66,8 @@ export function useAuthorsList() {
|
||||
sort,
|
||||
order,
|
||||
libraryId,
|
||||
hasPhoto,
|
||||
minBookCount,
|
||||
load,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthorDetail, AuthorSummary } from '@projectx/types'
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
export type AuthorListSort = 'name' | 'bookCount' | 'lastAddedAt'
|
||||
export type AuthorListSort = 'name' | 'sortName' | 'bookCount' | 'lastAddedAt' | 'lastEnrichedAt'
|
||||
export type AuthorBookSort = 'title' | 'publishedYear' | 'addedAt'
|
||||
|
||||
export type LibraryFilterOption = {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CheckCheck, ChevronsUpDown, Filter, RefreshCcw, Search, Trash2, X } from 'lucide-vue-next'
|
||||
import { ArrowUpDown, CheckCheck, ChevronsUpDown, Filter, RefreshCcw, Search, Trash2, X } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
@@ -31,7 +32,7 @@ const route = useRoute()
|
||||
const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
const { hasPermission, isSuperuser } = usePermissions()
|
||||
const { items, total, loading, error, hasMore, q, sort, order, libraryId, load } = useAuthorsList()
|
||||
const { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, minBookCount, load } = useAuthorsList()
|
||||
const { insights, loading: loadingInsights, error: insightsError, load: loadInsights } = useAuthorInsights()
|
||||
const { suggestions, loading: loadingSuggestions, error: suggestionsError, load: loadSuggestions } = useDuplicateSuggestions()
|
||||
const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors()
|
||||
@@ -65,12 +66,24 @@ const quickMergeDialogOpen = ref(false)
|
||||
const canRefreshMetadata = computed(() => hasPermission('library_edit_metadata'))
|
||||
const canDeleteAuthors = computed(() => isSuperuser.value)
|
||||
|
||||
const SORT_LABELS: Record<AuthorListSort, string> = {
|
||||
name: 'Name',
|
||||
sortName: 'Sort Name',
|
||||
bookCount: 'Book Count',
|
||||
lastAddedAt: 'Recent Additions',
|
||||
lastEnrichedAt: 'Last Enriched',
|
||||
}
|
||||
|
||||
const isDefaultSort = computed(() => sort.value === 'name' && order.value === 'asc')
|
||||
|
||||
const sortSummary = computed(() => `${SORT_LABELS[sort.value]} ${order.value === 'asc' ? '↑' : '↓'}`)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (q.value.trim()) count += 1
|
||||
if (sort.value !== 'name') count += 1
|
||||
if (order.value !== 'asc') count += 1
|
||||
if (libraryId.value !== null) count += 1
|
||||
if (hasPhoto.value !== null) count += 1
|
||||
if (minBookCount.value !== null) count += 1
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -113,7 +126,7 @@ function showRefreshResultToast(updated: { imageUrl?: string | null }) {
|
||||
}
|
||||
|
||||
function parseSort(value: unknown): AuthorListSort {
|
||||
return value === 'bookCount' || value === 'lastAddedAt' || value === 'name' ? value : 'name'
|
||||
return value === 'sortName' || value === 'bookCount' || value === 'lastAddedAt' || value === 'lastEnrichedAt' || value === 'name' ? value : 'name'
|
||||
}
|
||||
|
||||
function parseOrder(value: unknown): SortDirection {
|
||||
@@ -125,6 +138,17 @@ 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 syncRouteQuery() {
|
||||
void router.replace({
|
||||
name: 'authors',
|
||||
@@ -133,6 +157,8 @@ 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -141,6 +167,20 @@ function openAuthor(authorId: number) {
|
||||
void router.push({ name: 'author-detail', params: { id: authorId }, query: { from: route.fullPath } })
|
||||
}
|
||||
|
||||
function setSortField(field: AuthorListSort) {
|
||||
sort.value = field
|
||||
order.value = 'asc'
|
||||
}
|
||||
|
||||
function setSortOrder(dir: SortDirection) {
|
||||
order.value = dir
|
||||
}
|
||||
|
||||
function resetSort() {
|
||||
sort.value = 'name'
|
||||
order.value = 'asc'
|
||||
}
|
||||
|
||||
function handleAuthorSelect(authorId: number, event: MouseEvent) {
|
||||
if (!selectionMode.value) {
|
||||
enterSelectionMode()
|
||||
@@ -174,6 +214,8 @@ async function clearFilters() {
|
||||
sort.value = 'name'
|
||||
order.value = 'asc'
|
||||
libraryId.value = null
|
||||
hasPhoto.value = null
|
||||
minBookCount.value = null
|
||||
|
||||
syncRouteQuery()
|
||||
await load(true)
|
||||
@@ -350,6 +392,8 @@ 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)
|
||||
|
||||
await fetchLibraries()
|
||||
hydrating.value = false
|
||||
@@ -376,7 +420,7 @@ onUnmounted(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
watch([sort, order, libraryId], () => {
|
||||
watch([sort, order, libraryId, hasPhoto, minBookCount], () => {
|
||||
if (hydrating.value || suppressAutoReload.value) return
|
||||
if (selectionMode.value) exitSelectionMode()
|
||||
syncRouteQuery()
|
||||
@@ -448,6 +492,61 @@ watch(
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border" />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<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'
|
||||
"
|
||||
>
|
||||
<ArrowUpDown :size="13" />
|
||||
<span class="hidden lg:inline">{{ sortSummary }}</span>
|
||||
<span class="lg:hidden">Sort</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-56 p-2">
|
||||
<div class="mb-2 px-1 text-xs font-medium text-muted-foreground">Sort by</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)"
|
||||
>
|
||||
{{ SORT_LABELS[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' ? 'Ascending' : '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"
|
||||
@click="resetSort"
|
||||
>
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border" />
|
||||
|
||||
<button
|
||||
@click="filtersOpen = !filtersOpen"
|
||||
class="flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
|
||||
@@ -463,7 +562,7 @@ watch(
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
v-if="activeFilterCount > 0 || !isDefaultSort"
|
||||
@click="clearFilters"
|
||||
class="flex h-8 items-center gap-1 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
@@ -494,10 +593,9 @@ watch(
|
||||
<main class="flex-none pr-2">
|
||||
<AuthorFilters
|
||||
v-if="filtersOpen"
|
||||
v-model:search="q"
|
||||
v-model:sort="sort"
|
||||
v-model:order="order"
|
||||
v-model:library-id="libraryId"
|
||||
v-model:has-photo="hasPhoto"
|
||||
v-model:min-book-count="minBookCount"
|
||||
:active-count="activeFilterCount"
|
||||
:libraries="libraries.map((library) => ({ id: library.id, name: library.name }))"
|
||||
@clear="clearFilters"
|
||||
|
||||
@@ -54,43 +54,71 @@ export class AuthorsRepository {
|
||||
sort: AuthorListSort;
|
||||
order: SortDirection;
|
||||
libraryIds: number[];
|
||||
hasPhoto?: boolean;
|
||||
minBookCount?: number;
|
||||
}): Promise<{ items: AuthorSummaryRow[]; total: number; page: number; size: number }> {
|
||||
const where = this.buildAuthorWhere({ q: params.q, libraryIds: params.libraryIds });
|
||||
const where = this.buildAuthorWhere({ q: params.q, libraryIds: params.libraryIds, hasPhoto: params.hasPhoto });
|
||||
const bookCountExpr = sql<number>`count(distinct ${books.id})`;
|
||||
const lastAddedExpr = max(books.addedAt);
|
||||
|
||||
const sortNameExpr = sql`COALESCE(${authors.sortName}, ${authors.name})`;
|
||||
|
||||
const orderBy =
|
||||
params.sort === 'bookCount'
|
||||
? this.orderByDirection(bookCountExpr, params.order)
|
||||
: params.sort === 'lastAddedAt'
|
||||
? this.orderByDirection(lastAddedExpr, params.order)
|
||||
: this.orderByDirection(authors.sortName, params.order);
|
||||
: params.sort === 'lastEnrichedAt'
|
||||
? sql`${authors.lastEnrichedAt} ${sql.raw(params.order.toUpperCase())} NULLS LAST`
|
||||
: params.sort === 'sortName'
|
||||
? sql`${sortNameExpr} ${sql.raw(params.order.toUpperCase())}`
|
||||
: sql`${authors.name} ${sql.raw(params.order.toUpperCase())}`;
|
||||
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: authors.id,
|
||||
name: authors.name,
|
||||
sortName: authors.sortName,
|
||||
description: authors.description,
|
||||
bookCount: sql<number>`count(distinct ${books.id})::int`,
|
||||
lastAddedAt: lastAddedExpr,
|
||||
})
|
||||
.from(authors)
|
||||
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
|
||||
.innerJoin(books, eq(books.id, bookAuthors.bookId))
|
||||
.where(where)
|
||||
.groupBy(authors.id, authors.name, authors.sortName, authors.description)
|
||||
.orderBy(orderBy, asc(authors.sortName), asc(authors.name))
|
||||
.limit(params.size)
|
||||
.offset(params.page * params.size),
|
||||
this.db
|
||||
.select({ total: sql<number>`count(distinct ${authors.id})::int` })
|
||||
.from(authors)
|
||||
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
|
||||
.innerJoin(books, eq(books.id, bookAuthors.bookId))
|
||||
.where(where),
|
||||
]);
|
||||
const having = params.minBookCount !== undefined ? sql`count(distinct ${books.id}) >= ${params.minBookCount}` : undefined;
|
||||
|
||||
const dataQuery = this.db
|
||||
.select({
|
||||
id: authors.id,
|
||||
name: authors.name,
|
||||
sortName: authors.sortName,
|
||||
description: authors.description,
|
||||
bookCount: sql<number>`count(distinct ${books.id})::int`,
|
||||
lastAddedAt: lastAddedExpr,
|
||||
})
|
||||
.from(authors)
|
||||
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
|
||||
.innerJoin(books, eq(books.id, bookAuthors.bookId))
|
||||
.where(where)
|
||||
.groupBy(authors.id, authors.name, authors.sortName, authors.description)
|
||||
.having(having)
|
||||
.orderBy(orderBy, sql`${sortNameExpr} ASC`, asc(authors.name))
|
||||
.limit(params.size)
|
||||
.offset(params.page * params.size);
|
||||
|
||||
// When minBookCount is set, HAVING must be applied per-author before counting,
|
||||
// which requires a subquery. For the common case, use the cheaper scalar count.
|
||||
const countQuery = having
|
||||
? this.db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
this.db
|
||||
.select({ id: authors.id })
|
||||
.from(authors)
|
||||
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
|
||||
.innerJoin(books, eq(books.id, bookAuthors.bookId))
|
||||
.where(where)
|
||||
.groupBy(authors.id)
|
||||
.having(having)
|
||||
.as('filtered_authors'),
|
||||
)
|
||||
: this.db
|
||||
.select({ total: sql<number>`count(distinct ${authors.id})::int` })
|
||||
.from(authors)
|
||||
.innerJoin(bookAuthors, eq(bookAuthors.authorId, authors.id))
|
||||
.innerJoin(books, eq(books.id, bookAuthors.bookId))
|
||||
.where(where);
|
||||
|
||||
const [items, [{ total }]] = await Promise.all([dataQuery, countQuery]);
|
||||
|
||||
return { items, total: Number(total), page: params.page, size: params.size };
|
||||
}
|
||||
@@ -367,12 +395,15 @@ export class AuthorsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private buildAuthorWhere(params: { q?: string; libraryIds: number[] }): SQL {
|
||||
private buildAuthorWhere(params: { q?: string; libraryIds: number[]; hasPhoto?: boolean }): SQL {
|
||||
const clauses: SQL[] = [inArray(books.libraryId, params.libraryIds)];
|
||||
const query = params.q?.trim();
|
||||
if (query) {
|
||||
clauses.push(ilike(authors.name, `%${query}%`));
|
||||
}
|
||||
if (params.hasPhoto !== undefined) {
|
||||
clauses.push(eq(authors.hasPhoto, params.hasPhoto));
|
||||
}
|
||||
return and(...clauses)!;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ export class AuthorsService {
|
||||
sort: dto.sort ?? 'name',
|
||||
order: dto.order ?? 'asc',
|
||||
libraryIds,
|
||||
hasPhoto: dto.hasPhoto,
|
||||
minBookCount: dto.minBookCount,
|
||||
});
|
||||
|
||||
const mapped = page.items.map((item) => this.mapAuthorSummary(item));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export const AUTHOR_LIST_SORTS = ['name', 'bookCount', 'lastAddedAt'] as const;
|
||||
export const AUTHOR_LIST_SORTS = ['name', 'sortName', 'bookCount', 'lastAddedAt', 'lastEnrichedAt'] as const;
|
||||
export type AuthorListSort = (typeof AUTHOR_LIST_SORTS)[number];
|
||||
|
||||
export const SORT_DIRECTIONS = ['asc', 'desc'] as const;
|
||||
@@ -38,4 +38,15 @@ export class ListAuthorsDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
libraryId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
|
||||
@IsBoolean()
|
||||
hasPhoto?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
minBookCount?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user