diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-01 18:00:58 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-03-01 18:00:58 +0000 |
| commit | 75d315dda4232ee3b89abf054f0b6ee10105ffe3 (patch) | |
| tree | f0796a136578f3b5aa82b4b3313e54fa3061ff5f /packages/web | |
| parent | 588471d65039e6920751ac2add8874ee932bc2f1 (diff) | |
| download | karakeep-75d315dda4232ee3b89abf054f0b6ee10105ffe3.tar.zst | |
feature: Add support for creating and updating lists
Diffstat (limited to 'packages/web')
17 files changed, 989 insertions, 5 deletions
diff --git a/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx b/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx new file mode 100644 index 00000000..36e32ab7 --- /dev/null +++ b/packages/web/app/dashboard/bookmarks/components/AddToListModal.tsx @@ -0,0 +1,171 @@ +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; + +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import LoadingSpinner from "@/components/ui/spinner"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +export default function AddToListModal({ + bookmarkId, + open, + setOpen, +}: { + bookmarkId: string; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const formSchema = z.object({ + listId: z.string({ + required_error: "Please select a list", + }), + }); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + }); + + const { data: lists, isPending: isFetchingListsPending } = + api.lists.list.useQuery(); + + const listInvalidationFunction = api.useUtils().lists.get.invalidate; + const bookmarksInvalidationFunction = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate: addToList, isPending: isAddingToListPending } = + api.lists.addToList.useMutation({ + onSuccess: (_resp, req) => { + toast({ + description: "List has been updated!", + }); + listInvalidationFunction({ listId: req.listId }); + bookmarksInvalidationFunction(); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + const isPending = isFetchingListsPending || isAddingToListPending; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + addToList({ + bookmarkId: bookmarkId, + listId: value.listId, + }); + })} + > + <DialogHeader> + <DialogTitle>Add to List</DialogTitle> + </DialogHeader> + + <div className="py-4"> + {lists ? ( + <FormField + control={form.control} + name="listId" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Select onValueChange={field.onChange}> + <SelectTrigger className="w-full"> + <SelectValue placeholder="Select a list" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {lists && + lists.lists.map((l) => ( + <SelectItem key={l.id} value={l.id}> + {l.icon} {l.name} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + ) : ( + <LoadingSpinner /> + )} + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isAddingToListPending} + disabled={isPending} + > + Add + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} + +export function useAddToListModal(bookmarkId: string) { + const [open, setOpen] = useState(false); + + return [ + open, + setOpen, + <AddToListModal + key={bookmarkId} + bookmarkId={bookmarkId} + open={open} + setOpen={setOpen} + />, + ] as const; +} diff --git a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx index 3a2b6b35..d4447f29 100644 --- a/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx +++ b/packages/web/app/dashboard/bookmarks/components/BookmarkOptions.tsx @@ -13,6 +13,7 @@ import { import { Archive, Link, + List, MoreHorizontal, Pencil, RotateCw, @@ -23,12 +24,16 @@ import { import { useTagModel } from "./TagModal"; import { useState } from "react"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useAddToListModal } from "./AddToListModal"; export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { toast } = useToast(); const linkId = bookmark.id; const [_, setTagModalIsOpen, tagModal] = useTagModel(bookmark); + const [_2, setAddToListModalOpen, addToListModal] = useAddToListModal( + bookmark.id, + ); const [isTextEditorOpen, setTextEditorOpen] = useState(false); @@ -77,6 +82,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { return ( <> {tagModal} + {addToListModal} <BookmarkedTextEditor bookmark={bookmark} open={isTextEditorOpen} @@ -140,6 +146,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <span>Edit Tags</span> </DropdownMenuItem> + <DropdownMenuItem onClick={() => setAddToListModalOpen(true)}> + <List className="mr-2 size-4" /> + <span>Add to List</span> + </DropdownMenuItem> + {bookmark.content.type === "link" && ( <DropdownMenuItem onClick={() => diff --git a/packages/web/app/dashboard/components/AllLists.tsx b/packages/web/app/dashboard/components/AllLists.tsx new file mode 100644 index 00000000..6b5ca3b5 --- /dev/null +++ b/packages/web/app/dashboard/components/AllLists.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { api } from "@/lib/trpc"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import SidebarItem from "./SidebarItem"; +import LoadingSpinner from "@/components/ui/spinner"; +import NewListModal, { useNewListModal } from "./NewListModal"; +import { Plus } from "lucide-react"; +import Link from "next/link"; + +export default function AllLists() { + const { data: lists } = api.lists.list.useQuery(); + + const { setOpen } = useNewListModal(); + + return ( + <ul className="max-h-full gap-2 overflow-scroll text-sm font-medium"> + <NewListModal /> + <li className="flex justify-between py-2 font-bold"> + <p>Lists</p> + <Link href="#" onClick={() => setOpen(true)}> + <Plus /> + </Link> + </li> + {lists && lists.lists.length == 0 && <li>No lists</li>} + {lists ? ( + lists.lists.map((l) => ( + <SidebarItem + key={l.id} + logo={<span className="text-lg"> {l.icon}</span>} + name={l.name} + path={`/dashboard/lists/${l.id}`} + className="py-0.5" + /> + )) + ) : ( + <LoadingSpinner /> + )} + </ul> + ); +} diff --git a/packages/web/app/dashboard/components/NewListModal.tsx b/packages/web/app/dashboard/components/NewListModal.tsx new file mode 100644 index 00000000..17b72576 --- /dev/null +++ b/packages/web/app/dashboard/components/NewListModal.tsx @@ -0,0 +1,170 @@ +"use client"; + +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; + +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; + +import { create } from "zustand"; + +export const useNewListModal = create<{ + open: boolean; + setOpen: (v: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open: boolean) => set(() => ({ open })), +})); + +export default function NewListModal() { + const { open, setOpen } = useNewListModal(); + + const formSchema = z.object({ + name: z.string(), + icon: z.string(), + }); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + icon: "💡", + }, + }); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + + const { mutate: createList, isPending } = api.lists.create.useMutation({ + onSuccess: () => { + toast({ + description: "List has been created!", + }); + listsInvalidationFunction(); + setOpen(false); + }, + onError: (e) => { + if (e.data?.code == "BAD_REQUEST") { + toast({ + variant: "destructive", + description: e.message, + }); + } else { + toast({ + variant: "destructive", + title: "Something went wrong", + }); + } + }, + }); + + return ( + <Dialog + open={open} + onOpenChange={(s) => { + form.reset(); + setOpen(s); + }} + > + <DialogContent> + <Form {...form}> + <form + onSubmit={form.handleSubmit((value) => { + createList(value); + })} + > + <DialogHeader> + <DialogTitle>Create List</DialogTitle> + </DialogHeader> + <div className="flex w-full gap-2 py-4"> + <FormField + control={form.control} + name="icon" + render={({ field }) => { + return ( + <FormItem> + <FormControl> + <Popover> + <PopoverTrigger className="border-input h-full rounded border px-2 text-2xl"> + {field.value} + </PopoverTrigger> + <PopoverContent> + <Picker + data={data} + onEmojiSelect={(e: { native: string }) => + field.onChange(e.native) + } + /> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="grow"> + <FormControl> + <Input + type="text" + className="w-full" + placeholder="List Name" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + </div> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="submit" loading={isPending}> + Create + </ActionButton> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index b8b7fc56..7eea6b6d 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -4,6 +4,8 @@ import SidebarItem from "./SidebarItem"; import { getServerAuthSession } from "@/server/auth"; import Link from "next/link"; import SidebarProfileOptions from "./SidebarProfileOptions"; +import { Separator } from "@/components/ui/separator"; +import AllLists from "./AllLists"; export default async function Sidebar() { const session = await getServerAuthSession(); @@ -12,16 +14,16 @@ export default async function Sidebar() { } return ( - <aside className="flex h-full w-60 flex-col border-r p-4"> + <aside className="flex h-screen w-60 flex-col gap-5 border-r p-4"> <Link href={"/dashboard/bookmarks"}> - <div className="mb-5 flex items-center rounded-lg px-1 text-slate-900"> + <div className="flex items-center rounded-lg px-1 text-slate-900"> <PackageOpen /> <span className="ml-2 text-base font-semibold">Hoarder</span> </div> </Link> <hr /> <div> - <ul className="mt-5 space-y-2 text-sm font-medium"> + <ul className="space-y-2 text-sm font-medium"> <SidebarItem logo={<Home />} name="Home" @@ -45,6 +47,8 @@ export default async function Sidebar() { /> </ul> </div> + <Separator /> + <AllLists /> <div className="mt-auto flex justify-between justify-self-end"> <div className="my-auto"> {session.user.name} </div> <SidebarProfileOptions /> diff --git a/packages/web/app/dashboard/components/SidebarItem.tsx b/packages/web/app/dashboard/components/SidebarItem.tsx index 74d20bc0..856bdffd 100644 --- a/packages/web/app/dashboard/components/SidebarItem.tsx +++ b/packages/web/app/dashboard/components/SidebarItem.tsx @@ -8,20 +8,23 @@ export default function SidebarItem({ name, logo, path, + className, }: { name: string; logo: React.ReactNode; path: string; + className?: string; }) { const currentPath = usePathname(); return ( <li className={cn( - "rounded-lg hover:bg-slate-100", + "rounded-lg px-3 py-2 hover:bg-slate-100", path == currentPath ? "bg-gray-50" : "", + className, )} > - <Link href={path} className="flex w-full space-x-2 px-3 py-2"> + <Link href={path} className="flex w-full gap-x-2"> {logo} <span className="my-auto"> {name} </span> </Link> diff --git a/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx new file mode 100644 index 00000000..8961b2d0 --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/components/DeleteListButton.tsx @@ -0,0 +1,76 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Trash } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { useState } from "react"; +import { ZBookmarkList } from "@/lib/types/api/lists"; + +export default function DeleteListButton({ list }: { list: ZBookmarkList }) { + const [isDialogOpen, setDialogOpen] = useState(false); + + const router = useRouter(); + + const listsInvalidationFunction = api.useUtils().lists.list.invalidate; + const { mutate: deleteList, isPending } = api.lists.delete.useMutation({ + onSuccess: () => { + listsInvalidationFunction(); + toast({ + description: `List "${list.icon} ${list.name}" is deleted!`, + }); + router.push("/"); + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + return ( + <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button className="mt-auto flex gap-2" variant="destructive"> + <Trash className="size-5" /> + <span className="hidden md:block">Delete List</span> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + Delete {list.icon} {list.name}? + </DialogTitle> + </DialogHeader> + <span> + Are you sure you want to delete {list.icon} {list.name}? + </span> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + variant="destructive" + loading={isPending} + onClick={() => deleteList({ listId: list.id })} + > + Delete + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx new file mode 100644 index 00000000..c3d49b6a --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/components/ListView.tsx @@ -0,0 +1,35 @@ +"use client"; + +import BookmarksGrid from "@/app/dashboard/bookmarks/components/BookmarksGrid"; +import { ZBookmark } from "@/lib/types/api/bookmarks"; +import { ZBookmarkListWithBookmarks } from "@/lib/types/api/lists"; +import { api } from "@/lib/trpc"; +import DeleteListButton from "./DeleteListButton"; + +export default function ListView({ + bookmarks, + list: initialData, +}: { + list: ZBookmarkListWithBookmarks; + bookmarks: ZBookmark[]; +}) { + const { data } = api.lists.get.useQuery( + { listId: initialData.id }, + { + initialData, + }, + ); + + return ( + <div className="container flex flex-col gap-3"> + <div className="flex justify-between"> + <span className="pt-4 text-2xl"> + {data.icon} {data.name} + </span> + <DeleteListButton list={data} /> + </div> + <hr /> + <BookmarksGrid query={{ ids: data.bookmarks }} bookmarks={bookmarks} /> + </div> + ); +} diff --git a/packages/web/app/dashboard/lists/[listId]/page.tsx b/packages/web/app/dashboard/lists/[listId]/page.tsx new file mode 100644 index 00000000..b8ca79c3 --- /dev/null +++ b/packages/web/app/dashboard/lists/[listId]/page.tsx @@ -0,0 +1,32 @@ +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; +import { notFound, redirect } from "next/navigation"; +import ListView from "./components/ListView"; + +export default async function ListPage({ + params, +}: { + params: { listId: string }; +}) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + let list; + try { + list = await api.lists.get({ listId: params.listId }); + } catch (e) { + if (e instanceof TRPCError) { + if (e.code == "NOT_FOUND") { + notFound(); + } + } + throw e; + } + + const bookmarks = await api.bookmarks.getBookmarks({ ids: list.bookmarks }); + + return <ListView list={list} bookmarks={bookmarks.bookmarks} />; +} diff --git a/packages/web/components/ui/popover.tsx b/packages/web/components/ui/popover.tsx new file mode 100644 index 00000000..a361ba7d --- /dev/null +++ b/packages/web/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +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( + "bg-popover text-popover-foreground 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 z-50 w-72 rounded-md border p-4 shadow-md outline-none", + className, + )} + {...props} + /> + </PopoverPrimitive.Portal> +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/packages/web/components/ui/scroll-area.tsx b/packages/web/components/ui/scroll-area.tsx new file mode 100644 index 00000000..32cb6022 --- /dev/null +++ b/packages/web/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/packages/web/components/ui/select.tsx b/packages/web/components/ui/select.tsx new file mode 100644 index 00000000..efd4ff1e --- /dev/null +++ b/packages/web/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="size-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="size-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("bg-muted -mx-1 my-1 h-px", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/packages/web/lib/types/api/lists.ts b/packages/web/lib/types/api/lists.ts new file mode 100644 index 00000000..4b0ccaca --- /dev/null +++ b/packages/web/lib/types/api/lists.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const zBookmarkListSchema = z.object({ + id: z.string(), + name: z.string(), + icon: z.string(), +}); + +export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge( + z.object({ + bookmarks: z.array(z.string()), + }), +); + +export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>; +export type ZBookmarkListWithBookmarks = z.infer< + typeof zBookmarkListWithBookmarksSchema +>; diff --git a/packages/web/package.json b/packages/web/package.json index 0a4a1992..7687704f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@auth/drizzle-adapter": "^0.7.0", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", "@hoarder/db": "0.1.0", "@hoarder/shared": "0.1.0", "@hookform/resolvers": "^3.3.4", @@ -19,6 +21,9 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", @@ -53,6 +58,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.10", "@types/bcrypt": "^5.0.2", + "@types/emoji-mart": "^3.0.14", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index b958ef8f..6a1b05e9 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,11 +1,13 @@ import { router } from "../trpc"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; +import { listsAppRouter } from "./lists"; import { usersAppRouter } from "./users"; export const appRouter = router({ bookmarks: bookmarksAppRouter, apiKeys: apiKeysAppRouter, users: usersAppRouter, + lists: listsAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts index 4e98eb2f..8b59f1ef 100644 --- a/packages/web/server/api/routers/bookmarks.ts +++ b/packages/web/server/api/routers/bookmarks.ts @@ -284,6 +284,9 @@ export const bookmarksAppRouter = router({ .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { + if (input.ids && input.ids.length == 0) { + return { bookmarks: [] }; + } const results = await ctx.db.query.bookmarks.findMany({ where: and( eq(bookmarks.userId, ctx.user.id), diff --git a/packages/web/server/api/routers/lists.ts b/packages/web/server/api/routers/lists.ts new file mode 100644 index 00000000..7bf5eed5 --- /dev/null +++ b/packages/web/server/api/routers/lists.ts @@ -0,0 +1,173 @@ +import { Context, authedProcedure, router } from "../trpc"; +import { SqliteError } from "@hoarder/db"; +import { z } from "zod"; +import { TRPCError, experimental_trpcMiddleware } from "@trpc/server"; +import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema"; +import { and, eq } from "drizzle-orm"; +import { zBookmarkListSchema } from "@/lib/types/api/lists"; + +const ensureListOwnership = experimental_trpcMiddleware<{ + ctx: Context; + input: { listId: string }; +}>().create(async (opts) => { + const list = await opts.ctx.db.query.bookmarkLists.findFirst({ + where: eq(bookmarkLists.id, opts.input.listId), + columns: { + userId: true, + }, + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!list) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "List not found", + }); + } + if (list.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + + return opts.next(); +}); + +export const listsAppRouter = router({ + create: authedProcedure + .input( + z.object({ + name: z.string().min(1).max(20), + icon: z.string(), + }), + ) + .output(zBookmarkListSchema) + .mutation(async ({ input, ctx }) => { + try { + const result = await ctx.db + .insert(bookmarkLists) + .values({ + name: input.name, + icon: input.icon, + userId: ctx.user.id, + }) + .returning(); + return result[0]; + } catch (e) { + if (e instanceof SqliteError) { + if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "List already exists", + }); + } + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }); + } + }), + delete: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .use(ensureListOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .delete(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + ); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + }), + addToList: authedProcedure + .input( + z.object({ + listId: z.string(), + bookmarkId: z.string(), + }), + ) + .use(ensureListOwnership) + .mutation(async ({ input, ctx }) => { + try { + await ctx.db.insert(bookmarksInLists).values({ + listId: input.listId, + bookmarkId: input.bookmarkId, + }); + } catch (e) { + if (e instanceof SqliteError) { + if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Bookmark already in the list", + }); + } + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }); + } + }), + get: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + zBookmarkListSchema.merge( + z.object({ + bookmarks: z.array(z.string()), + }), + ), + ) + .use(ensureListOwnership) + .query(async ({ input, ctx }) => { + const res = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + with: { + bookmarksInLists: true, + }, + }); + if (!res) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + return { + id: res.id, + name: res.name, + icon: res.icon, + bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId), + }; + }), + list: authedProcedure + .output( + z.object({ + lists: z.array(zBookmarkListSchema), + }), + ) + .query(async ({ ctx }) => { + const lists = await ctx.db.query.bookmarkLists.findMany({ + where: and(eq(bookmarkLists.userId, ctx.user.id)), + }); + + return { lists }; + }), +}); |
