You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

884 lines
32 KiB

import {
RssFeedItem as TRssFeedItem,
isWebOnlyFauxRssItem
} from '@/services/rss-feed.service'
import WebPreview from '../WebPreview'
import { FormattedTimestamp } from '../FormattedTimestamp'
import { ExternalLink, Globe, Highlighter, Rss } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useNostr } from '@/providers/NostrProvider'
import PostEditor from '@/components/PostEditor'
import { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { cn } from '@/lib/utils'
import MediaPlayer from '@/components/MediaPlayer'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useSmartRssArticleNavigation } from '@/PageManager'
import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url'
import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext'
import { isClawstrDotComHttpHref } from '@/lib/rss-article'
/**
* Convert HTML to plain text by extracting text content and cleaning up whitespace
*/
function htmlToPlainText(html: string): string {
if (!html) return ''
// Create a temporary DOM element to extract text content
const temp = document.createElement('div')
temp.innerHTML = html
// Get text content and clean up whitespace
let text = temp.textContent || temp.innerText || ''
// Clean up multiple consecutive newlines and whitespace
text = text
.replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2
.replace(/[ \t]+/g, ' ') // Replace multiple spaces/tabs with single space
.replace(/ \n/g, '\n') // Remove spaces before newlines
.replace(/\n /g, '\n') // Remove spaces after newlines
.trim()
return text
}
export default function RssFeedItem({
item,
className,
layout = 'detail',
expandBodyFully = false,
sourceStrip
}: {
item: TRssFeedItem
className?: string
/** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */
layout?: 'list' | 'detail'
/** When `layout` is `detail`, show full article HTML without height cap or “Show more”. */
expandBodyFully?: boolean
/** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */
sourceStrip?: 'rss' | 'web'
}) {
const { t } = useTranslation()
const { suppressClawstrLinks } = useRssFeedDisplayPrefs()
const { pubkey, checkLogin } = useNostr()
const { isSmallScreen } = useScreenSize()
const { navigateToRssArticle } = useSmartRssArticleNavigation()
const isWebFaux = isWebOnlyFauxRssItem(item)
const isListLayout = layout === 'list'
const showFullBody = layout === 'detail'
const [selectedText, setSelectedText] = useState('')
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
const [showHighlightButton, setShowHighlightButton] = useState(false)
const [showHighlightDrawer, setShowHighlightDrawer] = useState(false)
const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null)
const [isPostEditorOpen, setIsPostEditorOpen] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData | undefined>(undefined)
const contentRef = useRef<HTMLDivElement>(null)
const selectionTimeoutRef = useRef<NodeJS.Timeout>()
const isSelectingRef = useRef(false)
const touchEndTimeoutRef = useRef<NodeJS.Timeout>()
const lastSelectionChangeRef = useRef<number>(0)
const selectionStableTimeoutRef = useRef<NodeJS.Timeout>()
// Handle text selection
useEffect(() => {
const handleSelection = (forceShow = false) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
setShowHighlightButton(false)
setSelectedText('')
setSelectionPosition(null)
return
}
const range = selection.getRangeAt(0)
// Check if selection is collapsed (no actual selection)
if (selection.isCollapsed || range.collapsed) {
setShowHighlightButton(false)
setSelectedText('')
setSelectionPosition(null)
return
}
// Check if contentRef exists
if (!contentRef.current) {
setShowHighlightButton(false)
setSelectedText('')
setSelectionPosition(null)
return
}
// Check if selection is within this item's content
// Use a more reliable containment check
const commonAncestor = range.commonAncestorContainer
// Check if the common ancestor is within our content element
// Handle both Element and Text nodes
let isSelectionInContent = false
if (contentRef.current) {
// For Element nodes, use contains directly
if (commonAncestor.nodeType === Node.ELEMENT_NODE) {
isSelectionInContent = contentRef.current.contains(commonAncestor as Element)
} else {
// For Text nodes, check if the parent element is contained
const parentElement = commonAncestor.parentElement
if (parentElement) {
isSelectionInContent = contentRef.current.contains(parentElement)
}
}
// Also check if the range intersects with our content
if (!isSelectionInContent) {
try {
const contentRect = contentRef.current.getBoundingClientRect()
const rangeRect = range.getBoundingClientRect()
// Check if ranges overlap
isSelectionInContent = !(
rangeRect.bottom < contentRect.top ||
rangeRect.top > contentRect.bottom ||
rangeRect.right < contentRect.left ||
rangeRect.left > contentRect.right
)
} catch {
// If getBoundingClientRect fails, fall back to false
isSelectionInContent = false
}
}
}
if (!isSelectionInContent) {
setShowHighlightButton(false)
setSelectedText('')
setSelectionPosition(null)
return
}
const text = selection.toString().trim()
if (text.length > 0) {
setSelectedText(text)
// On mobile, only show drawer after selection is complete (not while actively selecting)
// On desktop, show floating button immediately
if (isSmallScreen) {
// On mobile, wait until user finishes selecting before showing drawer
if (forceShow || !isSelectingRef.current) {
setShowHighlightDrawer(true)
setShowHighlightButton(false)
}
} else {
// Get selection position for button placement
const rect = range.getBoundingClientRect()
setSelectionPosition({
x: rect.left + rect.width / 2,
y: rect.top - 10
})
setShowHighlightButton(true)
setShowHighlightDrawer(false)
}
} else {
setShowHighlightButton(false)
setShowHighlightDrawer(false)
setSelectedText('')
setSelectionPosition(null)
}
}
const handleMouseUp = (e: MouseEvent) => {
// Don't process if clicking on the highlight button itself
if ((e.target as HTMLElement).closest('.highlight-button-container')) {
return
}
// Delay to allow selection to complete
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current)
}
selectionTimeoutRef.current = setTimeout(() => handleSelection(true), 50)
}
const handleClick = (e: MouseEvent) => {
// Hide button if clicking outside the selection area and not on the button itself
const target = e.target as HTMLElement
if (showHighlightButton && !target.closest('.highlight-button-container')) {
// Check if there's still a valid selection
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setShowHighlightButton(false)
setSelectedText('')
setSelectionPosition(null)
}
}
}
// Handle touch events for mobile
const handleTouchStart = () => {
if (isSmallScreen) {
isSelectingRef.current = true
// Clear any pending drawer show
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
setShowHighlightDrawer(false)
}
}
const handleTouchMove = () => {
if (isSmallScreen) {
isSelectingRef.current = true
// Clear any pending drawer show while actively selecting
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
setShowHighlightDrawer(false)
}
}
const handleTouchEnd = () => {
if (isSmallScreen) {
// Mark that touch selection has ended
// Wait a bit for native selection UI to appear
if (touchEndTimeoutRef.current) {
clearTimeout(touchEndTimeoutRef.current)
}
touchEndTimeoutRef.current = setTimeout(() => {
isSelectingRef.current = false
// Now check if there's a selection and show drawer after stability period
lastSelectionChangeRef.current = Date.now()
// Wait for selection to be stable (no changes for 1600ms)
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
selectionStableTimeoutRef.current = setTimeout(() => {
const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current
// Only show if selection hasn't changed in the last 1600ms and we're not actively selecting
if (timeSinceLastChange >= 1600 && !isSelectingRef.current) {
handleSelection(true)
}
}, 1600)
}, 600) // Wait 600ms for native selection UI
}
}
// Also listen for selectionchange events which fire more reliably
const handleSelectionChange = () => {
if (isSmallScreen) {
// On mobile, track when selection last changed
lastSelectionChangeRef.current = Date.now()
// If we're actively selecting (touch events), don't process yet
if (isSelectingRef.current) {
return
}
// Check if there's actually a selection
const selection = window.getSelection()
const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.toString().trim().length > 0
// If no selection, clear drawer immediately
if (!hasSelection) {
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
setShowHighlightDrawer(false)
return
}
// Clear any pending drawer shows and reset the timeout
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
// Wait for selection to be stable (no changes for 1600ms) before showing drawer
selectionStableTimeoutRef.current = setTimeout(() => {
const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current
// Only show if selection hasn't changed in the last 1600ms and we're not actively selecting
if (timeSinceLastChange >= 1600 && !isSelectingRef.current) {
handleSelection(true)
}
}, 1600)
} else {
// Desktop: shorter delay
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current)
}
selectionTimeoutRef.current = setTimeout(() => handleSelection(true), 50)
}
}
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('click', handleClick, true) // Use capture phase
document.addEventListener('selectionchange', handleSelectionChange)
// Add touch event listeners for mobile
if (isSmallScreen && contentRef.current) {
const contentElement = contentRef.current
contentElement.addEventListener('touchstart', handleTouchStart, { passive: true })
contentElement.addEventListener('touchmove', handleTouchMove, { passive: true })
contentElement.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('click', handleClick, true)
document.removeEventListener('selectionchange', handleSelectionChange)
contentElement.removeEventListener('touchstart', handleTouchStart)
contentElement.removeEventListener('touchmove', handleTouchMove)
contentElement.removeEventListener('touchend', handleTouchEnd)
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current)
}
if (touchEndTimeoutRef.current) {
clearTimeout(touchEndTimeoutRef.current)
}
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
}
}
return () => {
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('click', handleClick, true)
document.removeEventListener('selectionchange', handleSelectionChange)
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current)
}
if (touchEndTimeoutRef.current) {
clearTimeout(touchEndTimeoutRef.current)
}
if (selectionStableTimeoutRef.current) {
clearTimeout(selectionStableTimeoutRef.current)
}
}
}, [showHighlightButton, isSmallScreen])
const handleCreateHighlight = () => {
const currentSelection = window.getSelection()
const text = currentSelection?.toString().trim() || selectedText
if (!text) {
return
}
// Store the text to highlight
setHighlightText(text)
// Convert HTML description to plain text for context
const plainTextContext = htmlToPlainText(item.description)
if (!pubkey) {
checkLogin(() => {
// After login, create highlight data and open editor
const data: HighlightData = {
sourceType: 'url',
sourceValue: item.link,
context: plainTextContext
}
setHighlightData(data)
setIsPostEditorOpen(true)
// Clear selection
window.getSelection()?.removeAllRanges()
setShowHighlightButton(false)
setSelectedText('')
})
return
}
// Create highlight data
const data: HighlightData = {
sourceType: 'url',
sourceValue: item.link,
context: plainTextContext
}
// Open PostEditor in highlight mode
setHighlightData(data)
setIsPostEditorOpen(true)
// Clear selection
window.getSelection()?.removeAllRanges()
setShowHighlightButton(false)
setSelectedText('')
}
const standardFeedProfile = useMemo(
() => (isWebFaux ? null : getStandardRssFeedProfile(item.feedUrl)),
[item.feedUrl, isWebFaux]
)
// Format feed source name from URL (known shapes get a translated label)
const feedSourceName = useMemo(() => {
if (isWebFaux) return ''
if (standardFeedProfile) {
return t(standardFeedProfile.labelKey, {
defaultValue: standardFeedProfile.defaultLabel
})
}
try {
const url = new URL(item.feedUrl)
return url.hostname.replace(/^www\./, '')
} catch {
return item.feedTitle || 'RSS Feed'
}
}, [item.feedUrl, item.feedTitle, isWebFaux, standardFeedProfile, t])
// Clean and parse HTML description safely
// Decode HTML entities and remove any XML artifacts that might have leaked through
const descriptionHtml = useMemo(() => {
let html = item.description || ''
if (!html) return ''
// Decode HTML entities (like &lt; &gt; &amp; &quot; etc.)
// Use textarea element which automatically decodes HTML entities when setting innerHTML
// This is the most reliable way to decode entities in the browser
const decoder = document.createElement('textarea')
decoder.innerHTML = html
html = decoder.value
// Remove any trailing XML/CDATA artifacts
html = html
.replace(/\]\]\s*>\s*$/g, '') // Remove trailing ]]> from CDATA
.replace(/^\s*<!\[CDATA\[/g, '') // Remove leading CDATA declaration
.replace(/<\?xml[^>]*\?>/gi, '') // Remove XML declarations
.replace(/<\!DOCTYPE[^>]*>/gi, '') // Remove DOCTYPE declarations
.trim()
// Basic sanitization: remove script tags and dangerous attributes
// Remove script tags and their content (including nested tags)
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove event handlers (onclick, onerror, etc.)
html = html.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '')
// Remove javascript: URLs in href and src attributes
html = html.replace(/javascript:/gi, '')
// Remove data: URLs that might contain javascript (basic protection)
html = html.replace(/data:\s*text\/html/gi, '')
if (suppressClawstrLinks && html) {
const wrap = document.createElement('div')
wrap.innerHTML = html
wrap.querySelectorAll('a[href]').forEach((el) => {
const h = el.getAttribute('href') || ''
if (isClawstrDotComHttpHref(h)) el.remove()
})
html = wrap.innerHTML
}
return html
}, [item.description, suppressClawstrLinks])
// Format publication date
const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null
// Check if content exceeds 400px height (detail layout only)
const [needsCollapse, setNeedsCollapse] = useState(false)
const [longBodyExpanded, setLongBodyExpanded] = useState(() => expandBodyFully && layout === 'detail')
useEffect(() => {
if (expandBodyFully && layout === 'detail') {
setLongBodyExpanded(true)
}
}, [expandBodyFully, layout])
useEffect(() => {
if (isWebFaux) {
setNeedsCollapse(false)
return
}
if (expandBodyFully && showFullBody) {
setNeedsCollapse(false)
return
}
if (isListLayout || !contentRef.current || !descriptionHtml) {
setNeedsCollapse(false)
return
}
const checkHeight = () => {
const element = contentRef.current
if (!element) return
// Temporarily remove max-height to measure full content height
const hadMaxHeight = element.classList.contains('max-h-[400px]')
if (hadMaxHeight) {
element.classList.remove('max-h-[400px]')
element.style.maxHeight = 'none'
}
// Force a reflow to get accurate measurements
void element.offsetHeight
// Measure the actual content height
const fullHeight = element.scrollHeight
// Restore original state
if (hadMaxHeight) {
element.classList.add('max-h-[400px]')
element.style.maxHeight = ''
}
setNeedsCollapse(fullHeight > 400)
}
// Check height after content is rendered (multiple checks for dynamic content)
const timeoutId1 = setTimeout(checkHeight, 100)
const timeoutId2 = setTimeout(checkHeight, 500)
// Use ResizeObserver to detect when content changes
const resizeObserver = new ResizeObserver(() => {
if (!longBodyExpanded && !expandBodyFully) {
checkHeight()
}
})
if (contentRef.current) {
resizeObserver.observe(contentRef.current)
}
return () => {
clearTimeout(timeoutId1)
clearTimeout(timeoutId2)
resizeObserver.disconnect()
}
}, [descriptionHtml, longBodyExpanded, isListLayout, expandBodyFully, showFullBody, isWebFaux])
return (
<div
className={cn(
`border rounded-lg bg-background p-4 space-y-3 overflow-hidden ${className || ''}`,
isListLayout && 'cursor-pointer hover:bg-muted/40 transition-colors'
)}
onClick={
isListLayout
? (e) => {
const target = e.target as HTMLElement
if (
target.closest('a') ||
target.closest('button') ||
target.closest('[role="dialog"]') ||
target.closest('.highlight-button-container')
) {
return
}
navigateToRssArticle(item.link)
}
: undefined
}
>
{sourceStrip ? (
<div
className="flex items-center gap-1.5 pb-2 mb-2 border-b border-border/40 text-[11px] sm:text-xs text-muted-foreground"
aria-label={
sourceStrip === 'rss' ? t('RSS feed item label') : t('Web URL item label')
}
>
{sourceStrip === 'rss' ? (
<Rss className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
) : (
<Globe className="size-3.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
)}
<span>
{sourceStrip === 'rss'
? t('RSS feed item label')
: t('Web URL item label')}
</span>
</div>
) : null}
{/* Feed Header with Metadata */}
<div className="flex items-start gap-3 pb-3 border-b">
{/* Feed Image/Logo */}
{item.feedImage && !isWebFaux && (
<img
src={item.feedImage}
alt={item.feedTitle || feedSourceName}
className="w-12 h-12 rounded object-contain shrink-0"
onError={(e) => {
// Hide image on error
e.currentTarget.style.display = 'none'
}}
/>
)}
{/* Feed Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">
{isWebFaux ? t('Web page') : item.feedTitle || feedSourceName}
</h3>
{!isWebFaux && standardFeedProfile && item.feedTitle ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{feedSourceName}
{standardFeedProfile.detail ? ` · ${standardFeedProfile.detail}` : ''}
</p>
) : null}
{item.feedDescription && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{item.feedDescription}
</p>
)}
</div>
{pubDateTimestamp && (
<FormattedTimestamp timestamp={pubDateTimestamp} className="shrink-0 text-xs" short />
)}
</div>
</div>
</div>
{/* Title */}
<div className="min-w-0">
{isListLayout ? (
<div className="text-lg font-medium break-words flex items-start gap-2">
<span className="break-words flex-1 min-w-0">{item.title}</span>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/90 shrink-0 mt-0.5"
onClick={(e) => e.stopPropagation()}
title={t('Read full article')}
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
) : (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold hover:text-primary transition-colors inline-flex items-center gap-2 break-words"
onClick={(e) => e.stopPropagation()}
>
<span className="break-words">{item.title}</span>
<ExternalLink className="h-4 w-4 shrink-0" />
</a>
)}
</div>
{/* List layout: body lives in the secondary panel */}
{showFullBody ? (
<>
{/* Media (Images) */}
{!isWebFaux && item.media && item.media.length > 0 && (
<div className="space-y-2 overflow-hidden">
{item.media
.filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image')
.map((media, index) => {
const hasThumbnail = !!media.thumbnail
const imageUrl = media.thumbnail || media.url
return (
<div key={index} className="relative overflow-hidden">
<img
src={imageUrl}
alt={item.title}
className={`${hasThumbnail ? 'max-w-[120px] h-auto' : 'max-w-full md:max-w-[400px] max-h-96'} rounded-lg ${hasThumbnail ? 'object-contain' : 'object-cover'} cursor-pointer hover:opacity-90 transition-opacity`}
onClick={(e) => {
e.stopPropagation()
// Open full image in new tab
window.open(media.url, '_blank', 'noopener,noreferrer')
}}
onError={(e) => {
// Hide image on error
e.currentTarget.style.display = 'none'
}}
/>
{media.credit && (
<div className="text-xs text-muted-foreground mt-1">
{t('Photo')}: {media.credit}
</div>
)}
</div>
)
})}
</div>
)}
{/* Audio/Video Enclosure */}
{!isWebFaux &&
item.enclosure &&
(item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && (
<div className="space-y-2">
<div className="rounded-lg border bg-muted/50 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="text-sm font-medium">
{item.enclosure.type.startsWith('audio/') ? t('Audio') : t('Video')}
{item.enclosure.duration && (
<span className="text-muted-foreground ml-2">({item.enclosure.duration})</span>
)}
</div>
</div>
<MediaPlayer
src={item.enclosure.url}
className="w-full"
mustLoad={true}
/>
</div>
</div>
)}
{/* RSS HTML body or OpenGraph web preview for URL-only faux items */}
<div className="relative overflow-hidden">
{isWebFaux ? (
<div
ref={contentRef}
className="not-prose max-w-full rss-feed-content"
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
onMouseUp={(e) => e.stopPropagation()}
>
<WebPreview url={item.link} className="w-full" />
</div>
) : (
<div
ref={contentRef}
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere',
needsCollapse && !longBodyExpanded && !expandBodyFully && 'max-h-[400px] overflow-hidden',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
'[&_*]:max-w-full'
)}
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
onMouseUp={(e) => {
e.stopPropagation()
}}
/>
)}
{/* Gradient overlay when collapsed */}
{!isWebFaux && needsCollapse && !longBodyExpanded && !expandBodyFully && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/60 to-background pointer-events-none" />
)}
{!isWebFaux && showFullBody && needsCollapse && !expandBodyFully && (
<div className="flex justify-center mt-2 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setLongBodyExpanded((prev) => !prev)
}}
className="text-muted-foreground hover:text-foreground"
>
{longBodyExpanded ? (
t('Show less')
) : (
t('Show more')
)}
</Button>
</div>
)}
{/* Highlight Button (Desktop) */}
{!isSmallScreen && showHighlightButton && selectedText && selectionPosition && (
<div
className="highlight-button-container fixed z-50"
style={{
left: `${selectionPosition.x}px`,
top: `${selectionPosition.y}px`,
transform: 'translateX(-50%) translateY(-100%)'
}}
>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation()
handleCreateHighlight()
}}
className="shadow-lg"
>
<Highlighter className="h-4 w-4 mr-2" />
{t('Create Highlight')}
</Button>
</div>
)}
{/* Highlight Drawer (Mobile) */}
{isSmallScreen && (
<Drawer
open={showHighlightDrawer}
onOpenChange={(open) => {
setShowHighlightDrawer(open)
if (!open) {
// Clear selection when drawer closes
window.getSelection()?.removeAllRanges()
setSelectedText('')
setShowHighlightButton(false)
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('Create Highlight')}</DrawerTitle>
</DrawerHeader>
<div className="p-4 space-y-4">
<div className="text-sm text-muted-foreground">
{t('Selected text')}:
</div>
<div className="p-3 bg-muted rounded-lg text-sm break-words">
"{selectedText}"
</div>
<Button
className="w-full"
onClick={() => {
handleCreateHighlight()
setShowHighlightDrawer(false)
}}
>
<Highlighter className="h-4 w-4 mr-2" />
{t('Create Highlight')}
</Button>
</div>
</DrawerContent>
</Drawer>
)}
</div>
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 text-sm min-w-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1 min-w-0 truncate"
onClick={(e) => e.stopPropagation()}
>
<span className="truncate">{t('Read full article')}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
{/* Post Editor for highlights */}
<PostEditor
open={isPostEditorOpen}
setOpen={(open) => {
setIsPostEditorOpen(open)
if (!open) {
setHighlightData(undefined)
setHighlightText('')
}
}}
defaultContent={highlightText}
initialHighlightData={highlightData}
/>
</div>
)
}