16 changed files with 1042 additions and 39 deletions
@ -0,0 +1,244 @@ |
|||||||
|
import { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||||
|
import { ExternalLink, Highlighter } 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' |
||||||
|
|
||||||
|
export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, checkLogin } = useNostr() |
||||||
|
const [selectedText, setSelectedText] = useState('') |
||||||
|
const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor
|
||||||
|
const [showHighlightButton, setShowHighlightButton] = 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>() |
||||||
|
|
||||||
|
// Handle text selection
|
||||||
|
useEffect(() => { |
||||||
|
const handleSelection = () => { |
||||||
|
const selection = window.getSelection() |
||||||
|
if (!selection || selection.isCollapsed || !contentRef.current) { |
||||||
|
setShowHighlightButton(false) |
||||||
|
setSelectedText('') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Check if selection is within this item's content
|
||||||
|
const range = selection.getRangeAt(0) |
||||||
|
if (!contentRef.current.contains(range.commonAncestorContainer)) { |
||||||
|
setShowHighlightButton(false) |
||||||
|
setSelectedText('') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const text = selection.toString().trim() |
||||||
|
if (text.length > 0) { |
||||||
|
setSelectedText(text) |
||||||
|
|
||||||
|
// Get selection position for button placement
|
||||||
|
const rect = range.getBoundingClientRect() |
||||||
|
setSelectionPosition({ |
||||||
|
x: rect.left + rect.width / 2, |
||||||
|
y: rect.top - 10 |
||||||
|
}) |
||||||
|
setShowHighlightButton(true) |
||||||
|
} else { |
||||||
|
setShowHighlightButton(false) |
||||||
|
setSelectedText('') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleMouseUp = () => { |
||||||
|
// Delay to allow selection to complete
|
||||||
|
if (selectionTimeoutRef.current) { |
||||||
|
clearTimeout(selectionTimeoutRef.current) |
||||||
|
} |
||||||
|
selectionTimeoutRef.current = setTimeout(handleSelection, 100) |
||||||
|
} |
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => { |
||||||
|
// Hide button if clicking outside the selection area
|
||||||
|
if (showHighlightButton && !(e.target as HTMLElement).closest('.highlight-button-container')) { |
||||||
|
setShowHighlightButton(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleMouseUp) |
||||||
|
document.addEventListener('click', handleClick) |
||||||
|
|
||||||
|
return () => { |
||||||
|
document.removeEventListener('mouseup', handleMouseUp) |
||||||
|
document.removeEventListener('click', handleClick) |
||||||
|
if (selectionTimeoutRef.current) { |
||||||
|
clearTimeout(selectionTimeoutRef.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [showHighlightButton]) |
||||||
|
|
||||||
|
const handleCreateHighlight = () => { |
||||||
|
const currentSelection = window.getSelection() |
||||||
|
const text = currentSelection?.toString().trim() || selectedText |
||||||
|
|
||||||
|
if (!text) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Store the text to highlight
|
||||||
|
setHighlightText(text) |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
checkLogin(() => { |
||||||
|
// After login, create highlight data and open editor
|
||||||
|
const data: HighlightData = { |
||||||
|
sourceType: 'url', |
||||||
|
sourceValue: item.link, |
||||||
|
context: item.description |
||||||
|
} |
||||||
|
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: item.description |
||||||
|
} |
||||||
|
|
||||||
|
// Open PostEditor in highlight mode
|
||||||
|
setHighlightData(data) |
||||||
|
setIsPostEditorOpen(true) |
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
window.getSelection()?.removeAllRanges() |
||||||
|
setShowHighlightButton(false) |
||||||
|
setSelectedText('') |
||||||
|
} |
||||||
|
|
||||||
|
// Format feed source name from URL
|
||||||
|
const feedSourceName = useMemo(() => { |
||||||
|
try { |
||||||
|
const url = new URL(item.feedUrl) |
||||||
|
return url.hostname.replace(/^www\./, '') |
||||||
|
} catch { |
||||||
|
return item.feedTitle || 'RSS Feed' |
||||||
|
} |
||||||
|
}, [item.feedUrl, item.feedTitle]) |
||||||
|
|
||||||
|
// Parse HTML description safely
|
||||||
|
const descriptionHtml = item.description |
||||||
|
|
||||||
|
// Format publication date
|
||||||
|
const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={`border rounded-lg bg-background p-4 space-y-3 ${className || ''}`}> |
||||||
|
{/* Feed Source and Date */} |
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground"> |
||||||
|
<span className="font-medium">{feedSourceName}</span> |
||||||
|
{pubDateTimestamp && ( |
||||||
|
<FormattedTimestamp timestamp={pubDateTimestamp} className="shrink-0" short /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Title */} |
||||||
|
<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" |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
{item.title} |
||||||
|
<ExternalLink className="h-4 w-4 shrink-0" /> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Description with text selection support */} |
||||||
|
<div className="relative"> |
||||||
|
<div |
||||||
|
ref={contentRef} |
||||||
|
className="prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content" |
||||||
|
dangerouslySetInnerHTML={{ __html: descriptionHtml }} |
||||||
|
onMouseUp={(e) => { |
||||||
|
// Allow text selection
|
||||||
|
e.stopPropagation() |
||||||
|
}} |
||||||
|
style={{ |
||||||
|
userSelect: 'text', |
||||||
|
WebkitUserSelect: 'text', |
||||||
|
MozUserSelect: 'text', |
||||||
|
msUserSelect: 'text' |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Highlight Button */} |
||||||
|
{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> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Link to original article */} |
||||||
|
<div className="flex items-center gap-2 text-sm"> |
||||||
|
<a |
||||||
|
href={item.link} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1" |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
{t('Read full article')} |
||||||
|
<ExternalLink className="h-3 w-3" /> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Post Editor for highlights */} |
||||||
|
<PostEditor |
||||||
|
open={isPostEditorOpen} |
||||||
|
setOpen={(open) => { |
||||||
|
setIsPostEditorOpen(open) |
||||||
|
if (!open) { |
||||||
|
setHighlightData(undefined) |
||||||
|
setHighlightText('') |
||||||
|
} |
||||||
|
}} |
||||||
|
defaultContent={highlightText} |
||||||
|
initialHighlightData={highlightData} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,95 @@ |
|||||||
|
import { useEffect, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
||||||
|
import { ExtendedKind, DEFAULT_RSS_FEEDS } from '@/constants' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import RssFeedItem from '../RssFeedItem' |
||||||
|
import { Loader, AlertCircle } from 'lucide-react' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
|
||||||
|
export default function RssFeedList() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const [items, setItems] = useState<TRssFeedItem[]>([]) |
||||||
|
const [loading, setLoading] = useState(true) |
||||||
|
const [error, setError] = useState<string | null>(null) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const loadRssFeeds = async () => { |
||||||
|
setLoading(true) |
||||||
|
setError(null) |
||||||
|
|
||||||
|
try { |
||||||
|
// Get feed URLs from event or use default
|
||||||
|
let feedUrls: string[] = DEFAULT_RSS_FEEDS |
||||||
|
|
||||||
|
if (pubkey) { |
||||||
|
try { |
||||||
|
const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) |
||||||
|
if (event && event.content) { |
||||||
|
try { |
||||||
|
const urls = JSON.parse(event.content) as string[] |
||||||
|
if (Array.isArray(urls) && urls.length > 0) { |
||||||
|
feedUrls = urls |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
logger.error('[RssFeedList] Failed to parse RSS feed list', { error: e }) |
||||||
|
// Use default feeds on parse error
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
logger.error('[RssFeedList] Failed to load RSS feed list event', { error: e }) |
||||||
|
// Use default feeds on error
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch and merge feeds
|
||||||
|
const fetchedItems = await rssFeedService.fetchMultipleFeeds(feedUrls) |
||||||
|
setItems(fetchedItems) |
||||||
|
} catch (err) { |
||||||
|
logger.error('[RssFeedList] Error loading RSS feeds', { error: err }) |
||||||
|
setError(err instanceof Error ? err.message : t('Failed to load RSS feeds')) |
||||||
|
} finally { |
||||||
|
setLoading(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadRssFeeds() |
||||||
|
}, [pubkey, t]) |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<div className="flex flex-col items-center justify-center py-12"> |
||||||
|
<Loader className="h-8 w-8 animate-spin text-muted-foreground" /> |
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{t('Loading RSS feeds...')}</p> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (error) { |
||||||
|
return ( |
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4"> |
||||||
|
<AlertCircle className="h-8 w-8 text-destructive mb-4" /> |
||||||
|
<p className="text-sm text-destructive text-center">{error}</p> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (items.length === 0) { |
||||||
|
return ( |
||||||
|
<div className="flex flex-col items-center justify-center py-12"> |
||||||
|
<p className="text-sm text-muted-foreground">{t('No RSS feed items available')}</p> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-4 px-4 py-3"> |
||||||
|
{items.map((item) => ( |
||||||
|
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,256 @@ |
|||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||||
|
import { forwardRef, useEffect, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import storage from '@/services/local-storage.service' |
||||||
|
import { createRssFeedListDraftEvent } from '@/lib/draft-event' |
||||||
|
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' |
||||||
|
import { CloudUpload, Loader, Trash2, Plus } from 'lucide-react' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
|
||||||
|
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, publish, checkLogin } = useNostr() |
||||||
|
const [feedUrls, setFeedUrls] = useState<string[]>([]) |
||||||
|
const [newFeedUrl, setNewFeedUrl] = useState('') |
||||||
|
const [showRssFeed, setShowRssFeed] = useState(true) |
||||||
|
const [hasChange, setHasChange] = useState(false) |
||||||
|
const [pushing, setPushing] = useState(false) |
||||||
|
const [loading, setLoading] = useState(true) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
// Load show RSS feed setting
|
||||||
|
setShowRssFeed(storage.getShowRssFeed()) |
||||||
|
|
||||||
|
// Load RSS feed list from event
|
||||||
|
const loadRssFeedList = async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setLoading(false) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) |
||||||
|
if (event && event.content) { |
||||||
|
try { |
||||||
|
const urls = JSON.parse(event.content) as string[] |
||||||
|
if (Array.isArray(urls)) { |
||||||
|
setFeedUrls(urls) |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list', { error: e }) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
logger.error('[RssFeedSettingsPage] Failed to load RSS feed list', { error }) |
||||||
|
} finally { |
||||||
|
setLoading(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadRssFeedList() |
||||||
|
}, [pubkey]) |
||||||
|
|
||||||
|
const handleShowRssFeedChange = (checked: boolean) => { |
||||||
|
setShowRssFeed(checked) |
||||||
|
storage.setShowRssFeed(checked) |
||||||
|
// No need to set hasChange here as this is a local storage setting, not a Nostr event
|
||||||
|
} |
||||||
|
|
||||||
|
const handleAddFeed = () => { |
||||||
|
const url = newFeedUrl.trim() |
||||||
|
if (!url) return |
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
try { |
||||||
|
new URL(url) |
||||||
|
} catch { |
||||||
|
// Invalid URL
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (feedUrls.includes(url)) { |
||||||
|
// Feed already exists
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setFeedUrls([...feedUrls, url]) |
||||||
|
setNewFeedUrl('') |
||||||
|
setHasChange(true) |
||||||
|
} |
||||||
|
|
||||||
|
const handleRemoveFeed = (url: string) => { |
||||||
|
setFeedUrls(feedUrls.filter(u => u !== url)) |
||||||
|
setHasChange(true) |
||||||
|
} |
||||||
|
|
||||||
|
const handleSave = async () => { |
||||||
|
if (!pubkey) return |
||||||
|
|
||||||
|
setPushing(true) |
||||||
|
try { |
||||||
|
const event = createRssFeedListDraftEvent(feedUrls) |
||||||
|
const result = await publish(event) |
||||||
|
|
||||||
|
// Cache the event in IndexedDB for immediate access
|
||||||
|
try { |
||||||
|
await indexedDb.putReplaceableEvent(result) |
||||||
|
} catch (cacheError) { |
||||||
|
logger.warn('[RssFeedSettingsPage] Failed to cache RSS feed list event', { error: cacheError }) |
||||||
|
// Don't fail the save if caching fails
|
||||||
|
} |
||||||
|
|
||||||
|
// Read relayStatuses immediately before it might be deleted
|
||||||
|
const relayStatuses = (result as any).relayStatuses |
||||||
|
|
||||||
|
setHasChange(false) |
||||||
|
|
||||||
|
// Show publishing feedback
|
||||||
|
if (relayStatuses && relayStatuses.length > 0) { |
||||||
|
showPublishingFeedback({ |
||||||
|
success: true, |
||||||
|
relayStatuses: relayStatuses, |
||||||
|
successCount: relayStatuses.filter((s: any) => s.success).length, |
||||||
|
totalCount: relayStatuses.length |
||||||
|
}, { |
||||||
|
message: t('RSS feeds saved'), |
||||||
|
duration: 6000 |
||||||
|
}) |
||||||
|
} else { |
||||||
|
showSimplePublishSuccess(t('RSS feeds saved')) |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
logger.error('[RssFeedSettingsPage] Failed to save RSS feed list', { error }) |
||||||
|
// Show error feedback with relay statuses if available
|
||||||
|
if (error instanceof Error && (error as any).relayStatuses) { |
||||||
|
const errorRelayStatuses = (error as any).relayStatuses |
||||||
|
showPublishingFeedback({ |
||||||
|
success: false, |
||||||
|
relayStatuses: errorRelayStatuses, |
||||||
|
successCount: errorRelayStatuses.filter((s: any) => s.success).length, |
||||||
|
totalCount: errorRelayStatuses.length |
||||||
|
}, { |
||||||
|
message: error.message || t('Failed to save RSS feeds'), |
||||||
|
duration: 6000 |
||||||
|
}) |
||||||
|
} else { |
||||||
|
showPublishingError(error instanceof Error ? error : new Error(t('Failed to save RSS feeds'))) |
||||||
|
} |
||||||
|
} finally { |
||||||
|
setPushing(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return ( |
||||||
|
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}> |
||||||
|
<div className="flex flex-col w-full items-center py-8"> |
||||||
|
<Button size="lg" onClick={() => checkLogin()}> |
||||||
|
{t('Login to configure RSS feeds')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}> |
||||||
|
<div className="text-center text-sm text-muted-foreground py-8">{t('loading...')}</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS Feed Settings')}> |
||||||
|
<div className="px-4 pt-3 space-y-6"> |
||||||
|
{/* Show RSS Feed Toggle */} |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<Label htmlFor="show-rss-feed">{t('Show RSS Feed')}</Label> |
||||||
|
<Switch |
||||||
|
id="show-rss-feed" |
||||||
|
checked={showRssFeed} |
||||||
|
onCheckedChange={handleShowRssFeedChange} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="text-muted-foreground text-xs"> |
||||||
|
{t('Show or hide the RSS feed tab in the main feed')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* RSS Feed List */} |
||||||
|
<div className="space-y-4"> |
||||||
|
<div className="space-y-2"> |
||||||
|
<Label>{t('RSS Feeds')}</Label> |
||||||
|
<div className="text-muted-foreground text-xs"> |
||||||
|
{t('Add RSS feed URLs to subscribe to. If no feeds are configured, the default feed will be used.')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Add Feed Input */} |
||||||
|
<div className="flex gap-2"> |
||||||
|
<Input |
||||||
|
type="url" |
||||||
|
placeholder="https://example.com/feed.xml" |
||||||
|
value={newFeedUrl} |
||||||
|
onChange={(e) => setNewFeedUrl(e.target.value)} |
||||||
|
onKeyDown={(e) => { |
||||||
|
if (e.key === 'Enter') { |
||||||
|
handleAddFeed() |
||||||
|
} |
||||||
|
}} |
||||||
|
className="flex-1" |
||||||
|
/> |
||||||
|
<Button onClick={handleAddFeed} size="icon" variant="outline"> |
||||||
|
<Plus className="h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Feed List */} |
||||||
|
<div className="space-y-2"> |
||||||
|
{feedUrls.length === 0 ? ( |
||||||
|
<div className="text-sm text-muted-foreground py-4 text-center"> |
||||||
|
{t('No feeds configured. Default feed will be used.')} |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
feedUrls.map((url) => ( |
||||||
|
<div key={url} className="flex items-center justify-between p-3 border rounded-lg"> |
||||||
|
<span className="text-sm break-all flex-1 mr-2">{url}</span> |
||||||
|
<Button |
||||||
|
onClick={() => handleRemoveFeed(url)} |
||||||
|
size="icon" |
||||||
|
variant="ghost" |
||||||
|
className="flex-shrink-0" |
||||||
|
> |
||||||
|
<Trash2 className="h-4 w-4" /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
)) |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Save Button */} |
||||||
|
<Button |
||||||
|
className="w-full" |
||||||
|
disabled={pushing || !hasChange} |
||||||
|
onClick={handleSave} |
||||||
|
> |
||||||
|
{pushing ? <Loader className="animate-spin mr-2" /> : <CloudUpload className="mr-2" />} |
||||||
|
{t('Save')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
RssFeedSettingsPage.displayName = 'RssFeedSettingsPage' |
||||||
|
export default RssFeedSettingsPage |
||||||
|
|
||||||
@ -0,0 +1,301 @@ |
|||||||
|
import { DEFAULT_RSS_FEEDS } from '@/constants' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
|
||||||
|
export interface RssFeedItem { |
||||||
|
title: string |
||||||
|
link: string |
||||||
|
description: string |
||||||
|
pubDate: Date | null |
||||||
|
guid: string |
||||||
|
feedUrl: string |
||||||
|
feedTitle?: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface RssFeed { |
||||||
|
title: string |
||||||
|
link: string |
||||||
|
description: string |
||||||
|
items: RssFeedItem[] |
||||||
|
feedUrl: string |
||||||
|
} |
||||||
|
|
||||||
|
class RssFeedService { |
||||||
|
static instance: RssFeedService |
||||||
|
private feedCache: Map<string, { feed: RssFeed; timestamp: number }> = new Map() |
||||||
|
private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
constructor() { |
||||||
|
if (!RssFeedService.instance) { |
||||||
|
RssFeedService.instance = this |
||||||
|
} |
||||||
|
return RssFeedService.instance |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch and parse an RSS/Atom feed from a URL |
||||||
|
*/ |
||||||
|
async fetchFeed(url: string): Promise<RssFeed> { |
||||||
|
// Check cache first
|
||||||
|
const cached = this.feedCache.get(url) |
||||||
|
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) { |
||||||
|
return cached.feed |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Check if we should use proxy server to avoid CORS issues
|
||||||
|
const proxyServer = import.meta.env.VITE_PROXY_SERVER |
||||||
|
const isProxyUrl = url.includes('/sites/') |
||||||
|
|
||||||
|
// If proxy is configured and URL isn't already proxied, use proxy
|
||||||
|
let fetchUrl = url |
||||||
|
if (proxyServer && !isProxyUrl) { |
||||||
|
fetchUrl = `${proxyServer}/sites/${encodeURIComponent(url)}` |
||||||
|
} |
||||||
|
|
||||||
|
const controller = new AbortController() |
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||||
|
|
||||||
|
const res = await fetch(fetchUrl, { |
||||||
|
signal: controller.signal, |
||||||
|
mode: 'cors', |
||||||
|
credentials: 'omit', |
||||||
|
headers: { |
||||||
|
'Accept': 'application/rss+xml, application/xml, application/atom+xml, text/xml, */*' |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
clearTimeout(timeoutId) |
||||||
|
|
||||||
|
if (!res.ok) { |
||||||
|
throw new Error(`Failed to fetch feed: ${res.status} ${res.statusText}`) |
||||||
|
} |
||||||
|
|
||||||
|
const xmlText = await res.text() |
||||||
|
const feed = this.parseFeed(xmlText, url) |
||||||
|
|
||||||
|
// Cache the feed
|
||||||
|
this.feedCache.set(url, { feed, timestamp: Date.now() }) |
||||||
|
|
||||||
|
return feed |
||||||
|
} catch (error) { |
||||||
|
logger.error('[RssFeedService] Error fetching feed', { url, error }) |
||||||
|
throw error |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse RSS/Atom XML into structured data |
||||||
|
*/ |
||||||
|
private parseFeed(xmlText: string, feedUrl: string): RssFeed { |
||||||
|
const parser = new DOMParser() |
||||||
|
const doc = parser.parseFromString(xmlText, 'text/xml') |
||||||
|
|
||||||
|
// Check for parsing errors
|
||||||
|
const parserError = doc.querySelector('parsererror') |
||||||
|
if (parserError) { |
||||||
|
throw new Error('Failed to parse XML feed') |
||||||
|
} |
||||||
|
|
||||||
|
// Determine if it's RSS or Atom
|
||||||
|
const isAtom = doc.documentElement.tagName === 'feed' || doc.documentElement.namespaceURI === 'http://www.w3.org/2005/Atom' |
||||||
|
|
||||||
|
if (isAtom) { |
||||||
|
return this.parseAtomFeed(doc, feedUrl) |
||||||
|
} else { |
||||||
|
return this.parseRssFeed(doc, feedUrl) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse RSS 2.0 feed |
||||||
|
*/ |
||||||
|
private parseRssFeed(doc: Document, feedUrl: string): RssFeed { |
||||||
|
const channel = doc.querySelector('channel') |
||||||
|
if (!channel) { |
||||||
|
throw new Error('Invalid RSS feed: no channel element found') |
||||||
|
} |
||||||
|
|
||||||
|
const title = this.getTextContent(channel, 'title') || 'Untitled Feed' |
||||||
|
const link = this.getTextContent(channel, 'link') || feedUrl |
||||||
|
const description = this.getTextContent(channel, 'description') || '' |
||||||
|
|
||||||
|
const items: RssFeedItem[] = [] |
||||||
|
const itemElements = channel.querySelectorAll('item') |
||||||
|
|
||||||
|
itemElements.forEach((item) => { |
||||||
|
const itemTitle = this.getTextContent(item, 'title') || '' |
||||||
|
let itemLink = this.getTextContent(item, 'link') || '' |
||||||
|
// Convert relative URLs to absolute
|
||||||
|
if (itemLink && !itemLink.startsWith('http://') && !itemLink.startsWith('https://')) { |
||||||
|
try { |
||||||
|
const baseUrl = new URL(feedUrl) |
||||||
|
itemLink = new URL(itemLink, baseUrl.origin).href |
||||||
|
} catch { |
||||||
|
// If URL parsing fails, keep the original link
|
||||||
|
} |
||||||
|
} |
||||||
|
// For description, preserve HTML content
|
||||||
|
const itemDescription = this.getHtmlContent(item, 'description') || '' |
||||||
|
const itemPubDate = this.parseDate(this.getTextContent(item, 'pubDate')) |
||||||
|
const itemGuid = this.getTextContent(item, 'guid') || itemLink || '' |
||||||
|
|
||||||
|
items.push({ |
||||||
|
title: itemTitle, |
||||||
|
link: itemLink, |
||||||
|
description: itemDescription, |
||||||
|
pubDate: itemPubDate, |
||||||
|
guid: itemGuid, |
||||||
|
feedUrl, |
||||||
|
feedTitle: title |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
title, |
||||||
|
link, |
||||||
|
description, |
||||||
|
items, |
||||||
|
feedUrl |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse Atom 1.0 feed |
||||||
|
*/ |
||||||
|
private parseAtomFeed(doc: Document, feedUrl: string): RssFeed { |
||||||
|
const feed = doc.documentElement |
||||||
|
|
||||||
|
const title = this.getTextContent(feed, 'title') || 'Untitled Feed' |
||||||
|
const linkElement = feed.querySelector('link[rel="alternate"], link:not([rel])') |
||||||
|
const link = linkElement?.getAttribute('href') || feedUrl |
||||||
|
const description = this.getTextContent(feed, 'subtitle') || this.getTextContent(feed, 'description') || '' |
||||||
|
|
||||||
|
const items: RssFeedItem[] = [] |
||||||
|
const entryElements = feed.querySelectorAll('entry') |
||||||
|
|
||||||
|
entryElements.forEach((entry) => { |
||||||
|
const entryTitle = this.getTextContent(entry, 'title') || '' |
||||||
|
const entryLinkElement = entry.querySelector('link[rel="alternate"], link:not([rel])') |
||||||
|
let entryLink = entryLinkElement?.getAttribute('href') || '' |
||||||
|
// Convert relative URLs to absolute
|
||||||
|
if (entryLink && !entryLink.startsWith('http://') && !entryLink.startsWith('https://')) { |
||||||
|
try { |
||||||
|
const baseUrl = new URL(feedUrl) |
||||||
|
entryLink = new URL(entryLink, baseUrl.origin).href |
||||||
|
} catch { |
||||||
|
// If URL parsing fails, keep the original link
|
||||||
|
} |
||||||
|
} |
||||||
|
// For content/summary, preserve HTML content
|
||||||
|
const entryContent = this.getHtmlContent(entry, 'content') || this.getHtmlContent(entry, 'summary') || '' |
||||||
|
const entryPublished = this.getTextContent(entry, 'published') || this.getTextContent(entry, 'updated') |
||||||
|
const entryPubDate = this.parseDate(entryPublished) |
||||||
|
const entryId = this.getTextContent(entry, 'id') || entryLink || '' |
||||||
|
|
||||||
|
items.push({ |
||||||
|
title: entryTitle, |
||||||
|
link: entryLink, |
||||||
|
description: entryContent, |
||||||
|
pubDate: entryPubDate, |
||||||
|
guid: entryId, |
||||||
|
feedUrl, |
||||||
|
feedTitle: title |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
title, |
||||||
|
link, |
||||||
|
description, |
||||||
|
items, |
||||||
|
feedUrl |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get text content from an element, handling CDATA and nested elements |
||||||
|
*/ |
||||||
|
private getTextContent(element: Element | null, tagName: string): string { |
||||||
|
if (!element) return '' |
||||||
|
const child = element.querySelector(tagName) |
||||||
|
if (!child) return '' |
||||||
|
// Get text content which automatically decodes HTML entities
|
||||||
|
return child.textContent?.trim() || '' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get HTML content from an element (for descriptions that may contain HTML) |
||||||
|
*/ |
||||||
|
private getHtmlContent(element: Element | null, tagName: string): string { |
||||||
|
if (!element) return '' |
||||||
|
const child = element.querySelector(tagName) |
||||||
|
if (!child) return '' |
||||||
|
// Return innerHTML to preserve HTML formatting
|
||||||
|
return child.innerHTML?.trim() || child.textContent?.trim() || '' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse date string into Date object |
||||||
|
*/ |
||||||
|
private parseDate(dateString: string | null): Date | null { |
||||||
|
if (!dateString) return null |
||||||
|
try { |
||||||
|
return new Date(dateString) |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get feed URLs to use (from event or default) |
||||||
|
*/ |
||||||
|
getFeedUrls(eventFeedUrls: string[] | null | undefined): string[] { |
||||||
|
if (eventFeedUrls && eventFeedUrls.length > 0) { |
||||||
|
return eventFeedUrls |
||||||
|
} |
||||||
|
return DEFAULT_RSS_FEEDS |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch multiple feeds and merge items |
||||||
|
*/ |
||||||
|
async fetchMultipleFeeds(feedUrls: string[]): Promise<RssFeedItem[]> { |
||||||
|
const results = await Promise.allSettled( |
||||||
|
feedUrls.map(url => this.fetchFeed(url)) |
||||||
|
) |
||||||
|
|
||||||
|
const allItems: RssFeedItem[] = [] |
||||||
|
|
||||||
|
results.forEach((result, index) => { |
||||||
|
if (result.status === 'fulfilled') { |
||||||
|
allItems.push(...result.value.items) |
||||||
|
} else { |
||||||
|
logger.warn('[RssFeedService] Failed to fetch feed', { url: feedUrls[index], error: result.reason }) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
// Sort by publication date (newest first)
|
||||||
|
allItems.sort((a, b) => { |
||||||
|
const dateA = a.pubDate?.getTime() || 0 |
||||||
|
const dateB = b.pubDate?.getTime() || 0 |
||||||
|
return dateB - dateA |
||||||
|
}) |
||||||
|
|
||||||
|
return allItems |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear cache for a specific feed or all feeds |
||||||
|
*/ |
||||||
|
clearCache(url?: string) { |
||||||
|
if (url) { |
||||||
|
this.feedCache.delete(url) |
||||||
|
} else { |
||||||
|
this.feedCache.clear() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const instance = new RssFeedService() |
||||||
|
export default instance |
||||||
|
|
||||||
Loading…
Reference in new issue