diff --git a/README.md b/README.md index 3d664d6..cd834bf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ TagsForDays extends traditional bookmarking with advanced organization and searc - TODO: User: multitenancy - TODO: User: profiles +- TODO: General: finalize pagination (cursor vs offset) - TODO: General: performance optimizations - TODO: General: more data (seeded, production, etc.) - TODO: General: db writes/resets when testing diff --git a/app/components/pagination.tsx b/app/components/pagination.tsx index 88c6bcb..0f1ed66 100644 --- a/app/components/pagination.tsx +++ b/app/components/pagination.tsx @@ -3,190 +3,147 @@ import { forwardRef } from "react"; import { Button } from "~/components/ui/button"; import { Icon } from "~/components/ui/icon"; import { cn } from "~/utils/misc"; +import { toOffsetPagination } from "~/utils/pagination"; -export function paginateSearchParams({ - defaultPerPage, - searchParams, -}: { - /** The default number of items per page. */ - defaultPerPage: number; - /** The search params. **Required** */ - searchParams: URLSearchParams; -}) { - const skip = Number(searchParams.get("skip")) || 0; - const take = Number(searchParams.get("take")) || defaultPerPage; - const params: Array<[key: string, value: string]> = [ - ...Array.from(searchParams.entries()).filter( - ([key]) => key !== "skip" && key !== "take", - ), - ["take", String(take)], - ]; - return { params, skip, take }; +export interface PaginationFormProps + extends Omit< + React.ComponentPropsWithoutRef, + "method" | "preventScrollReset" + > { + /** Sets the content. **Required** */ + children: React.ReactNode; + /** Sets the `class` attribute. */ + className?: string | undefined; } -export function paginate({ - pagesMax, - skip, - take, - total, -}: { - /** The maximum number of pages to show. **Required** */ - pagesMax: number; - /** The number of items to skip. **Required** */ - skip: number; - /** The number of items per page. **Required** */ - take: number; - /** The total number of items. **Required** */ - total: number; -}) { - const pagesTotal = Math.ceil(total / take); - const pagesMaxHalved = Math.floor(pagesMax / 2); +export const PaginationForm = forwardRef< + React.ElementRef, + PaginationFormProps +>(({ children, className, ...props }, forwardedRef) => { + return ( +
+ {children} +
+ ); +}); - const currPageValue = Math.floor(skip / take) + 1; - const prevPageValue = Math.max(skip - take, 0); - const nextPageValue = Math.min(skip + take, total - take + 1); - const lastPageValue = (pagesTotal - 1) * take; +PaginationForm.displayName = "PaginationForm"; - const hasPrevPage = skip > 0; - const hasNextPage = skip + take < total; +export interface ButtonCursorPaginationProps + extends Omit, "children"> { + /** Sets the `class` attribute. */ + className?: string | undefined; +} - const skipPageNumbers: number[] = []; - if (pagesTotal <= pagesMax) { - for (let i = 1; i <= pagesTotal; i++) { - skipPageNumbers.push(i); - } - } else { - let startPage = currPageValue - pagesMaxHalved; - let endPage = currPageValue + pagesMaxHalved; - if (startPage < 1) { - endPage += Math.abs(startPage) + 1; - startPage = 1; - } - if (endPage > pagesTotal) { - startPage -= endPage - pagesTotal; - endPage = pagesTotal; - } - for (let i = startPage; i <= endPage; i++) { - skipPageNumbers.push(i); - } - } - const skipPages = skipPageNumbers.map((skipPageNumber) => { - const skipPageValue = (skipPageNumber - 1) * take; - const isCurrPage = skipPageNumber === currPageValue; - const isSkipPage = skipPageValue >= 0 && skipPageValue < total; - return { isCurrPage, isSkipPage, skipPageNumber, skipPageValue }; - }); +export const ButtonCursorPagination = forwardRef< + React.ElementRef<"button">, + ButtonCursorPaginationProps +>(({ className, ...props }, forwardedRef) => { + return ( + + ); +}); - return { - prevPageValue, - currPageValue, - nextPageValue, - lastPageValue, - hasPrevPage, - hasNextPage, - skipPages, - }; -} +ButtonCursorPagination.displayName = "ButtonCursorPagination"; -export interface PaginationProps - extends React.ComponentPropsWithoutRef { +export interface ButtonGroupOffsetPaginationProps + extends Omit, "children"> { /** Sets the `class` attribute. */ className?: string | undefined; /** The maximum number of pages to show. */ - pagesMax?: Parameters[0]["pagesMax"] | undefined; - /** Sets the hidden fields. **Required** */ - params: ReturnType["params"]; + pagesMax?: number | undefined; /** The number of items to skip. **Required** */ - skip: Parameters[0]["skip"]; + skip: number; /** The number of items per page. **Required** */ - take: Parameters[0]["take"]; + take: number; /** The total number of items. **Required** */ - total: Parameters[0]["total"]; + total: number; } -export const Pagination = forwardRef< - React.ElementRef, - PaginationProps ->( - ( - { children, className, pagesMax = 7, params, skip, take, total, ...props }, - forwardedRef, - ) => { - const pagination = paginate({ pagesMax, skip, take, total }); +export const ButtonGroupOffsetPagination = forwardRef< + React.ElementRef<"div">, + ButtonGroupOffsetPaginationProps +>(({ className, pagesMax = 7, skip, take, total, ...props }, forwardedRef) => { + const pagination = toOffsetPagination({ pagesMax, skip, take, total }); - return ( -
+ - - {pagination.skipPages.map((el) => ( - - ))} - + + First page + + + {pagination.skipPages.map((el) => ( - - ); - }, -); + ))} + + + + ); +}); -Pagination.displayName = "Pagination"; +ButtonGroupOffsetPagination.displayName = "ButtonGroupOffsetPagination"; diff --git a/app/models/bookmark.server.ts b/app/models/bookmark.server.ts index 81c548a..9c86e38 100644 --- a/app/models/bookmark.server.ts +++ b/app/models/bookmark.server.ts @@ -33,15 +33,52 @@ export async function getBookmarkByUrl({ url }: Pick) { }); } +export async function getBookmarksCount({ + searchKey, + searchValue, +}: { + searchKey?: BookmarkSearchKey | null; + searchValue?: string | null; +} = {}) { + if (searchValue && searchKey === "tags") { + return prisma.bookmark.count({ + where: { + tags: { some: { tag: { name: { contains: searchValue } } } }, + }, + }); + } + + if (searchValue && searchKey) { + return prisma.bookmark.count({ + where: { [searchKey]: { contains: searchValue } }, + }); + } + + return prisma.bookmark.count(); +} + export async function getBookmarks({ searchKey, searchValue, + cursorId, + skip, + take, }: { searchKey?: BookmarkSearchKey | null; searchValue?: string | null; + cursorId?: Bookmark["id"] | null; + skip?: number | null; + take?: number | null; } = {}) { + const pagination = { + ...(skip ? { skip } : {}), + ...(take ? { take } : {}), + ...(cursorId ? { cursor: { id: cursorId } } : {}), + }; + if (searchValue && searchKey === "tags") { return prisma.bookmark.findMany({ + ...pagination, select: { id: true, url: true, @@ -63,6 +100,7 @@ export async function getBookmarks({ if (searchValue && searchKey) { return prisma.bookmark.findMany({ + ...pagination, select: { id: true, url: true, @@ -77,6 +115,7 @@ export async function getBookmarks({ } return prisma.bookmark.findMany({ + ...pagination, select: { id: true, url: true, diff --git a/app/routes/bookmarks._index.tsx b/app/routes/bookmarks._index.tsx index 27b3192..35a2a08 100644 --- a/app/routes/bookmarks._index.tsx +++ b/app/routes/bookmarks._index.tsx @@ -1,20 +1,23 @@ import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; +import { useLoaderData, useNavigation } from "@remix-run/react"; import { BookmarksTable, bookmarksTableColumns, } from "~/components/bookmarks-table"; import { GeneralErrorBoundary } from "~/components/error-boundary"; import { Main } from "~/components/main"; -import { Pagination, paginateSearchParams } from "~/components/pagination"; +import { + ButtonCursorPagination, + PaginationForm, +} from "~/components/pagination"; import { SearchForm } from "~/components/search-form"; import { SearchHelp } from "~/components/search-help"; import { Badge } from "~/components/ui/badge"; import { H1 } from "~/components/ui/h1"; import { Icon } from "~/components/ui/icon"; import { LinkButton } from "~/components/ui/link-button"; -import { getBookmarks } from "~/models/bookmark.server"; +import { getBookmarks, getBookmarksCount } from "~/models/bookmark.server"; import { mapWithFaviconSrc } from "~/models/favicon.server"; import { BOOKMARK_SEARCH_KEYS, @@ -23,37 +26,49 @@ import { } from "~/utils/bookmark"; import { generateSocialMeta } from "~/utils/meta"; import { formatItemsFoundByCount, formatMetaTitle } from "~/utils/misc"; +import { + getCursorPaginationFieldEntries, + getCursorPaginationSearchParams, +} from "~/utils/pagination.server"; import { USER_LOGIN_ROUTE } from "~/utils/user"; export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const searchKey = parseBookmarkSearchKey(url.searchParams.get("searchKey")); const searchValue = url.searchParams.get("searchValue"); - - const defaultPerPage = 20; - const { params, skip, take } = paginateSearchParams({ + const { cursorId, limit, skip, take } = getCursorPaginationSearchParams({ searchParams: url.searchParams, - defaultPerPage, + initialLimit: 20, }); - const bookmarksResult = await getBookmarks({ searchKey, searchValue }); - const bookmarksLength = bookmarksResult.length; - const bookmarksPaginated = bookmarksResult.slice(skip, skip + take); - const bookmarks = await mapWithFaviconSrc(bookmarksPaginated); + const bookmarks = await getBookmarks({ + searchKey, + searchValue, + skip, + take, + cursorId, + }); + const count = await getBookmarksCount({ searchKey, searchValue }); + const data = await mapWithFaviconSrc(bookmarks); - const hasBookmarks = bookmarksLength > 0; - const hasPagination = bookmarksLength > defaultPerPage; + const nextCursorId = + data.length > limit ? bookmarks.at(-1)?.id ?? null : null; + const fields = getCursorPaginationFieldEntries({ + searchParams: url.searchParams, + cursor: nextCursorId, + limit, + }); + const hasData = data.length > 0; + const hasPagination = Boolean(nextCursorId); return json({ - bookmarks, - bookmarksLength, - hasBookmarks, + count, + data, + fields, + hasData, hasPagination, - params, searchKey, searchValue, - skip, - take, }); } @@ -63,7 +78,7 @@ export const meta: MetaFunction = ({ data }) => { const description = data?.searchKey && data?.searchValue ? `${formatItemsFoundByCount({ - count: data?.bookmarksLength ?? 0, + count: data?.count ?? 0, singular: "bookmark", plural: "bookmarks", })} within '${ @@ -80,15 +95,18 @@ export const meta: MetaFunction = ({ data }) => { export default function BookmarksIndexPage() { const loaderData = useLoaderData(); + const navigation = useNavigation(); + const isPending = navigation.state !== "idle"; return (

- Bookmarks {loaderData.bookmarksLength} + Bookmarks {loaderData.count}

+ Import bookmarks @@ -110,7 +128,7 @@ export default function BookmarksIndexPage() { /> @@ -135,22 +153,25 @@ export default function BookmarksIndexPage() { - {loaderData.hasBookmarks ? ( + {loaderData.hasData ? ( ) : null} {loaderData.hasPagination ? ( - + +
+ {loaderData.fields.map(([name, value]) => ( + + ))} + + +
+
) : null}
); diff --git a/app/routes/bookmarks.offset.tsx b/app/routes/bookmarks.offset.tsx new file mode 100644 index 0000000..9e708ea --- /dev/null +++ b/app/routes/bookmarks.offset.tsx @@ -0,0 +1,181 @@ +// TODO: remove route when pagination is finalized. + +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { useLoaderData, useNavigation } from "@remix-run/react"; +import { + BookmarksTable, + bookmarksTableColumns, +} from "~/components/bookmarks-table"; +import { GeneralErrorBoundary } from "~/components/error-boundary"; +import { Main } from "~/components/main"; +import { + ButtonGroupOffsetPagination, + PaginationForm, +} from "~/components/pagination"; +import { SearchForm } from "~/components/search-form"; +import { SearchHelp } from "~/components/search-help"; +import { Badge } from "~/components/ui/badge"; +import { H1 } from "~/components/ui/h1"; +import { Icon } from "~/components/ui/icon"; +import { LinkButton } from "~/components/ui/link-button"; +import { getBookmarks } from "~/models/bookmark.server"; +import { mapWithFaviconSrc } from "~/models/favicon.server"; +import { + BOOKMARK_SEARCH_KEYS, + BOOKMARK_SEARCH_KEYS_LABEL_MAP, + parseBookmarkSearchKey, +} from "~/utils/bookmark"; +import { generateSocialMeta } from "~/utils/meta"; +import { formatItemsFoundByCount, formatMetaTitle } from "~/utils/misc"; +import { + getOffsetPaginationFieldEntries, + getOffsetPaginationSearchParams, +} from "~/utils/pagination.server"; +import { USER_LOGIN_ROUTE } from "~/utils/user"; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const searchKey = parseBookmarkSearchKey(url.searchParams.get("searchKey")); + const searchValue = url.searchParams.get("searchValue"); + const { skip, take } = getOffsetPaginationSearchParams({ + searchParams: url.searchParams, + initialLimit: 20, + }); + + const bookmarks = await getBookmarks({ searchKey, searchValue }); + const count = bookmarks.length; + const data = await mapWithFaviconSrc(bookmarks.slice(skip, skip + take)); + + const fields = getOffsetPaginationFieldEntries({ + searchParams: url.searchParams, + take, + }); + const hasData = data.length > 0; + const hasPagination = count > take; + + return json({ + count, + data, + fields, + hasData, + hasPagination, + searchKey, + searchValue, + skip, + take, + }); +} + +export const meta: MetaFunction = ({ data }) => { + const title = formatMetaTitle("Bookmarks"); + + const description = + data?.searchKey && data?.searchValue + ? `${formatItemsFoundByCount({ + count: data?.count ?? 0, + singular: "bookmark", + plural: "bookmarks", + })} within '${ + BOOKMARK_SEARCH_KEYS_LABEL_MAP[data.searchKey] + }' containing '${data.searchValue}'.` + : `Browse and search all your bookmarks.`; + + return [ + { title }, + { name: "description", content: description }, + ...generateSocialMeta({ title, description }), + ]; +}; + +export default function BookmarksIndexPage() { + const loaderData = useLoaderData(); + const navigation = useNavigation(); + const isPending = navigation.state !== "idle"; + + return ( +
+
+

+ + Bookmarks {loaderData.count} +

+ + + + Import bookmarks + + + + + Add bookmark + +
+ + + + + + + Add bookmark + {" "} + + + View all bookmarks + + + + {loaderData.hasData ? ( + + ) : null} + + {loaderData.hasPagination ? ( + +
+ {loaderData.fields.map(([name, value]) => ( + + ))} + + +
+
+ ) : null} +
+ ); +} + +export function ErrorBoundary() { + return ; +} diff --git a/app/utils/pagination.server.ts b/app/utils/pagination.server.ts new file mode 100644 index 0000000..9f708aa --- /dev/null +++ b/app/utils/pagination.server.ts @@ -0,0 +1,64 @@ +export type InputHiddenEntries = Array<[name: string, value: string]>; + +export function getCursorPaginationSearchParams({ + initialLimit, + searchParams, +}: { + initialLimit: number; + searchParams: URLSearchParams; +}) { + const cursorId = searchParams.get("cursor"); + const limit = Number(searchParams.get("limit")) || initialLimit; + const skip = cursorId ? 1 : 0; // skip the cursor item if it exists + const take = limit + 1; // get extra item to check if there's a next page + return { cursorId, limit, skip, take }; +} + +export function getCursorPaginationFieldEntries({ + cursor, + limit, + searchParams, +}: { + cursor: string | null; + limit: number; + searchParams: URLSearchParams; +}) { + const nextEntries: InputHiddenEntries = cursor + ? [ + ["limit", String(limit)], + ["cursor", cursor], + ] + : [["limit", String(limit)]]; + const currEntries: InputHiddenEntries = Array.from( + searchParams.entries(), + ).filter(([key]) => key !== "cursor" && key !== "limit"); + const output: InputHiddenEntries = [...nextEntries, ...currEntries]; + return output; +} + +export function getOffsetPaginationSearchParams({ + initialLimit, + searchParams, +}: { + initialLimit: number; + searchParams: URLSearchParams; +}) { + const skip = Number(searchParams.get("skip")) || 0; + const take = Number(searchParams.get("take")) || initialLimit; + return { skip, take }; +} + +export function getOffsetPaginationFieldEntries({ + searchParams, + take, +}: { + searchParams: URLSearchParams; + take: number; +}) { + const nextEntries: InputHiddenEntries = [["take", String(take)]]; + const currEntries: InputHiddenEntries = Array.from( + searchParams.entries(), + ).filter(([key]) => key !== "skip" && key !== "take"); + const output: InputHiddenEntries = [...nextEntries, ...currEntries]; + return output; +} diff --git a/app/utils/pagination.ts b/app/utils/pagination.ts new file mode 100644 index 0000000..43b15d8 --- /dev/null +++ b/app/utils/pagination.ts @@ -0,0 +1,59 @@ +export function toOffsetPagination({ + pagesMax, + skip, + take, + total, +}: { + pagesMax: number; + skip: number; + take: number; + total: number; +}) { + const pagesTotal = Math.ceil(total / take); + const pagesMaxHalved = Math.floor(pagesMax / 2); + + const currPageValue = Math.floor(skip / take) + 1; + const prevPageValue = Math.max(skip - take, 0); + const nextPageValue = Math.min(skip + take, total - take + 1); + const lastPageValue = (pagesTotal - 1) * take; + + const hasPrevPage = skip > 0; + const hasNextPage = skip + take < total; + + const skipPageNumbers: number[] = []; + if (pagesTotal <= pagesMax) { + for (let i = 1; i <= pagesTotal; i++) { + skipPageNumbers.push(i); + } + } else { + let startPage = currPageValue - pagesMaxHalved; + let endPage = currPageValue + pagesMaxHalved; + if (startPage < 1) { + endPage += Math.abs(startPage) + 1; + startPage = 1; + } + if (endPage > pagesTotal) { + startPage -= endPage - pagesTotal; + endPage = pagesTotal; + } + for (let i = startPage; i <= endPage; i++) { + skipPageNumbers.push(i); + } + } + const skipPages = skipPageNumbers.map((skipPageNumber) => { + const skipPageValue = (skipPageNumber - 1) * take; + const isCurrPage = skipPageNumber === currPageValue; + const isSkipPage = skipPageValue >= 0 && skipPageValue < total; + return { isCurrPage, isSkipPage, skipPageNumber, skipPageValue }; + }); + + return { + prevPageValue, + currPageValue, + nextPageValue, + lastPageValue, + hasPrevPage, + hasNextPage, + skipPages, + }; +}