aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx322
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx2
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx97
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx94
-rw-r--r--apps/web/components/dashboard/preview/highlights.ts15
5 files changed, 524 insertions, 6 deletions
diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
new file mode 100644
index 00000000..b99a1a56
--- /dev/null
+++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
@@ -0,0 +1,322 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { PopoverAnchor } from "@radix-ui/react-popover";
+import { Check, Trash2 } from "lucide-react";
+
+import {
+ SUPPORTED_HIGHLIGHT_COLORS,
+ ZHighlightColor,
+} from "@hoarder/shared/types/highlights";
+
+import { HIGHLIGHT_COLOR_MAP } from "./highlights";
+
+interface ColorPickerMenuProps {
+ position: { x: number; y: number } | null;
+ onColorSelect: (color: ZHighlightColor) => void;
+ onDelete?: () => void;
+ selectedHighlight: Highlight | null;
+ onClose: () => void;
+}
+
+const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({
+ position,
+ onColorSelect,
+ onDelete,
+ selectedHighlight,
+ onClose,
+}) => {
+ return (
+ <Popover
+ open={position !== null}
+ onOpenChange={(val) => {
+ if (!val) {
+ onClose();
+ }
+ }}
+ >
+ <PopoverAnchor
+ className="fixed"
+ style={{
+ left: position?.x,
+ top: position?.y,
+ }}
+ />
+ <PopoverContent side="top" className="flex w-fit items-center gap-1 p-2">
+ {SUPPORTED_HIGHLIGHT_COLORS.map((color) => (
+ <Button
+ size="none"
+ key={color}
+ onClick={() => onColorSelect(color)}
+ variant="none"
+ className={cn(
+ `size-8 rounded-full hover:border focus-visible:ring-0`,
+ HIGHLIGHT_COLOR_MAP.bg[color],
+ )}
+ >
+ {selectedHighlight?.color === color && (
+ <Check className="size-5 text-gray-600" />
+ )}
+ </Button>
+ ))}
+ {selectedHighlight && (
+ <Button
+ size="none"
+ className="size-8 rounded-full"
+ onClick={onDelete}
+ variant="ghost"
+ >
+ <Trash2 className="size-5 text-destructive" />
+ </Button>
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+};
+
+export interface Highlight {
+ id: string;
+ startOffset: number;
+ endOffset: number;
+ color: ZHighlightColor;
+ text: string | null;
+}
+
+interface HTMLHighlighterProps {
+ htmlContent: string;
+ className?: string;
+ highlights?: Highlight[];
+ onHighlight?: (highlight: Highlight) => void;
+ onUpdateHighlight?: (highlight: Highlight) => void;
+ onDeleteHighlight?: (highlight: Highlight) => void;
+}
+
+function BookmarkHTMLHighlighter({
+ htmlContent,
+ className,
+ highlights = [],
+ onHighlight,
+ onUpdateHighlight,
+ onDeleteHighlight,
+}: HTMLHighlighterProps) {
+ const contentRef = useRef<HTMLDivElement>(null);
+ const [menuPosition, setMenuPosition] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const [pendingHighlight, setPendingHighlight] = useState<Highlight | null>(
+ null,
+ );
+ const [selectedHighlight, setSelectedHighlight] = useState<Highlight | null>(
+ null,
+ );
+
+ // Apply existing highlights when component mounts or highlights change
+ useEffect(() => {
+ if (!contentRef.current) return;
+
+ // Clear existing highlights first
+ const existingHighlights = contentRef.current.querySelectorAll(
+ "span[data-highlight]",
+ );
+ existingHighlights.forEach((el) => {
+ const parent = el.parentNode;
+ if (parent) {
+ while (el.firstChild) {
+ parent.insertBefore(el.firstChild, el);
+ }
+ parent.removeChild(el);
+ }
+ });
+
+ // Apply all highlights
+ highlights.forEach((highlight) => {
+ applyHighlightByOffset(highlight);
+ });
+ if (pendingHighlight) {
+ applyHighlightByOffset(pendingHighlight);
+ }
+ });
+
+ const handleMouseUp = (e: React.MouseEvent) => {
+ const selection = window.getSelection();
+
+ // Check if we clicked on an existing highlight
+ const target = e.target as HTMLElement;
+ if (target.dataset.highlight) {
+ const highlightId = target.dataset.highlightId;
+ if (highlightId && highlights) {
+ const highlight = highlights.find((h) => h.id === highlightId);
+ if (!highlight) {
+ return;
+ }
+ setSelectedHighlight(highlight);
+ setMenuPosition({
+ x: e.clientX,
+ y: e.clientY,
+ });
+ return;
+ }
+ }
+
+ if (!selection || selection.isCollapsed || !contentRef.current) {
+ return;
+ }
+
+ const range = selection.getRangeAt(0);
+
+ // Only process selections within our component
+ if (!contentRef.current.contains(range.commonAncestorContainer)) {
+ return;
+ }
+
+ // Position the menu above the selection
+ const rect = range.getBoundingClientRect();
+ setMenuPosition({
+ x: rect.left + rect.width / 2, // Center the menu
+ y: rect.top,
+ });
+
+ // Store the highlight for later use
+ setPendingHighlight(createHighlightFromRange(range, "yellow"));
+ };
+
+ const handleColorSelect = (color: ZHighlightColor) => {
+ if (pendingHighlight) {
+ pendingHighlight.color = color;
+ onHighlight?.(pendingHighlight);
+ } else if (selectedHighlight) {
+ selectedHighlight.color = color;
+ onUpdateHighlight?.(selectedHighlight);
+ }
+ closeColorPicker();
+ };
+
+ const closeColorPicker = () => {
+ setMenuPosition(null);
+ setPendingHighlight(null);
+ setSelectedHighlight(null);
+ window.getSelection()?.removeAllRanges();
+ };
+
+ const handleDelete = () => {
+ if (selectedHighlight && onDeleteHighlight) {
+ onDeleteHighlight(selectedHighlight);
+ closeColorPicker();
+ }
+ };
+
+ const getTextNodeOffset = (node: Node): number => {
+ let offset = 0;
+ const walker = document.createTreeWalker(
+ contentRef.current!,
+ NodeFilter.SHOW_TEXT,
+ null,
+ );
+
+ while (walker.nextNode()) {
+ if (walker.currentNode === node) {
+ return offset;
+ }
+ offset += walker.currentNode.textContent?.length ?? 0;
+ }
+ return -1;
+ };
+
+ const createHighlightFromRange = (
+ range: Range,
+ color: ZHighlightColor,
+ ): Highlight | null => {
+ if (!contentRef.current) return null;
+
+ const startOffset =
+ getTextNodeOffset(range.startContainer) + range.startOffset;
+ const endOffset = getTextNodeOffset(range.endContainer) + range.endOffset;
+
+ if (startOffset === -1 || endOffset === -1) return null;
+
+ const highlight: Highlight = {
+ id: "NOT_SET",
+ startOffset,
+ endOffset,
+ color,
+ text: range.toString(),
+ };
+
+ applyHighlightByOffset(highlight);
+ return highlight;
+ };
+
+ const applyHighlightByOffset = (highlight: Highlight) => {
+ if (!contentRef.current) return;
+
+ let currentOffset = 0;
+ const walker = document.createTreeWalker(
+ contentRef.current,
+ NodeFilter.SHOW_TEXT,
+ null,
+ );
+
+ const ranges: { node: Text; start: number; end: number }[] = [];
+
+ // Find all text nodes that need highlighting
+ let node: Text | null;
+ while ((node = walker.nextNode() as Text)) {
+ const nodeLength = node.length;
+ const nodeStart = currentOffset;
+ const nodeEnd = nodeStart + nodeLength;
+
+ if (nodeStart < highlight.endOffset && nodeEnd > highlight.startOffset) {
+ ranges.push({
+ node,
+ start: Math.max(0, highlight.startOffset - nodeStart),
+ end: Math.min(nodeLength, highlight.endOffset - nodeStart),
+ });
+ }
+
+ currentOffset += nodeLength;
+ }
+
+ // Apply highlights to found ranges
+ ranges.forEach(({ node, start, end }) => {
+ if (start > 0) {
+ node.splitText(start);
+ node = node.nextSibling as Text;
+ end -= start;
+ }
+ if (end < node.length) {
+ node.splitText(end);
+ }
+
+ const span = document.createElement("span");
+ span.classList.add(HIGHLIGHT_COLOR_MAP.bg[highlight.color]);
+ span.classList.add("text-gray-600");
+ span.dataset.highlight = "true";
+ span.dataset.highlightId = highlight.id;
+ node.parentNode?.insertBefore(span, node);
+ span.appendChild(node);
+ });
+ };
+
+ return (
+ <div>
+ <div
+ role="presentation"
+ ref={contentRef}
+ dangerouslySetInnerHTML={{ __html: htmlContent }}
+ onMouseUp={handleMouseUp}
+ className={className}
+ />
+ <ColorPickerMenu
+ position={menuPosition}
+ onColorSelect={handleColorSelect}
+ onDelete={handleDelete}
+ selectedHighlight={selectedHighlight}
+ onClose={closeColorPicker}
+ />
+ </div>
+ );
+}
+
+export default BookmarkHTMLHighlighter;
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index ff6330fa..b854146c 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -30,6 +30,7 @@ import ActionBar from "./ActionBar";
import { AssetContentSection } from "./AssetContentSection";
import AttachmentBox from "./AttachmentBox";
import { EditableTitle } from "./EditableTitle";
+import HighlightsBox from "./HighlightsBox";
import LinkContentSection from "./LinkContentSection";
import { NoteEditor } from "./NoteEditor";
import { TextContentSection } from "./TextContentSection";
@@ -150,6 +151,7 @@ export default function BookmarkPreview({
<NoteEditor bookmark={bookmark} />
</div>
<AttachmentBox bookmark={bookmark} />
+ <HighlightsBox bookmarkId={bookmark.id} />
<ActionBar bookmark={bookmark} />
</div>
</div>
diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx
new file mode 100644
index 00000000..3c873f3d
--- /dev/null
+++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx
@@ -0,0 +1,97 @@
+import { ActionButton } from "@/components/ui/action-button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import { Separator } from "@radix-ui/react-dropdown-menu";
+import { ChevronsDownUp, Trash2 } from "lucide-react";
+
+import { useDeleteHighlight } from "@hoarder/shared-react/hooks/highlights";
+import { ZHighlight } from "@hoarder/shared/types/highlights";
+
+import { HIGHLIGHT_COLOR_MAP } from "./highlights";
+
+function HighlightCard({ highlight }: { highlight: ZHighlight }) {
+ const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight(
+ {
+ onSuccess: () => {
+ toast({
+ description: "Highlight has been deleted!",
+ });
+ },
+ onError: () => {
+ toast({
+ description: "Something went wrong",
+ variant: "destructive",
+ });
+ },
+ },
+ );
+
+ const onBookmarkClick = () => {
+ document
+ .querySelector(`[data-highlight-id="${highlight.id}"]`)
+ ?.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ };
+ return (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <button onClick={onBookmarkClick}>
+ <blockquote
+ className={cn(
+ "prose border-l-[6px] p-2 pl-6 italic dark:prose-invert prose-p:text-sm",
+ HIGHLIGHT_COLOR_MAP["border-l"][highlight.color],
+ )}
+ >
+ <p>{highlight.text}</p>
+ </blockquote>
+ </button>
+ </div>
+ <div className="flex gap-2">
+ <ActionButton
+ loading={isDeleting}
+ variant="ghost"
+ onClick={() => deleteHighlight({ highlightId: highlight.id })}
+ >
+ <Trash2 className="size-4 text-destructive" />
+ </ActionButton>
+ </div>
+ </div>
+ );
+}
+
+export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) {
+ const { t } = useTranslation();
+
+ const { data: highlights, isPending: isLoading } =
+ api.highlights.getForBookmark.useQuery({ bookmarkId });
+
+ if (isLoading || !highlights || highlights?.highlights.length === 0) {
+ return null;
+ }
+
+ return (
+ <Collapsible defaultOpen={true}>
+ <CollapsibleTrigger className="flex w-full items-center justify-between gap-2 text-sm text-gray-400">
+ {t("common.highlights")}
+ <ChevronsDownUp className="size-4" />
+ </CollapsibleTrigger>
+ <CollapsibleContent className="group flex flex-col py-3 text-sm">
+ {highlights.highlights.map((highlight) => (
+ <>
+ <HighlightCard key={highlight.id} highlight={highlight} />
+ <Separator className="m-2 h-0.5 bg-gray-200 last:hidden" />
+ </>
+ ))}
+ </CollapsibleContent>
+ </Collapsible>
+ );
+}
diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx
index 320fc561..f09cc31f 100644
--- a/apps/web/components/dashboard/preview/LinkContentSection.tsx
+++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import Image from "next/image";
+import BookmarkHTMLHighlighter from "@/components/dashboard/preview/BookmarkHtmlHighlighter";
import {
Select,
SelectContent,
@@ -8,10 +9,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import {
+ useCreateHighlight,
+ useDeleteHighlight,
+ useUpdateHighlight,
+} from "@hoarder/shared-react/hooks/highlights";
+import {
BookmarkTypes,
ZBookmark,
ZBookmarkedLink,
@@ -42,7 +50,59 @@ function ScreenshotSection({ link }: { link: ZBookmarkedLink }) {
);
}
-function CachedContentSection({ link }: { link: ZBookmarkedLink }) {
+function CachedContentSection({
+ bookmarkId,
+ link,
+}: {
+ bookmarkId: string;
+ link: ZBookmarkedLink;
+}) {
+ const { data } = api.highlights.getForBookmark.useQuery({
+ bookmarkId,
+ });
+
+ 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 (!link.htmlContent) {
content = (
@@ -50,11 +110,31 @@ function CachedContentSection({ link }: { link: ZBookmarkedLink }) {
);
} else {
content = (
- <div
- dangerouslySetInnerHTML={{
- __html: link.htmlContent || "",
- }}
+ <BookmarkHTMLHighlighter
+ htmlContent={link.htmlContent || ""}
className="prose mx-auto dark:prose-invert"
+ highlights={data?.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,
+ })
+ }
/>
);
}
@@ -89,7 +169,9 @@ export default function LinkContentSection({
let content;
if (section === "cached") {
- content = <CachedContentSection link={bookmark.content} />;
+ content = (
+ <CachedContentSection bookmarkId={bookmark.id} link={bookmark.content} />
+ );
} else if (section === "archive") {
content = <FullPageArchiveSection link={bookmark.content} />;
} else if (section === "video") {
diff --git a/apps/web/components/dashboard/preview/highlights.ts b/apps/web/components/dashboard/preview/highlights.ts
new file mode 100644
index 00000000..e871303e
--- /dev/null
+++ b/apps/web/components/dashboard/preview/highlights.ts
@@ -0,0 +1,15 @@
+// Tailwind requires the color to be complete strings (can't be dynamic), so we have to list all the strings here manually.
+export const HIGHLIGHT_COLOR_MAP = {
+ bg: {
+ red: "bg-red-200",
+ green: "bg-green-200",
+ blue: "bg-blue-200",
+ yellow: "bg-yellow-200",
+ } as const,
+ ["border-l"]: {
+ red: "border-l-red-200",
+ green: "border-l-green-200",
+ blue: "border-l-blue-200",
+ yellow: "border-l-yellow-200",
+ } as const,
+};