diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-23 10:13:28 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-23 10:13:28 +0000 |
| commit | ed6a3bfac52437c0b86767d7a17dc1ae48d8ccb2 (patch) | |
| tree | a3be8e399f38705bc41b8e714ce116334e9c095a | |
| parent | 8ab5df675e98129bb57b106ee331a8d07d324a45 (diff) | |
| download | karakeep-ed6a3bfac52437c0b86767d7a17dc1ae48d8ccb2.tar.zst | |
feat: Add search bar to highlights page (#2155)
* feat: Add search bar to All highlights page
This commit adds a search bar to the "All highlights page" that allows users to search their highlights by text content or notes.
Changes:
- Added search method to Highlight model with SQL LIKE query on text and note fields
- Added search endpoint to highlights router with pagination support
- Updated AllHighlights component to include search input with debouncing
- Search input includes clear button and search icon
- Maintains existing infinite scroll pagination for search results
Technical details:
- Uses SQL ilike for case-insensitive search
- 300ms debounce to reduce API calls
- Conditionally uses search or getAll endpoint based on search query
* fix db query
* small fixes
---------
Co-authored-by: Claude <noreply@anthropic.com>
| -rw-r--r-- | apps/web/components/dashboard/highlights/AllHighlights.tsx | 127 | ||||
| -rw-r--r-- | packages/trpc/models/highlights.ts | 48 | ||||
| -rw-r--r-- | packages/trpc/routers/highlights.ts | 22 |
3 files changed, 156 insertions, 41 deletions
diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx index 23fa51d2..928f4e05 100644 --- a/apps/web/components/dashboard/highlights/AllHighlights.tsx +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -1,17 +1,19 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; +import { Input } from "@/components/ui/input"; import useRelativeTime from "@/lib/hooks/relative-time"; import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { Dot, LinkIcon } from "lucide-react"; +import { Dot, LinkIcon, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; import { ZGetAllHighlightsResponse, ZHighlight, @@ -48,18 +50,38 @@ export default function AllHighlights({ highlights: ZGetAllHighlightsResponse; }) { const { t } = useTranslation(); + const [searchInput, setSearchInput] = useState(""); + const debouncedSearch = useDebounce(searchInput, 300); + + // Use search endpoint if searchQuery is provided, otherwise use getAll + const useSearchQuery = debouncedSearch.trim().length > 0; + + const getAllQuery = api.highlights.getAll.useInfiniteQuery( + {}, + { + enabled: !useSearchQuery, + initialData: !useSearchQuery + ? () => ({ + pages: [initialHighlights], + pageParams: [null], + }) + : undefined, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const searchQueryResult = api.highlights.search.useInfiniteQuery( + { text: debouncedSearch }, + { + enabled: useSearchQuery, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - api.highlights.getAll.useInfiniteQuery( - {}, - { - initialData: () => ({ - pages: [initialHighlights], - pageParams: [null], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); + useSearchQuery ? searchQueryResult : getAllQuery; const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView(); useEffect(() => { @@ -71,33 +93,58 @@ export default function AllHighlights({ const allHighlights = data?.pages.flatMap((p) => p.highlights); return ( - <div className="flex flex-col gap-2"> - {allHighlights && - allHighlights.length > 0 && - allHighlights.map((h) => ( - <React.Fragment key={h.id}> - <Highlight highlight={h} /> - <Separator className="m-2 h-0.5 bg-gray-100 last:hidden" /> - </React.Fragment> - ))} - {allHighlights && allHighlights.length == 0 && ( - <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> - {t("highlights.no_highlights")} - </p> - )} - {hasNextPage && ( - <div className="flex justify-center"> - <ActionButton - ref={loadMoreRef} - ignoreDemoMode={true} - loading={isFetchingNextPage} - onClick={() => fetchNextPage()} - variant="ghost" - > - Load More - </ActionButton> - </div> - )} + <div className="flex flex-col gap-4"> + {/* Search Input */} + <div className="relative"> + <Input + type="text" + placeholder="Search highlights..." + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + startIcon={<Search className="size-4 text-muted-foreground" />} + endIcon={ + searchInput && ( + <button + onClick={() => setSearchInput("")} + className="text-muted-foreground hover:text-foreground" + > + <X className="size-4" /> + </button> + ) + } + className="w-full" + /> + </div> + + {/* Results */} + <div className="flex flex-col gap-2"> + {allHighlights && + allHighlights.length > 0 && + allHighlights.map((h) => ( + <React.Fragment key={h.id}> + <Highlight highlight={h} /> + <Separator className="m-2 h-0.5 bg-gray-100 last:hidden" /> + </React.Fragment> + ))} + {allHighlights && allHighlights.length == 0 && ( + <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> + {t("highlights.no_highlights")} + </p> + )} + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + Load More + </ActionButton> + </div> + )} + </div> </div> ); } diff --git a/packages/trpc/models/highlights.ts b/packages/trpc/models/highlights.ts index 49791467..48f0672a 100644 --- a/packages/trpc/models/highlights.ts +++ b/packages/trpc/models/highlights.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { and, desc, eq, lt, lte, or } from "drizzle-orm"; +import { and, desc, eq, like, lt, lte, or } from "drizzle-orm"; import { z } from "zod"; import { highlights } from "@karakeep/db/schema"; @@ -114,6 +114,52 @@ export class Highlight { }; } + static async search( + ctx: AuthedContext, + searchText: string, + cursor?: z.infer<typeof zCursorV2> | null, + limit = 50, + ): Promise<{ + highlights: Highlight[]; + nextCursor: z.infer<typeof zCursorV2> | null; + }> { + const searchPattern = `%${searchText}%`; + const results = await ctx.db.query.highlights.findMany({ + where: and( + eq(highlights.userId, ctx.user.id), + or( + like(highlights.text, searchPattern), + like(highlights.note, searchPattern), + ), + cursor + ? or( + lt(highlights.createdAt, cursor.createdAt), + and( + eq(highlights.createdAt, cursor.createdAt), + lte(highlights.id, cursor.id), + ), + ) + : undefined, + ), + limit: limit + 1, + orderBy: [desc(highlights.createdAt), desc(highlights.id)], + }); + + let nextCursor: z.infer<typeof zCursorV2> | null = null; + if (results.length > limit) { + const nextItem = results.pop()!; + nextCursor = { + id: nextItem.id, + createdAt: nextItem.createdAt, + }; + } + + return { + highlights: results.map((h) => new Highlight(ctx, h)), + nextCursor, + }; + } + async delete(): Promise<z.infer<typeof zHighlightSchema>> { const result = await this.ctx.db .delete(highlights) diff --git a/packages/trpc/routers/highlights.ts b/packages/trpc/routers/highlights.ts index 65d99880..7338077a 100644 --- a/packages/trpc/routers/highlights.ts +++ b/packages/trpc/routers/highlights.ts @@ -7,6 +7,7 @@ import { zNewHighlightSchema, zUpdateHighlightSchema, } from "@karakeep/shared/types/highlights"; +import { zCursorV2 } from "@karakeep/shared/types/pagination"; import { authedProcedure, router } from "../index"; import { Highlight } from "../models/highlights"; @@ -51,6 +52,27 @@ export const highlightsAppRouter = router({ nextCursor: result.nextCursor, }; }), + search: authedProcedure + .input( + z.object({ + text: z.string(), + cursor: zCursorV2.nullish(), + limit: z.number().optional().default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE), + }), + ) + .output(zGetAllHighlightsResponseSchema) + .query(async ({ input, ctx }) => { + const result = await Highlight.search( + ctx, + input.text, + input.cursor, + input.limit, + ); + return { + highlights: result.highlights.map((h) => h.asPublicHighlight()), + nextCursor: result.nextCursor, + }; + }), delete: authedProcedure .input(z.object({ highlightId: z.string() })) .output(zHighlightSchema) |
