"use client" import { useEffect, useRef, useState, useCallback } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { sanitizeEntryContent } from "@/lib/sanitize" import { useToggleEntryReadState, useToggleEntrySavedState, } from "@/lib/queries/use-entry-state-mutations" import { queryKeys } from "@/lib/queries/query-keys" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { useTimeline } from "@/lib/queries/use-timeline" import { useEntryShare } from "@/lib/queries/use-entry-share" import { useEntryHighlights } from "@/lib/queries/use-entry-highlights" import { useCreateHighlight, useUpdateHighlightNote, useDeleteHighlight, } from "@/lib/queries/use-highlight-mutations" import { serializeSelectionRange, deserializeHighlightRange, applyHighlightToRange, removeHighlightFromDom, } from "@/lib/highlight-positioning" import { HighlightSelectionToolbar } from "./highlight-selection-toolbar" import { HighlightPopover } from "./highlight-popover" import { formatDistanceToNow, format } from "date-fns" import { notify } from "@/lib/notify" import type { Highlight } from "@/lib/types/highlight" interface EntryDetailRow { id: string title: string | null url: string | null author: string | null content_html: string | null summary: string | null published_at: string | null enclosure_url: string | null feeds: { title: string | null } } function estimateReadingTimeMinutes(html: string): number { const text = html.replace(/<[^>]*>/g, "").replace(/&\w+;/g, " ") const wordCount = text.split(/\s+/).filter(Boolean).length return Math.max(1, Math.round(wordCount / 200)) } export function EntryDetailPanel({ entryIdentifier, }: { entryIdentifier: string }) { const supabaseClient = createSupabaseBrowserClient() const queryClient = useQueryClient() const toggleReadState = useToggleEntryReadState() const toggleSavedState = useToggleEntrySavedState() const setSelectedEntryIdentifier = useUserInterfaceStore( (state) => state.setSelectedEntryIdentifier ) const timeDisplayFormat = useUserInterfaceStore( (state) => state.timeDisplayFormat ) const showReadingTime = useUserInterfaceStore( (state) => state.showReadingTime ) const proseContainerReference = useRef(null) const [selectionToolbarState, setSelectionToolbarState] = useState<{ selectionRect: DOMRect containerRect: DOMRect range: Range } | null>(null) const [highlightPopoverState, setHighlightPopoverState] = useState<{ highlightIdentifier: string note: string | null anchorRect: DOMRect containerRect: DOMRect } | null>(null) const [unpositionedHighlights, setUnpositionedHighlights] = useState([]) const [isShareNoteDialogOpen, setIsShareNoteDialogOpen] = useState(false) const [shareNoteText, setShareNoteText] = useState("") const shareNoteTextareaReference = useRef(null) const { data: timelineData } = useTimeline() const currentEntry = timelineData?.pages .flatMap((page) => page) .find((entry) => entry.entryIdentifier === entryIdentifier) const { data: entryDetail, isLoading } = useQuery({ queryKey: queryKeys.entryDetail.single(entryIdentifier), queryFn: async () => { const { data, error } = await supabaseClient .from("entries") .select( "id, title, url, author, content_html, summary, published_at, enclosure_url, feeds!inner(title)" ) .eq("id", entryIdentifier) .single() if (error) throw error return data as unknown as EntryDetailRow }, }) const { data: shareData } = useEntryShare(entryIdentifier) const { data: highlightsData } = useEntryHighlights(entryIdentifier) const createHighlight = useCreateHighlight() const updateHighlightNote = useUpdateHighlightNote() const deleteHighlight = useDeleteHighlight() const shareMutation = useMutation({ mutationFn: async (note?: string | null) => { const response = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entryIdentifier, note: note ?? null }), }) if (!response.ok) throw new Error("failed to create share") return response.json() as Promise<{ shareToken: string shareUrl: string }> }, onSuccess: async (data) => { await navigator.clipboard.writeText(data.shareUrl) notify("link copied") queryClient.invalidateQueries({ queryKey: queryKeys.entryShare.single(entryIdentifier), }) queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) }, }) const unshareMutation = useMutation({ mutationFn: async (shareToken: string) => { const response = await fetch(`/api/share/${shareToken}`, { method: "DELETE", }) if (!response.ok) throw new Error("failed to delete share") }, onSuccess: () => { notify("share link removed") queryClient.invalidateQueries({ queryKey: queryKeys.entryShare.single(entryIdentifier), }) }, }) useEffect(() => { if (!currentEntry || currentEntry.isRead) return const autoReadTimeout = setTimeout(() => { toggleReadState.mutate({ entryIdentifier, isRead: true, }) }, 1500) return () => clearTimeout(autoReadTimeout) }, [entryIdentifier, currentEntry?.isRead]) const contentHtml = entryDetail?.content_html || entryDetail?.summary || "" const sanitisedContent = sanitizeEntryContent(contentHtml) useEffect(() => { const container = proseContainerReference.current if (!container || !sanitisedContent) return container.textContent = "" const template = document.createElement("template") template.innerHTML = sanitisedContent container.appendChild(template.content.cloneNode(true)) const failedHighlights: Highlight[] = [] if (highlightsData && highlightsData.length > 0) { const sortedHighlights = [...highlightsData].sort( (a, b) => b.textOffset - a.textOffset ) for (const highlight of sortedHighlights) { const range = deserializeHighlightRange(container, highlight) if (range) { applyHighlightToRange( range, highlight.identifier, highlight.color, !!highlight.note ) } else { failedHighlights.push(highlight) } } } setUnpositionedHighlights(failedHighlights) }, [sanitisedContent, highlightsData]) const handleTextSelection = useCallback(() => { const container = proseContainerReference.current if (!container) return const selection = window.getSelection() if (!selection || selection.isCollapsed || !selection.rangeCount) { setSelectionToolbarState(null) return } const range = selection.getRangeAt(0) if (!container.contains(range.commonAncestorContainer)) { setSelectionToolbarState(null) return } const selectionRect = range.getBoundingClientRect() const containerRect = container.getBoundingClientRect() setSelectionToolbarState({ selectionRect, containerRect, range: range.cloneRange(), }) setHighlightPopoverState(null) }, []) useEffect(() => { document.addEventListener("mouseup", handleTextSelection) document.addEventListener("touchend", handleTextSelection) return () => { document.removeEventListener("mouseup", handleTextSelection) document.removeEventListener("touchend", handleTextSelection) } }, [handleTextSelection]) useEffect(() => { const container = proseContainerReference.current if (!container) return const currentContainer = container function handleMarkClick(event: MouseEvent) { const target = event.target as HTMLElement const markElement = target.closest("mark[data-highlight-identifier]") if (!markElement) return const highlightIdentifier = markElement.getAttribute("data-highlight-identifier") if (!highlightIdentifier) return const matchingHighlight = highlightsData?.find( (h) => h.identifier === highlightIdentifier ) const anchorRect = markElement.getBoundingClientRect() const containerRect = currentContainer.getBoundingClientRect() setHighlightPopoverState({ highlightIdentifier, note: matchingHighlight?.note ?? null, anchorRect, containerRect, }) setSelectionToolbarState(null) } container.addEventListener("click", handleMarkClick) return () => container.removeEventListener("click", handleMarkClick) }, [highlightsData]) useEffect(() => { if (isShareNoteDialogOpen) { setTimeout(() => shareNoteTextareaReference.current?.focus(), 0) } }, [isShareNoteDialogOpen]) useEffect(() => { if (!isShareNoteDialogOpen) return function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault() event.stopPropagation() setIsShareNoteDialogOpen(false) } } document.addEventListener("keydown", handleKeyDown, true) return () => document.removeEventListener("keydown", handleKeyDown, true) }, [isShareNoteDialogOpen]) function handleShareConfirm() { shareMutation.mutate(shareNoteText.trim() || null) setIsShareNoteDialogOpen(false) } function handleCreateHighlight(note: string | null) { const container = proseContainerReference.current if (!container || !selectionToolbarState) return const serialized = serializeSelectionRange( container, selectionToolbarState.range ) if (!serialized) return createHighlight.mutate({ entryIdentifier, highlightedText: serialized.highlightedText, note, textOffset: serialized.textOffset, textLength: serialized.textLength, textPrefix: serialized.textPrefix, textSuffix: serialized.textSuffix, color: "yellow", }) window.getSelection()?.removeAllRanges() setSelectionToolbarState(null) } function handleUpdateHighlightNote(note: string | null) { if (!highlightPopoverState) return updateHighlightNote.mutate({ highlightIdentifier: highlightPopoverState.highlightIdentifier, note, entryIdentifier, }) setHighlightPopoverState(null) } function handleDeleteHighlight() { if (!highlightPopoverState) return const container = proseContainerReference.current if (container) { removeHighlightFromDom( container, highlightPopoverState.highlightIdentifier ) } deleteHighlight.mutate({ highlightIdentifier: highlightPopoverState.highlightIdentifier, entryIdentifier, }) setHighlightPopoverState(null) } if (isLoading || !entryDetail) { return (
loading ...
) } const readingTimeMinutes = estimateReadingTimeMinutes(contentHtml) const isRead = currentEntry?.isRead ?? false const isSaved = currentEntry?.isSaved ?? false return (
{entryDetail.url && ( open original )} {shareData?.isShared ? ( ) : ( )}

{entryDetail.title}

{entryDetail.feeds?.title && ( {entryDetail.feeds.title} )} {entryDetail.author && ( · {entryDetail.author} )} {entryDetail.published_at && ( {" "}·{" "} {timeDisplayFormat === "absolute" ? format(new Date(entryDetail.published_at), "MMM d, h:mm a") : formatDistanceToNow(new Date(entryDetail.published_at), { addSuffix: true, })} )} {showReadingTime && ( · {readingTimeMinutes} min read )}
{entryDetail.enclosure_url && (
)} {unpositionedHighlights.length > 0 && (

{unpositionedHighlights.length} highlight {unpositionedHighlights.length !== 1 && "s"} could not be positioned (the article content may have changed)

{unpositionedHighlights.map((highlight) => (
{highlight.highlightedText} {highlight.note && ( — {highlight.note} )}
))}
)}
{selectionToolbarState && ( setSelectionToolbarState(null)} /> )} {highlightPopoverState && ( setHighlightPopoverState(null)} /> )}
{isShareNoteDialogOpen && (
setIsShareNoteDialogOpen(false)} />

share entry

{ event.preventDefault() handleShareConfirm() }} className="space-y-4" >