diff options
Diffstat (limited to 'packages/web/components/dashboard/bookmarks')
13 files changed, 1263 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> + </> + ); +} |
