feat(client): add responsive header with global search and filter bar

This commit is contained in:
neon
2026-01-15 05:38:45 -06:00
parent 588c1b8972
commit 86ff97bc82
8 changed files with 448 additions and 108 deletions
+313 -105
View File
@@ -1,153 +1,361 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Search, SlidersHorizontal, LayoutGrid, List, X } from 'lucide-vue-next'
import { computed, nextTick, ref, watch } from 'vue'
import { ArrowLeft, LayoutGrid, List, Moon, MoreHorizontal, Search, SlidersHorizontal, Sun, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import AccentPicker from '@/components/AccentPicker.vue'
import RadiusPicker from '@/components/RadiusPicker.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useThemeStore } from '@/stores/theme'
import { useGlobalSearch, type GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
const props = defineProps<{
title: string
total: number
loaded: number
search: string
coverSize: number
gridGap: number
viewMode: 'grid' | 'list'
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:coverSize': [value: number]
'update:gridGap': [value: number]
'update:viewMode': [value: 'grid' | 'list']
}>()
const router = useRouter()
const themeStore = useThemeStore()
const searchFocused = ref(false)
const mobileSearchOpen = ref(false)
const mobileDisplayOpen = ref(false)
const mobileSearchInput = ref<HTMLInputElement | null>(null)
const globalSearchQuery = ref('')
const { results: globalResults, loading: globalSearchLoading, clear: clearGlobalSearch } = useGlobalSearch(globalSearchQuery)
const showDropdown = computed(
() =>
(searchFocused.value || mobileSearchOpen.value) &&
globalSearchQuery.value.trim().length >= 2 &&
(globalResults.value.length > 0 || globalSearchLoading.value),
)
watch(mobileSearchOpen, (open) => {
if (open) nextTick(() => mobileSearchInput.value?.focus())
})
function clearSearch() {
globalSearchQuery.value = ''
clearGlobalSearch()
}
function closeMobileSearch() {
mobileSearchOpen.value = false
clearSearch()
}
function navigateToResult(result: GlobalSearchResult) {
clearSearch()
mobileSearchOpen.value = false
router.push({ name: 'library', params: { id: result.libraryId } })
}
</script>
<template>
<header class="flex h-14 shrink-0 items-center gap-2 border-b border-primary/20 bg-background/90 backdrop-blur-md px-3 shadow-sm sticky top-0 z-10">
<!-- Left: context -->
<SidebarTrigger class="-ml-1 text-muted-foreground hover:text-foreground" />
<Separator orientation="vertical" class="mx-1 h-4" />
<div class="flex items-center gap-2">
<span class="font-serif font-semibold text-[15px] text-foreground tracking-tight">{{ title }}</span>
<span class="text-[11px] font-medium text-primary/70 bg-primary/8 px-2 py-0.5 rounded-full tabular-nums border border-primary/15">
{{ loaded.toLocaleString() }}<span class="text-muted-foreground/60 mx-0.5">/</span>{{ total.toLocaleString() }}
</span>
</div>
<!-- Right: actions -->
<div class="ml-auto flex items-center gap-1">
<!-- Search -->
<div class="relative flex items-center transition-all duration-200" :class="searchFocused || props.search ? 'w-56' : 'w-44'">
<!-- Mobile: search active overlay -->
<template v-if="mobileSearchOpen">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="closeMobileSearch()">
<ArrowLeft :size="16" />
</Button>
<div class="flex-1 relative flex items-center">
<Search class="absolute left-2.5 text-muted-foreground pointer-events-none" :size="13" />
<input
:value="props.search"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
@focus="searchFocused = true"
@blur="searchFocused = false"
placeholder="Search books..."
ref="mobileSearchInput"
v-model="globalSearchQuery"
@keydown.esc="clearSearch()"
placeholder="Search all books..."
class="w-full h-8 pl-8 pr-7 text-sm rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary transition-shadow"
/>
<button v-if="props.search" @click="emit('update:search', '')" class="absolute right-2 text-muted-foreground hover:text-foreground">
<button v-if="globalSearchQuery" @click="clearSearch()" class="absolute right-2 text-muted-foreground hover:text-foreground">
<X :size="13" />
</button>
<!-- Mobile search dropdown -->
<div
v-if="showDropdown"
@mousedown.prevent
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-72 overflow-y-auto"
>
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
<div v-else-if="!globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">No results</div>
<button
v-for="result in globalResults"
:key="result.id"
@click="navigateToResult(result)"
class="w-full flex items-center gap-3 px-3 py-2 hover:bg-accent transition-colors text-left"
>
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-11 w-8 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ result.title ?? 'Untitled' }}</p>
<p v-if="result.authors.length" class="text-xs text-muted-foreground truncate">{{ result.authors.join(', ') }}</p>
</div>
<span
class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate"
>
{{ result.libraryName }}
</span>
</button>
</div>
</div>
</template>
<!-- Normal state -->
<template v-else>
<!-- Left: sidebar trigger + library context -->
<SidebarTrigger class="-ml-1 text-muted-foreground hover:text-foreground" />
<Separator orientation="vertical" class="mx-1 h-4" />
<div class="flex items-center gap-2 min-w-0">
<span class="font-serif font-semibold text-[15px] text-foreground tracking-tight truncate">
{{ title }}
</span>
<span
class="hidden md:inline-flex text-[11px] font-medium text-primary/70 bg-primary/8 px-2 py-0.5 rounded-full tabular-nums border border-primary/15 shrink-0"
>
{{ loaded.toLocaleString() }}<span class="text-muted-foreground/60 mx-0.5">/</span>{{ total.toLocaleString() }}
</span>
</div>
<Separator orientation="vertical" class="mx-1 h-4" />
<!-- Center: desktop global search -->
<div
class="hidden md:flex flex-1 mx-4 relative items-center transition-all duration-200"
:class="searchFocused || globalSearchQuery ? 'max-w-sm' : 'max-w-xs'"
>
<Search class="absolute left-2.5 text-muted-foreground pointer-events-none" :size="13" />
<input
v-model="globalSearchQuery"
@focus="searchFocused = true"
@blur="searchFocused = false"
@keydown.esc="clearSearch()"
placeholder="Search all books..."
class="w-full h-8 pl-8 pr-7 text-sm rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary transition-shadow"
/>
<button v-if="globalSearchQuery" @click="clearSearch()" class="absolute right-2 text-muted-foreground hover:text-foreground">
<X :size="13" />
</button>
<!-- Display settings -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-foreground">
<SlidersHorizontal :size="15" />
<!-- Desktop search dropdown -->
<div
v-if="showDropdown"
@mousedown.prevent
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-72 overflow-y-auto"
>
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
<div v-else-if="!globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">No results</div>
<button
v-for="result in globalResults"
:key="result.id"
@click="navigateToResult(result)"
class="w-full flex items-center gap-3 px-3 py-2 hover:bg-accent transition-colors text-left"
>
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-11 w-8 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">{{ result.title ?? 'Untitled' }}</p>
<p v-if="result.authors.length" class="text-xs text-muted-foreground truncate">{{ result.authors.join(', ') }}</p>
</div>
<span
class="text-[10px] font-medium text-primary/70 bg-primary/8 px-1.5 py-0.5 rounded-full border border-primary/15 shrink-0 max-w-[80px] truncate"
>
{{ result.libraryName }}
</span>
</button>
</div>
</div>
<!-- Right: actions -->
<div class="ml-auto flex items-center gap-1">
<!-- Mobile: search icon -->
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground" @click="mobileSearchOpen = true">
<Search :size="15" />
</Button>
<!-- Desktop: view toggle -->
<div class="hidden md:flex items-center">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'grid' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'grid')"
>
<LayoutGrid :size="15" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Display</p>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'list' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'list')"
>
<List :size="15" />
</Button>
</div>
<!-- Cover size -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.coverSize }}px</span>
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Desktop: display settings popover -->
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground">
<SlidersHorizontal :size="15" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Display</p>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.coverSize }}px</span>
</div>
<input
:value="props.coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<input
:value="props.coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<!-- Grid gap -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.gridGap }}px</span>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.gridGap }}px</span>
</div>
<input
:value="props.gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
<Separator />
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
<input
:value="props.gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
</PopoverContent>
</Popover>
<Separator />
<Separator orientation="vertical" class="hidden md:block mx-1 h-4" />
<!-- Accent -->
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<!-- Desktop: theme toggle -->
<div class="hidden md:flex">
<ThemeToggle />
</div>
<!-- Radius -->
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
</div>
</PopoverContent>
</Popover>
<!-- View toggle -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'grid' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'grid')"
>
<LayoutGrid :size="15" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="props.viewMode === 'list' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="emit('update:viewMode', 'list')"
>
<List :size="15" />
</Button>
<Separator orientation="vertical" class="mx-1 h-4" />
<ThemeToggle />
</div>
<!-- Mobile: overflow dropdown -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground">
<MoreHorizontal :size="15" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel class="text-xs text-muted-foreground">View</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="props.viewMode" @update:model-value="emit('update:viewMode', $event as 'grid' | 'list')">
<DropdownMenuRadioItem value="grid">Grid</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">List</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="mobileDisplayOpen = true">
<SlidersHorizontal :size="14" class="mr-2" />
Display
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="themeStore.toggleTheme()">
<component :is="themeStore.theme === 'dark' ? Sun : Moon" :size="14" class="mr-2" />
{{ themeStore.theme === 'dark' ? 'Light mode' : 'Dark mode' }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
</header>
<!-- Mobile display settings sheet (portaled to body) -->
<Sheet v-model:open="mobileDisplayOpen">
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Display</SheetTitle>
</SheetHeader>
<div class="space-y-4 px-4 pb-6">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Cover size</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.coverSize }}px</span>
</div>
<input
:value="props.coverSize"
@input="emit('update:coverSize', Number(($event.target as HTMLInputElement).value))"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">Grid gap</span>
<span class="text-xs font-medium tabular-nums text-foreground">{{ props.gridGap }}px</span>
</div>
<input
:value="props.gridGap"
@input="emit('update:gridGap', Number(($event.target as HTMLInputElement).value))"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
<Separator />
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
</div>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,46 @@
import { ref, watch, type Ref } from 'vue'
import { api } from '@/lib/api'
export interface GlobalSearchResult {
id: number
title: string | null
authors: string[]
libraryId: number
libraryName: string
}
export function useGlobalSearch(query: Ref<string>) {
const results = ref<GlobalSearchResult[]>([])
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
watch(query, (q) => {
if (timer) clearTimeout(timer)
if (q.trim().length < 2) {
results.value = []
loading.value = false
return
}
loading.value = true
timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ q: q.trim(), limit: '10' })
const res = await api(`/api/books/search?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
results.value = await res.json()
} catch {
results.value = []
} finally {
loading.value = false
}
}, 300)
})
function clear() {
if (timer) clearTimeout(timer)
results.value = []
loading.value = false
}
return { results, loading, clear }
}
+14 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Search, X } from 'lucide-vue-next'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import AppHeader from '@/components/AppHeader.vue'
@@ -87,7 +88,6 @@ watch(search, () => {
:title="title"
:total="total"
:loaded="books.length"
v-model:search="search"
v-model:coverSize="coverSize"
v-model:gridGap="gridGap"
v-model:viewMode="viewMode"
@@ -96,6 +96,19 @@ watch(search, () => {
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
<div v-if="error" class="text-sm text-destructive mb-4">{{ error }}</div>
<!-- Library filter -->
<div class="relative mb-4 max-w-xs">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" :size="13" />
<input
v-model="search"
placeholder="Filter library..."
class="w-full h-8 pl-8 pr-7 text-sm rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary transition-shadow"
/>
<button v-if="search" @click="search = ''" class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X :size="13" />
</button>
</div>
<!-- Grid view -->
<div
v-if="viewMode === 'grid'"
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1768503997010,
"when": 1768523691867,
"tag": "0000_baseline",
"breakpoints": true
}
@@ -7,6 +7,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { BookService } from './book.service';
import { GetBooksDto } from './dto/get-books.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
import { SearchBooksDto } from './dto/search-books.dto';
@Controller('books')
export class BookController {
@@ -17,6 +18,12 @@ export class BookController {
return this.bookService.getCards(dto, user);
}
// Must be before @Get(':id') so NestJS does not treat 'search' as an :id param
@Get('search')
searchBooks(@Query() dto: SearchBooksDto, @CurrentUser() user: RequestUser) {
return this.bookService.searchAcrossLibraries(dto.q, dto.limit ?? 10, user);
}
@Get(':id/cover')
async getCover(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const coverPath = await this.bookService.getCoverPath(id, user);
+46 -1
View File
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, readingProgress, tags } from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, libraries, readingProgress, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -119,6 +119,51 @@ export class BookRepository {
return row ?? null;
}
async searchAcrossLibraries(libraryIds: number[], q: string, limit: number) {
if (libraryIds.length === 0) return [];
const rows = await this.db
.select({
id: books.id,
title: bookMetadata.title,
libraryId: books.libraryId,
libraryName: libraries.name,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(and(inArray(books.libraryId, libraryIds), ilike(bookMetadata.title, `%${q}%`)))
.orderBy(bookMetadata.title)
.limit(limit);
const bookIds = rows.map((r) => r.id);
const authorRows =
bookIds.length > 0
? await this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder)
: [];
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
return rows.map((r) => ({
id: r.id,
title: r.title,
authors: authorsByBook.get(r.id) ?? [],
libraryId: r.libraryId,
libraryName: r.libraryName,
}));
}
async upsertProgress(userId: number, fileId: number, cfi: string | null, pageNumber: number | null, percentage: number) {
await this.db
.insert(readingProgress)
+6
View File
@@ -97,6 +97,12 @@ export class BookService {
return { path: file.absolutePath, size, format: file.format ?? 'unknown' };
}
async searchAcrossLibraries(q: string, limit: number, user: RequestUser) {
const libs = await this.libraryService.findAll(user);
const libraryIds = libs.map((l) => l.id);
return this.bookRepo.searchAcrossLibraries(libraryIds, q, limit);
}
async getProgress(userId: number, fileId: number, user: RequestUser) {
await this.verifyFileAccess(fileId, user);
return this.bookRepo.findProgress(userId, fileId);
@@ -0,0 +1,15 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator';
export class SearchBooksDto {
@IsString()
@MinLength(1)
q: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(20)
limit?: number = 10;
}