aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-04-23 13:56:35 +0100
committerMohamedBassem <me@mbassem.com>2024-04-23 13:56:35 +0100
commit77b1aba5acc66dfaeb02b08d60d88442336026a6 (patch)
tree950f71d7c868869902e742644697e077db734769
parent0e260954c13cfedb03e75d3f0db8a2e839fd008d (diff)
downloadkarakeep-77b1aba5acc66dfaeb02b08d60d88442336026a6.tar.zst
feature(extension): Allow adding tags and lists to newly hoarded bookmarks
-rw-r--r--apps/browser-extension/package.json1
-rw-r--r--apps/browser-extension/src/BookmarkDeletedPage.tsx2
-rw-r--r--apps/browser-extension/src/BookmarkSavedPage.tsx30
-rw-r--r--apps/browser-extension/src/Layout.tsx2
-rw-r--r--apps/browser-extension/src/components/BookmarkLists.tsx45
-rw-r--r--apps/browser-extension/src/components/ListsSelector.tsx107
-rw-r--r--apps/browser-extension/src/components/TagList.tsx32
-rw-r--r--apps/browser-extension/src/components/TagsSelector.tsx117
-rw-r--r--apps/browser-extension/src/components/ui/badge.tsx37
-rw-r--r--apps/browser-extension/src/components/ui/command.tsx155
-rw-r--r--apps/browser-extension/src/components/ui/popover.tsx29
-rw-r--r--packages/shared-react/hooks/bookmarks.ts19
-rw-r--r--packages/shared-react/hooks/lists.ts7
-rw-r--r--packages/shared/utils/listUtils.ts7
-rw-r--r--packages/trpc/routers/lists.ts20
-rw-r--r--pnpm-lock.yaml16
16 files changed, 617 insertions, 9 deletions
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
index 9b204519..6c40d372 100644
--- a/apps/browser-extension/package.json
+++ b/apps/browser-extension/package.json
@@ -23,6 +23,7 @@
"@trpc/next": "11.0.0-next-beta.308",
"@trpc/react-query": "11.0.0-next-beta.308",
"@trpc/server": "11.0.0-next-beta.308",
+ "@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
diff --git a/apps/browser-extension/src/BookmarkDeletedPage.tsx b/apps/browser-extension/src/BookmarkDeletedPage.tsx
index 23e1d9da..c26e9616 100644
--- a/apps/browser-extension/src/BookmarkDeletedPage.tsx
+++ b/apps/browser-extension/src/BookmarkDeletedPage.tsx
@@ -1,3 +1,3 @@
export default function BookmarkDeletedPage() {
- return <p className="text-lg">Bookmark Deleted!</p>;
+ return <p className="text-xl">Bookmark Deleted!</p>;
}
diff --git a/apps/browser-extension/src/BookmarkSavedPage.tsx b/apps/browser-extension/src/BookmarkSavedPage.tsx
index 3535ade8..2c594ad8 100644
--- a/apps/browser-extension/src/BookmarkSavedPage.tsx
+++ b/apps/browser-extension/src/BookmarkSavedPage.tsx
@@ -4,7 +4,13 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { useDeleteBookmark } from "@hoarder/shared-react/hooks/bookmarks";
+import BookmarkLists from "./components/BookmarkLists";
+import { ListsSelector } from "./components/ListsSelector";
+import TagList from "./components/TagList";
+import { TagsSelector } from "./components/TagsSelector";
+import { Button, buttonVariants } from "./components/ui/button";
import Spinner from "./Spinner";
+import { cn } from "./utils/css";
import usePluginSettings from "./utils/settings";
export default function BookmarkSavedPage() {
@@ -31,10 +37,13 @@ export default function BookmarkSavedPage() {
<div className="flex flex-col gap-2">
{error && <p className="text-red-500">{error}</p>}
<div className="flex items-center justify-between gap-2">
- <p className="text-lg">Bookmarked!</p>
+ <p className="text-xl">Hoarded!</p>
<div className="flex gap-2">
<Link
- className="flex gap-2 rounded-md p-3 text-black hover:text-black"
+ className={cn(
+ buttonVariants({ variant: "link" }),
+ "flex gap-2 rounded-md p-3",
+ )}
target="_blank"
rel="noreferrer"
to={`${settings.address}/dashboard/preview/${bookmarkId}`}
@@ -42,9 +51,10 @@ export default function BookmarkSavedPage() {
<ArrowUpRightFromSquare className="my-auto" size="20" />
<p className="my-auto">Open</p>
</Link>
- <button
- onClick={() => deleteBookmark({ bookmarkId: bookmarkId })}
- className="flex gap-2 bg-transparent text-red-500 hover:text-red-500"
+ <Button
+ variant="link"
+ onClick={() => deleteBookmark({ bookmarkId })}
+ className="flex gap-2 text-red-500 hover:text-red-500"
>
{!isPending ? (
<>
@@ -56,9 +66,17 @@ export default function BookmarkSavedPage() {
<Spinner />
</span>
)}
- </button>
+ </Button>
</div>
</div>
+ <hr />
+ <p className="text-lg">Tags</p>
+ <TagList bookmarkId={bookmarkId} />
+ <TagsSelector bookmarkId={bookmarkId} />
+ <hr />
+ <p className="text-lg">Lists</p>
+ <BookmarkLists bookmarkId={bookmarkId} />
+ <ListsSelector bookmarkId={bookmarkId} />
</div>
);
}
diff --git a/apps/browser-extension/src/Layout.tsx b/apps/browser-extension/src/Layout.tsx
index 0431f550..5c6eb3d2 100644
--- a/apps/browser-extension/src/Layout.tsx
+++ b/apps/browser-extension/src/Layout.tsx
@@ -18,7 +18,7 @@ export default function Layout() {
return (
<div className="flex flex-col space-y-2">
- <div className="rounded-md bg-yellow-100 p-4 text-black">
+ <div className="rounded-md bg-gray-100 p-4 dark:bg-gray-900">
<Outlet />
</div>
<hr />
diff --git a/apps/browser-extension/src/components/BookmarkLists.tsx b/apps/browser-extension/src/components/BookmarkLists.tsx
new file mode 100644
index 00000000..9ccd8951
--- /dev/null
+++ b/apps/browser-extension/src/components/BookmarkLists.tsx
@@ -0,0 +1,45 @@
+import { X } from "lucide-react";
+
+import {
+ useBookmarkLists,
+ useRemoveBookmarkFromList,
+} from "@hoarder/shared-react/hooks/lists";
+
+import { api } from "../utils/trpc";
+import { Button } from "./ui/button";
+
+export default function BookmarkLists({ bookmarkId }: { bookmarkId: string }) {
+ const { data: allLists } = useBookmarkLists();
+
+ const { mutate: deleteFromList } = useRemoveBookmarkFromList();
+
+ const { data: lists } = api.lists.getListsOfBookmark.useQuery({ bookmarkId });
+ if (!lists || !allLists) {
+ return null;
+ }
+
+ return (
+ <ul className="flex flex-col gap-1">
+ {lists.lists.map((l) => (
+ <li
+ key={l.id}
+ className="flex items-center justify-between rounded border border-border bg-background p-2 text-sm text-foreground"
+ >
+ <span>
+ {allLists
+ .getPathById(l.id)!
+ .map((l) => `${l.icon} ${l.name}`)
+ .join(" / ")}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => deleteFromList({ bookmarkId, listId: l.id })}
+ >
+ <X className="size-4" />
+ </Button>
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/apps/browser-extension/src/components/ListsSelector.tsx b/apps/browser-extension/src/components/ListsSelector.tsx
new file mode 100644
index 00000000..adddf4b4
--- /dev/null
+++ b/apps/browser-extension/src/components/ListsSelector.tsx
@@ -0,0 +1,107 @@
+import * as React from "react";
+import { useSet } from "@uidotdev/usehooks";
+import { Check, ChevronsUpDown } from "lucide-react";
+
+import {
+ useAddBookmarkToList,
+ useBookmarkLists,
+ useRemoveBookmarkFromList,
+} from "@hoarder/shared-react/hooks/lists";
+
+import { cn } from "../utils/css";
+import { api } from "../utils/trpc";
+import { Button } from "./ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "./ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+
+export function ListsSelector({ bookmarkId }: { bookmarkId: string }) {
+ const currentlyUpdating = useSet<string>();
+ const [open, setOpen] = React.useState(false);
+
+ const { mutate: addToList } = useAddBookmarkToList();
+ const { mutate: removeFromList } = useRemoveBookmarkFromList();
+ const { data: existingLists } = api.lists.getListsOfBookmark.useQuery({
+ bookmarkId,
+ });
+
+ const { data: allLists } = useBookmarkLists();
+
+ const existingListIds = new Set(existingLists?.lists.map((list) => list.id));
+
+ const toggleList = (listId: string) => {
+ currentlyUpdating.add(listId);
+ if (existingListIds.has(listId)) {
+ removeFromList(
+ { bookmarkId, listId },
+ {
+ onSettled: (_resp, _err, req) => currentlyUpdating.delete(req.listId),
+ },
+ );
+ } else {
+ addToList(
+ { bookmarkId, listId },
+ {
+ onSettled: (_resp, _err, req) => currentlyUpdating.delete(req.listId),
+ },
+ );
+ }
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="justify-between"
+ >
+ Add to List...
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[200px] p-0">
+ <Command>
+ <CommandInput placeholder="Search Lists ..." />
+ <CommandList>
+ <CommandEmpty>You don&apos;t have any lists.</CommandEmpty>
+ <CommandGroup>
+ {allLists?.allPaths.map((path) => {
+ const lastItem = path[path.length - 1];
+
+ return (
+ <CommandItem
+ key={lastItem.id}
+ value={lastItem.id}
+ keywords={[lastItem.name, lastItem.icon]}
+ onSelect={toggleList}
+ disabled={currentlyUpdating.has(lastItem.id)}
+ >
+ <Check
+ className={cn(
+ "mr-2 size-4",
+ existingListIds.has(lastItem.id)
+ ? "opacity-100"
+ : "opacity-0",
+ )}
+ />
+ {path
+ .map((item) => `${item.icon} ${item.name}`)
+ .join(" / ")}
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+}
diff --git a/apps/browser-extension/src/components/TagList.tsx b/apps/browser-extension/src/components/TagList.tsx
new file mode 100644
index 00000000..96b8501e
--- /dev/null
+++ b/apps/browser-extension/src/components/TagList.tsx
@@ -0,0 +1,32 @@
+import { useAutoRefreshingBookmarkQuery } from "@hoarder/shared-react/hooks/bookmarks";
+import { isBookmarkStillTagging } from "@hoarder/shared-react/utils/bookmarkUtils";
+
+import { Badge } from "./ui/badge";
+
+export default function TagList({ bookmarkId }: { bookmarkId: string }) {
+ const { data: bookmark } = useAutoRefreshingBookmarkQuery({ bookmarkId });
+ if (!bookmark) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {bookmark.tags.length === 0 && !isBookmarkStillTagging(bookmark) && (
+ <Badge variant="secondary">No tags</Badge>
+ )}
+ {bookmark.tags.map((tag) => (
+ <Badge
+ key={tag.id}
+ className={
+ tag.attachedBy == "ai" ? "bg-purple-500 text-white" : undefined
+ }
+ >
+ {tag.name}
+ </Badge>
+ ))}
+ {isBookmarkStillTagging(bookmark) && (
+ <Badge variant="secondary">AI tags loading...</Badge>
+ )}
+ </div>
+ );
+}
diff --git a/apps/browser-extension/src/components/TagsSelector.tsx b/apps/browser-extension/src/components/TagsSelector.tsx
new file mode 100644
index 00000000..45cf11d5
--- /dev/null
+++ b/apps/browser-extension/src/components/TagsSelector.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+import { useSet } from "@uidotdev/usehooks";
+import { Check, ChevronsUpDown, Plus } from "lucide-react";
+
+import {
+ useAutoRefreshingBookmarkQuery,
+ useUpdateBookmarkTags,
+} from "@hoarder/shared-react/hooks/bookmarks";
+
+import { cn } from "../utils/css";
+import { api } from "../utils/trpc";
+import { Button } from "./ui/button";
+import {
+ Command,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "./ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+
+export function TagsSelector({ bookmarkId }: { bookmarkId: string }) {
+ const { data: allTags } = api.tags.list.useQuery();
+ const { data: bookmark } = useAutoRefreshingBookmarkQuery({ bookmarkId });
+
+ const existingTagIds = new Set(bookmark?.tags.map((t) => t.id) ?? []);
+
+ const [input, setInput] = React.useState("");
+ const [open, setOpen] = React.useState(false);
+ const currentlyUpdating = useSet<string>();
+
+ const { mutate } = useUpdateBookmarkTags({
+ onMutate: (req) => {
+ req.attach.forEach((t) => currentlyUpdating.add(t.tagId ?? ""));
+ req.detach.forEach((t) => currentlyUpdating.add(t.tagId));
+ },
+ onSettled: (_resp, _err, req) => {
+ if (!req) {
+ return;
+ }
+ req.attach.forEach((t) => currentlyUpdating.delete(t.tagId ?? ""));
+ req.detach.forEach((t) => currentlyUpdating.delete(t.tagId));
+ },
+ });
+
+ const toggleTag = (tagId: string) => {
+ mutate({
+ bookmarkId,
+ attach: existingTagIds.has(tagId) ? [] : [{ tagId }],
+ detach: existingTagIds.has(tagId) ? [{ tagId }] : [],
+ });
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="justify-between"
+ >
+ Add Tags...
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[200px] p-0">
+ <Command>
+ <CommandInput
+ value={input}
+ onValueChange={setInput}
+ placeholder="Search Tags ..."
+ />
+ <CommandList>
+ <CommandGroup>
+ {allTags?.tags
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((tag) => (
+ <CommandItem
+ key={tag.id}
+ value={tag.id}
+ keywords={[tag.name]}
+ onSelect={toggleTag}
+ disabled={currentlyUpdating.has(tag.id)}
+ >
+ <Check
+ className={cn(
+ "mr-2 size-4",
+ existingTagIds.has(tag.id)
+ ? "opacity-100"
+ : "opacity-0",
+ )}
+ />
+ {tag.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ <CommandGroup>
+ <CommandItem
+ onSelect={() =>
+ mutate({
+ bookmarkId,
+ attach: [{ tagName: input }],
+ detach: [],
+ })
+ }
+ >
+ <Plus className="mr-2 size-4" />
+ Create &quot;{input}&quot; ...
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+}
diff --git a/apps/browser-extension/src/components/ui/badge.tsx b/apps/browser-extension/src/components/ui/badge.tsx
new file mode 100644
index 00000000..0b043596
--- /dev/null
+++ b/apps/browser-extension/src/components/ui/badge.tsx
@@ -0,0 +1,37 @@
+import type { VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { cva } from "class-variance-authority";
+
+import { cn } from "../../utils/css";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/browser-extension/src/components/ui/command.tsx b/apps/browser-extension/src/components/ui/command.tsx
new file mode 100644
index 00000000..2d5cd64e
--- /dev/null
+++ b/apps/browser-extension/src/components/ui/command.tsx
@@ -0,0 +1,155 @@
+import type { DialogProps } from "@radix-ui/react-dialog";
+import * as React from "react";
+import { Command as CommandPrimitive } from "cmdk";
+import { Search } from "lucide-react";
+
+import { cn } from "../../utils/css";
+import { Dialog, DialogContent } from "./dialog";
+
+const Command = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive
+ ref={ref}
+ className={cn(
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
+ className,
+ )}
+ {...props}
+ />
+));
+Command.displayName = CommandPrimitive.displayName;
+
+type CommandDialogProps = DialogProps;
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+ <Dialog {...props}>
+ <DialogContent className="overflow-hidden p-0 shadow-lg">
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+ {children}
+ </Command>
+ </DialogContent>
+ </Dialog>
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Input>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
+>(({ className, ...props }, ref) => (
+ // https://github.com/shadcn-ui/ui/issues/3366
+ // eslint-disable-next-line react/no-unknown-property
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+ <CommandPrimitive.Input
+ ref={ref}
+ className={cn(
+ "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ </div>
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.List>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.List
+ ref={ref}
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
+ {...props}
+ />
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Empty>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
+>((props, ref) => (
+ <CommandPrimitive.Empty
+ ref={ref}
+ className="py-6 text-center text-sm"
+ {...props}
+ />
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Group>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Group
+ ref={ref}
+ className={cn(
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
+ className,
+ )}
+ {...props}
+ />
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Separator
+ ref={ref}
+ className={cn("-mx-1 h-px bg-border", className)}
+ {...props}
+ />
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Item
+ ref={ref}
+ className={cn(
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn(
+ "ml-auto text-xs tracking-widest text-muted-foreground",
+ className,
+ )}
+ {...props}
+ />
+ );
+};
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/apps/browser-extension/src/components/ui/popover.tsx b/apps/browser-extension/src/components/ui/popover.tsx
new file mode 100644
index 00000000..8b20f11b
--- /dev/null
+++ b/apps/browser-extension/src/components/ui/popover.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+
+import { cn } from "../../utils/css";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef<typeof PopoverPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ ref={ref}
+ align={align}
+ sideOffset={sideOffset}
+ className={cn(
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ className,
+ )}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts
index 2a549d1f..ceaed3a9 100644
--- a/packages/shared-react/hooks/bookmarks.ts
+++ b/packages/shared-react/hooks/bookmarks.ts
@@ -1,7 +1,26 @@
import { api } from "../trpc";
+import { isBookmarkStillLoading } from "../utils/bookmarkUtils";
import { useBookmarkGridContext } from "./bookmark-grid-context";
import { useAddBookmarkToList } from "./lists";
+export function useAutoRefreshingBookmarkQuery(
+ input: Parameters<typeof api.bookmarks.getBookmark.useQuery>[0],
+) {
+ return api.bookmarks.getBookmark.useQuery(input, {
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ if (!data) {
+ return false;
+ }
+ // If the link is not crawled or not tagged
+ if (isBookmarkStillLoading(data)) {
+ return 1000;
+ }
+ return false;
+ },
+ });
+}
+
export function useCreateBookmarkWithPostHook(
...opts: Parameters<typeof api.bookmarks.createBookmark.useMutation>
) {
diff --git a/packages/shared-react/hooks/lists.ts b/packages/shared-react/hooks/lists.ts
index 61e50689..10633a08 100644
--- a/packages/shared-react/hooks/lists.ts
+++ b/packages/shared-react/hooks/lists.ts
@@ -41,6 +41,9 @@ export function useAddBookmarkToList(
...opts[0],
onSuccess: (res, req, meta) => {
apiUtils.bookmarks.getBookmarks.invalidate({ listId: req.listId });
+ apiUtils.lists.getListsOfBookmark.invalidate({
+ bookmarkId: req.bookmarkId,
+ });
return opts[0]?.onSuccess?.(res, req, meta);
},
});
@@ -54,6 +57,9 @@ export function useRemoveBookmarkFromList(
...opts[0],
onSuccess: (res, req, meta) => {
apiUtils.bookmarks.getBookmarks.invalidate({ listId: req.listId });
+ apiUtils.lists.getListsOfBookmark.invalidate({
+ bookmarkId: req.bookmarkId,
+ });
return opts[0]?.onSuccess?.(res, req, meta);
},
});
@@ -90,6 +96,7 @@ export function augmentBookmarkListsWithInitialData(
data: ZBookmarkList[];
root: ZBookmarkListRoot;
allPaths: ZBookmarkList[][];
+ getPathById: (id: string) => ZBookmarkList[] | undefined;
}
| undefined,
initialData: ZBookmarkList[],
diff --git a/packages/shared/utils/listUtils.ts b/packages/shared/utils/listUtils.ts
index 1dd3d476..d6fe7688 100644
--- a/packages/shared/utils/listUtils.ts
+++ b/packages/shared/utils/listUtils.ts
@@ -51,5 +51,10 @@ export function listsToTree(lists: ZBookmarkList[]) {
dfs(node, []);
});
- return { allPaths, root };
+ return {
+ allPaths,
+ root,
+ getPathById: (id: string) =>
+ allPaths.find((path) => path[path.length - 1].id === id),
+ };
}
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index 5cab0ac3..d4b56ecf 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -1,3 +1,4 @@
+import assert from "node:assert";
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
@@ -222,4 +223,23 @@ export const listsAppRouter = router({
return { lists };
}),
+ getListsOfBookmark: authedProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .output(
+ z.object({
+ lists: z.array(zBookmarkListSchema),
+ }),
+ )
+ .use(ensureBookmarkOwnership)
+ .query(async ({ input, ctx }) => {
+ const lists = await ctx.db.query.bookmarksInLists.findMany({
+ where: and(eq(bookmarksInLists.bookmarkId, input.bookmarkId)),
+ with: {
+ list: true,
+ },
+ });
+ assert(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id));
+
+ return { lists: lists.map((l) => l.list) };
+ }),
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5b7aec5..f7b88839 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -76,6 +76,9 @@ importers:
'@trpc/server':
specifier: 11.0.0-next-beta.308
version: 11.0.0-next-beta.308
+ '@uidotdev/usehooks':
+ specifier: ^2.4.1
+ version: 2.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -4422,6 +4425,13 @@ packages:
resolution: {integrity: sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==}
engines: {node: ^18.18.0 || >=20.0.0}
+ '@uidotdev/usehooks@2.4.1':
+ resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@@ -18328,6 +18338,12 @@ snapshots:
eslint-visitor-keys: 3.4.3
dev: false
+ '@uidotdev/usehooks@2.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
'@ungap/structured-clone@1.2.0': {}
'@urql/core@2.3.6(graphql@15.8.0)':