aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/browser-extension/package.json2
-rw-r--r--apps/browser-extension/tsconfig.json2
-rw-r--r--apps/cli/package.json2
-rw-r--r--apps/cli/src/commands/bookmarks.ts10
-rw-r--r--apps/cli/src/commands/lists.ts12
-rw-r--r--apps/landing/app/page.tsx6
-rw-r--r--apps/landing/package.json6
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx19
-rw-r--r--apps/mobile/package.json2
-rw-r--r--apps/web/app/api/assets/[assetId]/route.ts73
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts37
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts36
-rw-r--r--apps/web/app/api/v1/bookmarks/search/route.ts39
-rw-r--r--apps/web/app/api/v1/highlights/route.ts2
-rw-r--r--apps/web/app/api/v1/lists/[listId]/route.ts6
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx16
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx11
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx53
-rw-r--r--apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx63
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx119
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx38
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx4
-rw-r--r--apps/web/components/dashboard/preview/ActionBar.tsx37
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx2
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx162
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx51
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts15
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json38
-rw-r--r--apps/web/package.json6
-rw-r--r--apps/workers/package.json2
31 files changed, 726 insertions, 147 deletions
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
index b6ac2549..c696f95c 100644
--- a/apps/browser-extension/package.json
+++ b/apps/browser-extension/package.json
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"format": "prettier .",
+ "format:fix": "prettier . --write",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "lint:fix": "eslint . --ext ts,tsx --fix",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json
index f77e7f00..b9bc1c46 100644
--- a/apps/browser-extension/tsconfig.json
+++ b/apps/browser-extension/tsconfig.json
@@ -16,7 +16,7 @@
"jsx": "react-jsx",
"strict": true,
- "noUnusedLocals": true,
+ "noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 383b8f40..2a4fb987 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -33,7 +33,9 @@
"build": "vite build",
"run": "tsx src/index.ts",
"lint": "eslint .",
+ "lint:fix": "eslint . --fix",
"format": "prettier . --ignore-path ../../.prettierignore",
+ "format:fix": "prettier . --write --ignore-path ../../.prettierignore",
"typecheck": "tsc --noEmit"
},
"repository": {
diff --git a/apps/cli/src/commands/bookmarks.ts b/apps/cli/src/commands/bookmarks.ts
index 1537740b..b6f7b2d3 100644
--- a/apps/cli/src/commands/bookmarks.ts
+++ b/apps/cli/src/commands/bookmarks.ts
@@ -73,6 +73,10 @@ bookmarkCmd
collect<string>,
[],
)
+ .option(
+ "--title <title>",
+ "if set, this will be used as the bookmark's title",
+ )
.action(async (opts) => {
const api = getAPIClient();
@@ -81,7 +85,7 @@ bookmarkCmd
const promises = [
...opts.link.map((url) =>
api.bookmarks.createBookmark
- .mutate({ type: BookmarkTypes.LINK, url })
+ .mutate({ type: BookmarkTypes.LINK, url, title: opts.title })
.then((bookmark: ZBookmark) => {
results.push(normalizeBookmark(bookmark));
})
@@ -89,7 +93,7 @@ bookmarkCmd
),
...opts.note.map((text) =>
api.bookmarks.createBookmark
- .mutate({ type: BookmarkTypes.TEXT, text })
+ .mutate({ type: BookmarkTypes.TEXT, text, title: opts.title })
.then((bookmark: ZBookmark) => {
results.push(normalizeBookmark(bookmark));
})
@@ -105,7 +109,7 @@ bookmarkCmd
const text = fs.readFileSync(0, "utf-8");
promises.push(
api.bookmarks.createBookmark
- .mutate({ type: BookmarkTypes.TEXT, text })
+ .mutate({ type: BookmarkTypes.TEXT, text, title: opts.title })
.then((bookmark: ZBookmark) => {
results.push(normalizeBookmark(bookmark));
})
diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts
index 4b157cdf..57b6d948 100644
--- a/apps/cli/src/commands/lists.ts
+++ b/apps/cli/src/commands/lists.ts
@@ -89,9 +89,17 @@ listsCmd
.action(async (opts) => {
const api = getAPIClient();
try {
- const results = await api.lists.get.query({ listId: opts.list });
+ let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list });
+ let results: string[] = resp.bookmarks.map((b) => b.id);
+ while (resp.nextCursor) {
+ resp = await api.bookmarks.getBookmarks.query({
+ listId: opts.list,
+ cursor: resp.nextCursor,
+ });
+ results = [...results, ...resp.bookmarks.map((b) => b.id)];
+ }
- printObject(results.bookmarks);
+ printObject(results);
} catch (error) {
printErrorMessageWithReason(
"Failed to get the ids of the bookmarks in the list",
diff --git a/apps/landing/app/page.tsx b/apps/landing/app/page.tsx
index b2c9b414..db5fbd64 100644
--- a/apps/landing/app/page.tsx
+++ b/apps/landing/app/page.tsx
@@ -107,7 +107,7 @@ function NavBar() {
href={GITHUB_LINK}
className="flex justify-center gap-2 text-center"
>
- Github
+ GitHub
</Link>
<Link
href={DEMO_LINK}
@@ -162,7 +162,7 @@ function Hero() {
buttonVariants({ variant: "outline", size: "lg" }),
)}
>
- <Github /> Github
+ <Github /> GitHub
</Link>
</div>
</div>
@@ -234,7 +234,7 @@ function Footer() {
href={GITHUB_LINK}
className="flex justify-center gap-2 text-center"
>
- Github
+ GitHub
</Link>
</div>
</div>
diff --git a/apps/landing/package.json b/apps/landing/package.json
index ca5c7a98..143b8ea7 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -11,7 +11,9 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
- "format": "prettier --check . --ignore-path ../../.gitignore"
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "format:fix": "prettier --write . --ignore-path ../../.gitignore",
+ "lint:fix": "next lint --fix"
},
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
@@ -19,7 +21,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.330.0",
- "next": "14.2.15",
+ "next": "14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-select": "^5.8.0",
diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
index 13d639c9..ce294a6f 100644
--- a/apps/mobile/components/bookmarks/BookmarkCard.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -1,5 +1,6 @@
import {
ActivityIndicator,
+ Alert,
Image,
Platform,
Pressable,
@@ -70,6 +71,20 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
onError,
});
+ const deleteBookmarkAlert = () =>
+ Alert.alert(
+ "Delete bookmark?",
+ "Are you sure you want to delete this bookmark?",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ onPress: () => deleteBookmark({ bookmarkId: bookmark.id }),
+ style: "destructive",
+ },
+ ],
+ );
+
return (
<View className="flex flex-row gap-4">
{(isArchivePending || isDeletionPending) && <ActivityIndicator />}
@@ -93,9 +108,7 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
onPressAction={({ nativeEvent }) => {
Haptics.selectionAsync();
if (nativeEvent.event === "delete") {
- deleteBookmark({
- bookmarkId: bookmark.id,
- });
+ deleteBookmarkAlert();
} else if (nativeEvent.event === "archive") {
archiveBookmark({
bookmarkId: bookmark.id,
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 6f21820c..2634bdd7 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -9,7 +9,9 @@
"ios": "expo run:ios",
"web": "expo start --web",
"format": "prettier .",
+ "format:fix": "prettier . --write",
"lint": "eslint .",
+ "lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit"
},
"dependencies": {
diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts
index 3bff79ba..66ec6754 100644
--- a/apps/web/app/api/assets/[assetId]/route.ts
+++ b/apps/web/app/api/assets/[assetId]/route.ts
@@ -2,7 +2,11 @@ import { createContextFromRequest } from "@/server/api/client";
import { and, eq } from "drizzle-orm";
import { assets } from "@hoarder/db/schema";
-import { readAsset } from "@hoarder/shared/assetdb";
+import {
+ createAssetReadStream,
+ getAssetSize,
+ readAssetMetadata,
+} from "@hoarder/shared/assetdb";
export const dynamic = "force-dynamic";
export async function GET(
@@ -22,35 +26,60 @@ export async function GET(
return Response.json({ error: "Asset not found" }, { status: 404 });
}
- const { asset, metadata } = await readAsset({
- userId: ctx.user.id,
- assetId: params.assetId,
- });
+ const [metadata, size] = await Promise.all([
+ readAssetMetadata({
+ userId: ctx.user.id,
+ assetId: params.assetId,
+ }),
+
+ getAssetSize({
+ userId: ctx.user.id,
+ assetId: params.assetId,
+ }),
+ ]);
const range = request.headers.get("Range");
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
- const end = parts[1] ? parseInt(parts[1], 10) : asset.length - 1;
+ const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
- // TODO: Don't read the whole asset into memory in the first place
- const chunk = asset.subarray(start, end + 1);
- return new Response(chunk, {
- status: 206, // Partial Content
- headers: {
- "Content-Range": `bytes ${start}-${end}/${asset.length}`,
- "Accept-Ranges": "bytes",
- "Content-Length": chunk.length.toString(),
- "Content-type": metadata.contentType,
- },
+ const stream = createAssetReadStream({
+ userId: ctx.user.id,
+ assetId: params.assetId,
+ start,
+ end,
});
- } else {
- return new Response(asset, {
- status: 200,
- headers: {
- "Content-Length": asset.length.toString(),
- "Content-type": metadata.contentType,
+
+ return new Response(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+ stream as any,
+ {
+ status: 206, // Partial Content
+ headers: {
+ "Content-Range": `bytes ${start}-${end}/${size}`,
+ "Accept-Ranges": "bytes",
+ "Content-Length": (end - start + 1).toString(),
+ "Content-type": metadata.contentType,
+ },
},
+ );
+ } else {
+ const stream = createAssetReadStream({
+ userId: ctx.user.id,
+ assetId: params.assetId,
});
+
+ return new Response(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+ stream as any,
+ {
+ status: 200,
+ headers: {
+ "Content-Length": size.toString(),
+ "Content-type": metadata.contentType,
+ },
+ },
+ );
}
}
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
new file mode 100644
index 00000000..3fc50801
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest } from "next/server";
+import { buildHandler } from "@/app/api/v1/utils/handler";
+import { z } from "zod";
+
+export const dynamic = "force-dynamic";
+
+export const PUT = (
+ req: NextRequest,
+ params: { params: { bookmarkId: string; assetId: string } },
+) =>
+ buildHandler({
+ req,
+ bodySchema: z.object({ assetId: z.string() }),
+ handler: async ({ api, body }) => {
+ await api.bookmarks.replaceAsset({
+ bookmarkId: params.params.bookmarkId,
+ oldAssetId: params.params.assetId,
+ newAssetId: body!.assetId,
+ });
+ return { status: 204 };
+ },
+ });
+
+export const DELETE = (
+ req: NextRequest,
+ params: { params: { bookmarkId: string; assetId: string } },
+) =>
+ buildHandler({
+ req,
+ handler: async ({ api }) => {
+ await api.bookmarks.detachAsset({
+ bookmarkId: params.params.bookmarkId,
+ assetId: params.params.assetId,
+ });
+ return { status: 204 };
+ },
+ });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
new file mode 100644
index 00000000..e5284a39
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest } from "next/server";
+import { buildHandler } from "@/app/api/v1/utils/handler";
+
+import { zAssetSchema } from "@hoarder/shared/types/bookmarks";
+
+export const dynamic = "force-dynamic";
+
+export const GET = (
+ req: NextRequest,
+ params: { params: { bookmarkId: string } },
+) =>
+ buildHandler({
+ req,
+ handler: async ({ api }) => {
+ const resp = await api.bookmarks.getBookmark({
+ bookmarkId: params.params.bookmarkId,
+ });
+ return { status: 200, resp: { assets: resp.assets } };
+ },
+ });
+
+export const POST = (
+ req: NextRequest,
+ params: { params: { bookmarkId: string } },
+) =>
+ buildHandler({
+ req,
+ bodySchema: zAssetSchema,
+ handler: async ({ api, body }) => {
+ const asset = await api.bookmarks.attachAsset({
+ bookmarkId: params.params.bookmarkId,
+ asset: body!,
+ });
+ return { status: 201, resp: asset };
+ },
+ });
diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts
new file mode 100644
index 00000000..f0c5417a
--- /dev/null
+++ b/apps/web/app/api/v1/bookmarks/search/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest } from "next/server";
+import { z } from "zod";
+
+import { buildHandler } from "../../utils/handler";
+
+export const dynamic = "force-dynamic";
+
+export const GET = (req: NextRequest) =>
+ buildHandler({
+ req,
+ searchParamsSchema: z.object({
+ q: z.string(),
+ limit: z.coerce.number().optional(),
+ cursor: z
+ .string()
+ // Search cursor V1 is just a number
+ .pipe(z.coerce.number())
+ .transform((val) => {
+ return { ver: 1 as const, offset: val };
+ })
+ .optional(),
+ }),
+ handler: async ({ api, searchParams }) => {
+ const bookmarks = await api.bookmarks.searchBookmarks({
+ text: searchParams.q,
+ cursor: searchParams.cursor,
+ limit: searchParams.limit,
+ });
+ return {
+ status: 200,
+ resp: {
+ bookmarks: bookmarks.bookmarks,
+ nextCursor: bookmarks.nextCursor
+ ? `${bookmarks.nextCursor.offset}`
+ : null,
+ },
+ };
+ },
+ });
diff --git a/apps/web/app/api/v1/highlights/route.ts b/apps/web/app/api/v1/highlights/route.ts
index ebb96bae..a324d498 100644
--- a/apps/web/app/api/v1/highlights/route.ts
+++ b/apps/web/app/api/v1/highlights/route.ts
@@ -25,6 +25,6 @@ export const POST = (req: NextRequest) =>
bodySchema: zNewHighlightSchema,
handler: async ({ body, api }) => {
const resp = await api.highlights.create(body!);
- return { status: 200, resp };
+ return { status: 201, resp };
},
});
diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts
index 69c99fda..3fd0a32d 100644
--- a/apps/web/app/api/v1/lists/[listId]/route.ts
+++ b/apps/web/app/api/v1/lists/[listId]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
-import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
+import { zEditBookmarkListSchema } from "@hoarder/shared/types/lists";
export const dynamic = "force-dynamic";
@@ -28,11 +28,11 @@ export const PATCH = (
) =>
buildHandler({
req,
- bodySchema: zNewBookmarkListSchema.partial(),
+ bodySchema: zEditBookmarkListSchema.omit({ listId: true }),
handler: async ({ api, body }) => {
const list = await api.lists.edit({
- listId: params.listId,
...body!,
+ listId: params.listId,
});
return { status: 200, resp: list };
},
diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx
index f8c5e0b6..159730a1 100644
--- a/apps/web/app/dashboard/lists/[listId]/page.tsx
+++ b/apps/web/app/dashboard/lists/[listId]/page.tsx
@@ -4,6 +4,8 @@ import ListHeader from "@/components/dashboard/lists/ListHeader";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
+import { BookmarkListContextProvider } from "@hoarder/shared-react/hooks/bookmark-list-context";
+
export default async function ListPage({
params,
}: {
@@ -22,11 +24,13 @@ export default async function ListPage({
}
return (
- <Bookmarks
- query={{ listId: list.id }}
- showDivider={true}
- showEditorCard={true}
- header={<ListHeader initialData={list} />}
- />
+ <BookmarkListContextProvider list={list}>
+ <Bookmarks
+ query={{ listId: list.id }}
+ showDivider={true}
+ showEditorCard={list.type === "manual"}
+ header={<ListHeader initialData={list} />}
+ />
+ </BookmarkListContextProvider>
);
}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
index 1df0c197..a2323987 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
@@ -30,6 +30,13 @@ interface Props {
wrapTags: boolean;
}
+function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) {
+ const createdAt = dayjs(bookmark.createdAt);
+ const oneYearAgo = dayjs().subtract(1, "year");
+ const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY";
+ return createdAt.format(formatString);
+}
+
function BottomRow({
footer,
bookmark,
@@ -45,7 +52,7 @@ function BottomRow({
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
>
- {dayjs(bookmark.createdAt).format("MMM DD")}
+ <BookmarkFormattedCreatedAt bookmark={bookmark} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
@@ -232,7 +239,7 @@ function CompactView({ bookmark, title, footer, className }: Props) {
suppressHydrationWarning
className="shrink-0 gap-2 text-gray-500"
>
- {dayjs(bookmark.createdAt).format("MMM DD")}
+ <BookmarkFormattedCreatedAt bookmark={bookmark} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
index 74eb0868..60a6d634 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
@@ -30,7 +30,7 @@ export function BookmarkMarkdownComponent({
});
};
return (
- <div className="h-full overflow-hidden">
+ <div className="h-full">
{readOnly ? (
<MarkdownReadonly>{bookmark.content.text}</MarkdownReadonly>
) : (
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
index 8dfb96fd..c37c6417 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
@@ -28,15 +28,16 @@ import type {
ZBookmarkedLink,
} from "@hoarder/shared/types/bookmarks";
import {
- useDeleteBookmark,
useRecrawlBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks//bookmarks";
import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists";
import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context";
+import { useBookmarkListContext } from "@hoarder/shared-react/hooks/bookmark-list-context";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
+import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog";
import { ArchivedActionIcon, FavouritedActionIcon } from "./icons";
import { useManageListsModal } from "./ManageListsModal";
import { useTagModel } from "./TagModal";
@@ -53,9 +54,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { setOpen: setManageListsModalOpen, content: manageListsModal } =
useManageListsModal(bookmark.id);
+ const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] =
+ useState(false);
const [isTextEditorOpen, setTextEditorOpen] = useState(false);
const { listId } = useBookmarkGridContext() ?? {};
+ const withinListContext = useBookmarkListContext();
const onError = () => {
toast({
@@ -63,14 +67,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
title: t("common.something_went_wrong"),
});
};
- const deleteBookmarkMutator = useDeleteBookmark({
- onSuccess: () => {
- toast({
- description: t("toasts.bookmarks.deleted"),
- });
- },
- onError,
- });
const updateBookmarkMutator = useUpdateBookmark({
onSuccess: () => {
@@ -112,6 +108,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
<>
{tagModal}
{manageListsModal}
+ <DeleteBookmarkConfirmationDialog
+ bookmark={bookmark}
+ open={deleteBookmarkDialogOpen}
+ setOpen={setDeleteBookmarkDialogOpen}
+ />
<BookmarkedTextEditor
bookmark={bookmark}
open={isTextEditorOpen}
@@ -211,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
<span>{t("actions.manage_lists")}</span>
</DropdownMenuItem>
- {listId && (
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- removeFromListMutator.mutate({
- listId,
- bookmarkId: bookmark.id,
- })
- }
- >
- <ListX className="mr-2 size-4" />
- <span>{t("actions.remove_from_list")}</span>
- </DropdownMenuItem>
- )}
+ {listId &&
+ withinListContext &&
+ withinListContext.type === "manual" && (
+ <DropdownMenuItem
+ disabled={demoMode}
+ onClick={() =>
+ removeFromListMutator.mutate({
+ listId,
+ bookmarkId: bookmark.id,
+ })
+ }
+ >
+ <ListX className="mr-2 size-4" />
+ <span>{t("actions.remove_from_list")}</span>
+ </DropdownMenuItem>
+ )}
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
@@ -240,9 +243,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
<DropdownMenuItem
disabled={demoMode}
className="text-destructive"
- onClick={() =>
- deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id })
- }
+ onClick={() => setDeleteBookmarkDialogOpen(true)}
>
<Trash2 className="mr-2 size-4" />
<span>{t("actions.delete")}</span>
diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx
new file mode 100644
index 00000000..4a69e3d0
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx
@@ -0,0 +1,63 @@
+import { usePathname, useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+
+import { useDeleteBookmark } from "@hoarder/shared-react/hooks//bookmarks";
+import { ZBookmark } from "@hoarder/shared/types/bookmarks";
+
+export default function DeleteBookmarkConfirmationDialog({
+ bookmark,
+ children,
+ open,
+ setOpen,
+}: {
+ bookmark: ZBookmark;
+ children?: React.ReactNode;
+ open: boolean;
+ setOpen: (v: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ const currentPath = usePathname();
+ const router = useRouter();
+
+ const { mutate: deleteBoomark, isPending } = useDeleteBookmark({
+ onSuccess: () => {
+ toast({
+ description: t("toasts.bookmarks.deleted"),
+ });
+ setOpen(false);
+ if (currentPath.includes(bookmark.id)) {
+ router.push("/dashboard/bookmarks");
+ }
+ },
+ onError: () => {
+ toast({
+ variant: "destructive",
+ description: `Something went wrong`,
+ });
+ },
+ });
+
+ return (
+ <ActionConfirmingDialog
+ open={open}
+ setOpen={setOpen}
+ title={t("dialogs.bookmarks.delete_confirmation_title")}
+ description={t("dialogs.bookmarks.delete_confirmation_description")}
+ actionButton={() => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={isPending}
+ onClick={() => deleteBoomark({ bookmarkId: bookmark.id })}
+ >
+ Delete
+ </ActionButton>
+ )}
+ >
+ {children}
+ </ActionConfirmingDialog>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx
index d66d7096..98bb19be 100644
--- a/apps/web/components/dashboard/lists/EditListModal.tsx
+++ b/apps/web/components/dashboard/lists/EditListModal.tsx
@@ -25,6 +25,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import data from "@emoji-mart/data";
@@ -38,7 +45,10 @@ import {
useCreateBookmarkList,
useEditBookmarkList,
} from "@hoarder/shared-react/hooks/lists";
-import { ZBookmarkList } from "@hoarder/shared/types/lists";
+import {
+ ZBookmarkList,
+ zNewBookmarkListSchema,
+} from "@hoarder/shared/types/lists";
import { BookmarkListSelector } from "./BookmarkListSelector";
@@ -46,13 +56,13 @@ export function EditListModal({
open: userOpen,
setOpen: userSetOpen,
list,
- parent,
+ prefill,
children,
}: {
open?: boolean;
setOpen?: (v: boolean) => void;
list?: ZBookmarkList;
- parent?: ZBookmarkList;
+ prefill?: Partial<Omit<ZBookmarkList, "id">>;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
@@ -64,17 +74,14 @@ export function EditListModal({
throw new Error("You must provide both open and setOpen or neither");
}
const [customOpen, customSetOpen] = useState(false);
- const formSchema = z.object({
- name: z.string(),
- icon: z.string(),
- parentId: z.string().nullish(),
- });
- const form = useForm<z.infer<typeof formSchema>>({
- resolver: zodResolver(formSchema),
+ const form = useForm<z.infer<typeof zNewBookmarkListSchema>>({
+ resolver: zodResolver(zNewBookmarkListSchema),
defaultValues: {
- name: list?.name ?? "",
- icon: list?.icon ?? "🚀",
- parentId: list?.parentId ?? parent?.id,
+ name: list?.name ?? prefill?.name ?? "",
+ icon: list?.icon ?? prefill?.icon ?? "🚀",
+ parentId: list?.parentId ?? prefill?.parentId,
+ type: list?.type ?? prefill?.type ?? "manual",
+ query: list?.query ?? prefill?.query ?? undefined,
},
});
const [open, setOpen] = [
@@ -84,9 +91,11 @@ export function EditListModal({
useEffect(() => {
form.reset({
- name: list?.name ?? "",
- icon: list?.icon ?? "🚀",
- parentId: list?.parentId ?? parent?.id,
+ name: list?.name ?? prefill?.name ?? "",
+ icon: list?.icon ?? prefill?.icon ?? "🚀",
+ parentId: list?.parentId ?? prefill?.parentId,
+ type: list?.type ?? prefill?.type ?? "manual",
+ query: list?.query ?? prefill?.query ?? undefined,
});
}, [open]);
@@ -154,14 +163,24 @@ export function EditListModal({
}
},
});
+ const listType = form.watch("type");
+
+ useEffect(() => {
+ if (listType !== "smart") {
+ form.resetField("query");
+ }
+ }, [listType]);
const isEdit = !!list;
const isPending = isCreating || isEditing;
- const onSubmit = form.handleSubmit((value: z.infer<typeof formSchema>) => {
- value.parentId = value.parentId === "" ? null : value.parentId;
- isEdit ? editList({ ...value, listId: list.id }) : createList(value);
- });
+ const onSubmit = form.handleSubmit(
+ (value: z.infer<typeof zNewBookmarkListSchema>) => {
+ value.parentId = value.parentId === "" ? null : value.parentId;
+ value.query = value.type === "smart" ? value.query : undefined;
+ isEdit ? editList({ ...value, listId: list.id }) : createList(value);
+ },
+ );
return (
<Dialog
@@ -176,7 +195,9 @@ export function EditListModal({
<Form {...form}>
<form onSubmit={onSubmit}>
<DialogHeader>
- <DialogTitle>{isEdit ? "Edit" : "New"} List</DialogTitle>
+ <DialogTitle>
+ {isEdit ? t("lists.edit_list") : t("lists.new_list")}
+ </DialogTitle>
</DialogHeader>
<div className="flex w-full gap-2 py-4">
<FormField
@@ -232,7 +253,7 @@ export function EditListModal({
render={({ field }) => {
return (
<FormItem className="grow pb-4">
- <FormLabel>Parent</FormLabel>
+ <FormLabel>{t("lists.parent_list")}</FormLabel>
<div className="flex items-center gap-1">
<FormControl>
<BookmarkListSelector
@@ -240,7 +261,7 @@ export function EditListModal({
hideSubtreeOf={list ? list.id : undefined}
value={field.value}
onChange={field.onChange}
- placeholder={"No Parent"}
+ placeholder={t("lists.no_parent")}
/>
</FormControl>
<Button
@@ -258,6 +279,58 @@ export function EditListModal({
);
}}
/>
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => {
+ return (
+ <FormItem className="grow pb-4">
+ <FormLabel>{t("lists.list_type")}</FormLabel>
+ <FormControl>
+ <Select
+ disabled={isEdit}
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="manual">
+ {t("lists.manual_list")}
+ </SelectItem>
+ <SelectItem value="smart">
+ {t("lists.smart_list")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ {listType === "smart" && (
+ <FormField
+ control={form.control}
+ name="query"
+ render={({ field }) => {
+ return (
+ <FormItem className="grow pb-4">
+ <FormLabel>{t("lists.search_query")}</FormLabel>
+ <FormControl>
+ <Input
+ value={field.value}
+ onChange={field.onChange}
+ placeholder={t("lists.search_query")}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ )}
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx
index a6780e1e..b8bfb4ad 100644
--- a/apps/web/components/dashboard/lists/ListHeader.tsx
+++ b/apps/web/components/dashboard/lists/ListHeader.tsx
@@ -1,19 +1,24 @@
"use client";
+import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
-import { MoreHorizontal } from "lucide-react";
+import { useTranslation } from "@/lib/i18n/client";
+import { MoreHorizontal, SearchIcon } from "lucide-react";
import { api } from "@hoarder/shared-react/trpc";
+import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
import { ZBookmarkList } from "@hoarder/shared/types/lists";
+import QueryExplainerTooltip from "../search/QueryExplainerTooltip";
import { ListOptions } from "./ListOptions";
export default function ListHeader({
initialData,
}: {
- initialData: ZBookmarkList & { bookmarks: string[] };
+ initialData: ZBookmarkList;
}) {
+ const { t } = useTranslation();
const router = useRouter();
const { data: list, error } = api.lists.get.useQuery(
{
@@ -24,6 +29,13 @@ export default function ListHeader({
},
);
+ const parsedQuery = useMemo(() => {
+ if (!list.query) {
+ return null;
+ }
+ return parseSearchQuery(list.query);
+ }, [list.query]);
+
if (error) {
// This is usually exercised during list deletions.
if (error.data?.code == "NOT_FOUND") {
@@ -33,10 +45,24 @@ export default function ListHeader({
return (
<div className="flex items-center justify-between">
- <span className="text-2xl">
- {list.icon} {list.name}
- </span>
- <div className="flex">
+ <div className="flex items-center gap-2">
+ <span className="text-2xl">
+ {list.icon} {list.name}
+ </span>
+ </div>
+ <div className="flex items-center">
+ {parsedQuery && (
+ <QueryExplainerTooltip
+ header={
+ <div className="flex items-center justify-center gap-1">
+ <SearchIcon className="size-3" />
+ <span className="text-sm">{t("lists.smart_list")}</span>
+ </div>
+ }
+ parsedSearchQuery={parsedQuery}
+ className="size-6 stroke-foreground"
+ />
+ )}
<ListOptions list={list}>
<Button variant="ghost">
<MoreHorizontal />
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index e663a2e0..a7217954 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -33,7 +33,9 @@ export function ListOptions({
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
- parent={list}
+ prefill={{
+ parentId: list.id,
+ }}
/>
<EditListModal
open={editModalOpen}
diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx
index 38ad8fa2..86c86d5a 100644
--- a/apps/web/components/dashboard/preview/ActionBar.tsx
+++ b/apps/web/components/dashboard/preview/ActionBar.tsx
@@ -1,5 +1,6 @@
-import { useRouter } from "next/navigation";
+import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
@@ -10,16 +11,16 @@ import { useTranslation } from "@/lib/i18n/client";
import { Trash2 } from "lucide-react";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
-import {
- useDeleteBookmark,
- useUpdateBookmark,
-} from "@hoarder/shared-react/hooks/bookmarks";
+import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
+import DeleteBookmarkConfirmationDialog from "../bookmarks/DeleteBookmarkConfirmationDialog";
import { ArchivedActionIcon, FavouritedActionIcon } from "../bookmarks/icons";
export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
const { t } = useTranslation();
- const router = useRouter();
+ const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] =
+ useState(false);
+
const onError = () => {
toast({
variant: "destructive",
@@ -44,16 +45,6 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
},
onError,
});
- const { mutate: deleteBookmark, isPending: pendingDeletion } =
- useDeleteBookmark({
- onSuccess: () => {
- toast({
- description: "The bookmark has been deleted!",
- });
- router.back();
- },
- onError,
- });
return (
<div className="flex items-center justify-center gap-3">
@@ -100,17 +91,19 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
+ <DeleteBookmarkConfirmationDialog
+ bookmark={bookmark}
+ open={deleteBookmarkDialogOpen}
+ setOpen={setDeleteBookmarkDialogOpen}
+ />
<TooltipTrigger asChild>
- <ActionButton
- loading={pendingDeletion}
+ <Button
className="size-14 rounded-full bg-background"
variant="none"
- onClick={() => {
- deleteBookmark({ bookmarkId: bookmark.id });
- }}
+ onClick={() => setDeleteBookmarkDialogOpen(true)}
>
<Trash2 />
- </ActionButton>
+ </Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("actions.delete")}</TooltipContent>
</Tooltip>
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index c257d902..fd70fd4e 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -114,7 +114,7 @@ export default function BookmarkPreview({
<div className="row-span-2 h-full w-full overflow-auto p-2 md:col-span-2 lg:row-auto">
{isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content}
</div>
- <div className="lg:col-span1 row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 lg:row-auto">
+ <div className="row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 md:col-span-2 lg:col-span-1 lg:row-auto">
<div className="flex w-full flex-col items-center justify-center gap-y-2">
<EditableTitle bookmark={bookmark} />
{sourceUrl && (
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
new file mode 100644
index 00000000..f5f73be3
--- /dev/null
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -0,0 +1,162 @@
+import InfoTooltip from "@/components/ui/info-tooltip";
+import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
+import { useTranslation } from "@/lib/i18n/client";
+
+import { TextAndMatcher } from "@hoarder/shared/searchQueryParser";
+import { Matcher } from "@hoarder/shared/types/search";
+
+export default function QueryExplainerTooltip({
+ parsedSearchQuery,
+ header,
+ className,
+}: {
+ header?: React.ReactNode;
+ parsedSearchQuery: TextAndMatcher & { result: string };
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ if (parsedSearchQuery.result == "invalid") {
+ return null;
+ }
+
+ const MatcherComp = ({ matcher }: { matcher: Matcher }) => {
+ switch (matcher.type) {
+ case "tagName":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.does_not_have_tag")
+ : t("search.has_tag")}
+ </TableCell>
+ <TableCell>{matcher.tagName}</TableCell>
+ </TableRow>
+ );
+ case "listName":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.is_not_in_list")
+ : t("search.is_in_list")}
+ </TableCell>
+ <TableCell>{matcher.listName}</TableCell>
+ </TableRow>
+ );
+ case "dateAfter":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.not_created_on_or_after")
+ : t("search.created_on_or_after")}
+ </TableCell>
+ <TableCell>{matcher.dateAfter.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "dateBefore":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.not_created_on_or_before")
+ : t("search.created_on_or_before")}
+ </TableCell>
+ <TableCell>{matcher.dateBefore.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "favourited":
+ return (
+ <TableRow>
+ <TableCell colSpan={2} className="text-center">
+ {matcher.favourited
+ ? t("search.is_favorited")
+ : t("search.is_not_favorited")}
+ </TableCell>
+ </TableRow>
+ );
+ case "archived":
+ return (
+ <TableRow>
+ <TableCell colSpan={2} className="text-center">
+ {matcher.archived
+ ? t("search.is_archived")
+ : t("search.is_not_archived")}
+ </TableCell>
+ </TableRow>
+ );
+ case "tagged":
+ return (
+ <TableRow>
+ <TableCell colSpan={2} className="text-center">
+ {matcher.tagged
+ ? t("search.has_any_tag")
+ : t("search.has_no_tags")}
+ </TableCell>
+ </TableRow>
+ );
+ case "inlist":
+ return (
+ <TableRow>
+ <TableCell colSpan={2} className="text-center">
+ {matcher.inList
+ ? t("search.is_in_any_list")
+ : t("search.is_not_in_any_list")}
+ </TableCell>
+ </TableRow>
+ );
+ case "and":
+ case "or":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.type === "and" ? t("search.and") : t("search.or")}
+ </TableCell>
+ <TableCell>
+ <Table>
+ <TableBody>
+ {matcher.matchers.map((m, i) => (
+ <MatcherComp key={i} matcher={m} />
+ ))}
+ </TableBody>
+ </Table>
+ </TableCell>
+ </TableRow>
+ );
+ case "url":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.url_does_not_contain")
+ : t("search.url_contains")}
+ </TableCell>
+ <TableCell>{matcher.url}</TableCell>
+ </TableRow>
+ );
+ default: {
+ const _exhaustiveCheck: never = matcher;
+ return null;
+ }
+ }
+ };
+
+ return (
+ <InfoTooltip className={className}>
+ {header}
+ <Table>
+ <TableBody>
+ {parsedSearchQuery.text && (
+ <TableRow>
+ <TableCell>{t("search.full_text_search")}</TableCell>
+ <TableCell>{parsedSearchQuery.text}</TableCell>
+ </TableRow>
+ )}
+ {parsedSearchQuery.matcher && (
+ <MatcherComp matcher={parsedSearchQuery.matcher} />
+ )}
+ </TableBody>
+ </Table>
+ </InfoTooltip>
+ );
+}
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 55f304e3..5e46fc18 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -1,9 +1,14 @@
"use client";
-import React, { useEffect, useImperativeHandle, useRef } from "react";
+import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
+import { cn } from "@/lib/utils";
+
+import { EditListModal } from "../lists/EditListModal";
+import QueryExplainerTooltip from "./QueryExplainerTooltip";
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
@@ -47,7 +52,8 @@ const SearchInput = React.forwardRef<
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { t } = useTranslation();
- const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch();
+ const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } =
+ useDoBookmarkSearch();
const [value, setValue] = React.useState(searchQuery);
@@ -59,6 +65,7 @@ const SearchInput = React.forwardRef<
useFocusSearchOnKeyPress(inputRef, onChange);
useImperativeHandle(ref, () => inputRef.current!);
+ const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
useEffect(() => {
if (!isInSearchPage) {
@@ -67,14 +74,38 @@ const SearchInput = React.forwardRef<
}, [isInSearchPage]);
return (
- <Input
- ref={inputRef}
- value={value}
- onChange={onChange}
- placeholder={t("common.search")}
- className={className}
- {...props}
- />
+ <div className={cn("relative flex-1", className)}>
+ <EditListModal
+ open={newNestedListModalOpen}
+ setOpen={setNewNestedListModalOpen}
+ prefill={{
+ type: "smart",
+ query: value,
+ }}
+ />
+ <QueryExplainerTooltip
+ className="-translate-1/2 absolute right-1.5 top-2 stroke-foreground p-0.5"
+ parsedSearchQuery={parsedSearchQuery}
+ />
+ {parsedSearchQuery.result === "full" &&
+ parsedSearchQuery.text.length == 0 && (
+ <Button
+ onClick={() => setNewNestedListModalOpen(true)}
+ size="none"
+ variant="secondary"
+ className="absolute right-9 top-2 z-50 px-2 py-1 text-xs"
+ >
+ {t("actions.save")}
+ </Button>
+ )}
+ <Input
+ ref={inputRef}
+ value={value}
+ onChange={onChange}
+ placeholder={t("common.search")}
+ {...props}
+ />
+ </div>
);
});
SearchInput.displayName = "SearchInput";
diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts
index 9890ac6f..386355f7 100644
--- a/apps/web/lib/hooks/bookmark-search.ts
+++ b/apps/web/lib/hooks/bookmark-search.ts
@@ -1,17 +1,20 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
+import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
+
function useSearchQuery() {
const searchParams = useSearchParams();
- const searchQuery = searchParams.get("q") ?? "";
- return { searchQuery };
+ const searchQuery = decodeURIComponent(searchParams.get("q") ?? "");
+ const parsed = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]);
+ return { searchQuery, parsedSearchQuery: parsed };
}
export function useDoBookmarkSearch() {
const router = useRouter();
- const { searchQuery } = useSearchQuery();
+ const { searchQuery, parsedSearchQuery } = useSearchQuery();
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const pathname = usePathname();
@@ -26,7 +29,7 @@ export function useDoBookmarkSearch() {
const doSearch = (val: string) => {
setTimeoutId(undefined);
- router.replace(`/dashboard/search?q=${val}`);
+ router.replace(`/dashboard/search?q=${encodeURIComponent(val)}`);
};
const debounceSearch = (val: string) => {
@@ -43,6 +46,7 @@ export function useDoBookmarkSearch() {
doSearch,
debounceSearch,
searchQuery,
+ parsedSearchQuery,
isInSearchPage: pathname.startsWith("/dashboard/search"),
};
}
@@ -75,7 +79,6 @@ export function useBookmarkSearch() {
}
return {
- searchQuery,
error,
data,
isPending,
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 92f6e956..e1bd443a 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -166,7 +166,14 @@
"all_lists": "All Lists",
"favourites": "Favourites",
"new_list": "New List",
- "new_nested_list": "New Nested List"
+ "edit_list": "Edit List",
+ "new_nested_list": "New Nested List",
+ "parent_list": "Parent List",
+ "no_parent": "No Parent",
+ "list_type": "List Type",
+ "manual_list": "Manual List",
+ "smart_list": "Smart List",
+ "search_query": "Search Query"
},
"tags": {
"all_tags": "All Tags",
@@ -181,6 +188,29 @@
"drag_and_drop_merging_info": "Drag and drop tags on each other to merge them",
"sort_by_name": "Sort by Name"
},
+ "search": {
+ "is_favorited": "Is Favorited",
+ "is_not_favorited": "Is Not Favorited",
+ "is_archived": "Is Archived",
+ "is_not_archived": "Is Not Archived",
+ "has_any_tag": "Has Any Tag",
+ "has_no_tags": "Has No Tag",
+ "is_in_any_list": "Is In Any List",
+ "is_not_in_any_list": "Is not In Any List",
+ "created_on_or_after": "Created on or After",
+ "not_created_on_or_after": "Not Created on or After",
+ "created_on_or_before": "Created on or Before",
+ "not_created_on_or_before": "Not Created on or Before",
+ "url_contains": "URL Contains",
+ "url_does_not_contain": "URL Does Not Contain",
+ "is_in_list": "Is In List",
+ "is_not_in_list": "Is not In List",
+ "has_tag": "Has Tag",
+ "does_not_have_tag": "Does Not Have Tag",
+ "full_text_search": "Full Text Search",
+ "and": "And",
+ "or": "Or"
+ },
"preview": {
"view_original": "View Original",
"cached_content": "Cached Content"
@@ -243,6 +273,12 @@
}
}
},
+ "dialogs": {
+ "bookmarks": {
+ "delete_confirmation_title": "Delete Bookmark?",
+ "delete_confirmation_description": "Are you sure you want to delete this bookmark?"
+ }
+ },
"toasts": {
"bookmarks": {
"updated": "The bookmark has been updated!",
diff --git a/apps/web/package.json b/apps/web/package.json
index 96058ece..d2cbd589 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,7 +12,9 @@
"lint": "next lint",
"test": "vitest",
"typecheck": "tsc --noEmit",
- "format": "prettier --check . --ignore-path ../../.gitignore"
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "format:fix": "prettier --write . --ignore-path ../../.gitignore",
+ "lint:fix": "next lint --fix"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.4.2",
@@ -61,7 +63,7 @@
"i18next-resources-to-backend": "^1.2.1",
"lexical": "^0.20.2",
"lucide-react": "^0.330.0",
- "next": "14.2.15",
+ "next": "14.2.21",
"next-auth": "^4.24.5",
"next-i18next": "^15.3.1",
"next-pwa": "^5.6.0",
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 61d429a6..1e522024 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -53,7 +53,9 @@
"start": "tsx watch index.ts",
"start:prod": "tsx index.ts",
"lint": "eslint .",
+ "lint:fix": "eslint . --fix",
"format": "prettier . --ignore-path ../../.prettierignore",
+ "format:fix": "prettier . --ignore-path ../../.prettierignore --write",
"typecheck": "tsc --noEmit"
},
"eslintConfig": {