aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-23 10:13:28 +0000
committerGitHub <noreply@github.com>2025-11-23 10:13:28 +0000
commited6a3bfac52437c0b86767d7a17dc1ae48d8ccb2 (patch)
treea3be8e399f38705bc41b8e714ce116334e9c095a
parent8ab5df675e98129bb57b106ee331a8d07d324a45 (diff)
downloadkarakeep-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.tsx127
-rw-r--r--packages/trpc/models/highlights.ts48
-rw-r--r--packages/trpc/routers/highlights.ts22
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)