From 77b1aba5acc66dfaeb02b08d60d88442336026a6 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Tue, 23 Apr 2024 13:56:35 +0100 Subject: feature(extension): Allow adding tags and lists to newly hoarded bookmarks --- apps/browser-extension/src/BookmarkDeletedPage.tsx | 2 +- apps/browser-extension/src/BookmarkSavedPage.tsx | 30 +++- apps/browser-extension/src/Layout.tsx | 2 +- .../src/components/BookmarkLists.tsx | 45 ++++++ .../src/components/ListsSelector.tsx | 107 ++++++++++++++ apps/browser-extension/src/components/TagList.tsx | 32 +++++ .../src/components/TagsSelector.tsx | 117 ++++++++++++++++ apps/browser-extension/src/components/ui/badge.tsx | 37 +++++ .../src/components/ui/command.tsx | 155 +++++++++++++++++++++ .../src/components/ui/popover.tsx | 29 ++++ 10 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 apps/browser-extension/src/components/BookmarkLists.tsx create mode 100644 apps/browser-extension/src/components/ListsSelector.tsx create mode 100644 apps/browser-extension/src/components/TagList.tsx create mode 100644 apps/browser-extension/src/components/TagsSelector.tsx create mode 100644 apps/browser-extension/src/components/ui/badge.tsx create mode 100644 apps/browser-extension/src/components/ui/command.tsx create mode 100644 apps/browser-extension/src/components/ui/popover.tsx (limited to 'apps/browser-extension/src') 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

Bookmark Deleted!

; + return

Bookmark Deleted!

; } 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() {
{error &&

{error}

}
-

Bookmarked!

+

Hoarded!

Open

- +
+
+

Tags

+ + +
+

Lists

+ +
); } 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 (
-
+

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 ( +
    + {lists.lists.map((l) => ( +
  • + + {allLists + .getPathById(l.id)! + .map((l) => `${l.icon} ${l.name}`) + .join(" / ")} + + +
  • + ))} +
+ ); +} 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(); + 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 ( + + + + + + + + + You don't have any lists. + + {allLists?.allPaths.map((path) => { + const lastItem = path[path.length - 1]; + + return ( + + + {path + .map((item) => `${item.icon} ${item.name}`) + .join(" / ")} + + ); + })} + + + + + + ); +} 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 ( +
+ {bookmark.tags.length === 0 && !isBookmarkStillTagging(bookmark) && ( + No tags + )} + {bookmark.tags.map((tag) => ( + + {tag.name} + + ))} + {isBookmarkStillTagging(bookmark) && ( + AI tags loading... + )} +
+ ); +} 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(); + + 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 ( + + + + + + + + + + {allTags?.tags + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tag) => ( + + + {tag.name} + + ))} + + + + mutate({ + bookmarkId, + attach: [{ tagName: input }], + detach: [], + }) + } + > + + Create "{input}" ... + + + + + + + ); +} 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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +type CommandDialogProps = DialogProps; + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + // https://github.com/shadcn-ui/ui/issues/3366 + // eslint-disable-next-line react/no-unknown-property +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; -- cgit v1.2.3-70-g09d2