aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/cli/src/commands/lists.ts12
-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/BookmarkOptions.tsx32
-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/search/QueryExplainerTooltip.tsx3
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx24
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json9
10 files changed, 206 insertions, 57 deletions
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/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/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
index e9e5834b..c37c6417 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
@@ -33,6 +33,7 @@ import {
} 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";
@@ -58,6 +59,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const [isTextEditorOpen, setTextEditorOpen] = useState(false);
const { listId } = useBookmarkGridContext() ?? {};
+ const withinListContext = useBookmarkListContext();
const onError = () => {
toast({
@@ -210,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
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/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
index eb7282d0..13174fb2 100644
--- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -6,8 +6,10 @@ import { Matcher } from "@hoarder/shared/types/search";
export default function QueryExplainerTooltip({
parsedSearchQuery,
+ header,
className,
}: {
+ header?: React.ReactNode;
parsedSearchQuery: TextAndMatcher & { result: string };
className?: string;
}) {
@@ -98,6 +100,7 @@ export default function QueryExplainerTooltip({
return (
<InfoTooltip className={className}>
+ {header}
<Table>
<TableBody>
{parsedSearchQuery.text && (
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 8ed2ea3c..ace3d785 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -1,11 +1,13 @@
"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(
@@ -63,6 +65,7 @@ const SearchInput = React.forwardRef<
useFocusSearchOnKeyPress(inputRef, onChange);
useImperativeHandle(ref, () => inputRef.current!);
+ const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
useEffect(() => {
if (!isInSearchPage) {
@@ -72,10 +75,29 @@ const SearchInput = React.forwardRef<
return (
<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 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}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index cdd31922..27799121 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",