diff options
Diffstat (limited to 'packages/web/components/dashboard')
26 files changed, 2136 insertions, 0 deletions
diff --git a/packages/web/components/dashboard/bookmarks/AddBookmark.tsx b/packages/web/components/dashboard/bookmarks/AddBookmark.tsx new file mode 100644 index 00000000..d12fc663 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/AddBookmark.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Pencil, Plus } from "lucide-react"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { useState } from "react"; + +function AddText() { + const [isEditorOpen, setEditorOpen] = useState(false); + + return ( + <div className="flex"> + <BookmarkedTextEditor open={isEditorOpen} setOpen={setEditorOpen} /> + <Button className="m-auto" onClick={() => setEditorOpen(true)}> + <Pencil /> + </Button> + </div> + ); +} + +function AddLink() { + const formSchema = z.object({ + url: z.string().url({ message: "The link must be a valid URL" }), + }); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + url: "", + }, + }); + + const invalidateBookmarksCache = api.useUtils().bookmarks.invalidate; + const createBookmarkMutator = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateBookmarksCache(); + form.reset(); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + <Form {...form}> + <form + className="flex-grow" + onSubmit={form.handleSubmit( + (value) => + createBookmarkMutator.mutate({ url: value.url, type: "link" }), + onError, + )} + > + <div className="flex w-full items-center space-x-2 py-4"> + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input type="text" placeholder="Link" {...field} /> + </FormControl> + </FormItem> + ); + }} + /> + <ActionButton type="submit" loading={createBookmarkMutator.isPending}> + <Plus /> + </ActionButton> + </div> + </form> + </Form> + ); +} + +export default function AddBookmark() { + return ( + <div className="container flex gap-2"> + <AddLink /> + <AddText /> + </div> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/AddToListModal.tsx b/packages/web/components/dashboard/bookmarks/AddToListModal.tsx new file mode 100644 index 00000000..c9fd5da0 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/AddToListModal.tsx @@ -0,0 +1,168 @@ +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, + content: ( + <AddToListModal bookmarkId={bookmarkId} open={open} setOpen={setOpen} /> + ), + }; +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx b/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx new file mode 100644 index 00000000..1f5fa433 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx @@ -0,0 +1,30 @@ +import { + ImageCard, + ImageCardBody, + ImageCardContent, + ImageCardFooter, + ImageCardTitle, + ImageCardBanner, +} from "@/components/ui/imageCard"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function BookmarkCardSkeleton() { + return ( + <ImageCard + className={ + "border-grey-100 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all" + } + > + <ImageCardBanner src="/blur.avif" /> + <ImageCardContent> + <ImageCardTitle></ImageCardTitle> + <ImageCardBody className="space-y-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </ImageCardBody> + <ImageCardFooter></ImageCardFooter> + </ImageCardContent> + </ImageCard> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx new file mode 100644 index 00000000..4f08ebee --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useToast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ZBookmark, ZBookmarkedLink } from "@hoarder/trpc/types/bookmarks"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Archive, + Link, + List, + MoreHorizontal, + Pencil, + RotateCw, + Star, + Tags, + Trash2, +} from "lucide-react"; +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 { setOpen: setTagModalIsOpen, content: tagModal } = + useTagModel(bookmark); + const { setOpen: setAddToListModalOpen, content: addToListModal } = + useAddToListModal(bookmark.id); + + const [isTextEditorOpen, setTextEditorOpen] = useState(false); + + const invalidateAllBookmarksCache = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const invalidateBookmarkCache = + api.useUtils().bookmarks.getBookmark.invalidate; + + const onError = () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }; + const deleteBookmarkMutator = api.bookmarks.deleteBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "The bookmark has been deleted!", + }); + }, + onError, + onSettled: () => { + invalidateAllBookmarksCache(); + }, + }); + + const updateBookmarkMutator = api.bookmarks.updateBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "The bookmark has been updated!", + }); + }, + onError, + onSettled: () => { + invalidateBookmarkCache({ bookmarkId: bookmark.id }); + invalidateAllBookmarksCache(); + }, + }); + + const crawlBookmarkMutator = api.bookmarks.recrawlBookmark.useMutation({ + onSuccess: () => { + toast({ + description: "Re-fetch has been enqueued!", + }); + }, + onError, + onSettled: () => { + invalidateBookmarkCache({ bookmarkId: bookmark.id }); + }, + }); + + return ( + <> + {tagModal} + {addToListModal} + <BookmarkedTextEditor + bookmark={bookmark} + open={isTextEditorOpen} + setOpen={setTextEditorOpen} + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + className="px-1 focus-visible:ring-0 focus-visible:ring-offset-0" + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-fit"> + {bookmark.content.type === "text" && ( + <DropdownMenuItem onClick={() => setTextEditorOpen(true)}> + <Pencil className="mr-2 size-4" /> + <span>Edit</span> + </DropdownMenuItem> + )} + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + favourited: !bookmark.favourited, + }) + } + > + <Star className="mr-2 size-4" /> + <span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => + updateBookmarkMutator.mutate({ + bookmarkId: linkId, + archived: !bookmark.archived, + }) + } + > + <Archive className="mr-2 size-4" /> + <span>{bookmark.archived ? "Un-archive" : "Archive"}</span> + </DropdownMenuItem> + {bookmark.content.type === "link" && ( + <DropdownMenuItem + onClick={() => { + navigator.clipboard.writeText( + (bookmark.content as ZBookmarkedLink).url, + ); + toast({ + description: "Link was added to your clipboard!", + }); + }} + > + <Link className="mr-2 size-4" /> + <span>Copy Link</span> + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => setTagModalIsOpen(true)}> + <Tags className="mr-2 size-4" /> + <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={() => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <RotateCw className="mr-2 size-4" /> + <span>Refresh</span> + </DropdownMenuItem> + )} + <DropdownMenuItem + className="text-destructive" + onClick={() => + deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) + } + > + <Trash2 className="mr-2 size-4" /> + <span>Delete</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx new file mode 100644 index 00000000..2a8ae1b1 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { BackButton } from "@/components/ui/back-button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isBookmarkStillCrawling } from "@/lib/bookmarkUtils"; +import { api } from "@/lib/trpc"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ArrowLeftCircle, CalendarDays, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import Markdown from "react-markdown"; + +export default function BookmarkPreview({ + initialData, +}: { + initialData: ZBookmark; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + // If the link is not crawled or not tagged + if (isBookmarkStillCrawling(data)) { + return 1000; + } + return false; + }, + }, + ); + + const linkHeader = bookmark.content.type == "link" && ( + <div className="flex flex-col space-y-2"> + <p className="text-center text-3xl"> + {bookmark.content.title || bookmark.content.url} + </p> + <Link href={bookmark.content.url} className="mx-auto flex gap-2"> + <span className="my-auto">View Original</span> + <ExternalLink /> + </Link> + </div> + ); + + let content; + switch (bookmark.content.type) { + case "link": { + if (!bookmark.content.htmlContent) { + content = ( + <div className="text-red-500">Failed to fetch link content ...</div> + ); + } else { + content = ( + <div + dangerouslySetInnerHTML={{ + __html: bookmark.content.htmlContent || "", + }} + className="prose" + /> + ); + } + break; + } + case "text": { + content = <Markdown className="prose">{bookmark.content.text}</Markdown>; + break; + } + } + + return ( + <div className="bg-background m-4 min-h-screen space-y-4 rounded-md border p-4"> + <div className="flex justify-between"> + <BackButton className="ghost" variant="ghost"> + <ArrowLeftCircle /> + </BackButton> + <div className="my-auto"> + <span className="my-auto flex gap-2"> + <CalendarDays /> {bookmark.createdAt.toLocaleString()} + </span> + </div> + </div> + <hr /> + {linkHeader} + <div className="mx-auto flex h-full border-x p-2 px-4 lg:w-2/3"> + {isBookmarkStillCrawling(bookmark) ? ( + <div className="flex w-full flex-col gap-2"> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + </div> + ) : ( + content + )} + </div> + </div> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx new file mode 100644 index 00000000..a5b58f1a --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx @@ -0,0 +1,109 @@ +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { toast } from "@/components/ui/use-toast"; + +export function BookmarkedTextEditor({ + bookmark, + open, + setOpen, +}: { + bookmark?: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const isNewBookmark = bookmark === undefined; + const [noteText, setNoteText] = useState( + bookmark && bookmark.content.type == "text" ? bookmark.content.text : "", + ); + + const invalidateAllBookmarksCache = + api.useUtils().bookmarks.getBookmarks.invalidate; + const invalidateOneBookmarksCache = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate: createBookmarkMutator, isPending: isCreationPending } = + api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + invalidateAllBookmarksCache(); + toast({ + description: "Note created!", + }); + setOpen(false); + setNoteText(""); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const { mutate: updateBookmarkMutator, isPending: isUpdatePending } = + api.bookmarks.updateBookmarkText.useMutation({ + onSuccess: () => { + invalidateOneBookmarksCache({ + bookmarkId: bookmark!.id, + }); + toast({ + description: "Note updated!", + }); + setOpen(false); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + const isPending = isCreationPending || isUpdatePending; + + const onSave = () => { + if (isNewBookmark) { + createBookmarkMutator({ + type: "text", + text: noteText, + }); + } else { + updateBookmarkMutator({ + bookmarkId: bookmark.id, + text: noteText, + }); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle> + <DialogDescription> + Write your note with markdown support + </DialogDescription> + </DialogHeader> + <Textarea + value={noteText} + onChange={(e) => setNoteText(e.target.value)} + className="h-52 grow" + /> + <DialogFooter className="flex-shrink gap-1 sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton type="button" loading={isPending} onClick={onSave}> + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx b/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx new file mode 100644 index 00000000..8a620341 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx @@ -0,0 +1,20 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import Markdown from "react-markdown"; + +export function BookmarkedTextViewer({ + content, + open, + setOpen, +}: { + content: string; + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="max-h-[75%] overflow-auto"> + <Markdown className="prose">{content}</Markdown> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/Bookmarks.tsx b/packages/web/components/dashboard/bookmarks/Bookmarks.tsx new file mode 100644 index 00000000..1ad3670c --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/Bookmarks.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import BookmarksGrid from "./BookmarksGrid"; +import { ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function Bookmarks({ + favourited, + archived, + title, + showDivider, +}: ZGetBookmarksRequest & { title: string; showDivider?: boolean }) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const query = { + favourited, + archived, + }; + + const bookmarks = await api.bookmarks.getBookmarks(query); + + return ( + <div className="container flex flex-col gap-3"> + <div className="text-2xl">{title}</div> + {showDivider && <hr />} + <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} /> + </div> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx new file mode 100644 index 00000000..4d5b6b0a --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -0,0 +1,64 @@ +"use client"; + +import LinkCard from "./LinkCard"; +import { ZBookmark, ZGetBookmarksRequest } from "@hoarder/trpc/types/bookmarks"; +import { api } from "@/lib/trpc"; +import TextCard from "./TextCard"; +import { Slot } from "@radix-ui/react-slot"; +import Masonry from "react-masonry-css"; +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwindConfig from "@/tailwind.config"; +import { useMemo } from "react"; + +function getBreakpointConfig() { + const fullConfig = resolveConfig(tailwindConfig); + + const breakpointColumnsObj: { [key: number]: number; default: number } = { + default: 3, + }; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1; + breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1; + return breakpointColumnsObj; +} + +function renderBookmark(bookmark: ZBookmark) { + let comp; + switch (bookmark.content.type) { + case "link": + comp = <LinkCard bookmark={bookmark} />; + break; + case "text": + comp = <TextCard bookmark={bookmark} />; + break; + } + return ( + <Slot + key={bookmark.id} + className="border-grey-100 mb-4 border bg-gray-50 duration-300 ease-in hover:border-blue-300 hover:transition-all" + > + {comp} + </Slot> + ); +} + +export default function BookmarksGrid({ + query, + bookmarks: initialBookmarks, +}: { + query: ZGetBookmarksRequest; + bookmarks: ZBookmark[]; +}) { + const { data } = api.bookmarks.getBookmarks.useQuery(query, { + initialData: { bookmarks: initialBookmarks }, + }); + const breakpointConfig = useMemo(() => getBreakpointConfig(), []); + if (data.bookmarks.length == 0) { + return <p>No bookmarks</p>; + } + return ( + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {data.bookmarks.map((b) => renderBookmark(b))} + </Masonry> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/LinkCard.tsx b/packages/web/components/dashboard/bookmarks/LinkCard.tsx new file mode 100644 index 00000000..50f30e47 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/LinkCard.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + ImageCard, + ImageCardBanner, + ImageCardBody, + ImageCardContent, + ImageCardFooter, + ImageCardTitle, +} from "@/components/ui/imageCard"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import Link from "next/link"; +import BookmarkOptions from "./BookmarkOptions"; +import { api } from "@/lib/trpc"; +import { Maximize2, Star } from "lucide-react"; +import TagList from "./TagList"; +import { + isBookmarkStillCrawling, + isBookmarkStillLoading, + isBookmarkStillTagging, +} from "@/lib/bookmarkUtils"; + +export default function LinkCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + 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; + }, + }, + ); + const link = bookmark.content; + if (link.type != "link") { + throw new Error("Unexpected bookmark type"); + } + const parsedUrl = new URL(link.url); + + // A dummy white pixel for when there's no image. + // TODO: Better handling for cards with no images + const image = + link.imageUrl ?? + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII="; + + return ( + <ImageCard className={className}> + <Link href={link.url}> + <ImageCardBanner + src={isBookmarkStillCrawling(bookmark) ? "/blur.avif" : image} + /> + </Link> + <ImageCardContent> + <ImageCardTitle> + <Link className="line-clamp-2" href={link.url} target="_blank"> + {link?.title ?? parsedUrl.host} + </Link> + </ImageCardTitle> + {/* There's a hack here. Every tag has the full hight of the container itself. That why, when we enable flex-wrap, + the overflowed don't show up. */} + <ImageCardBody className="flex h-full flex-wrap space-x-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </ImageCardBody> + <ImageCardFooter> + <div className="mt-1 flex justify-between text-gray-500"> + <div className="my-auto"> + <Link + className="line-clamp-1 hover:text-black" + href={link.url} + target="_blank" + > + {parsedUrl.host} + </Link> + </div> + <div className="flex"> + {bookmark.favourited && ( + <Star + className="m-1 size-8 rounded p-1" + color="#ebb434" + fill="#ebb434" + /> + )} + <Link + className="my-auto block px-2" + href={`/dashboard/preview/${bookmark.id}`} + > + <Maximize2 size="20" /> + </Link> + <BookmarkOptions bookmark={bookmark} /> + </div> + </div> + </ImageCardFooter> + </ImageCardContent> + </ImageCard> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/TagList.tsx b/packages/web/components/dashboard/bookmarks/TagList.tsx new file mode 100644 index 00000000..6c9d2d22 --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/TagList.tsx @@ -0,0 +1,39 @@ +import { badgeVariants } from "@/components/ui/badge"; +import Link from "next/link"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { cn } from "@/lib/utils"; + +export default function TagList({ + bookmark, + loading, +}: { + bookmark: ZBookmark; + loading?: boolean; +}) { + if (loading) { + return ( + <div className="flex w-full flex-col justify-end space-y-2 p-2"> + <Skeleton className="h-4 w-full" /> + <Skeleton className="h-4 w-full" /> + </div> + ); + } + return ( + <> + {bookmark.tags.map((t) => ( + <div key={t.id} className="flex h-full flex-col justify-end"> + <Link + className={cn( + badgeVariants({ variant: "outline" }), + "hover:bg-foreground hover:text-secondary text-nowrap", + )} + href={`/dashboard/tags/${t.name}`} + > + {t.name} + </Link> + </div> + ))} + </> + ); +} diff --git a/packages/web/components/dashboard/bookmarks/TagModal.tsx b/packages/web/components/dashboard/bookmarks/TagModal.tsx new file mode 100644 index 00000000..8c09d00e --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/TagModal.tsx @@ -0,0 +1,207 @@ +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 { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZAttachedByEnum } from "@hoarder/trpc/types/tags"; +import { cn } from "@/lib/utils"; +import { Sparkles, X } from "lucide-react"; +import { useState, KeyboardEvent, useEffect } from "react"; + +type EditableTag = { attachedBy: ZAttachedByEnum; id?: string; name: string }; + +function TagAddInput({ addTag }: { addTag: (tag: string) => void }) { + const onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + addTag(e.currentTarget.value); + e.currentTarget.value = ""; + } + }; + return ( + <Input + onKeyUp={onKeyUp} + className="h-8 w-full border-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + ); +} + +function TagPill({ + tag, + deleteCB, +}: { + tag: { attachedBy: ZAttachedByEnum; id?: string; name: string }; + deleteCB: () => void; +}) { + const isAttachedByAI = tag.attachedBy == "ai"; + return ( + <div + className={cn( + "flex min-h-8 space-x-1 rounded px-2", + isAttachedByAI + ? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white" + : "bg-gray-200", + )} + > + {isAttachedByAI && <Sparkles className="m-auto size-4" />} + <p className="m-auto">{tag.name}</p> + <button className="m-auto size-4" onClick={deleteCB}> + <X className="size-4" /> + </button> + </div> + ); +} + +function TagEditor({ + tags, + setTags, +}: { + tags: Map<string, EditableTag>; + setTags: ( + cb: (m: Map<string, EditableTag>) => Map<string, EditableTag>, + ) => void; +}) { + return ( + <div className="mt-4 flex flex-wrap gap-2 rounded border p-2"> + {[...tags.values()].map((t) => ( + <TagPill + key={t.name} + tag={t} + deleteCB={() => + setTags((m) => { + const newMap = new Map(m); + newMap.delete(t.name); + return newMap; + }) + } + /> + ))} + <div className="flex-1"> + <TagAddInput + addTag={(val) => { + setTags((m) => { + if (m.has(val)) { + // Tag already exists + // Do nothing + return m; + } + const newMap = new Map(m); + newMap.set(val, { attachedBy: "human", name: val }); + return newMap; + }); + }} + /> + </div> + </div> + ); +} + +export default function TagModal({ + bookmark, + open, + setOpen, +}: { + bookmark: ZBookmark; + open: boolean; + setOpen: (open: boolean) => void; +}) { + const [tags, setTags] = useState<Map<string, EditableTag>>(new Map()); + useEffect(() => { + const m = new Map<string, EditableTag>(); + for (const t of bookmark.tags) { + m.set(t.name, { attachedBy: t.attachedBy, id: t.id, name: t.name }); + } + setTags(m); + }, [bookmark.tags]); + + const bookmarkInvalidationFunction = + api.useUtils().bookmarks.getBookmark.invalidate; + + const { mutate, isPending } = api.bookmarks.updateTags.useMutation({ + onSuccess: () => { + toast({ + description: "Tags has been updated!", + }); + bookmarkInvalidationFunction({ bookmarkId: bookmark.id }); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + }, + }); + + const onSaveButton = () => { + const exitingTags = new Set(bookmark.tags.map((t) => t.name)); + + const attach = []; + const detach = []; + for (const t of tags.values()) { + if (!exitingTags.has(t.name)) { + attach.push({ tag: t.name }); + } + } + for (const t of bookmark.tags) { + if (!tags.has(t.name)) { + detach.push({ tagId: t.id }); + } + } + mutate({ + bookmarkId: bookmark.id, + attach, + detach, + }); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Edit Tags</DialogTitle> + </DialogHeader> + <TagEditor tags={tags} setTags={setTags} /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + loading={isPending} + onClick={onSaveButton} + > + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function useTagModel(bookmark: ZBookmark) { + const [open, setOpen] = useState(false); + + return { + open, + setOpen, + content: ( + <TagModal + key={bookmark.id} + bookmark={bookmark} + open={open} + setOpen={setOpen} + /> + ), + }; +} diff --git a/packages/web/components/dashboard/bookmarks/TextCard.tsx b/packages/web/components/dashboard/bookmarks/TextCard.tsx new file mode 100644 index 00000000..2565e69d --- /dev/null +++ b/packages/web/components/dashboard/bookmarks/TextCard.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import BookmarkOptions from "./BookmarkOptions"; +import { api } from "@/lib/trpc"; +import { Maximize2, Star } from "lucide-react"; +import { cn } from "@/lib/utils"; +import TagList from "./TagList"; +import Markdown from "react-markdown"; +import { useState } from "react"; +import { BookmarkedTextViewer } from "./BookmarkedTextViewer"; +import Link from "next/link"; +import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; + +export default function TextCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + if (isBookmarkStillTagging(data)) { + return 1000; + } + return false; + }, + }, + ); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + const bookmarkedText = bookmark.content; + if (bookmarkedText.type != "text") { + throw new Error("Unexpected bookmark type"); + } + + return ( + <> + <BookmarkedTextViewer + content={bookmarkedText.text} + open={previewModalOpen} + setOpen={setPreviewModalOpen} + /> + <div + className={cn( + className, + cn( + "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg p-2 shadow-md", + ), + )} + > + <Markdown className="prose grow overflow-hidden"> + {bookmarkedText.text} + </Markdown> + <div className="mt-4 flex flex-none flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + <div className="flex w-full justify-between"> + <div /> + <div className="flex gap-0 text-gray-500"> + <div> + {bookmark.favourited && ( + <Star + className="my-1 size-8 rounded p-1" + color="#ebb434" + fill="#ebb434" + /> + )} + </div> + <Link + className="my-auto block px-2" + href={`/dashboard/preview/${bookmark.id}`} + > + <Maximize2 size="20" /> + </Link> + <BookmarkOptions bookmark={bookmark} /> + </div> + </div> + </div> + </> + ); +} diff --git a/packages/web/components/dashboard/lists/AllListsView.tsx b/packages/web/components/dashboard/lists/AllListsView.tsx new file mode 100644 index 00000000..81f31cde --- /dev/null +++ b/packages/web/components/dashboard/lists/AllListsView.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/trpc"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; +import { keepPreviousData } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { useNewListModal } from "@/components/dashboard/sidebar/NewListModal"; + +function ListItem({ + name, + icon, + path, +}: { + name: string; + icon: string; + path: string; +}) { + return ( + <Link href={path}> + <div className="bg-background rounded-md border border-gray-200 px-4 py-2 text-lg"> + <p className="text-nowrap"> + {icon} {name} + </p> + </div> + </Link> + ); +} + +export default function AllListsView({ + initialData, +}: { + initialData: ZBookmarkList[]; +}) { + const { setOpen: setIsNewListModalOpen } = useNewListModal(); + let { data: lists } = api.lists.list.useQuery(undefined, { + initialData: { lists: initialData }, + placeholderData: keepPreviousData, + }); + + // TODO: This seems to be a bug in react query + lists ||= { lists: initialData }; + + return ( + <div className="flex flex-col flex-wrap gap-2 md:flex-row"> + <Button + className="my-auto flex h-full" + onClick={() => setIsNewListModalOpen(true)} + > + <Plus /> + <span className="my-auto">New List</span> + </Button> + <ListItem name="Favourites" icon="⭐️" path={`/dashboard/favourites`} /> + <ListItem name="Archive" icon="🗄️" path={`/dashboard/archive`} /> + {lists.lists.map((l) => ( + <ListItem + key={l.id} + name={l.name} + icon={l.icon} + path={`/dashboard/lists/${l.id}`} + /> + ))} + </div> + ); +} diff --git a/packages/web/components/dashboard/lists/DeleteListButton.tsx b/packages/web/components/dashboard/lists/DeleteListButton.tsx new file mode 100644 index 00000000..5303b217 --- /dev/null +++ b/packages/web/components/dashboard/lists/DeleteListButton.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + 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 "@hoarder/trpc/types/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/components/dashboard/lists/ListView.tsx b/packages/web/components/dashboard/lists/ListView.tsx new file mode 100644 index 00000000..2d48d9e3 --- /dev/null +++ b/packages/web/components/dashboard/lists/ListView.tsx @@ -0,0 +1,25 @@ +"use client"; + +import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid"; +import { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { ZBookmarkListWithBookmarks } from "@hoarder/trpc/types/lists"; +import { api } from "@/lib/trpc"; + +export default function ListView({ + bookmarks, + list: initialData, +}: { + list: ZBookmarkListWithBookmarks; + bookmarks: ZBookmark[]; +}) { + const { data } = api.lists.get.useQuery( + { listId: initialData.id }, + { + initialData, + }, + ); + + return ( + <BookmarksGrid query={{ ids: data.bookmarks }} bookmarks={bookmarks} /> + ); +} diff --git a/packages/web/components/dashboard/settings/AddApiKey.tsx b/packages/web/components/dashboard/settings/AddApiKey.tsx new file mode 100644 index 00000000..a4fd9c25 --- /dev/null +++ b/packages/web/components/dashboard/settings/AddApiKey.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { z } from "zod"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { ActionButton } from "@/components/ui/action-button"; + +function ApiKeySuccess({ apiKey }: { apiKey: string }) { + const [isCopied, setCopied] = useState(false); + + const onCopy = () => { + navigator.clipboard.writeText(apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <div> + <div className="py-4"> + Note: please copy the key and store it somewhere safe. Once you close + the dialog, you won't be able to access it again. + </div> + <div className="flex space-x-2 pt-2"> + <Input value={apiKey} readOnly /> + <Button onClick={onCopy}> + {!isCopied ? ( + <Copy className="size-4" /> + ) : ( + <Check className="size-4" /> + )} + </Button> + </div> + </div> + ); +} + +function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const formSchema = z.object({ + name: z.string(), + }); + const router = useRouter(); + const mutator = api.apiKeys.create.useMutation({ + onSuccess: (resp) => { + onSuccess(resp.key); + router.refresh(); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer<typeof formSchema>) { + mutator.mutate({ name: value.name }); + } + + const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit, onError)} + className="flex w-full space-x-3 space-y-8 pt-4" + > + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Name</FormLabel> + <FormControl> + <Input type="text" placeholder="Name" {...field} /> + </FormControl> + <FormDescription> + Give your API key a unique name + </FormDescription> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + className="h-full" + type="submit" + loading={mutator.isPending} + > + Create + </ActionButton> + </form> + </Form> + ); +} + +export default function AddApiKey() { + const [key, setKey] = useState<string | undefined>(undefined); + const [dialogOpen, setDialogOpen] = useState<boolean>(false); + return ( + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button>New API Key</Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle> + {key ? "Key was successfully created" : "Create API key"} + </DialogTitle> + <DialogDescription> + {key ? ( + <ApiKeySuccess apiKey={key} /> + ) : ( + <AddApiKeyForm onSuccess={setKey} /> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + onClick={() => setKey(undefined)} + > + Close + </Button> + </DialogClose> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/components/dashboard/settings/ApiKeySettings.tsx b/packages/web/components/dashboard/settings/ApiKeySettings.tsx new file mode 100644 index 00000000..1598f25f --- /dev/null +++ b/packages/web/components/dashboard/settings/ApiKeySettings.tsx @@ -0,0 +1,49 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/server/api/client"; +import DeleteApiKey from "./DeleteApiKey"; +import AddApiKey from "./AddApiKey"; + +export default async function ApiKeys() { + const keys = await api.apiKeys.list(); + return ( + <div className="pt-4"> + <span className="text-xl">API Keys</span> + <hr className="my-2" /> + <div className="flex flex-col space-y-3"> + <div className="flex flex-1 justify-end"> + <AddApiKey /> + </div> + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Key</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Action</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {keys.keys.map((k) => ( + <TableRow key={k.id}> + <TableCell>{k.name}</TableCell> + <TableCell>**_{k.keyId}_**</TableCell> + <TableCell>{k.createdAt.toLocaleString()}</TableCell> + <TableCell> + <DeleteApiKey name={k.name} id={k.id} /> + </TableCell> + </TableRow> + ))} + <TableRow></TableRow> + </TableBody> + </Table> + </div> + </div> + ); +} diff --git a/packages/web/components/dashboard/settings/DeleteApiKey.tsx b/packages/web/components/dashboard/settings/DeleteApiKey.tsx new file mode 100644 index 00000000..566136af --- /dev/null +++ b/packages/web/components/dashboard/settings/DeleteApiKey.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Trash } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +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"; + +export default function DeleteApiKey({ + name, + id, +}: { + name: string; + id: string; +}) { + const [isDialogOpen, setDialogOpen] = useState(false); + const router = useRouter(); + const mutator = api.apiKeys.revoke.useMutation({ + onSuccess: () => { + toast({ + description: "Key was successfully deleted", + }); + setDialogOpen(false); + router.refresh(); + }, + }); + + return ( + <Dialog open={isDialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger asChild> + <Button variant="destructive"> + <Trash className="size-5" /> + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete API Key</DialogTitle> + <DialogDescription> + Are you sure you want to delete the API key "{name}"? Any + service using this API key will lose access. + </DialogDescription> + </DialogHeader> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="button" + variant="destructive" + loading={mutator.isPending} + onClick={() => mutator.mutate({ id })} + > + Delete + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/packages/web/components/dashboard/sidebar/AllLists.tsx b/packages/web/components/dashboard/sidebar/AllLists.tsx new file mode 100644 index 00000000..a77252d0 --- /dev/null +++ b/packages/web/components/dashboard/sidebar/AllLists.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { api } from "@/lib/trpc"; +import SidebarItem from "./SidebarItem"; +import NewListModal, { useNewListModal } from "./NewListModal"; +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { ZBookmarkList } from "@hoarder/trpc/types/lists"; + +export default function AllLists({ + initialData, +}: { + initialData: { lists: ZBookmarkList[] }; +}) { + let { data: lists } = api.lists.list.useQuery(undefined, { + initialData, + }); + // TODO: This seems to be a bug in react query + lists ||= initialData; + const { setOpen } = useNewListModal(); + + return ( + <ul className="max-h-full gap-y-2 overflow-auto text-sm font-medium"> + <NewListModal /> + <li className="flex justify-between pb-2 font-bold"> + <p>Lists</p> + <Link href="#" onClick={() => setOpen(true)}> + <Plus /> + </Link> + </li> + <SidebarItem + logo={<span className="text-lg">📋</span>} + name="All Lists" + path={`/dashboard/lists`} + className="py-0.5" + /> + <SidebarItem + logo={<span className="text-lg">⭐️</span>} + name="Favourties" + path={`/dashboard/favourites`} + className="py-0.5" + /> + <SidebarItem + logo={<span className="text-lg">🗄️</span>} + name="Archive" + path={`/dashboard/archive`} + className="py-0.5" + /> + {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" + /> + ))} + </ul> + ); +} diff --git a/packages/web/components/dashboard/sidebar/ModileSidebar.tsx b/packages/web/components/dashboard/sidebar/ModileSidebar.tsx new file mode 100644 index 00000000..4bd6a347 --- /dev/null +++ b/packages/web/components/dashboard/sidebar/ModileSidebar.tsx @@ -0,0 +1,24 @@ +import MobileSidebarItem from "./ModileSidebarItem"; +import { + Tag, + PackageOpen, + Settings, + Search, + ClipboardList, +} from "lucide-react"; +import SidebarProfileOptions from "./SidebarProfileOptions"; + +export default async function MobileSidebar() { + return ( + <aside className="w-full"> + <ul className="flex justify-between space-x-2 border-b-black bg-gray-100 px-5 py-2 pt-5"> + <MobileSidebarItem logo={<PackageOpen />} path="/dashboard/bookmarks" /> + <MobileSidebarItem logo={<Search />} path="/dashboard/search" /> + <MobileSidebarItem logo={<ClipboardList />} path="/dashboard/lists" /> + <MobileSidebarItem logo={<Tag />} path="/dashboard/tags" /> + <MobileSidebarItem logo={<Settings />} path="/dashboard/settings" /> + <SidebarProfileOptions /> + </ul> + </aside> + ); +} diff --git a/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx b/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx new file mode 100644 index 00000000..9389d2e4 --- /dev/null +++ b/packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function MobileSidebarItem({ + logo, + path, +}: { + logo: React.ReactNode; + path: string; +}) { + const currentPath = usePathname(); + return ( + <li + className={cn( + "flex w-full rounded-lg hover:bg-gray-50", + path == currentPath ? "bg-gray-50" : "", + )} + > + <Link href={path} className="mx-auto px-3 py-2"> + {logo} + </Link> + </li> + ); +} diff --git a/packages/web/components/dashboard/sidebar/NewListModal.tsx b/packages/web/components/dashboard/sidebar/NewListModal.tsx new file mode 100644 index 00000000..f51616ed --- /dev/null +++ b/packages/web/components/dashboard/sidebar/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>New 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/components/dashboard/sidebar/Sidebar.tsx b/packages/web/components/dashboard/sidebar/Sidebar.tsx new file mode 100644 index 00000000..a5c1d7a5 --- /dev/null +++ b/packages/web/components/dashboard/sidebar/Sidebar.tsx @@ -0,0 +1,66 @@ +import { Tag, Home, PackageOpen, Settings, Search, Shield } from "lucide-react"; +import { redirect } from "next/navigation"; +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"; +import serverConfig from "@hoarder/shared/config"; +import { api } from "@/server/api/client"; + +export default async function Sidebar() { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const lists = await api.lists.list(); + + return ( + <aside className="flex h-screen w-60 flex-col gap-5 border-r p-4"> + <Link href={"/dashboard/bookmarks"}> + <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="space-y-2 text-sm font-medium"> + <SidebarItem + logo={<Home />} + name="Home" + path="/dashboard/bookmarks" + /> + {serverConfig.meilisearch && ( + <SidebarItem + logo={<Search />} + name="Search" + path="/dashboard/search" + /> + )} + <SidebarItem logo={<Tag />} name="Tags" path="/dashboard/tags" /> + <SidebarItem + logo={<Settings />} + name="Settings" + path="/dashboard/settings" + /> + {session.user.role == "admin" && ( + <SidebarItem + logo={<Shield />} + name="Admin" + path="/dashboard/admin" + /> + )} + </ul> + </div> + <Separator /> + <AllLists initialData={lists} /> + <div className="mt-auto flex justify-between justify-self-end"> + <div className="my-auto"> {session.user.name} </div> + <SidebarProfileOptions /> + </div> + </aside> + ); +} diff --git a/packages/web/components/dashboard/sidebar/SidebarItem.tsx b/packages/web/components/dashboard/sidebar/SidebarItem.tsx new file mode 100644 index 00000000..856bdffd --- /dev/null +++ b/packages/web/components/dashboard/sidebar/SidebarItem.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +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 px-3 py-2 hover:bg-slate-100", + path == currentPath ? "bg-gray-50" : "", + className, + )} + > + <Link href={path} className="flex w-full gap-x-2"> + {logo} + <span className="my-auto"> {name} </span> + </Link> + </li> + ); +} diff --git a/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx new file mode 100644 index 00000000..f931b63e --- /dev/null +++ b/packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { LogOut, MoreHorizontal } from "lucide-react"; +import { signOut } from "next-auth/react"; + +export default function SidebarProfileOptions() { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost"> + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-fit"> + <DropdownMenuItem + onClick={() => + signOut({ + callbackUrl: "/", + }) + } + > + <LogOut className="mr-2 size-4" /> + <span>Sign Out</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} |
