diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-01 20:26:25 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-19 11:45:30 +0000 |
| commit | 49f38efdbe3718055d2c84847d7dab92ae359be9 (patch) | |
| tree | 17d4b6d062ce885fd2de3a05ced224ab49292586 /apps/web/components | |
| parent | 4a4ff37b6283df1d6327a3f8ddff8b74f989ec36 (diff) | |
| download | karakeep-49f38efdbe3718055d2c84847d7dab92ae359be9.tar.zst | |
feat: Add a proper reader mode
Diffstat (limited to 'apps/web/components')
6 files changed, 234 insertions, 158 deletions
diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx index 16668e67..4b8218a9 100644 --- a/apps/web/components/dashboard/lists/ShareListModal.tsx +++ b/apps/web/components/dashboard/lists/ShareListModal.tsx @@ -4,13 +4,13 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { useTranslation } from "@/lib/i18n/client"; -import { DialogDescription } from "@radix-ui/react-dialog"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx index dc446112..19499d3e 100644 --- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx +++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx @@ -92,6 +92,7 @@ export interface Highlight { interface HTMLHighlighterProps { htmlContent: string; + style?: React.CSSProperties; className?: string; highlights?: Highlight[]; onHighlight?: (highlight: Highlight) => void; @@ -102,6 +103,7 @@ interface HTMLHighlighterProps { function BookmarkHTMLHighlighter({ htmlContent, className, + style, highlights = [], onHighlight, onUpdateHighlight, @@ -345,6 +347,7 @@ function BookmarkHTMLHighlighter({ dangerouslySetInnerHTML={{ __html: htmlContent }} onPointerUp={handlePointerUp} className={className} + style={style} /> <ColorPickerMenu position={menuPosition} diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index e86be6b7..eefec701 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import Image from "next/image"; -import BookmarkHTMLHighlighter from "@/components/dashboard/preview/BookmarkHtmlHighlighter"; -import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -10,23 +11,22 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { ScrollArea } from "@radix-ui/react-scroll-area"; -import { Archive, BookOpen, Camera, Video } from "lucide-react"; +import { Archive, BookOpen, Camera, ExpandIcon, Video } from "lucide-react"; import { - useCreateHighlight, - useDeleteHighlight, - useUpdateHighlight, -} from "@karakeep/shared-react/hooks/highlights"; -import { BookmarkTypes, ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import ReaderView from "./ReaderView"; + function FullPageArchiveSection({ link }: { link: ZBookmarkedLink }) { const archiveAssetId = link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; @@ -54,106 +54,6 @@ function ScreenshotSection({ link }: { link: ZBookmarkedLink }) { ); } -function CachedContentSection({ bookmarkId }: { bookmarkId: string }) { - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: cachedContent, isPending: isCachedContentLoading } = - api.bookmarks.getBookmark.useQuery( - { - bookmarkId, - includeContent: true, - }, - { - select: (data) => - data.content.type == BookmarkTypes.LINK - ? data.content.htmlContent - : null, - }, - ); - - const { mutate: createHighlight } = useCreateHighlight({ - onSuccess: () => { - toast({ - description: "Highlight has been created!", - }); - }, - onError: () => { - toast({ - variant: "destructive", - description: "Something went wrong", - }); - }, - }); - - const { mutate: updateHighlight } = useUpdateHighlight({ - onSuccess: () => { - toast({ - description: "Highlight has been updated!", - }); - }, - onError: () => { - toast({ - variant: "destructive", - description: "Something went wrong", - }); - }, - }); - - const { mutate: deleteHighlight } = useDeleteHighlight({ - onSuccess: () => { - toast({ - description: "Highlight has been deleted!", - }); - }, - onError: () => { - toast({ - variant: "destructive", - description: "Something went wrong", - }); - }, - }); - - let content; - if (isCachedContentLoading) { - content = <FullPageSpinner />; - } else if (!cachedContent) { - content = ( - <div className="text-destructive">Failed to fetch link content ...</div> - ); - } else { - content = ( - <BookmarkHTMLHighlighter - htmlContent={cachedContent || ""} - className="prose mx-auto dark:prose-invert" - highlights={highlights?.highlights ?? []} - onDeleteHighlight={(h) => - deleteHighlight({ - highlightId: h.id, - }) - } - onUpdateHighlight={(h) => - updateHighlight({ - highlightId: h.id, - color: h.color, - }) - } - onHighlight={(h) => - createHighlight({ - startOffset: h.startOffset, - endOffset: h.endOffset, - color: h.color, - bookmarkId, - text: h.text, - note: null, - }) - } - /> - ); - } - return <ScrollArea className="h-full">{content}</ScrollArea>; -} - function VideoSection({ link }: { link: ZBookmarkedLink }) { return ( <div className="relative h-full w-full overflow-hidden"> @@ -182,7 +82,14 @@ export default function LinkContentSection({ let content; if (section === "cached") { - content = <CachedContentSection bookmarkId={bookmark.id} />; + content = ( + <ScrollArea className="h-full"> + <ReaderView + className="prose mx-auto dark:prose-invert" + bookmarkId={bookmark.id} + /> + </ScrollArea> + ); } else if (section === "archive") { content = <FullPageArchiveSection link={bookmark.content} />; } else if (section === "video") { @@ -193,50 +100,68 @@ export default function LinkContentSection({ return ( <div className="flex h-full flex-col items-center gap-2"> - <Select onValueChange={setSection} value={section}> - <SelectTrigger className="w-fit"> - <span className="mr-2"> - <SelectValue /> - </span> - </SelectTrigger> - <SelectContent> - <SelectGroup> - <SelectItem value="cached"> - <div className="flex items-center"> - <BookOpen className="mr-2 h-4 w-4" /> - {t("preview.reader_view")} - </div> - </SelectItem> - <SelectItem - value="screenshot" - disabled={!bookmark.content.screenshotAssetId} - > - <div className="flex items-center"> - <Camera className="mr-2 h-4 w-4" /> - {t("common.screenshot")} - </div> - </SelectItem> - <SelectItem - value="archive" - disabled={ - !bookmark.content.fullPageArchiveAssetId && - !bookmark.content.precrawledArchiveAssetId - } - > - <div className="flex items-center"> - <Archive className="mr-2 h-4 w-4" /> - {t("common.archive")} - </div> - </SelectItem> - <SelectItem value="video" disabled={!bookmark.content.videoAssetId}> - <div className="flex items-center"> - <Video className="mr-2 h-4 w-4" /> - {t("common.video")} - </div> - </SelectItem> - </SelectGroup> - </SelectContent> - </Select> + <div className="flex items-center gap-2"> + <Select onValueChange={setSection} value={section}> + <SelectTrigger className="w-fit"> + <span className="mr-2"> + <SelectValue /> + </span> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="cached"> + <div className="flex items-center"> + <BookOpen className="mr-2 h-4 w-4" /> + {t("preview.reader_view")} + </div> + </SelectItem> + <SelectItem + value="screenshot" + disabled={!bookmark.content.screenshotAssetId} + > + <div className="flex items-center"> + <Camera className="mr-2 h-4 w-4" /> + {t("common.screenshot")} + </div> + </SelectItem> + <SelectItem + value="archive" + disabled={ + !bookmark.content.fullPageArchiveAssetId && + !bookmark.content.precrawledArchiveAssetId + } + > + <div className="flex items-center"> + <Archive className="mr-2 h-4 w-4" /> + {t("common.archive")} + </div> + </SelectItem> + <SelectItem + value="video" + disabled={!bookmark.content.videoAssetId} + > + <div className="flex items-center"> + <Video className="mr-2 h-4 w-4" /> + {t("common.video")} + </div> + </SelectItem> + </SelectGroup> + </SelectContent> + </Select> + {section === "cached" && ( + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + )} + </div> {content} </div> ); diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx new file mode 100644 index 00000000..bf4c27a5 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderView.tsx @@ -0,0 +1,121 @@ +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +import { + useCreateHighlight, + useDeleteHighlight, + useUpdateHighlight, +} from "@karakeep/shared-react/hooks/highlights"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import BookmarkHTMLHighlighter from "./BookmarkHtmlHighlighter"; + +export default function ReaderView({ + bookmarkId, + className, + style, +}: { + bookmarkId: string; + className?: string; + style?: React.CSSProperties; +}) { + const { data: highlights } = api.highlights.getForBookmark.useQuery({ + bookmarkId, + }); + const { data: cachedContent, isPending: isCachedContentLoading } = + api.bookmarks.getBookmark.useQuery( + { + bookmarkId, + includeContent: true, + }, + { + select: (data) => + data.content.type == BookmarkTypes.LINK + ? data.content.htmlContent + : null, + }, + ); + + const { mutate: createHighlight } = useCreateHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been created!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + + const { mutate: updateHighlight } = useUpdateHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been updated!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + + const { mutate: deleteHighlight } = useDeleteHighlight({ + onSuccess: () => { + toast({ + description: "Highlight has been deleted!", + }); + }, + onError: () => { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + }, + }); + + let content; + if (isCachedContentLoading) { + content = <FullPageSpinner />; + } else if (!cachedContent) { + content = ( + <div className="text-destructive">Failed to fetch link content ...</div> + ); + } else { + content = ( + <BookmarkHTMLHighlighter + className={className} + style={style} + htmlContent={cachedContent || ""} + highlights={highlights?.highlights ?? []} + onDeleteHighlight={(h) => + deleteHighlight({ + highlightId: h.id, + }) + } + onUpdateHighlight={(h) => + updateHighlight({ + highlightId: h.id, + color: h.color, + }) + } + onHighlight={(h) => + createHighlight({ + startOffset: h.startOffset, + endOffset: h.endOffset, + color: h.color, + bookmarkId, + text: h.text, + note: null, + }) + } + /> + ); + } + return content; +} diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx index 4e33bb92..a4510698 100644 --- a/apps/web/components/dashboard/preview/TextContentSection.tsx +++ b/apps/web/components/dashboard/preview/TextContentSection.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent"; -import { ScrollArea } from "@radix-ui/react-scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/ui/slider.tsx b/apps/web/components/ui/slider.tsx new file mode 100644 index 00000000..a789595b --- /dev/null +++ b/apps/web/components/ui/slider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +const Slider = React.forwardRef< + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> +>(({ className, ...props }, ref) => ( + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className, + )} + {...props} + > + <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; |
