diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-04-14 00:51:56 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-04-14 00:51:56 +0300 |
| commit | 4f17ea61cbb11a72712a1ea8c98904a1cc513e41 (patch) | |
| tree | 4f1dd775e25feb3495ddb208c5fe4aa03c66fe3a /apps | |
| parent | cf0df0e6d84a76649d8cbf8adcbf83efb6e883ab (diff) | |
| download | karakeep-4f17ea61cbb11a72712a1ea8c98904a1cc513e41.tar.zst | |
feature(web): Allow changing the bookmark grid layout (#98)
Diffstat (limited to 'apps')
17 files changed, 519 insertions, 267 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f879780e..1beb4b5c 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,8 +5,14 @@ import "@hoarder/tailwind-config/globals.css"; import type { Viewport } from "next"; import React from "react"; +import { cookies } from "next/headers"; import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; +import { + defaultUserLocalSettings, + parseUserLocalSettings, + USER_LOCAL_SETTINGS_COOKIE_NAME, +} from "@/lib/userLocalSettings/types"; import { getServerAuthSession } from "@/server/auth"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -45,7 +51,15 @@ export default async function RootLayout({ return ( <html lang="en"> <body className={inter.className}> - <Providers session={session} clientConfig={clientConfig}> + <Providers + session={session} + clientConfig={clientConfig} + userLocalSettings={ + parseUserLocalSettings( + cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value, + ) ?? defaultUserLocalSettings() + } + > {children} <ReactQueryDevtools initialIsOpen={false} /> </Providers> diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx index 8997a7e2..3bda1ee8 100644 --- a/apps/web/components/dashboard/bookmarks/AssetCard.tsx +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -3,12 +3,48 @@ import Image from "next/image"; import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; import { api } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; -import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import type { + ZBookmark, + ZBookmarkTypeAsset, +} from "@hoarder/trpc/types/bookmarks"; -import BookmarkActionBar from "./BookmarkActionBar"; -import TagList from "./TagList"; +import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; + +function AssetImage({ + bookmark, + className, +}: { + bookmark: ZBookmarkTypeAsset; + className?: string; +}) { + const bookmarkedAsset = bookmark.content; + switch (bookmarkedAsset.assetType) { + case "image": { + return ( + <Image + alt="asset" + src={`/api/assets/${bookmarkedAsset.assetId}`} + fill={true} + className={className} + /> + ); + } + case "pdf": { + return ( + <iframe + title={bookmarkedAsset.assetId} + className={className} + src={`/api/assets/${bookmarkedAsset.assetId}`} + /> + ); + } + default: { + const _exhaustiveCheck: never = bookmarkedAsset.assetType; + return <span />; + } + } +} export default function AssetCard({ bookmark: initialData, @@ -35,49 +71,25 @@ export default function AssetCard({ }, }, ); - const bookmarkedAsset = bookmark.content; - if (bookmarkedAsset.type != "asset") { + + if (bookmark.content.type != "asset") { throw new Error("Unexpected bookmark type"); } + const bookmarkedAsset = { ...bookmark, content: bookmark.content }; + return ( - <div - className={cn( - className, - cn( - "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg shadow-md", - ), - )} - > - {bookmarkedAsset.assetType == "image" && ( - <div className="relative h-56 max-h-56"> - <Image - alt="asset" - src={`/api/assets/${bookmarkedAsset.assetId}`} - fill={true} - className="rounded-t-lg object-cover" - /> + <BookmarkLayoutAdaptingCard + title={bookmarkedAsset.content.fileName} + footer={null} + bookmark={bookmarkedAsset} + className={className} + wrapTags={true} + image={(_layout, className) => ( + <div className="relative size-full flex-1"> + <AssetImage bookmark={bookmarkedAsset} className={className} /> </div> )} - {bookmarkedAsset.assetType == "pdf" && ( - <iframe - title={bookmarkedAsset.assetId} - className="h-56 max-h-56 w-full" - src={`/api/assets/${bookmarkedAsset.assetId}`} - /> - )} - <div className="flex flex-col gap-y-1 overflow-hidden p-2"> - <div className="flex h-full flex-wrap gap-1 overflow-hidden"> - <TagList - bookmark={bookmark} - loading={isBookmarkStillTagging(bookmark)} - /> - </div> - <div className="flex w-full justify-between"> - <div /> - <BookmarkActionBar bookmark={bookmark} /> - </div> - </div> - </div> + /> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx deleted file mode 100644 index 026b8d37..00000000 --- a/apps/web/components/dashboard/bookmarks/BookmarkCardSkeleton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - ImageCard, - ImageCardBanner, - ImageCardBody, - ImageCardContent, - ImageCardFooter, - ImageCardTitle, -} 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/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx new file mode 100644 index 00000000..3d7b93f3 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -0,0 +1,131 @@ +import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; +import React from "react"; +import Link from "next/link"; +import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; +import { + bookmarkLayoutSwitch, + useBookmarkLayout, +} from "@/lib/userLocalSettings/bookmarksLayout"; +import { cn } from "@/lib/utils"; +import dayjs from "dayjs"; + +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; + +import BookmarkActionBar from "./BookmarkActionBar"; +import TagList from "./TagList"; + +interface Props { + bookmark: ZBookmark; + image: (layout: BookmarksLayoutTypes, className: string) => React.ReactNode; + title?: React.ReactNode; + content?: React.ReactNode; + footer?: React.ReactNode; + className?: string; + fitHeight?: boolean; + wrapTags: boolean; +} + +function BottomRow({ + footer, + bookmark, +}: { + footer?: React.ReactNode; + bookmark: ZBookmark; +}) { + return ( + <div className="justify flex w-full shrink-0 justify-between text-gray-500"> + <div className="flex items-center gap-2 overflow-hidden text-nowrap"> + {footer && <>{footer}•</>} + <Link href={`/dashboard/preview/${bookmark.id}`}> + {dayjs(bookmark.createdAt).format("MMM DD")} + </Link> + </div> + <BookmarkActionBar bookmark={bookmark} /> + </div> + ); +} + +function ListView({ + bookmark, + image, + title, + content, + footer, + className, +}: Props) { + return ( + <div + className={cn( + "flex max-h-96 gap-4 overflow-hidden rounded-lg p-2 shadow-md", + className, + )} + > + <div className="flex size-32 items-center justify-center overflow-hidden"> + {image("list", "object-cover rounded-lg size-32")} + </div> + <div className="flex h-full flex-1 flex-col justify-between gap-2 overflow-hidden"> + <div className="flex flex-col gap-2 overflow-hidden"> + {title && <div className="flex-none shrink-0 text-lg">{title}</div>} + {content && <div className="shrink-1 overflow-hidden">{content}</div>} + <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + </div> + <BottomRow footer={footer} bookmark={bookmark} /> + </div> + </div> + ); +} + +function GridView({ + bookmark, + image, + title, + content, + footer, + className, + wrapTags, + layout, + fitHeight = false, +}: Props & { layout: BookmarksLayoutTypes }) { + const img = image("grid", "h-56 min-h-56 w-full object-cover rounded-t-lg"); + + return ( + <div + className={cn( + "flex flex-col overflow-hidden rounded-lg shadow-md", + className, + fitHeight && layout != "grid" ? "max-h-96" : "h-96", + )} + > + {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} + <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> + <div className="grow-1 flex flex-col gap-2 overflow-hidden"> + {title && <div className="flex-none shrink-0 text-lg">{title}</div>} + {content && <div className="shrink-1 overflow-hidden">{content}</div>} + <div className="flex shrink-0 flex-wrap gap-1 overflow-hidden"> + <TagList + className={wrapTags ? undefined : "h-full"} + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + </div> + <BottomRow footer={footer} bookmark={bookmark} /> + </div> + </div> + ); +} + +export function BookmarkLayoutAdaptingCard(props: Props) { + const layout = useBookmarkLayout(); + + return bookmarkLayoutSwitch(layout, { + masonry: <GridView layout={layout} {...props} />, + grid: <GridView layout={layout} {...props} />, + list: <ListView {...props} />, + }); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index bace3435..01f18815 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -1,5 +1,9 @@ import { useMemo } from "react"; import { ActionButton } from "@/components/ui/action-button"; +import { + bookmarkLayoutSwitch, + useBookmarkLayout, +} from "@/lib/userLocalSettings/bookmarksLayout"; import tailwindConfig from "@/tailwind.config"; import { Slot } from "@radix-ui/react-slot"; import Masonry from "react-masonry-css"; @@ -36,13 +40,15 @@ function renderBookmark(bookmark: ZBookmark) { let comp; switch (bookmark.content.type) { case "link": - comp = <LinkCard bookmark={bookmark} />; + comp = <LinkCard bookmark={{ ...bookmark, content: bookmark.content }} />; break; case "text": - comp = <TextCard bookmark={bookmark} />; + comp = <TextCard bookmark={{ ...bookmark, content: bookmark.content }} />; break; case "asset": - comp = <AssetCard bookmark={bookmark} />; + comp = ( + <AssetCard bookmark={{ ...bookmark, content: bookmark.content }} /> + ); break; } return <BookmarkCard key={bookmark.id}>{comp}</BookmarkCard>; @@ -61,20 +67,36 @@ export default function BookmarksGrid({ isFetchingNextPage?: boolean; fetchNextPage?: () => void; }) { + const layout = useBookmarkLayout(); const breakpointConfig = useMemo(() => getBreakpointConfig(), []); + if (bookmarks.length == 0 && !showEditorCard) { return <p>No bookmarks</p>; } + + const children = [ + showEditorCard && ( + <BookmarkCard key={"editor"}> + <EditorCard /> + </BookmarkCard> + ), + ...bookmarks.map((b) => renderBookmark(b)), + ]; return ( <> - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> - {showEditorCard && ( - <BookmarkCard> - <EditorCard /> - </BookmarkCard> - )} - {bookmarks.map((b) => renderBookmark(b))} - </Masonry> + {bookmarkLayoutSwitch(layout, { + masonry: ( + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {children} + </Masonry> + ), + grid: ( + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {children} + </Masonry> + ), + list: <div className="grid grid-cols-1">{children}</div>, + })} {hasNextPage && ( <div className="flex justify-center"> <ActionButton diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index 10ad1f13..f6ea0c9a 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -7,6 +7,7 @@ import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; +import { useBookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -78,13 +79,19 @@ export default function EditorCard({ className }: { className?: string }) { variant: "destructive", }); }; + const cardHeight = useBookmarkLayoutSwitch({ + grid: "h-96", + masonry: "h-96", + list: undefined, + }); return ( <Form {...form}> <form className={cn( className, - "flex h-96 flex-col gap-2 rounded-xl bg-card p-4", + "flex flex-col gap-2 rounded-xl bg-card p-4", + cardHeight, )} onSubmit={form.handleSubmit(onSubmit, onError)} > diff --git a/apps/web/components/dashboard/bookmarks/LinkCard.tsx b/apps/web/components/dashboard/bookmarks/LinkCard.tsx index 9796ed4f..5c329424 100644 --- a/apps/web/components/dashboard/bookmarks/LinkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/LinkCard.tsx @@ -2,30 +2,70 @@ import Link from "next/link"; import { - ImageCard, - ImageCardBanner, - ImageCardBody, - ImageCardContent, - ImageCardFooter, - ImageCardTitle, -} from "@/components/ui/imageCard"; -import { isBookmarkStillCrawling, isBookmarkStillLoading, - isBookmarkStillTagging, } from "@/lib/bookmarkUtils"; import { api } from "@/lib/trpc"; -import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import type { ZBookmarkTypeLink } from "@hoarder/trpc/types/bookmarks"; + +import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; + +function LinkTitle({ bookmark }: { bookmark: ZBookmarkTypeLink }) { + const link = bookmark.content; + const parsedUrl = new URL(link.url); + return ( + <Link className="line-clamp-2" href={link.url} target="_blank"> + {link?.title ?? parsedUrl.host} + </Link> + ); +} + +function LinkImage({ + bookmark, + className, +}: { + bookmark: ZBookmarkTypeLink; + className?: string; +}) { + const link = bookmark.content; + + // 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 ( + <Link href={link.url} target="_blank"> + {/* eslint-disable-next-line @next/next/no-img-element */} + <img + className={className} + alt="card banner" + src={isBookmarkStillCrawling(bookmark) ? "/blur.avif" : image} + /> + </Link> + ); +} -import BookmarkActionBar from "./BookmarkActionBar"; -import TagList from "./TagList"; +function LinkUrl({ bookmark }: { bookmark: ZBookmarkTypeLink }) { + const link = bookmark.content; + const parsedUrl = new URL(link.url); + return ( + <Link + className="line-clamp-1 hover:text-foreground" + href={link.url} + target="_blank" + > + {parsedUrl.host} + </Link> + ); +} export default function LinkCard({ bookmark: initialData, className, }: { - bookmark: ZBookmark; + bookmark: ZBookmarkTypeLink; className?: string; }) { const { data: bookmark } = api.bookmarks.getBookmark.useQuery( @@ -47,54 +87,23 @@ export default function LinkCard({ }, }, ); - const link = bookmark.content; - if (link.type != "link") { - throw new Error("Unexpected bookmark type"); + + if (bookmark.content.type !== "link") { + throw new Error("Invalid 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="; + const bookmarkLink = { ...bookmark, content: bookmark.content }; return ( - <ImageCard className={className}> - <Link href={link.url} target="_blank"> - <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-foreground" - href={link.url} - target="_blank" - > - {parsedUrl.host} - </Link> - </div> - <BookmarkActionBar bookmark={bookmark} /> - </div> - </ImageCardFooter> - </ImageCardContent> - </ImageCard> + <BookmarkLayoutAdaptingCard + title={<LinkTitle bookmark={bookmarkLink} />} + footer={<LinkUrl bookmark={bookmarkLink} />} + bookmark={bookmarkLink} + wrapTags={false} + image={(_layout, className) => ( + <LinkImage className={className} bookmark={bookmarkLink} /> + )} + className={className} + /> ); } diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index e9161961..e0387bd2 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -8,9 +8,11 @@ import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; export default function TagList({ bookmark, loading, + className, }: { bookmark: ZBookmark; loading?: boolean; + className?: string; }) { if (loading) { return ( @@ -23,8 +25,9 @@ export default function TagList({ return ( <> {bookmark.tags.map((t) => ( - <div key={t.id} className="flex h-full flex-col justify-end"> + <div key={t.id} className={className}> <Link + key={t.id} className={cn( badgeVariants({ variant: "outline" }), "text-nowrap font-normal hover:bg-foreground hover:text-secondary", diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx index 42d09c23..c715c8ab 100644 --- a/apps/web/components/dashboard/bookmarks/TextCard.tsx +++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx @@ -3,14 +3,14 @@ import { useState } from "react"; import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; import { api } from "@/lib/trpc"; +import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import Markdown from "react-markdown"; import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; -import BookmarkActionBar from "./BookmarkActionBar"; import { BookmarkedTextViewer } from "./BookmarkedTextViewer"; -import TagList from "./TagList"; +import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard"; export default function TextCard({ bookmark: initialData, @@ -50,28 +50,34 @@ export default function TextCard({ 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 dark:prose-invert"> - {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 /> - <BookmarkActionBar bookmark={bookmark} /> - </div> - </div> + <BookmarkLayoutAdaptingCard + content={ + <Markdown className="prose dark:prose-invert"> + {bookmarkedText.text} + </Markdown> + } + footer={null} + wrapTags={true} + bookmark={bookmark} + className={className} + fitHeight={true} + image={(layout, className) => + bookmarkLayoutSwitch(layout, { + grid: null, + masonry: null, + list: ( + <div + className={cn( + "flex size-full items-center justify-center bg-accent text-center", + className, + )} + > + Note + </div> + ), + }) + } + /> </> ); } diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index cf8bc2d8..4cd9199d 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -128,7 +128,7 @@ export default function BookmarkPreview({ return ( <div className="grid grid-rows-3 gap-2 overflow-hidden bg-background lg:grid-cols-3 lg:grid-rows-none"> - <div className="row-span-2 h-full w-full overflow-hidden p-2 md:col-span-2 lg:row-auto"> + <div className="row-span-2 h-full w-full overflow-auto p-2 md:col-span-2 lg:row-auto"> {isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content} </div> <div className="lg:col-span1 row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 lg:row-auto"> diff --git a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx index 3fe4d52f..c75e292a 100644 --- a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx +++ b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx @@ -6,33 +6,86 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Slot } from "@radix-ui/react-slot"; -import { LogOut, Moon, MoreHorizontal, Sun } from "lucide-react"; +import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout"; +import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings"; +import { + Check, + LayoutDashboard, + LayoutGrid, + LayoutList, + LayoutPanelLeft, + LogOut, + Moon, + MoreHorizontal, + Sun, +} from "lucide-react"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; +function BookmarkLayoutSelector() { + const layout = useBookmarkLayout(); + + const checkedComp = <Check className="ml-2 size-4" />; + + return ( + <> + <DropdownMenuItem + className="justify-between" + onClick={async () => await updateBookmarksLayout("masonry")} + > + <div className="flex items-center gap-2"> + <LayoutDashboard className="size-4" /> + <span>Masonry</span> + </div> + {layout == "masonry" && checkedComp} + </DropdownMenuItem> + <DropdownMenuItem + className="justify-between" + onClick={async () => await updateBookmarksLayout("grid")} + > + <div className="flex items-center gap-2"> + <LayoutGrid className="size-4" /> + <span>Grid</span> + </div> + {layout == "grid" && checkedComp} + </DropdownMenuItem> + <DropdownMenuItem + className="justify-between" + onClick={async () => await updateBookmarksLayout("list")} + > + <div className="flex items-center gap-2"> + <LayoutList className="size-4" /> + <span>List</span> + </div> + {layout == "list" && checkedComp} + </DropdownMenuItem> + </> + ); +} + function DarkModeToggle() { const { theme } = useTheme(); - let comp; if (theme == "dark") { - comp = ( - <span> - <Sun className="size-4" /> - <p>Light Mode</p> - </span> + return ( + <> + <Sun className="mr-2 size-4" /> + <span>Light Mode</span> + </> ); } else { - comp = ( - <span> - <Moon className="size-4" /> - <p>Dark Mode</p> - </span> + return ( + <> + <Moon className="mr-2 size-4" /> + <span>Dark Mode</span> + </> ); } - return <Slot className="flex flex-row gap-2">{comp}</Slot>; } export default function SidebarProfileOptions() { @@ -48,6 +101,15 @@ export default function SidebarProfileOptions() { <DropdownMenuItem onClick={toggleTheme}> <DarkModeToggle /> </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger> + <LayoutPanelLeft className="mr-2 size-4" /> + <span>Layout</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <BookmarkLayoutSelector /> + </DropdownMenuSubContent> + </DropdownMenuSub> <DropdownMenuItem onClick={() => signOut({ diff --git a/apps/web/components/ui/imageCard.tsx b/apps/web/components/ui/imageCard.tsx deleted file mode 100644 index de059b83..00000000 --- a/apps/web/components/ui/imageCard.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; - -export function ImageCard({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { - return ( - <div - className={cn("h-96 overflow-hidden rounded-lg shadow-md", className)} - {...props} - /> - ); -} - -export function ImageCardBanner({ - className, - ...props -}: React.ImgHTMLAttributes<HTMLImageElement>) { - return ( - // eslint-disable-next-line @next/next/no-img-element - <img - className={cn("h-56 min-h-56 w-full object-cover", className)} - alt="card banner" - {...props} - /> - ); -} - -export function ImageCardContent({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { - return ( - <div - className={cn( - "flex h-40 min-h-40 flex-col justify-between p-2", - className, - )} - {...props} - /> - ); -} - -export function ImageCardTitle({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { - return ( - <div - className={cn("order-first flex-none text-lg", className)} - {...props} - /> - ); -} - -export function ImageCardBody({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { - return <div className={cn("order-1", className)} {...props} />; -} - -export function ImageCardFooter({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) { - return <div className={cn("order-last", className)} {...props} />; -} diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index 60f30931..9c937281 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -1,9 +1,11 @@ "use client"; +import type { UserLocalSettings } from "@/lib/userLocalSettings/types"; import type { Session } from "next-auth"; import React, { useState } from "react"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink, loggerLink } from "@trpc/client"; import { SessionProvider } from "next-auth/react"; @@ -46,10 +48,12 @@ export default function Providers({ children, session, clientConfig, + userLocalSettings, }: { children: React.ReactNode; session: Session | null; clientConfig: ClientConfig; + userLocalSettings: UserLocalSettings; }) { const queryClient = getQueryClient(); @@ -72,20 +76,22 @@ export default function Providers({ return ( <ClientConfigCtx.Provider value={clientConfig}> - <SessionProvider session={session}> - <api.Provider client={trpcClient} queryClient={queryClient}> - <QueryClientProvider client={queryClient}> - <ThemeProvider - attribute="class" - defaultTheme="system" - enableSystem - disableTransitionOnChange - > - <TooltipProvider delayDuration={0}>{children}</TooltipProvider> - </ThemeProvider> - </QueryClientProvider> - </api.Provider> - </SessionProvider> + <UserLocalSettingsCtx.Provider value={userLocalSettings}> + <SessionProvider session={session}> + <api.Provider client={trpcClient} queryClient={queryClient}> + <QueryClientProvider client={queryClient}> + <ThemeProvider + attribute="class" + defaultTheme="system" + enableSystem + disableTransitionOnChange + > + <TooltipProvider delayDuration={0}>{children}</TooltipProvider> + </ThemeProvider> + </QueryClientProvider> + </api.Provider> + </SessionProvider> + </UserLocalSettingsCtx.Provider> </ClientConfigCtx.Provider> ); } diff --git a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx new file mode 100644 index 00000000..424046b9 --- /dev/null +++ b/apps/web/lib/userLocalSettings/bookmarksLayout.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { z } from "zod"; +import { createContext, useContext } from "react"; + +import type { BookmarksLayoutTypes, zUserLocalSettings } from "./types"; + +const defaultLayout: BookmarksLayoutTypes = "masonry"; + +export const UserLocalSettingsCtx = createContext< + z.infer<typeof zUserLocalSettings> +>({ + bookmarkGridLayout: defaultLayout, +}); + +function useUserLocalSettings() { + return useContext(UserLocalSettingsCtx); +} + +export function useBookmarkLayout() { + const settings = useUserLocalSettings(); + return settings.bookmarkGridLayout; +} + +export function bookmarkLayoutSwitch<T>( + layout: BookmarksLayoutTypes, + data: Record<BookmarksLayoutTypes, T>, +) { + return data[layout]; +} + +export function useBookmarkLayoutSwitch<T>( + data: Record<BookmarksLayoutTypes, T>, +) { + const layout = useBookmarkLayout(); + return data[layout]; +} diff --git a/apps/web/lib/userLocalSettings/types.ts b/apps/web/lib/userLocalSettings/types.ts new file mode 100644 index 00000000..155469f5 --- /dev/null +++ b/apps/web/lib/userLocalSettings/types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const USER_LOCAL_SETTINGS_COOKIE_NAME = "hoarder-user-local-settings"; + +const zBookmarkGridLayout = z.enum(["grid", "list", "masonry"]); +export type BookmarksLayoutTypes = z.infer<typeof zBookmarkGridLayout>; + +export const zUserLocalSettings = z.object({ + bookmarkGridLayout: zBookmarkGridLayout.optional().default("masonry"), +}); + +export type UserLocalSettings = z.infer<typeof zUserLocalSettings>; + +export function parseUserLocalSettings(str: string | undefined) { + try { + return zUserLocalSettings.parse(JSON.parse(str ?? "{}")); + } catch (e) { + return undefined; + } +} + +export function defaultUserLocalSettings() { + return zUserLocalSettings.parse({}); +} diff --git a/apps/web/lib/userLocalSettings/userLocalSettings.ts b/apps/web/lib/userLocalSettings/userLocalSettings.ts new file mode 100644 index 00000000..8b39d0d6 --- /dev/null +++ b/apps/web/lib/userLocalSettings/userLocalSettings.ts @@ -0,0 +1,18 @@ +"use server"; + +import { cookies } from "next/headers"; + +import type { BookmarksLayoutTypes } from "./types"; +import { + parseUserLocalSettings, + USER_LOCAL_SETTINGS_COOKIE_NAME, +} from "./types"; + +export async function updateBookmarksLayout(layout: BookmarksLayoutTypes) { + const userSettings = cookies().get(USER_LOCAL_SETTINGS_COOKIE_NAME); + const parsed = parseUserLocalSettings(userSettings?.value); + cookies().set( + USER_LOCAL_SETTINGS_COOKIE_NAME, + JSON.stringify({ ...parsed, bookmarkGridLayout: layout }), + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 6b8e0805..af8a286e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,8 +20,8 @@ "@emoji-mart/react": "^1.1.1", "@hoarder/db": "workspace:^0.1.0", "@hoarder/shared": "workspace:^0.1.0", - "@hoarder/trpc": "workspace:^0.1.0", "@hoarder/shared-react": "workspace:^0.1.0", + "@hoarder/trpc": "workspace:^0.1.0", "@hookform/resolvers": "^3.3.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", |
