aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-01 20:26:25 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-19 11:45:30 +0000
commit49f38efdbe3718055d2c84847d7dab92ae359be9 (patch)
tree17d4b6d062ce885fd2de3a05ced224ab49292586 /apps
parent4a4ff37b6283df1d6327a3f8ddff8b74f989ec36 (diff)
downloadkarakeep-49f38efdbe3718055d2c84847d7dab92ae359be9.tar.zst
feat: Add a proper reader mode
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/layout.tsx6
-rw-r--r--apps/web/app/reader/[bookmarkId]/page.tsx312
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx2
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx3
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx237
-rw-r--r--apps/web/components/dashboard/preview/ReaderView.tsx121
-rw-r--r--apps/web/components/dashboard/preview/TextContentSection.tsx2
-rw-r--r--apps/web/components/ui/slider.tsx27
-rw-r--r--apps/web/package.json1
9 files changed, 548 insertions, 163 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index d5af9e35..e8673c78 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -50,11 +50,7 @@ export default async function RootLayout({
const userSettings = await getUserLocalSettings();
const isRTL = userSettings.lang === "ar";
return (
- <html
- className="sm:overflow-hidden"
- lang={userSettings.lang}
- dir={isRTL ? "rtl" : "ltr"}
- >
+ <html className="" lang={userSettings.lang} dir={isRTL ? "rtl" : "ltr"}>
<body className={inter.className}>
<NuqsAdapter>
<Providers
diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
new file mode 100644
index 00000000..7c2b0c9e
--- /dev/null
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { Suspense, useState } from "react";
+import { useRouter } from "next/navigation";
+import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
+import ReaderView from "@/components/dashboard/preview/ReaderView";
+import { Button } from "@/components/ui/button";
+import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Slider } from "@/components/ui/slider";
+import {
+ HighlighterIcon as Highlight,
+ Minus,
+ Plus,
+ Printer,
+ Settings,
+ Type,
+ X,
+} from "lucide-react";
+
+import { api } from "@karakeep/shared-react/trpc";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+
+export default function ReaderViewPage({
+ params,
+}: {
+ params: { bookmarkId: string };
+}) {
+ const bookmarkId = params.bookmarkId;
+ const { data: highlights } = api.highlights.getForBookmark.useQuery({
+ bookmarkId,
+ });
+ const { data: bookmark } = api.bookmarks.getBookmark.useQuery({
+ bookmarkId,
+ });
+
+ const router = useRouter();
+ const [fontSize, setFontSize] = useState([18]);
+ const [lineHeight, setLineHeight] = useState([1.6]);
+ const [fontFamily, setFontFamily] = useState("serif");
+ const [showHighlights, setShowHighlights] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+
+ const fontFamilies = {
+ serif: "ui-serif, Georgia, Cambria, serif",
+ sans: "ui-sans-serif, system-ui, sans-serif",
+ mono: "ui-monospace, Menlo, Monaco, monospace",
+ };
+
+ const onClose = () => {
+ if (window.history.length > 1) {
+ router.back();
+ } else {
+ router.push("/dashboard");
+ }
+ };
+
+ const handlePrint = () => {
+ window.print();
+ };
+
+ return (
+ <div className="min-h-screen bg-background">
+ {/* Header */}
+ <header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 print:hidden">
+ <div className="flex h-14 items-center justify-between px-4">
+ <div className="flex items-center gap-2">
+ <Button variant="ghost" size="icon" onClick={onClose}>
+ <X className="h-4 w-4" />
+ </Button>
+ <span className="text-sm text-muted-foreground">Reader View</span>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button variant="ghost" size="icon" onClick={handlePrint}>
+ <Printer className="h-4 w-4" />
+ </Button>
+
+ <Popover open={showSettings} onOpenChange={setShowSettings}>
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <Settings className="h-4 w-4" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent side="bottom" align="end" className="w-80">
+ <div className="space-y-4">
+ <div className="flex items-center gap-2 pb-2">
+ <Type className="h-4 w-4" />
+ <h3 className="font-semibold">Reading Settings</h3>
+ </div>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <label className="text-sm font-medium">Font Family</label>
+ <Select value={fontFamily} onValueChange={setFontFamily}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="serif">Serif</SelectItem>
+ <SelectItem value="sans">Sans Serif</SelectItem>
+ <SelectItem value="mono">Monospace</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">Font Size</label>
+ <span className="text-sm text-muted-foreground">
+ {fontSize[0]}px
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ setFontSize([Math.max(12, fontSize[0] - 1)])
+ }
+ >
+ <Minus className="h-3 w-3" />
+ </Button>
+ <Slider
+ value={fontSize}
+ onValueChange={setFontSize}
+ max={24}
+ min={12}
+ step={1}
+ className="flex-1"
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ className="h-7 w-7 bg-transparent"
+ onClick={() =>
+ setFontSize([Math.min(24, fontSize[0] + 1)])
+ }
+ >
+ <Plus className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">
+ Line Height
+ </label>
+ <span className="text-sm text-muted-foreground">
+ {lineHeight[0]}
+ </span>
+ </div>
+ <Slider
+ value={lineHeight}
+ onValueChange={setLineHeight}
+ max={2.5}
+ min={1.2}
+ step={0.1}
+ />
+ </div>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+
+ <Button
+ variant={showHighlights ? "default" : "ghost"}
+ size="icon"
+ onClick={() => setShowHighlights(!showHighlights)}
+ >
+ <Highlight className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </header>
+
+ <div className="flex overflow-hidden">
+ {/* Mobile backdrop */}
+ {showHighlights && (
+ <button
+ className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
+ onClick={() => setShowHighlights(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setShowHighlights(false);
+ }
+ }}
+ aria-label="Close highlights sidebar"
+ />
+ )}
+
+ {/* Main Content */}
+ <main
+ className={`flex-1 overflow-x-hidden transition-all duration-300 ${showHighlights ? "lg:mr-80" : ""}`}
+ >
+ <article className="mx-auto max-w-3xl overflow-x-hidden px-4 py-8 sm:px-6">
+ {bookmark ? (
+ <>
+ {/* Article Header */}
+ <header className="mb-8 space-y-4">
+ <h1
+ className="font-bold leading-tight"
+ style={{
+ fontFamily:
+ fontFamilies[fontFamily as keyof typeof fontFamilies],
+ fontSize: `${fontSize[0] * 1.8}px`,
+ lineHeight: lineHeight[0] * 0.9,
+ }}
+ >
+ {getBookmarkTitle(bookmark)}
+ </h1>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ {bookmark.content.type == BookmarkTypes.LINK && (
+ <span>By {bookmark.content.author}</span>
+ )}
+ <Separator orientation="vertical" className="h-4" />
+ <span>8 min</span>
+ </div>
+ </header>
+
+ {/* Article Content */}
+ <Suspense fallback={<FullPageSpinner />}>
+ <div className="overflow-x-hidden">
+ <ReaderView
+ className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
+ style={{
+ fontFamily:
+ fontFamilies[fontFamily as keyof typeof fontFamilies],
+ fontSize: `${fontSize[0]}px`,
+ lineHeight: lineHeight[0],
+ }}
+ bookmarkId={bookmarkId}
+ />
+ </div>
+ </Suspense>
+ </>
+ ) : (
+ <FullPageSpinner />
+ )}
+ </article>
+ </main>
+
+ {/* Mobile backdrop */}
+ {showHighlights && (
+ <button
+ className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
+ onClick={() => setShowHighlights(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setShowHighlights(false);
+ }
+ }}
+ aria-label="Close highlights sidebar"
+ />
+ )}
+
+ {/* Highlights Sidebar */}
+ {showHighlights && highlights && (
+ <aside className="fixed right-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-full border-l bg-background sm:w-80 lg:z-auto lg:bg-background/95 lg:backdrop-blur lg:supports-[backdrop-filter]:bg-background/60 print:hidden">
+ <div className="flex h-full flex-col">
+ <div className="border-b p-4">
+ <div className="flex items-center justify-between">
+ <h2 className="font-semibold">Highlights</h2>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ {highlights.highlights.length} saved
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 lg:hidden"
+ onClick={() => setShowHighlights(false)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1 overflow-auto p-4">
+ <div className="space-y-4">
+ {highlights.highlights.map((highlight) => (
+ <HighlightCard
+ key={highlight.id}
+ highlight={highlight}
+ clickable={true}
+ />
+ ))}
+ </div>
+ </div>
+ </div>
+ </aside>
+ )}
+ </div>
+ </div>
+ );
+}
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 };
diff --git a/apps/web/package.json b/apps/web/package.json
index bcff4f65..df88638e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -40,6 +40,7 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",