aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web/components/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web/components/dashboard')
-rw-r--r--packages/web/components/dashboard/bookmarks/AddBookmark.tsx100
-rw-r--r--packages/web/components/dashboard/bookmarks/AddToListModal.tsx168
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx30
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarkOptions.tsx185
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarkPreview.tsx101
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx109
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarkedTextViewer.tsx20
-rw-r--r--packages/web/components/dashboard/bookmarks/Bookmarks.tsx32
-rw-r--r--packages/web/components/dashboard/bookmarks/BookmarksGrid.tsx64
-rw-r--r--packages/web/components/dashboard/bookmarks/LinkCard.tsx114
-rw-r--r--packages/web/components/dashboard/bookmarks/TagList.tsx39
-rw-r--r--packages/web/components/dashboard/bookmarks/TagModal.tsx207
-rw-r--r--packages/web/components/dashboard/bookmarks/TextCard.tsx94
-rw-r--r--packages/web/components/dashboard/lists/AllListsView.tsx66
-rw-r--r--packages/web/components/dashboard/lists/DeleteListButton.tsx77
-rw-r--r--packages/web/components/dashboard/lists/ListView.tsx25
-rw-r--r--packages/web/components/dashboard/settings/AddApiKey.tsx167
-rw-r--r--packages/web/components/dashboard/settings/ApiKeySettings.tsx49
-rw-r--r--packages/web/components/dashboard/settings/DeleteApiKey.tsx74
-rw-r--r--packages/web/components/dashboard/sidebar/AllLists.tsx60
-rw-r--r--packages/web/components/dashboard/sidebar/ModileSidebar.tsx24
-rw-r--r--packages/web/components/dashboard/sidebar/ModileSidebarItem.tsx27
-rw-r--r--packages/web/components/dashboard/sidebar/NewListModal.tsx170
-rw-r--r--packages/web/components/dashboard/sidebar/Sidebar.tsx66
-rw-r--r--packages/web/components/dashboard/sidebar/SidebarItem.tsx33
-rw-r--r--packages/web/components/dashboard/sidebar/SidebarProfileOptions.tsx35
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&apos;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 &quot;{name}&quot;? 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>
+ );
+}