diff options
| author | MohamedBassem <me@mbassem.com> | 2024-04-23 13:56:35 +0100 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-04-23 13:56:35 +0100 |
| commit | 77b1aba5acc66dfaeb02b08d60d88442336026a6 (patch) | |
| tree | 950f71d7c868869902e742644697e077db734769 /apps/browser-extension/src | |
| parent | 0e260954c13cfedb03e75d3f0db8a2e839fd008d (diff) | |
| download | karakeep-77b1aba5acc66dfaeb02b08d60d88442336026a6.tar.zst | |
feature(extension): Allow adding tags and lists to newly hoarded bookmarks
Diffstat (limited to 'apps/browser-extension/src')
| -rw-r--r-- | apps/browser-extension/src/BookmarkDeletedPage.tsx | 2 | ||||
| -rw-r--r-- | apps/browser-extension/src/BookmarkSavedPage.tsx | 30 | ||||
| -rw-r--r-- | apps/browser-extension/src/Layout.tsx | 2 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/BookmarkLists.tsx | 45 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ListsSelector.tsx | 107 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/TagList.tsx | 32 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/TagsSelector.tsx | 117 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ui/badge.tsx | 37 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ui/command.tsx | 155 | ||||
| -rw-r--r-- | apps/browser-extension/src/components/ui/popover.tsx | 29 |
10 files changed, 548 insertions, 8 deletions
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'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 "{input}" ... + </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 }; |
