diff options
| -rw-r--r-- | apps/web/app/dashboard/preview/[bookmarkId]/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx | 9 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx | 204 | ||||
| -rw-r--r-- | apps/web/components/ui/dialog.tsx | 16 | ||||
| -rw-r--r-- | apps/web/components/ui/tooltip.tsx | 36 | ||||
| -rw-r--r-- | apps/web/package.json | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 40 |
7 files changed, 244 insertions, 69 deletions
diff --git a/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx index 5f10b56e..c9f27eac 100644 --- a/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx +++ b/apps/web/app/dashboard/preview/[bookmarkId]/page.tsx @@ -10,5 +10,9 @@ export default async function BookmarkPreviewPage({ bookmarkId: params.bookmarkId, }); - return <BookmarkPreview initialData={bookmark} />; + return ( + <div className="max-h-screen p-4"> + <BookmarkPreview initialData={bookmark} /> + </div> + ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx index 0d98cc1f..d2beb8d4 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkActionBar.tsx @@ -1,6 +1,5 @@ import { Button } from "@/components/ui/button"; -import { DialogContent } from "@/components/ui/dialog"; -import { Dialog, DialogTrigger } from "@radix-ui/react-dialog"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Maximize2, Star } from "lucide-react"; import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; @@ -28,7 +27,11 @@ export default function BookmarkActionBar({ <Maximize2 size="20" /> </Button> </DialogTrigger> - <DialogContent className="h-[90%] max-w-[90%] overflow-hidden"> + <DialogContent + className="h-[90%] max-w-[90%] overflow-hidden p-0" + hideCloseBtn={true} + onOpenAutoFocus={(e) => e.preventDefault()} + > <BookmarkPreview initialData={bookmark} /> </DialogContent> </Dialog> diff --git a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx index 4209192e..632422c4 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx @@ -2,58 +2,98 @@ import Image from "next/image"; import Link from "next/link"; -import { BackButton } from "@/components/ui/back-button"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; -import { isBookmarkStillCrawling } from "@/lib/bookmarkUtils"; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + isBookmarkStillCrawling, + isBookmarkStillLoading, +} from "@/lib/bookmarkUtils"; import { api } from "@/lib/trpc"; -import { ArrowLeftCircle, CalendarDays, ExternalLink } from "lucide-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { CalendarDays, ExternalLink } from "lucide-react"; import Markdown from "react-markdown"; import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; -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; - }, - }, +import { TagsEditor } from "./TagsEditor"; + +dayjs.extend(relativeTime); + +function ContentLoading() { + return ( + <div className="flex w-full flex-col gap-2"> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + <Skeleton className="h-4" /> + </div> ); +} - 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"> +function CreationTime({ createdAt }: { createdAt: Date }) { + return ( + <TooltipProvider> + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <span className="flex w-fit gap-2"> + <CalendarDays /> {dayjs(createdAt).fromNow()} + </span> + </TooltipTrigger> + <TooltipContent>{createdAt.toLocaleString()}</TooltipContent> + </Tooltip> + </TooltipProvider> + ); +} + +function LinkHeader({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== "link") { + throw new Error("Unexpected content type"); + } + + const title = bookmark.content.title ?? bookmark.content.url; + + return ( + <div className="flex w-full flex-col items-center justify-center space-y-3"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <p className="line-clamp-2 text-center text-lg">{title}</p> + </TooltipTrigger> + <TooltipPortal> + <TooltipContent side="bottom" className="w-96"> + {title} + </TooltipContent> + </TooltipPortal> + </Tooltip> + </TooltipProvider> + <Link + href={bookmark.content.url} + className="mx-auto flex gap-2 text-gray-400" + > <span className="my-auto">View Original</span> <ExternalLink /> </Link> + <hr /> </div> ); +} +function TextContentSection({ bookmark }: { bookmark: ZBookmark }) { let content; switch (bookmark.content.type) { case "link": { if (!bookmark.content.htmlContent) { content = ( - <div className="text-red-500">Failed to fetch link content ...</div> + <div className="text-destructive"> + Failed to fetch link content ... + </div> ); } else { content = ( @@ -61,24 +101,39 @@ export default function BookmarkPreview({ dangerouslySetInnerHTML={{ __html: bookmark.content.htmlContent || "", }} - className="prose" + className="prose mx-auto" /> ); } break; } case "text": { - content = <Markdown className="prose">{bookmark.content.text}</Markdown>; + content = ( + <Markdown className="prose mx-auto">{bookmark.content.text}</Markdown> + ); break; } - case "asset": { + } + + return <ScrollArea className="h-full">{content}</ScrollArea>; +} + +function AssetContentSection({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type != "asset") { + throw new Error("Invalid content type"); + } + + let content; + switch (bookmark.content.assetType) { + case "image": { switch (bookmark.content.assetType) { case "image": { content = ( - <div className="relative w-full"> + <div className="relative h-full min-w-full"> <Image alt="asset" fill={true} + className="object-contain" src={`/api/assets/${bookmark.content.assetId}`} /> </div> @@ -88,31 +143,62 @@ export default function BookmarkPreview({ break; } } + return content; +} + +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 (isBookmarkStillLoading(data)) { + return 1000; + } + return false; + }, + }, + ); + + let content; + switch (bookmark.content.type) { + case "link": + case "text": { + content = <TextContentSection bookmark={bookmark} />; + break; + } + case "asset": { + content = <AssetContentSection bookmark={bookmark} />; + break; + } + } + + const linkHeader = bookmark.content.type == "link" && ( + <LinkHeader bookmark={bookmark} /> + ); return ( - <div className="m-4 min-h-screen space-y-4 rounded-md border bg-background 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 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"> + {isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content} </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 className="lg:col-span1 row-span-1 flex flex-col gap-4 bg-gray-100 p-4 lg:row-auto"> + {linkHeader} + <CreationTime createdAt={bookmark.createdAt} /> + <div className="flex gap-2"> + <TagsEditor bookmark={bookmark} /> + </div> </div> </div> ); diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx index 8e0c3c6c..18795408 100644 --- a/apps/web/components/ui/dialog.tsx +++ b/apps/web/components/ui/dialog.tsx @@ -30,8 +30,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { + hideCloseBtn?: boolean; + } +>(({ className, children, hideCloseBtn = false, ...props }, ref) => ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content @@ -43,10 +45,12 @@ const DialogContent = React.forwardRef< {...props} > {children} - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="size-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> + {!hideCloseBtn && ( + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="size-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} </DialogPrimitive.Content> </DialogPortal> )); diff --git a/apps/web/components/ui/tooltip.tsx b/apps/web/components/ui/tooltip.tsx new file mode 100644 index 00000000..020f6151 --- /dev/null +++ b/apps/web/components/ui/tooltip.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipPortal = TooltipPrimitive.Portal; + +const TooltipContent = React.forwardRef< + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className, + )} + {...props} + /> +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TooltipPortal, +}; diff --git a/apps/web/package.json b/apps/web/package.json index 85cd8942..aa38b882 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.24.8", "@tanstack/react-query-devtools": "^5.21.0", "@trpc/client": "11.0.0-next-beta.308", @@ -40,6 +41,7 @@ "better-sqlite3": "^9.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "dayjs": "^1.11.10", "drizzle-orm": "^0.29.4", "lucide-react": "^0.330.0", "next": "14.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7167470f..eb983e37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.1.5 version: 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tanstack/react-query': specifier: ^5.24.8 version: 5.24.8(react@18.2.0) @@ -406,6 +409,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.0 + dayjs: + specifier: ^1.11.10 + version: 1.11.10 drizzle-orm: specifier: ^0.29.4 version: 0.29.4(@types/react@18.2.58)(better-sqlite3@9.4.3)(react@18.2.0) @@ -3013,6 +3019,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.0.7': + resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.0.1': resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} peerDependencies: @@ -15893,6 +15912,27 @@ snapshots: react-dom: 18.2.0(react@18.2.0) dev: false + '@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/react': 18.2.58 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.58)(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 |
