diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7ec7ba72..a1289f1b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,9 +3,9 @@ module.exports = { 'mantine', 'plugin:@next/next/recommended', 'plugin:jest/recommended', - 'plugin:prettier/recommended', 'plugin:@tanstack/eslint-plugin-query/recommended', 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', ], plugins: ['testing-library', 'jest'], overrides: [ diff --git a/app/api/barometers/route.ts b/app/api/barometers/route.ts index e9043d86..8abb7754 100644 --- a/app/api/barometers/route.ts +++ b/app/api/barometers/route.ts @@ -5,52 +5,34 @@ import Barometer, { IBarometer } from '@/models/barometer' import BarometerType from '@/models/type' import '@/models/condition' import Manufacturer from '@/models/manufacturer' -import { cleanObject, slug as slugify, parseDate } from '@/utils/misc' +import { cleanObject, slug as slugify } from '@/utils/misc' import { SortValue } from '@/app/collection/types/[type]/types' +import { DEFAULT_PAGE_SIZE, type PaginationDTO } from '../types' -// dependencies to include in resulting barometers array -const deps = ['type', 'condition', 'manufacturer'] - -/** - * Sort barometer list by one of the parameters: manufacturer, name, date or catalogue no. - */ -function sortBarometers(barometers: IBarometer[], sortBy: SortValue | null): IBarometer[] { - return barometers.toSorted((a, b) => { - switch (sortBy) { - case 'manufacturer': - return (a.manufacturer?.name ?? '').localeCompare(b.manufacturer?.name ?? '') - case 'name': - return a.name.localeCompare(b.name) - case 'date': { - if (!a.dating || !b.dating) return 0 - const yearA = parseDate(a.dating)?.[0] - const yearB = parseDate(b.dating)?.[0] - if (!yearA || !yearB) return 0 - const dateA = new Date(yearA, 0, 1).getTime() - const dateB = new Date(yearB, 0, 1).getTime() - return dateA - dateB - } - case 'cat-no': - return a.collectionId.localeCompare(b.collectionId) - default: - return 0 - } - }) -} - -/** - * Search barometers matching a query - */ -async function searchBarometers(query: string) { - const quotedQuery = `"${query}"` - const barometers = await Barometer.find({ $text: { $search: quotedQuery } }).populate(deps) - return NextResponse.json(barometers, { status: 200 }) +function getSortCriteria(sortBy: SortValue | null): Record { + switch (sortBy) { + case 'manufacturer': + return { 'manufacturer.name': 1 } + case 'name': + return { name: 1 } + case 'date': + return { 'dating.year': 1 } + case 'cat-no': + return { collectionId: 1 } + default: + return { name: 1 } + } } /** * Find a list of barometers of a certain type */ -async function getBarometersByType(typeName: string, limit: number, sortBy: SortValue | null) { +async function getBarometersByType( + typeName: string, + page: number, + pageSize: number, + sortBy: SortValue | null, +) { // perform case-insensitive compare with the stored types const barometerType = await BarometerType.findOne({ name: { $regex: new RegExp(`^${typeName}$`, 'i') }, @@ -58,20 +40,66 @@ async function getBarometersByType(typeName: string, limit: number, sortBy: Sort if (!barometerType) return NextResponse.json([], { status: 404 }) // if existing barometer type match the `type` param, return all corresponding barometers - const barometers = sortBarometers( - await Barometer.find({ type: barometerType._id }) - .limit(limit) - .populate(['type', 'condition', 'manufacturer']), - sortBy, + const skip = (page - 1) * pageSize + const sortCriteria = getSortCriteria(sortBy) + + const barometers = await Barometer.aggregate([ + { + $match: { type: barometerType._id }, + }, + { + $lookup: { + from: 'manufacturers', + localField: 'manufacturer', + foreignField: '_id', + as: 'manufacturer', + }, + }, + { $unwind: { path: '$manufacturer', preserveNullAndEmptyArrays: true } }, + { + $lookup: { + from: 'barometerTypes', + localField: 'type', + foreignField: '_id', + as: 'type', + }, + }, + { $unwind: { path: '$type', preserveNullAndEmptyArrays: true } }, + { + $lookup: { + from: 'barometerConditions', + localField: 'condition', + foreignField: '_id', + as: 'condition', + }, + }, + { $unwind: { path: '$condition', preserveNullAndEmptyArrays: true } }, + { + $sort: sortCriteria, + }, + { $skip: skip }, + { $limit: pageSize }, + ]) + + const totalItems = await Barometer.countDocuments({ type: barometerType._id }) + + return NextResponse.json( + { + barometers, + page, + totalItems, + pageSize, + totalPages: Math.ceil(totalItems / pageSize), + }, + { status: barometers.length > 0 ? 200 : 404 }, ) - return NextResponse.json(barometers, { status: barometers.length > 0 ? 200 : 404 }) } /** - * Find all barometers + * List all barometers */ -async function getAllBarometers(limit: number, sortBy: SortValue | null) { - const barometers = sortBarometers(await Barometer.find().limit(limit).populate(deps), sortBy) +async function getAllBarometers() { + const barometers = await Barometer.find().populate(['type', 'condition', 'manufacturer']) return NextResponse.json(barometers, { status: 200 }) } @@ -86,14 +114,12 @@ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl const typeName = searchParams.get('type') const sortBy = searchParams.get('sort') as SortValue | null - const limit = Number(searchParams.get('limit')) ?? 0 - const query = searchParams.get('q') - // query param was received: ?q=aneroid%20barometer - if (query) return await searchBarometers(query) + const size = Math.max(Number(searchParams.get('size')) || DEFAULT_PAGE_SIZE, 1) + const page = Math.max(Number(searchParams.get('page')) || 1, 1) // if `type` search param was not passed return all barometers list - if (!typeName || !typeName.trim()) return await getAllBarometers(limit, sortBy) + if (!typeName || !typeName.trim()) return await getAllBarometers() // type was passed - return await getBarometersByType(typeName, limit, sortBy) + return await getBarometersByType(typeName, page, size, sortBy) } catch (error) { return NextResponse.json( { message: error instanceof Error ? error.message : 'Could not retrieve barometer list' }, diff --git a/app/api/barometers/search/route.ts b/app/api/barometers/search/route.ts new file mode 100644 index 00000000..40e0993e --- /dev/null +++ b/app/api/barometers/search/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import Barometer from '@/models/barometer' +import { DEFAULT_PAGE_SIZE, type PaginationDTO } from '../../types' +import { connectMongoose } from '@/utils/mongoose' + +// dependencies to include in resulting barometers array +const deps = ['type', 'condition', 'manufacturer'] + +/** + * Search barometers matching a query + */ +async function searchBarometers(query: string, page: number, pageSize: number) { + const quotedQuery = `"${query.trim()}"` + const skip = (page - 1) * pageSize + const [barometers, totalItems] = await Promise.all([ + Barometer.find({ $text: { $search: quotedQuery } }) + .populate(deps) + .skip(skip) + .limit(pageSize), + Barometer.countDocuments({ $text: { $search: quotedQuery } }), + ]) + return NextResponse.json( + { + barometers, + totalItems, + page, + totalPages: Math.ceil(totalItems / pageSize), + pageSize, + }, + { status: 200 }, + ) +} + +export async function GET(req: NextRequest) { + try { + await connectMongoose() + const { searchParams } = req.nextUrl + const query = searchParams.get('q') + const pageSize = Math.max(Number(searchParams.get('pageSize')) || DEFAULT_PAGE_SIZE, 1) + const page = Math.max(Number(searchParams.get('page')) || 1, 1) + if (!query) + return NextResponse.json( + { + barometers: [], + page: 0, + pageSize, + totalItems: 0, + totalPages: 0, + }, + { status: 200 }, + ) + return await searchBarometers(query, page, pageSize) + } catch (error) { + return NextResponse.json( + { message: error instanceof Error ? error.message : 'Could not execute the query' }, + { status: 500 }, + ) + } +} diff --git a/app/api/types.ts b/app/api/types.ts new file mode 100644 index 00000000..b396ac3d --- /dev/null +++ b/app/api/types.ts @@ -0,0 +1,12 @@ +import { IBarometer } from '@/models/barometer' + +export interface PaginationDTO { + barometers: IBarometer[] + totalItems: number + totalPages: number + page: number + pageSize: number +} + +// pagination page size +export const DEFAULT_PAGE_SIZE = 12 diff --git a/app/collection/items/[slug]/page.tsx b/app/collection/items/[slug]/page.tsx index 0595f113..4c11d880 100644 --- a/app/collection/items/[slug]/page.tsx +++ b/app/collection/items/[slug]/page.tsx @@ -67,7 +67,7 @@ export async function generateMetadata({ * to be used as static parameters for Next.js static generation. */ export async function generateStaticParams(): Promise { - const barometers = await fetchBarometers() + const { barometers } = await fetchBarometers() return barometers.map(({ slug, name }) => ({ slug: slug ?? slugify(name), })) diff --git a/app/collection/types/[type]/page.tsx b/app/collection/types/[type]/page.tsx index 54bcbe9a..f2dfc762 100644 --- a/app/collection/types/[type]/page.tsx +++ b/app/collection/types/[type]/page.tsx @@ -9,6 +9,7 @@ import Sort from './sort' import { fetchBarometers, fetchTypes } from '@/utils/fetch' import { DescriptionText } from '@/app/components/description-text' import { title, openGraph, twitter } from '@/app/metadata' +import { Pagination } from '@/app/components/pagination' interface CollectionProps { params: { @@ -16,14 +17,17 @@ interface CollectionProps { } searchParams: { sort?: SortValue + page: string } } +const PAGE_SIZE = '12' + export async function generateMetadata({ params: { type } }: CollectionProps): Promise { const { description } = await fetchTypes({ type }) - const barometersOfType = await fetchBarometers({ type, limit: '5' }) + const barometersOfType = await fetchBarometers({ type, size: '5', page: '1' }) const collectionTitle = `${title}: ${capitalize(type)} Barometers Collection` - const barometerImages = barometersOfType + const barometerImages = barometersOfType.barometers .filter(({ images }) => images && images.length > 0) .map(({ images, name }) => ({ url: googleStorageImagesFolder + images!.at(0), @@ -50,8 +54,14 @@ export async function generateMetadata({ params: { type } }: CollectionProps): P } export default async function Collection({ params: { type }, searchParams }: CollectionProps) { - const sortBy = searchParams.sort ?? 'date' - const barometersOfType = await fetchBarometers({ type, sort: sortBy }) + const sort = searchParams.sort ?? 'date' + const page = searchParams.page ?? 1 + const { barometers, totalPages } = await fetchBarometers({ + type, + sort, + pageSize: PAGE_SIZE, + page, + }) // selected barometer type details const { description } = await fetchTypes({ type }) return ( @@ -61,9 +71,9 @@ export default async function Collection({ params: { type }, searchParams }: Col {type} {description && } - + - {barometersOfType.map(({ name, _id, images, manufacturer }, i) => ( + {barometers.map(({ name, _id, images, manufacturer }, i) => ( ))} + {totalPages > 1 && } ) diff --git a/app/components/header/menudata.ts b/app/components/header/menudata.ts index 93bd1335..a4652c74 100644 --- a/app/components/header/menudata.ts +++ b/app/components/header/menudata.ts @@ -19,11 +19,16 @@ export const menuData: MenuItem[] = [ }, { id: 2, + label: 'Search', + link: 'search', + }, + { + id: 3, label: 'History', link: 'history', }, { - id: 3, + id: 4, label: 'About', link: 'about', }, diff --git a/app/components/pagination/index.ts b/app/components/pagination/index.ts new file mode 100644 index 00000000..48e614c0 --- /dev/null +++ b/app/components/pagination/index.ts @@ -0,0 +1 @@ +export * from './pagination' diff --git a/app/components/pagination/pagination.tsx b/app/components/pagination/pagination.tsx new file mode 100644 index 00000000..6c1c19cd --- /dev/null +++ b/app/components/pagination/pagination.tsx @@ -0,0 +1,31 @@ +'use client' + +import { Pagination as MantinePagination, PaginationProps } from '@mantine/core' +import { useRouter, useSearchParams, usePathname } from 'next/navigation' + +export function Pagination(props: PaginationProps) { + const router = useRouter() + const searchParams = useSearchParams() + const pathname = usePathname() + const updateQueryParams = (key: string, value: string | number) => { + const params = new URLSearchParams(searchParams?.toString() || '') + params.set(key, String(value)) + return params.toString() + } + const handlePageChange = (newPage: number) => { + const updatedQuery = updateQueryParams('page', newPage) + router.push(`${pathname}?${updatedQuery}`) + } + return ( + + ) +} diff --git a/app/components/search-field/search-field.tsx b/app/components/search-field/search-field.tsx index 56432db1..271685ec 100644 --- a/app/components/search-field/search-field.tsx +++ b/app/components/search-field/search-field.tsx @@ -6,19 +6,18 @@ import { useEffect } from 'react' import { Box, TextInput, BoxProps, CloseButton, ActionIcon, ButtonGroup } from '@mantine/core' import { useForm } from '@mantine/form' import { isLength } from 'validator' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { IconSearch } from '@tabler/icons-react' import sx from './search-field.module.scss' -interface SearchProps extends BoxProps { - queryString?: string -} - interface QueryForm extends Record { q: string } -export function SearchField({ queryString, ...props }: SearchProps) { +const PAGE_SIZE = '6' + +export function SearchField(props: BoxProps) { + const searchParams = useSearchParams() const router = useRouter() const form = useForm({ initialValues: { @@ -32,14 +31,14 @@ export function SearchField({ queryString, ...props }: SearchProps) { // fill querystring from the page to the form useEffect(() => { - if (!queryString) return - form.setValues({ q: queryString }) - }, [queryString]) + if (!searchParams.has('q')) return + form.setValues({ q: searchParams.get('q') ?? '' }) + }, [searchParams]) const handleSearch = async ({ q }: QueryForm) => { const qs = q.trim() - const query = new URLSearchParams({ q: qs }) - router.push(`/search?${query}#top`, { scroll: true }) + const query = new URLSearchParams({ q: qs, pageSize: PAGE_SIZE }) + router.push(`/search?${query}`, { scroll: true }) } return ( { - q: string -} interface SearchProps { - searchParams: SearchParams + searchParams: Record } export default async function Search({ searchParams }: SearchProps) { - if (!searchParams.q) throw new Error('Search string was not provided') const baseUrl = process.env.NEXT_PUBLIC_BASE_URL - const res = await fetch(`${baseUrl + barometersApiRoute}?${new URLSearchParams(searchParams)}`) - if (!res.ok) throw new Error(res.statusText) - const barometers: IBarometer[] = await res.json() - if (!barometers || !Array.isArray(barometers)) throw new Error('Bad barometers data') + const url = `${baseUrl + barometersSearchRoute}?${new URLSearchParams(searchParams)}` + const res = await fetch(url, { cache: 'no-cache' }) + const { barometers = [], page = 1, totalPages = 0 }: PaginationDTO = await res.json() return ( - - - - - Search results - - {barometers.length > 0 ? ( - - {barometers.map(({ _id, name, manufacturer, images, slug, dating }) => ( - - ))} - - ) : ( - No barometer matches your request: {searchParams.q} - )} + + + + + Search results + + + + {barometers.map(({ _id, name, manufacturer, images, slug, dating }) => ( + + ))} + + + {totalPages > 1 && } + ) } diff --git a/utils/fetch.ts b/utils/fetch.ts index 0625f010..641ff526 100644 --- a/utils/fetch.ts +++ b/utils/fetch.ts @@ -1,3 +1,4 @@ +import { PaginationDTO } from '@/app/api/types' import { barometersApiRoute, barometerTypesApiRoute } from '@/app/constants' import { IBarometer } from '@/models/barometer' import { IBarometerType } from '@/models/type' @@ -10,15 +11,15 @@ export function fetchBarometers(slug: string): Promise /** * Returns a full list of barometers in the collection */ -export function fetchBarometers(): Promise +export function fetchBarometers(): Promise /** * Returns a list of barometers filtered by the query string (type, sort) * @param qs - query string parameters */ -export function fetchBarometers(qs: Record): Promise +export function fetchBarometers(qs: Record): Promise export async function fetchBarometers( slugOrQs?: string | Record, -): Promise { +): Promise { const input = process.env.NEXT_PUBLIC_BASE_URL + barometersApiRoute + @@ -29,7 +30,6 @@ export async function fetchBarometers( revalidate: 600, }, }) - if (!res.ok) throw new Error(res.statusText) return res.json() } diff --git a/utils/misc.ts b/utils/misc.ts index e9787716..50f8cdea 100644 --- a/utils/misc.ts +++ b/utils/misc.ts @@ -76,3 +76,16 @@ export function parseDate(dated: string): number[] | null { return null // if parsing failed } +/* +Sorting by date + + case 'date': { + if (!a.dating || !b.dating) return 0 + const yearA = parseDate(a.dating)?.[0] + const yearB = parseDate(b.dating)?.[0] + if (!yearA || !yearB) return 0 + const dateA = new Date(yearA, 0, 1).getTime() + const dateB = new Date(yearB, 0, 1).getTime() + return dateA - dateB + } +*/