|
|
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, Download, Upload } from 'lucide-react' |
|
|
import logger from '@/lib/logger' |
|
|
import { ExtendedKind } from '@/constants' |
|
|
import indexedDb from '@/services/indexed-db.service' |
|
|
import rssFeedService from '@/services/rss-feed.service' |
|
|
import { parseOpml, generateOpml, downloadFile } from '@/lib/opml' |
|
|
import { toast } from 'sonner' |
|
|
import { normalizeHttpUrl } from '@/lib/url' |
|
|
|
|
|
// Helper function to normalize and deduplicate feed URLs |
|
|
const normalizeAndDeduplicateUrls = (urls: string[]): string[] => { |
|
|
const normalizedUrls = urls |
|
|
.map(url => normalizeHttpUrl(url.trim())) |
|
|
.filter((url): url is string => url.length > 0) // Filter out invalid URLs |
|
|
|
|
|
// Deduplicate by creating a Set of normalized URLs, preserving order |
|
|
const seen = new Set<string>() |
|
|
const unique: string[] = [] |
|
|
|
|
|
for (const url of normalizedUrls) { |
|
|
if (!seen.has(url)) { |
|
|
seen.add(url) |
|
|
unique.push(url) |
|
|
} |
|
|
} |
|
|
|
|
|
return unique |
|
|
} |
|
|
|
|
|
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
|
|
const { t } = useTranslation() |
|
|
const { pubkey, publish, checkLogin, rssFeedListEvent, updateRssFeedListEvent } = 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) |
|
|
|
|
|
// Load RSS feed list from context (which is loaded from cache first, then relays if stale) |
|
|
useEffect(() => { |
|
|
// Load show RSS feed setting |
|
|
setShowRssFeed(storage.getShowRssFeed()) |
|
|
|
|
|
// Load RSS feed list from context event (which comes from cache) |
|
|
if (!pubkey) { |
|
|
setLoading(false) |
|
|
return |
|
|
} |
|
|
|
|
|
if (rssFeedListEvent) { |
|
|
try { |
|
|
// Extract URLs from "u" tags and normalize them |
|
|
const urls = rssFeedListEvent.tags |
|
|
.filter(tag => tag[0] === 'u' && tag[1]) |
|
|
.map(tag => tag[1] as string) |
|
|
.filter((url): url is string => { |
|
|
if (typeof url !== 'string') { |
|
|
logger.warn('[RssFeedSettingsPage] Invalid RSS feed URL (not a string)', { url, type: typeof url }) |
|
|
return false |
|
|
} |
|
|
const trimmed = url.trim() |
|
|
if (trimmed.length === 0) { |
|
|
logger.warn('[RssFeedSettingsPage] Empty RSS feed URL found') |
|
|
return false |
|
|
} |
|
|
return true |
|
|
}) |
|
|
.map(url => url.trim()) |
|
|
|
|
|
// Normalize and deduplicate URLs |
|
|
const normalizedUrls = normalizeAndDeduplicateUrls(urls) |
|
|
|
|
|
if (normalizedUrls.length > 0) { |
|
|
setFeedUrls(normalizedUrls) |
|
|
logger.info('[RssFeedSettingsPage] Loaded RSS feed list from context', { |
|
|
count: normalizedUrls.length, |
|
|
urls: normalizedUrls, |
|
|
originalCount: urls.length |
|
|
}) |
|
|
} else { |
|
|
logger.info('[RssFeedSettingsPage] RSS feed list is empty or contains no valid URLs') |
|
|
} |
|
|
} catch (e) { |
|
|
logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list from tags', { |
|
|
error: e, |
|
|
tags: rssFeedListEvent.tags |
|
|
}) |
|
|
} |
|
|
} else { |
|
|
logger.info('[RssFeedSettingsPage] No RSS feed list event in context (user may not have created one yet)') |
|
|
} |
|
|
|
|
|
setLoading(false) |
|
|
}, [pubkey, rssFeedListEvent]) |
|
|
|
|
|
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 |
|
|
|
|
|
// Normalize and deduplicate all URLs (including the new one) |
|
|
const allUrls = [...feedUrls, url] |
|
|
const normalizedUrls = normalizeAndDeduplicateUrls(allUrls) |
|
|
|
|
|
// Check if the new URL was actually added (not a duplicate) |
|
|
const normalizedExistingUrls = normalizeAndDeduplicateUrls(feedUrls) |
|
|
const normalizedNewUrl = normalizeHttpUrl(url) |
|
|
|
|
|
if (!normalizedNewUrl) { |
|
|
// Invalid URL |
|
|
return |
|
|
} |
|
|
|
|
|
if (normalizedExistingUrls.includes(normalizedNewUrl)) { |
|
|
// Feed already exists |
|
|
return |
|
|
} |
|
|
|
|
|
setFeedUrls(normalizedUrls) |
|
|
setNewFeedUrl('') |
|
|
setHasChange(true) |
|
|
} |
|
|
|
|
|
const handleRemoveFeed = (url: string) => { |
|
|
setFeedUrls(feedUrls.filter(u => u !== url)) |
|
|
setHasChange(true) |
|
|
} |
|
|
|
|
|
const handleImportOpml = async (event: React.ChangeEvent<HTMLInputElement>) => { |
|
|
const file = event.target.files?.[0] |
|
|
if (!file) return |
|
|
|
|
|
// Reset input |
|
|
event.target.value = '' |
|
|
|
|
|
try { |
|
|
const text = await file.text() |
|
|
const feeds = parseOpml(text) |
|
|
|
|
|
if (feeds.length === 0) { |
|
|
toast.error(t('No RSS feeds found in OPML file')) |
|
|
return |
|
|
} |
|
|
|
|
|
// Extract URLs from OPML feeds |
|
|
const opmlUrls = feeds |
|
|
.map(feed => feed.xmlUrl) |
|
|
.filter((url): url is string => { |
|
|
try { |
|
|
new URL(url) |
|
|
return true |
|
|
} catch { |
|
|
return false |
|
|
} |
|
|
}) |
|
|
|
|
|
if (opmlUrls.length === 0) { |
|
|
toast.error(t('No valid RSS feed URLs found in OPML file')) |
|
|
return |
|
|
} |
|
|
|
|
|
// Merge with existing feeds and normalize/deduplicate everything |
|
|
const allUrls = [...feedUrls, ...opmlUrls] |
|
|
const normalizedUrls = normalizeAndDeduplicateUrls(allUrls) |
|
|
|
|
|
// Check how many new URLs were actually added |
|
|
const normalizedExistingUrls = new Set(normalizeAndDeduplicateUrls(feedUrls)) |
|
|
const newUrls = normalizedUrls.filter(url => !normalizedExistingUrls.has(url)) |
|
|
|
|
|
if (newUrls.length === 0) { |
|
|
toast.info(t('All feeds from OPML file are already added')) |
|
|
return |
|
|
} |
|
|
|
|
|
// Update with normalized and deduplicated URLs |
|
|
setFeedUrls(normalizedUrls) |
|
|
setHasChange(true) |
|
|
toast.success(t('Imported {{count}} feed(s) from OPML file', { count: newUrls.length })) |
|
|
} catch (error) { |
|
|
logger.error('[RssFeedSettingsPage] Failed to import OPML file', { error }) |
|
|
toast.error(t('Failed to import OPML file: {{error}}', { |
|
|
error: error instanceof Error ? error.message : String(error) |
|
|
})) |
|
|
} |
|
|
} |
|
|
|
|
|
const handleExportOpml = () => { |
|
|
// Normalize and deduplicate before exporting |
|
|
const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls) |
|
|
|
|
|
if (normalizedUrls.length === 0) { |
|
|
toast.error(t('No feeds to export')) |
|
|
return |
|
|
} |
|
|
|
|
|
try { |
|
|
const opmlContent = generateOpml(normalizedUrls, 'Jumble RSS Feeds') |
|
|
const filename = `jumble-rss-feeds-${new Date().toISOString().split('T')[0]}.opml` |
|
|
downloadFile(opmlContent, filename, 'application/xml') |
|
|
toast.success(t('RSS feeds exported to OPML file')) |
|
|
} catch (error) { |
|
|
logger.error('[RssFeedSettingsPage] Failed to export OPML file', { error }) |
|
|
toast.error(t('Failed to export OPML file')) |
|
|
} |
|
|
} |
|
|
|
|
|
const handleSave = async () => { |
|
|
if (!pubkey) { |
|
|
logger.error('[RssFeedSettingsPage] Cannot save: no pubkey') |
|
|
return |
|
|
} |
|
|
|
|
|
setPushing(true) |
|
|
try { |
|
|
// Normalize and deduplicate URLs before saving |
|
|
const normalizedUrls = normalizeAndDeduplicateUrls(feedUrls) |
|
|
|
|
|
logger.info('[RssFeedSettingsPage] Creating RSS feed list event', { |
|
|
pubkey: pubkey.substring(0, 8), |
|
|
feedCount: normalizedUrls.length, |
|
|
originalCount: feedUrls.length, |
|
|
feedUrls: normalizedUrls |
|
|
}) |
|
|
|
|
|
const event = createRssFeedListDraftEvent(normalizedUrls) |
|
|
|
|
|
// Validate the event structure before publishing |
|
|
logger.info('[RssFeedSettingsPage] Draft event created', { |
|
|
kind: event.kind, |
|
|
tagCount: event.tags.length, |
|
|
tags: event.tags, |
|
|
created_at: event.created_at |
|
|
}) |
|
|
console.log('✅ [RSS] Event created with tags', { |
|
|
kind: event.kind, |
|
|
tagCount: event.tags.length, |
|
|
tags: event.tags |
|
|
}) |
|
|
|
|
|
console.log('🔵 [RSS] About to call publish()') |
|
|
let result |
|
|
try { |
|
|
result = await publish(event) |
|
|
console.log('✅ [RSS] Event published successfully!', { |
|
|
id: result.id, |
|
|
kind: result.kind, |
|
|
pubkey: result.pubkey?.substring(0, 8), |
|
|
content: result.content |
|
|
}) |
|
|
} catch (publishError) { |
|
|
console.error('❌ [RSS] Publish failed!', publishError) |
|
|
throw publishError |
|
|
} |
|
|
|
|
|
logger.info('[RssFeedSettingsPage] Event published', { |
|
|
eventId: result.id, |
|
|
kind: result.kind, |
|
|
pubkey: result.pubkey, |
|
|
created_at: result.created_at, |
|
|
content: result.content |
|
|
}) |
|
|
|
|
|
// Cache the event in IndexedDB for immediate access |
|
|
console.log('🔵 [RSS] About to cache event in IndexedDB', { |
|
|
eventId: result.id, |
|
|
kind: result.kind, |
|
|
pubkey: result.pubkey?.substring(0, 8) |
|
|
}) |
|
|
|
|
|
try { |
|
|
logger.info('[RssFeedSettingsPage] Attempting to cache event in IndexedDB', { |
|
|
eventId: result.id, |
|
|
kind: result.kind, |
|
|
pubkey: result.pubkey |
|
|
}) |
|
|
|
|
|
console.log('🔵 [RSS] Calling indexedDb.putReplaceableEvent()...') |
|
|
const savedEvent = await indexedDb.putReplaceableEvent(result) |
|
|
console.log('✅ [RSS] Successfully cached to IndexedDB!', { |
|
|
eventId: savedEvent.id, |
|
|
kind: savedEvent.kind, |
|
|
pubkey: savedEvent.pubkey?.substring(0, 8), |
|
|
content: savedEvent.content |
|
|
}) |
|
|
logger.info('[RssFeedSettingsPage] Successfully cached RSS feed list event to IndexedDB', { |
|
|
eventId: savedEvent.id, |
|
|
kind: savedEvent.kind, |
|
|
pubkey: savedEvent.pubkey, |
|
|
feedCount: feedUrls.length |
|
|
}) |
|
|
} catch (cacheError) { |
|
|
console.error('❌ [RSS] Failed to cache to IndexedDB!', { |
|
|
error: cacheError, |
|
|
errorMessage: cacheError instanceof Error ? cacheError.message : String(cacheError), |
|
|
errorStack: cacheError instanceof Error ? cacheError.stack : undefined, |
|
|
eventId: result.id, |
|
|
kind: result.kind |
|
|
}) |
|
|
logger.error('[RssFeedSettingsPage] Failed to cache RSS feed list event', { |
|
|
error: cacheError, |
|
|
eventId: result.id, |
|
|
kind: result.kind |
|
|
}) |
|
|
// Don't fail the save if caching fails, but log the error |
|
|
} |
|
|
|
|
|
// Verify the event was saved by reading it back |
|
|
console.log('🔵 [RSS] Verifying event was saved...') |
|
|
try { |
|
|
logger.info('[RssFeedSettingsPage] Verifying event was saved to IndexedDB', { |
|
|
pubkey: pubkey.substring(0, 8), |
|
|
kind: ExtendedKind.RSS_FEED_LIST |
|
|
}) |
|
|
|
|
|
const savedEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) |
|
|
if (savedEvent) { |
|
|
console.log('✅ [RSS] Event found in IndexedDB!', { |
|
|
eventId: savedEvent.id, |
|
|
expectedId: result.id, |
|
|
match: savedEvent.id === result.id, |
|
|
content: savedEvent.content |
|
|
}) |
|
|
logger.info('[RssFeedSettingsPage] Event found in IndexedDB', { |
|
|
eventId: savedEvent.id, |
|
|
expectedId: result.id, |
|
|
match: savedEvent.id === result.id, |
|
|
created_at: savedEvent.created_at, |
|
|
content: savedEvent.content |
|
|
}) |
|
|
|
|
|
if (savedEvent.id === result.id) { |
|
|
console.log('✅ [RSS] Event IDs match! Verification successful!') |
|
|
logger.info('[RssFeedSettingsPage] Verified RSS feed list event in IndexedDB', { eventId: savedEvent.id }) |
|
|
} else { |
|
|
console.warn('⚠️ [RSS] Event ID mismatch!', { |
|
|
expectedId: result.id, |
|
|
foundId: savedEvent.id |
|
|
}) |
|
|
logger.warn('[RssFeedSettingsPage] RSS feed list event ID mismatch', { |
|
|
expectedId: result.id, |
|
|
foundId: savedEvent.id, |
|
|
expectedCreatedAt: result.created_at, |
|
|
foundCreatedAt: savedEvent.created_at |
|
|
}) |
|
|
} |
|
|
} else { |
|
|
console.error('❌ [RSS] Event NOT found in IndexedDB after save!', { |
|
|
expectedId: result.id, |
|
|
pubkey: pubkey.substring(0, 8), |
|
|
kind: ExtendedKind.RSS_FEED_LIST |
|
|
}) |
|
|
logger.error('[RssFeedSettingsPage] RSS feed list event not found in IndexedDB after save', { |
|
|
expectedId: result.id, |
|
|
pubkey: pubkey.substring(0, 8), |
|
|
kind: ExtendedKind.RSS_FEED_LIST |
|
|
}) |
|
|
} |
|
|
} catch (verifyError) { |
|
|
console.error('❌ [RSS] Error verifying event in IndexedDB!', verifyError) |
|
|
logger.error('[RssFeedSettingsPage] Failed to verify RSS feed list event in IndexedDB', { |
|
|
error: verifyError, |
|
|
pubkey: pubkey.substring(0, 8), |
|
|
kind: ExtendedKind.RSS_FEED_LIST |
|
|
}) |
|
|
} |
|
|
|
|
|
// Update the context with the new event |
|
|
await updateRssFeedListEvent(result) |
|
|
|
|
|
// Dispatch custom event to notify other components (like RssFeedList) to refresh |
|
|
window.dispatchEvent(new CustomEvent('rssFeedListUpdated', { |
|
|
detail: { pubkey, feedUrls: normalizedUrls, eventId: result.id } |
|
|
})) |
|
|
|
|
|
// Trigger background refresh of feeds (don't wait for it) |
|
|
logger.info('[RssFeedSettingsPage] Triggering background refresh of RSS feeds', { feedCount: normalizedUrls.length }) |
|
|
rssFeedService.backgroundRefreshFeeds(normalizedUrls).catch(err => { |
|
|
logger.error('[RssFeedSettingsPage] Background refresh failed', { error: err }) |
|
|
}) |
|
|
|
|
|
// Update local state with normalized URLs if they changed |
|
|
if (normalizedUrls.length !== feedUrls.length || |
|
|
JSON.stringify(normalizedUrls.sort()) !== JSON.stringify(feedUrls.sort())) { |
|
|
setFeedUrls(normalizedUrls) |
|
|
} |
|
|
|
|
|
// Read relayStatuses immediately before it might be deleted |
|
|
const relayStatuses = (result as any).relayStatuses |
|
|
logger.info('[RssFeedSettingsPage] Publishing complete', { |
|
|
eventId: result.id, |
|
|
relayStatusCount: relayStatuses?.length || 0, |
|
|
successCount: relayStatuses?.filter((s: any) => s.success).length || 0 |
|
|
}) |
|
|
|
|
|
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, |
|
|
errorMessage: error instanceof Error ? error.message : String(error), |
|
|
errorStack: error instanceof Error ? error.stack : undefined |
|
|
}) |
|
|
// 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"> |
|
|
<div className="flex items-center justify-between"> |
|
|
<Label>{t('RSS Feeds')}</Label> |
|
|
<div className="flex gap-2"> |
|
|
<Button |
|
|
variant="outline" |
|
|
size="sm" |
|
|
onClick={handleExportOpml} |
|
|
disabled={feedUrls.length === 0} |
|
|
className="text-xs" |
|
|
> |
|
|
<Download className="h-3 w-3 mr-1" /> |
|
|
{t('Export OPML')} |
|
|
</Button> |
|
|
<label> |
|
|
<Button |
|
|
variant="outline" |
|
|
size="sm" |
|
|
asChild |
|
|
className="text-xs cursor-pointer" |
|
|
> |
|
|
<span> |
|
|
<Upload className="h-3 w-3 mr-1" /> |
|
|
{t('Import OPML')} |
|
|
</span> |
|
|
</Button> |
|
|
<input |
|
|
type="file" |
|
|
accept=".opml,application/xml,text/xml" |
|
|
onChange={handleImportOpml} |
|
|
className="hidden" |
|
|
/> |
|
|
</label> |
|
|
</div> |
|
|
</div> |
|
|
<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 |
|
|
|
|
|
|