Browse Source

Add feed counter and compacter

truncate feed selector and alllow multi-
implemented pagination
lengthened selection time for highlights, on mobile
imwald
Silberengel 4 months ago
parent
commit
67b6b46d49
  1. 376
      src/components/RssFeedItem/index.tsx
  2. 202
      src/components/RssFeedList/index.tsx
  3. 8
      src/i18n/locales/en.ts

376
src/components/RssFeedItem/index.tsx

@ -36,7 +36,7 @@ function htmlToPlainText(html: string): string {
return text return text
} }
export default function RssFeedItem({ item, className }: { item: TRssFeedItem; className?: string }) { export default function RssFeedItem({ item, className, compact = false }: { item: TRssFeedItem; className?: string; compact?: boolean }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -238,14 +238,14 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c
return return
} }
// Wait for selection to be stable (no changes for 500ms) before showing drawer // Wait for selection to be stable (no changes for 1500ms) before showing drawer
selectionStableTimeoutRef.current = setTimeout(() => { selectionStableTimeoutRef.current = setTimeout(() => {
const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current
// Only show if selection hasn't changed in the last 500ms // Only show if selection hasn't changed in the last 1500ms (3x original delay)
if (timeSinceLastChange >= 500 && !isSelectingRef.current) { if (timeSinceLastChange >= 2000 && !isSelectingRef.current) {
handleSelection(true) handleSelection(true)
} }
}, 500) }, 1500)
} else { } else {
// Desktop: shorter delay // Desktop: shorter delay
if (selectionTimeoutRef.current) { if (selectionTimeoutRef.current) {
@ -454,7 +454,7 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c
}, [descriptionHtml, isExpanded]) }, [descriptionHtml, isExpanded])
return ( return (
<div className={`border rounded-lg bg-background p-4 space-y-3 ${className || ''}`}> <div className={`border rounded-lg bg-background p-4 space-y-3 overflow-hidden ${className || ''}`}>
{/* Feed Header with Metadata */} {/* Feed Header with Metadata */}
<div className="flex items-start gap-3 pb-3 border-b"> <div className="flex items-start gap-3 pb-3 border-b">
{/* Feed Image/Logo */} {/* Feed Image/Logo */}
@ -491,206 +491,238 @@ export default function RssFeedItem({ item, className }: { item: TRssFeedItem; c
</div> </div>
{/* Title */} {/* Title */}
<div> <div className="min-w-0">
<a <a
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-lg font-semibold hover:text-primary transition-colors inline-flex items-center gap-2" className={cn(
"text-lg hover:text-primary transition-colors inline-flex items-center gap-2 break-words",
!compact || isExpanded ? "font-semibold" : ""
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{item.title} <span className="break-words">{item.title}</span>
<ExternalLink className="h-4 w-4 shrink-0" /> <ExternalLink className="h-4 w-4 shrink-0" />
</a> </a>
</div> </div>
{/* Media (Images) */} {/* Compact view: Hide media and description when compact and not expanded */}
{item.media && item.media.length > 0 && ( {!compact || isExpanded ? (
<div className="space-y-2"> <>
{item.media {/* Media (Images) */}
.filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') {item.media && item.media.length > 0 && (
.map((media, index) => { <div className="space-y-2 overflow-hidden">
const hasThumbnail = !!media.thumbnail {item.media
const imageUrl = media.thumbnail || media.url .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image')
return ( .map((media, index) => {
<div key={index} className="relative"> const hasThumbnail = !!media.thumbnail
<img const imageUrl = media.thumbnail || media.url
src={imageUrl} return (
alt={item.title} <div key={index} className="relative overflow-hidden">
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`} <img
onClick={(e) => { src={imageUrl}
e.stopPropagation() alt={item.title}
// Open full image in new tab 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`}
window.open(media.url, '_blank', 'noopener,noreferrer') onClick={(e) => {
}} e.stopPropagation()
onError={(e) => { // Open full image in new tab
// Hide image on error window.open(media.url, '_blank', 'noopener,noreferrer')
e.currentTarget.style.display = 'none' }}
}} onError={(e) => {
/> // Hide image on error
{media.credit && ( e.currentTarget.style.display = 'none'
<div className="text-xs text-muted-foreground mt-1"> }}
{t('Photo')}: {media.credit} />
{media.credit && (
<div className="text-xs text-muted-foreground mt-1">
{t('Photo')}: {media.credit}
</div>
)}
</div> </div>
)} )
})}
</div>
)}
{/* Audio/Video Enclosure */}
{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> </div>
) <MediaPlayer
})} src={item.enclosure.url}
</div> className="w-full"
)} mustLoad={true}
/>
{/* Audio/Video Enclosure */}
{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>
</div> </div>
<MediaPlayer
src={item.enclosure.url}
className="w-full"
mustLoad={true}
/>
</div>
</div>
)}
{/* Description with text selection support and collapse/expand */}
<div className="relative">
<div
ref={contentRef}
className={cn(
'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200',
needsCollapse && !isExpanded && 'max-h-[400px] overflow-hidden',
'[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg'
)} )}
style={{
userSelect: 'text', {/* Description with text selection support and collapse/expand */}
WebkitUserSelect: 'text', <div className="relative overflow-hidden">
MozUserSelect: 'text', <div
msUserSelect: 'text' ref={contentRef}
}} className={cn(
dangerouslySetInnerHTML={{ __html: descriptionHtml }} 'prose prose-sm dark:prose-invert max-w-none break-words rss-feed-content transition-all duration-200 overflow-wrap-anywhere',
onMouseUp={(e) => { needsCollapse && !isExpanded && 'max-h-[400px] overflow-hidden',
// Allow text selection '[&_img]:max-w-full [&_img]:md:max-w-[400px] [&_img]:h-auto [&_img]:rounded-lg',
e.stopPropagation() '[&_*]:max-w-full'
}}
/>
{/* Gradient overlay when collapsed */}
{needsCollapse && !isExpanded && (
<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" />
)}
{/* Collapse/Expand Button */}
{needsCollapse && (
<div className="flex justify-center mt-2 relative z-10">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
{t('Show less')}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{t('Show more')}
</>
)} )}
</Button> style={{
</div> userSelect: 'text',
)} WebkitUserSelect: 'text',
MozUserSelect: 'text',
{/* Highlight Button (Desktop) */} msUserSelect: 'text'
{!isSmallScreen && showHighlightButton && selectedText && selectionPosition && ( }}
<div dangerouslySetInnerHTML={{ __html: descriptionHtml }}
className="highlight-button-container fixed z-50" onMouseUp={(e) => {
style={{ // Allow text selection
left: `${selectionPosition.x}px`,
top: `${selectionPosition.y}px`,
transform: 'translateX(-50%) translateY(-100%)'
}}
>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleCreateHighlight()
}} }}
className="shadow-lg" />
>
<Highlighter className="h-4 w-4 mr-2" /> {/* Gradient overlay when collapsed */}
{t('Create Highlight')} {needsCollapse && !isExpanded && (
</Button> <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" />
</div> )}
)}
{/* Collapse/Expand Button - Only show in full view */}
{/* Highlight Drawer (Mobile) */} {!compact && needsCollapse && (
{isSmallScreen && ( <div className="flex justify-center mt-2 relative z-10">
<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 <Button
className="w-full" variant="ghost"
onClick={() => { size="sm"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
{t('Show less')}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{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() handleCreateHighlight()
setShowHighlightDrawer(false)
}} }}
className="shadow-lg"
> >
<Highlighter className="h-4 w-4 mr-2" /> <Highlighter className="h-4 w-4 mr-2" />
{t('Create Highlight')} {t('Create Highlight')}
</Button> </Button>
</div> </div>
</DrawerContent> )}
</Drawer>
)} {/* Highlight Drawer (Mobile) */}
</div> {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}
{/* Link to original article */} {/* Link to original article and expand button */}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center justify-between gap-2 text-sm min-w-0">
<a <a
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1 min-w-0 truncate"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{t('Read full article')} <span className="truncate">{t('Read full article')}</span>
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3 shrink-0" />
</a> </a>
{compact && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className="text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
{t('Show less')}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{t('Expand')}
</>
)}
</Button>
)}
</div> </div>
{/* Post Editor for highlights */} {/* Post Editor for highlights */}

202
src/components/RssFeedList/index.tsx

@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service'
@ -8,7 +8,12 @@ import { Loader, AlertCircle, Search } from 'lucide-react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Check, ChevronDown } from 'lucide-react'
export default function RssFeedList() { export default function RssFeedList() {
const { t } = useTranslation() const { t } = useTranslation()
@ -20,10 +25,16 @@ export default function RssFeedList() {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
// Filter states // Filter states
const [selectedFeed, setSelectedFeed] = useState<string>('all') const [selectedFeeds, setSelectedFeeds] = useState<string[]>(['all'])
const [timeFilter, setTimeFilter] = useState<string>('all') const [timeFilter, setTimeFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState<string>('') const [searchQuery, setSearchQuery] = useState<string>('')
const [showFilters, setShowFilters] = useState<boolean>(false) const [showFilters, setShowFilters] = useState<boolean>(false)
const [isCompactView, setIsCompactView] = useState<boolean>(true)
const [feedPopoverOpen, setFeedPopoverOpen] = useState<boolean>(false)
// Pagination state
const [showCount, setShowCount] = useState<number>(25)
const bottomRef = useRef<HTMLDivElement>(null)
// Listen for filter toggle events // Listen for filter toggle events
useEffect(() => { useEffect(() => {
@ -85,7 +96,6 @@ export default function RssFeedList() {
try { try {
// Get feed URLs from event or use default // Get feed URLs from event or use default
let feedUrls: string[] = [] let feedUrls: string[] = []
let useDefaultFeeds = false
if (pubkey && rssFeedListEvent) { if (pubkey && rssFeedListEvent) {
// User has an event - use only feeds from that event (even if empty) // User has an event - use only feeds from that event (even if empty)
@ -129,7 +139,6 @@ export default function RssFeedList() {
// No event exists - use default feeds for demo // No event exists - use default feeds for demo
logger.info('[RssFeedList] No RSS feed list event in context, using default feeds') logger.info('[RssFeedList] No RSS feed list event in context, using default feeds')
feedUrls = DEFAULT_RSS_FEEDS feedUrls = DEFAULT_RSS_FEEDS
useDefaultFeeds = true
// Trigger background refresh for default feeds when no event exists // Trigger background refresh for default feeds when no event exists
rssFeedService.backgroundRefreshFeeds(feedUrls, abortController.signal).catch(err => { rssFeedService.backgroundRefreshFeeds(feedUrls, abortController.signal).catch(err => {
if (!(err instanceof DOMException && err.name === 'AbortError')) { if (!(err instanceof DOMException && err.name === 'AbortError')) {
@ -139,7 +148,6 @@ export default function RssFeedList() {
} else { } else {
// No pubkey - use default feeds // No pubkey - use default feeds
feedUrls = DEFAULT_RSS_FEEDS feedUrls = DEFAULT_RSS_FEEDS
useDefaultFeeds = true
} }
// Check if aborted before fetching // Check if aborted before fetching
@ -306,14 +314,39 @@ export default function RssFeedList() {
return Array.from(feedMap.values()) return Array.from(feedMap.values())
}, [items]) }, [items])
// Helper function to truncate text
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
// Handle feed selection change
const handleFeedToggle = (feedUrl: string, checked: boolean) => {
if (feedUrl === 'all') {
// If "all" is checked, clear all other selections
setSelectedFeeds(checked ? ['all'] : [])
} else {
// If a specific feed is checked, remove "all" if present
setSelectedFeeds(prev => {
const newSelection = checked
? [...prev.filter(f => f !== 'all'), feedUrl]
: prev.filter(f => f !== feedUrl)
// If nothing is selected, default to "all"
return newSelection.length === 0 ? ['all'] : newSelection
})
}
}
// Filter items based on selected filters // Filter items based on selected filters
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let filtered = items let filtered = items
// Filter by feed // Filter by feed
if (selectedFeed !== 'all') { if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) {
const normalizedSelectedFeed = normalizeFeedUrl(selectedFeed) const normalizedSelectedFeeds = selectedFeeds.map(f => normalizeFeedUrl(f))
filtered = filtered.filter(item => normalizeFeedUrl(item.feedUrl) === normalizedSelectedFeed) filtered = filtered.filter(item =>
normalizedSelectedFeeds.includes(normalizeFeedUrl(item.feedUrl))
)
} }
// Filter by time // Filter by time
@ -354,7 +387,49 @@ export default function RssFeedList() {
} }
return filtered return filtered
}, [items, selectedFeed, timeFilter, searchQuery]) }, [items, selectedFeeds, timeFilter, searchQuery])
// Reset showCount when filters change
useEffect(() => {
setShowCount(25)
}, [selectedFeeds, timeFilter, searchQuery])
// Pagination: slice to showCount for display
const displayedItems = useMemo(() => {
return filteredItems.slice(0, showCount)
}, [filteredItems, showCount])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!bottomRef.current || displayedItems.length >= filteredItems.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedItems.length < filteredItems.length) {
setShowCount((prev) => Math.min(prev + 25, filteredItems.length))
}
},
{ root: null, rootMargin: '100px', threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => {
observer.disconnect()
}
}, [displayedItems.length, filteredItems.length])
// Get display text for feed selector
const feedSelectorText = useMemo(() => {
if (selectedFeeds.includes('all') || selectedFeeds.length === 0) {
return t('All feeds')
}
if (selectedFeeds.length === 1) {
const feed = availableFeeds.find(f => f.url === selectedFeeds[0])
return feed ? truncateText(feed.title, 50) : t('All feeds')
}
return t('{{count}} feeds', { count: selectedFeeds.length })
}, [selectedFeeds, availableFeeds, t])
if (loading) { if (loading) {
return ( return (
@ -384,24 +459,85 @@ export default function RssFeedList() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Feed Counter Header - Always visible */}
<div className="sticky top-0 z-10 bg-background border-b px-4 py-1.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Switch
id="compact-view"
checked={isCompactView}
onCheckedChange={setIsCompactView}
/>
<Label htmlFor="compact-view" className="text-xs text-muted-foreground cursor-pointer">
{isCompactView ? t('Compact') : t('Full')}
</Label>
</div>
<p className="text-xs text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: displayedItems.length,
total: filteredItems.length
})}
</p>
</div>
</div>
{/* Filter Bar - Collapsible */} {/* Filter Bar - Collapsible */}
{showFilters && ( {showFilters && (
<div className="sticky top-0 z-10 bg-background border-b px-4 py-2"> <div className="sticky top-[2.5rem] z-10 bg-background border-b px-4 py-2">
<div className={`flex ${isSmallScreen ? 'flex-col' : 'flex-row'} items-stretch gap-2`}> <div className={`flex ${isSmallScreen ? 'flex-col' : 'flex-row'} items-stretch gap-2`}>
{/* Feed Selector */} {/* Feed Selector - Multi-select with Popover */}
<Select value={selectedFeed} onValueChange={setSelectedFeed}> <Popover open={feedPopoverOpen} onOpenChange={setFeedPopoverOpen}>
<SelectTrigger className="h-8 text-xs md:text-sm md:h-9 flex-shrink-0 w-full md:w-auto" style={{ minWidth: isSmallScreen ? '100%' : '150px' }}> <PopoverTrigger asChild>
<SelectValue placeholder={t('All feeds')} /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
<SelectItem value="all">{t('All feeds')}</SelectItem> className="h-8 text-xs md:text-sm md:h-9 flex-shrink-0 w-full md:w-auto justify-between"
{availableFeeds.map((feed) => ( style={{ minWidth: isSmallScreen ? '100%' : '300px' }}
<SelectItem key={feed.url} value={feed.url}> >
<span className="truncate max-w-[200px]">{feed.title}</span> <span className="truncate">{feedSelectorText}</span>
</SelectItem> <ChevronDown className="ml-2 h-3 w-3 md:h-4 md:w-4 shrink-0 opacity-50" />
))} </Button>
</SelectContent> </PopoverTrigger>
</Select> <PopoverContent className={`${isSmallScreen ? 'w-[calc(100vw-2rem)]' : 'w-[calc(100vw-2rem)] max-w-[400px]'} p-0`} align="start">
<div className="max-h-[300px] overflow-y-auto">
<div className="p-2">
{/* All feeds option */}
<div
className="flex items-center space-x-2 p-2 rounded-sm hover:bg-accent cursor-pointer"
onClick={() => {
const isAllSelected = selectedFeeds.includes('all')
handleFeedToggle('all', !isAllSelected)
}}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{selectedFeeds.includes('all') && <Check className="w-3 h-3" />}
</div>
<label className="text-sm cursor-pointer flex-1">
{t('All feeds')}
</label>
</div>
{/* Individual feed options */}
{availableFeeds.map((feed) => {
const isChecked = selectedFeeds.includes(feed.url)
return (
<div
key={feed.url}
className="flex items-center space-x-2 p-2 rounded-sm hover:bg-accent cursor-pointer"
onClick={() => handleFeedToggle(feed.url, !isChecked)}
>
<div className="flex items-center justify-center w-4 h-4 border border-border rounded">
{isChecked && <Check className="w-3 h-3" />}
</div>
<label className="text-sm cursor-pointer flex-1 truncate" title={feed.title}>
{truncateText(feed.title, 50)}
</label>
</div>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Time Filter */} {/* Time Filter */}
<Select value={timeFilter} onValueChange={setTimeFilter}> <Select value={timeFilter} onValueChange={setTimeFilter}>
@ -441,18 +577,26 @@ export default function RssFeedList() {
</div> </div>
)} )}
{filteredItems.length === 0 ? ( {displayedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{searchQuery || selectedFeed !== 'all' || timeFilter !== 'all' {searchQuery || (!selectedFeeds.includes('all') && selectedFeeds.length > 0) || timeFilter !== 'all'
? t('No items match your filters') ? t('No items match your filters')
: t('No RSS feed items available')} : t('No RSS feed items available')}
</p> </p>
</div> </div>
) : ( ) : (
filteredItems.map((item) => ( <>
<RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} /> {displayedItems.map((item) => (
)) <RssFeedItem key={`${item.feedUrl}-${item.guid}`} item={item} compact={isCompactView} />
))}
{/* Bottom ref for infinite scroll */}
{displayedItems.length < filteredItems.length && (
<div ref={bottomRef} className="flex items-center justify-center py-4">
<Loader className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</>
)} )}
</div> </div>
</div> </div>

8
src/i18n/locales/en.ts

@ -421,7 +421,6 @@ export default {
'See extra info for each notification': 'See extra info for each notification', 'See extra info for each notification': 'See extra info for each notification',
'See more notifications at a glance': 'See more notifications at a glance', 'See more notifications at a glance': 'See more notifications at a glance',
Detailed: 'Detailed', Detailed: 'Detailed',
Compact: 'Compact',
'Submit Relay': 'Submit Relay', 'Submit Relay': 'Submit Relay',
Homepage: 'Homepage', Homepage: 'Homepage',
'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficulty {{minPow}})', 'Proof of Work (difficulty {{minPow}})': 'Proof of Work (difficulty {{minPow}})',
@ -482,6 +481,11 @@ export default {
'Last month': 'Last month', 'Last month': 'Last month',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'Toggle filters': 'Toggle filters' '{{count}} feeds': '{{count}} feeds',
'Toggle filters': 'Toggle filters',
'Showing {{filtered}} of {{total}} items': 'Showing {{filtered}} of {{total}} items',
'Full': 'Full',
'Compact': 'Compact',
'Expand': 'Expand'
} }
} }

Loading…
Cancel
Save