Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for smart lists #802

Merged
merged 11 commits into from
Jan 2, 2025
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ README.md
**/*.db
**/.env*
.git
./data
12 changes: 10 additions & 2 deletions apps/cli/src/commands/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/api/v1/lists/[listId]/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 };
},
Expand Down
16 changes: 10 additions & 6 deletions apps/web/app/dashboard/lists/[listId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand All @@ -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>
);
}
32 changes: 18 additions & 14 deletions apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
119 changes: 96 additions & 23 deletions apps/web/components/dashboard/lists/EditListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,21 +45,24 @@ 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";

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();
Expand All @@ -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] = [
Expand All @@ -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]);

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -232,15 +253,15 @@ 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
// Hide the current list from the list of parents
hideSubtreeOf={list ? list.id : undefined}
value={field.value}
onChange={field.onChange}
placeholder={"No Parent"}
placeholder={t("lists.no_parent")}
/>
</FormControl>
<Button
Expand All @@ -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">
Expand Down
38 changes: 32 additions & 6 deletions apps/web/components/dashboard/lists/ListHeader.tsx
Original file line number Diff line number Diff line change
@@ -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(
{
Expand All @@ -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") {
Expand All @@ -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 />
Expand Down
Loading
Loading