From ed6a3bfac52437c0b86767d7a17dc1ae48d8ccb2 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Sun, 23 Nov 2025 10:13:28 +0000
Subject: 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>
---
 .../dashboard/highlights/AllHighlights.tsx         | 127 ++++++++++++++-------
 1 file changed, 87 insertions(+), 40 deletions(-)

(limited to 'apps/web/components/dashboard/highlights/AllHighlights.tsx')

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>
   );
 }
-- 
cgit v1.2.3-70-g09d2

