aboutsummaryrefslogtreecommitdiffstats
path: root/packages/shared
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-28 11:03:48 +0100
committerGitHub <noreply@github.com>2025-09-28 11:03:48 +0100
commit62f7d900c52784ff05d933b52379e5455ea6bd00 (patch)
tree2702d74c96576447974af84850f3ba6b66beeeb4 /packages/shared
parent9fe09bfa9021c8d85d2d9aef591936101cab19f6 (diff)
downloadkarakeep-62f7d900c52784ff05d933b52379e5455ea6bd00.tar.zst
feat: Add tag search and pagination (#1987)
* feat: Add tag search and use in the homepage * use paginated query in the all tags view * wire the load more buttons * add skeleton to all tags page * fix attachedby aggregation * fix loading states * fix hasNextPage * use action buttons for load more buttons * migrate the tags auto complete to the search api * Migrate the tags editor to the new search API * Replace tag merging dialog with tag auto completion * Merge both search and list APIs * fix tags.list * add some tests for the endpoint * add relevance based sorting * change cursor * update the REST API * fix review comments * more fixes * fix lockfile * i18n * fix visible tags
Diffstat (limited to '')
-rw-r--r--packages/shared-react/hooks/tags.ts35
-rw-r--r--packages/shared-react/hooks/use-debounce.ts17
-rw-r--r--packages/shared/types/tags.ts58
-rw-r--r--packages/shared/utils/switch.ts6
4 files changed, 116 insertions, 0 deletions
diff --git a/packages/shared-react/hooks/tags.ts b/packages/shared-react/hooks/tags.ts
index bbbe3d0e..f02ebc8f 100644
--- a/packages/shared-react/hooks/tags.ts
+++ b/packages/shared-react/hooks/tags.ts
@@ -1,5 +1,40 @@
+import { keepPreviousData } from "@tanstack/react-query";
+
+import { ZTagListResponse } from "@karakeep/shared/types/tags";
+
import { api } from "../trpc";
+export function usePaginatedSearchTags(
+ input: Parameters<typeof api.tags.list.useInfiniteQuery>[0],
+) {
+ return api.tags.list.useInfiniteQuery(input, {
+ placeholderData: keepPreviousData,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ select: (data) => ({
+ tags: data.pages.flatMap((page) => page.tags),
+ }),
+ gcTime: 60_000,
+ });
+}
+
+export function useTagAutocomplete<T>(opts: {
+ nameContains: string;
+ select?: (tags: ZTagListResponse) => T;
+}) {
+ return api.tags.list.useQuery(
+ {
+ nameContains: opts.nameContains,
+ limit: 50,
+ sortBy: opts.nameContains ? "relevance" : "usage",
+ },
+ {
+ select: opts.select,
+ placeholderData: keepPreviousData,
+ gcTime: opts.nameContains?.length > 0 ? 60_000 : 3_600_000,
+ },
+ );
+}
+
export function useCreateTag(
...opts: Parameters<typeof api.tags.create.useMutation>
) {
diff --git a/packages/shared-react/hooks/use-debounce.ts b/packages/shared-react/hooks/use-debounce.ts
new file mode 100644
index 00000000..a973d774
--- /dev/null
+++ b/packages/shared-react/hooks/use-debounce.ts
@@ -0,0 +1,17 @@
+import React from "react";
+
+export function useDebounce<T>(value: T, delayMs: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
+
+ React.useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delayMs);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delayMs]);
+
+ return debouncedValue;
+}
diff --git a/packages/shared/types/tags.ts b/packages/shared/types/tags.ts
index efb26bfa..91ad1d96 100644
--- a/packages/shared/types/tags.ts
+++ b/packages/shared/types/tags.ts
@@ -2,6 +2,8 @@ import { z } from "zod";
import { normalizeTagName } from "../utils/tag";
+export const MAX_NUM_TAGS_PER_PAGE = 1000;
+
const zTagNameSchemaWithValidation = z
.string()
.transform((s) => normalizeTagName(s).trim())
@@ -38,3 +40,59 @@ export const zTagBasicSchema = z.object({
name: z.string(),
});
export type ZTagBasic = z.infer<typeof zTagBasicSchema>;
+
+export const zTagCursorSchema = z.object({
+ page: z.number().int().min(0),
+});
+
+export const zTagListRequestSchema = z.object({
+ nameContains: z.string().optional(),
+ attachedBy: z.enum([...zAttachedByEnumSchema.options, "none"]).optional(),
+ sortBy: z.enum(["name", "usage", "relevance"]).optional().default("usage"),
+ cursor: zTagCursorSchema.nullish().default({ page: 0 }),
+ // TODO: Remove the optional to enforce a limit after the next release
+ limit: z.number().int().min(1).max(MAX_NUM_TAGS_PER_PAGE).optional(),
+});
+
+export const zTagListValidatedRequestSchema = zTagListRequestSchema.refine(
+ (val) => val.sortBy != "relevance" || val.nameContains !== undefined,
+ {
+ message: "Relevance sorting requires a nameContains filter",
+ path: ["sortBy"],
+ },
+);
+
+export const zTagListResponseSchema = z.object({
+ tags: z.array(zGetTagResponseSchema),
+ nextCursor: zTagCursorSchema.nullish(),
+});
+export type ZTagListResponse = z.infer<typeof zTagListResponseSchema>;
+
+// API Types
+
+export const zTagListQueryParamsSchema = z.object({
+ nameContains: zTagListRequestSchema.shape.nameContains,
+ sort: zTagListRequestSchema.shape.sortBy,
+ attachedBy: zTagListRequestSchema.shape.attachedBy,
+ cursor: z
+ .string()
+ .transform((val, ctx) => {
+ try {
+ return JSON.parse(Buffer.from(val, "base64url").toString("utf8"));
+ } catch {
+ ctx.addIssue({
+ code: "custom",
+ message: "Invalid cursor",
+ });
+ return z.NEVER;
+ }
+ })
+ .optional()
+ .pipe(zTagListRequestSchema.shape.cursor),
+ limit: z.coerce.number().optional(),
+});
+
+export const zTagListApiResultSchema = z.object({
+ tags: zTagListResponseSchema.shape.tags,
+ nextCursor: z.string().nullish(),
+});
diff --git a/packages/shared/utils/switch.ts b/packages/shared/utils/switch.ts
new file mode 100644
index 00000000..9123c060
--- /dev/null
+++ b/packages/shared/utils/switch.ts
@@ -0,0 +1,6 @@
+export function switchCase<T extends string | number, R>(
+ value: T,
+ cases: Record<T, R>,
+) {
+ return cases[value];
+}