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.
821 lines
25 KiB
821 lines
25 KiB
<script lang="ts"> |
|
import Header from '../../lib/components/layout/Header.svelte'; |
|
import { sessionManager } from '../../lib/services/auth/session-manager.js'; |
|
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; |
|
import { config } from '../../lib/services/nostr/config.js'; |
|
import { cacheRSSFeed, getCachedRSSFeed, type RSSItem } from '../../lib/services/cache/rss-cache.js'; |
|
import { onMount } from 'svelte'; |
|
import { goto } from '$app/navigation'; |
|
import type { NostrEvent } from '../../lib/types/nostr.js'; |
|
import { KIND } from '../../lib/types/kind-lookup.js'; |
|
import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte'; |
|
import RSSCommentForm from '../../lib/modules/rss/RSSCommentForm.svelte'; |
|
import IconButton from '../../lib/components/ui/IconButton.svelte'; |
|
|
|
const RSS_FEED_KIND = 10895; |
|
|
|
|
|
let currentPubkey = $state<string | null>(null); |
|
let rssEvent = $state<NostrEvent | null>(null); |
|
let loading = $state(true); |
|
let loadingFeeds = $state(false); |
|
let rssItems = $state<RSSItem[]>([]); |
|
let feedErrors = $state<Map<string, string>>(new Map()); |
|
let feedsLoaded = $state(false); // Track if feeds have been loaded to prevent re-triggering |
|
let currentPage = $state(1); |
|
const itemsPerPage = 50; |
|
|
|
let subscribedFeeds = $derived.by(() => { |
|
if (!rssEvent) return []; |
|
return rssEvent.tags |
|
.filter(tag => tag[0] === 'u' && tag[1]) |
|
.map(tag => tag[1]); |
|
}); |
|
|
|
// Track the last feeds we loaded to detect changes |
|
let lastLoadedFeeds = $state<string[]>([]); |
|
|
|
// Computed: get items for current page |
|
let paginatedItems = $derived.by(() => { |
|
const start = (currentPage - 1) * itemsPerPage; |
|
const end = start + itemsPerPage; |
|
return rssItems.slice(start, end); |
|
}); |
|
|
|
// Computed: total pages |
|
let totalPages = $derived.by(() => Math.ceil(rssItems.length / itemsPerPage)); |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
const session = sessionManager.getSession(); |
|
if (!session) { |
|
goto('/login'); |
|
return; |
|
} |
|
|
|
currentPubkey = session.pubkey; |
|
await checkRssEvent(); |
|
}); |
|
|
|
$effect(() => { |
|
// Only load if: |
|
// 1. We have feeds to load |
|
// 2. We have an rssEvent |
|
// 3. We're not currently loading |
|
// 4. The feeds have changed (different URLs) or haven't been loaded yet |
|
const feedsChanged = JSON.stringify(subscribedFeeds) !== JSON.stringify(lastLoadedFeeds); |
|
if (subscribedFeeds.length > 0 && rssEvent && !loadingFeeds && (feedsChanged || !feedsLoaded)) { |
|
lastLoadedFeeds = [...subscribedFeeds]; |
|
loadRssFeeds(); |
|
} else if (subscribedFeeds.length === 0 && feedsLoaded) { |
|
// Reset if feeds were removed |
|
feedsLoaded = false; |
|
rssItems = []; |
|
feedErrors.clear(); |
|
lastLoadedFeeds = []; |
|
currentPage = 1; |
|
} |
|
}); |
|
|
|
// Reset to page 1 when items change significantly |
|
$effect(() => { |
|
if (rssItems.length > 0 && currentPage > totalPages) { |
|
currentPage = 1; |
|
} |
|
}); |
|
|
|
async function checkRssEvent() { |
|
if (!currentPubkey) return; |
|
|
|
// Load from cache first (fast - instant display) |
|
try { |
|
const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js'); |
|
const cachedEvents = await getRecentCachedEvents([RSS_FEED_KIND], 60 * 60 * 1000, 1); // 1 hour cache |
|
const cachedRssEvent = cachedEvents.find(e => e.pubkey === currentPubkey); |
|
|
|
if (cachedRssEvent) { |
|
// Only update if it's actually different (prevents unnecessary re-renders) |
|
if (!rssEvent || rssEvent.id !== cachedRssEvent.id) { |
|
rssEvent = cachedRssEvent; |
|
// Reset feeds loaded state when event changes |
|
feedsLoaded = false; |
|
lastLoadedFeeds = []; |
|
} |
|
loading = false; // Show cached content immediately |
|
} else { |
|
loading = true; // Only show loading if no cache |
|
} |
|
} catch (error) { |
|
// Cache error (non-critical) |
|
loading = true; // Show loading if cache check fails |
|
} |
|
|
|
try { |
|
const relays = relayManager.getProfileReadRelays(); |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [RSS_FEED_KIND], authors: [currentPubkey], limit: 1 }], |
|
relays, |
|
{ |
|
useCache: 'cache-first', // Already shown cache above, now stream updates |
|
cacheResults: true, |
|
timeout: config.standardTimeout |
|
} |
|
); |
|
|
|
if (events.length > 0) { |
|
const newRssEvent = events[0]; |
|
// Only update if it's actually different (prevents unnecessary re-renders) |
|
if (!rssEvent || rssEvent.id !== newRssEvent.id) { |
|
rssEvent = newRssEvent; |
|
// Reset feeds loaded state when event changes |
|
feedsLoaded = false; |
|
lastLoadedFeeds = []; |
|
currentPage = 1; // Reset to first page when event changes |
|
} |
|
} |
|
} catch (error) { |
|
// Failed to load RSS event |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
async function loadRssFeeds() { |
|
if (subscribedFeeds.length === 0 || loadingFeeds || feedsLoaded) return; |
|
|
|
loadingFeeds = true; |
|
feedErrors.clear(); |
|
const allItems: RSSItem[] = []; |
|
|
|
try { |
|
// First, try to load from cache |
|
const cachePromises = subscribedFeeds.map(async (feedUrl) => { |
|
const cached = await getCachedRSSFeed(feedUrl); |
|
if (cached && cached.length > 0) { |
|
return { feedUrl, items: cached, fromCache: true }; |
|
} |
|
return { feedUrl, items: null, fromCache: false }; |
|
}); |
|
|
|
const cacheResults = await Promise.all(cachePromises); |
|
|
|
// Add cached items immediately |
|
for (const result of cacheResults) { |
|
if (result.items) { |
|
allItems.push(...result.items); |
|
} |
|
} |
|
|
|
// If we have cached items, show them immediately |
|
if (allItems.length > 0) { |
|
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); |
|
rssItems = allItems; |
|
feedsLoaded = true; |
|
} |
|
|
|
// Then fetch fresh data in background for feeds that need updating |
|
const feedsToFetch = cacheResults |
|
.filter(result => !result.fromCache || !result.items) |
|
.map(result => result.feedUrl); |
|
|
|
if (feedsToFetch.length > 0) { |
|
const fetchPromises = feedsToFetch.map(async (feedUrl) => { |
|
try { |
|
// Add timeout to prevent hanging requests |
|
const timeoutPromise = new Promise<never>((_, reject) => { |
|
setTimeout(() => reject(new Error('Request timeout')), 10000); // 10 second timeout |
|
}); |
|
|
|
const items = await Promise.race([ |
|
fetchRssFeed(feedUrl), |
|
timeoutPromise |
|
]); |
|
|
|
// Cache the fetched items |
|
await cacheRSSFeed(feedUrl, items); |
|
return { feedUrl, items }; |
|
} catch (error) { |
|
// Only log non-CORS errors to avoid console spam |
|
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch feed'; |
|
// Feed fetch failed (errors logged only for non-CORS/timeout issues) |
|
feedErrors.set(feedUrl, errorMessage); |
|
return { feedUrl, items: [] }; |
|
} |
|
}); |
|
|
|
const fetchResults = await Promise.all(fetchPromises); |
|
|
|
// Merge fresh items with cached items |
|
const freshItems: RSSItem[] = []; |
|
for (const result of fetchResults) { |
|
freshItems.push(...result.items); |
|
} |
|
|
|
// Combine with existing items and deduplicate by link |
|
const itemMap = new Map<string, RSSItem>(); |
|
for (const item of allItems) { |
|
itemMap.set(item.link, item); |
|
} |
|
for (const item of freshItems) { |
|
itemMap.set(item.link, item); |
|
} |
|
|
|
// Sort by date (newest first) |
|
const combinedItems = Array.from(itemMap.values()); |
|
combinedItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); |
|
|
|
rssItems = combinedItems; |
|
currentPage = 1; // Reset to first page when items update |
|
} |
|
|
|
feedsLoaded = true; |
|
} catch (error) { |
|
// Failed to load RSS feeds |
|
} finally { |
|
loadingFeeds = false; |
|
} |
|
} |
|
|
|
async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> { |
|
// Always use a CORS proxy to avoid CORS errors |
|
// Direct fetch will fail for most RSS feeds due to CORS restrictions |
|
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`; |
|
const response = await fetch(proxyUrl); |
|
|
|
if (!response.ok) { |
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
|
} |
|
|
|
const xmlText = await response.text(); |
|
const parser = new DOMParser(); |
|
const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); |
|
|
|
// Check for parsing errors |
|
const parserError = xmlDoc.querySelector('parsererror'); |
|
if (parserError) { |
|
throw new Error('Failed to parse RSS XML'); |
|
} |
|
|
|
// Get feed title |
|
const feedTitle = xmlDoc.querySelector('channel > title')?.textContent?.trim() || |
|
xmlDoc.querySelector('title')?.textContent?.trim(); |
|
|
|
// Parse items (handle both RSS 2.0 and Atom formats) |
|
const items: RSSItem[] = []; |
|
|
|
// RSS 2.0 format |
|
const rssItems = xmlDoc.querySelectorAll('item'); |
|
rssItems.forEach((item) => { |
|
const title = item.querySelector('title')?.textContent?.trim() || 'Untitled'; |
|
const link = item.querySelector('link')?.textContent?.trim() || ''; |
|
const description = item.querySelector('description')?.textContent?.trim() || ''; |
|
const pubDateStr = item.querySelector('pubDate')?.textContent?.trim() || ''; |
|
|
|
let pubDate = new Date(); |
|
if (pubDateStr) { |
|
const parsed = new Date(pubDateStr); |
|
if (!isNaN(parsed.getTime())) { |
|
pubDate = parsed; |
|
} |
|
} |
|
|
|
items.push({ |
|
title, |
|
link, |
|
description, |
|
pubDate, |
|
feedUrl, |
|
feedTitle |
|
}); |
|
}); |
|
|
|
// Atom format (if no RSS items found) |
|
if (items.length === 0) { |
|
const atomEntries = xmlDoc.querySelectorAll('entry'); |
|
atomEntries.forEach((entry) => { |
|
const title = entry.querySelector('title')?.textContent?.trim() || 'Untitled'; |
|
const linkEl = entry.querySelector('link'); |
|
const link = linkEl?.getAttribute('href') || linkEl?.textContent?.trim() || ''; |
|
const description = entry.querySelector('summary')?.textContent?.trim() || |
|
entry.querySelector('content')?.textContent?.trim() || ''; |
|
const pubDateStr = entry.querySelector('published')?.textContent?.trim() || |
|
entry.querySelector('updated')?.textContent?.trim() || ''; |
|
|
|
let pubDate = new Date(); |
|
if (pubDateStr) { |
|
const parsed = new Date(pubDateStr); |
|
if (!isNaN(parsed.getTime())) { |
|
pubDate = parsed; |
|
} |
|
} |
|
|
|
items.push({ |
|
title, |
|
link, |
|
description, |
|
pubDate, |
|
feedUrl, |
|
feedTitle |
|
}); |
|
}); |
|
} |
|
|
|
return items; |
|
} |
|
|
|
function getRelativeTime(date: Date): string { |
|
const now = new Date(); |
|
const diff = now.getTime() - date.getTime(); |
|
const hours = Math.floor(diff / (1000 * 60 * 60)); |
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); |
|
const minutes = Math.floor(diff / (1000 * 60)); |
|
|
|
if (days > 0) return `${days}d ago`; |
|
if (hours > 0) return `${hours}h ago`; |
|
if (minutes > 0) return `${minutes}m ago`; |
|
return 'just now'; |
|
} |
|
|
|
function handleCreateRss() { |
|
goto(`/write?kind=${RSS_FEED_KIND}`); |
|
} |
|
|
|
// Track which RSS item has reply form open |
|
let openReplyFormFor = $state<string | null>(null); // URL of the item |
|
|
|
function toggleReplyForm(itemLink: string) { |
|
if (openReplyFormFor === itemLink) { |
|
openReplyFormFor = null; |
|
} else { |
|
openReplyFormFor = itemLink; |
|
} |
|
} |
|
|
|
// Generate a deterministic thread ID from the URL (for RSS items, we use the URL as threadId) |
|
function getThreadIdFromUrl(url: string): string { |
|
// Use the URL as the threadId for RSS items |
|
return url; |
|
} |
|
</script> |
|
|
|
<Header /> |
|
|
|
<main class="container mx-auto px-4 py-8"> |
|
<div class="rss-page"> |
|
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/RSS</h1> |
|
|
|
{#if loading} |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading...</p> |
|
{:else if !rssEvent} |
|
<div class="rss-setup"> |
|
<p class="mb-4 text-fog-text dark:text-fog-dark-text"> |
|
You don't have an RSS feed subscription event yet. Create a kind {RSS_FEED_KIND} event to subscribe to external RSS feeds. |
|
</p> |
|
<p class="mb-4 text-sm text-fog-text-light dark:text-fog-dark-text-light"> |
|
Add "u" tags with RSS feed URLs to subscribe to external feeds. |
|
</p> |
|
<button |
|
onclick={handleCreateRss} |
|
class="create-rss-button" |
|
> |
|
Create RSS Feed Subscription Event |
|
</button> |
|
</div> |
|
{:else} |
|
<div class="rss-content"> |
|
<div class="rss-info"> |
|
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">Subscribed RSS Feeds</h2> |
|
{#if subscribedFeeds.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light"> |
|
No RSS feeds subscribed. Edit your kind {RSS_FEED_KIND} event to add "u" tags with RSS feed URLs. |
|
</p> |
|
{:else} |
|
<ul class="rss-feed-list"> |
|
{#each subscribedFeeds as feedUrl} |
|
<li class="rss-feed-item"> |
|
<a |
|
href={feedUrl} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
class="rss-feed-link" |
|
> |
|
{feedUrl} |
|
</a> |
|
{#if feedErrors.has(feedUrl)} |
|
<span class="feed-error">Error: {feedErrors.get(feedUrl)}</span> |
|
{/if} |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
<div class="mt-6 pt-6 border-t border-fog-border dark:border-fog-dark-border"> |
|
<p class="mb-2 text-sm text-fog-text dark:text-fog-dark-text"> |
|
To add or remove feeds, edit your kind {RSS_FEED_KIND} event. |
|
</p> |
|
{#if rssEvent} |
|
<a |
|
href="/write" |
|
class="edit-rss-button" |
|
onclick={(e) => { |
|
if (rssEvent) { |
|
const cloneData = { |
|
kind: rssEvent.kind, |
|
content: rssEvent.content, |
|
tags: rssEvent.tags, |
|
isClone: false |
|
}; |
|
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(cloneData)); |
|
} |
|
}} |
|
> |
|
Edit RSS Feed Event |
|
</a> |
|
{:else} |
|
<a |
|
href="/write?kind={RSS_FEED_KIND}" |
|
class="edit-rss-button" |
|
> |
|
Create RSS Feed Event |
|
</a> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if subscribedFeeds.length > 0} |
|
<div class="rss-items-section"> |
|
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">RSS Feed Items</h2> |
|
{#if loadingFeeds} |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading RSS feeds...</p> |
|
{:else if rssItems.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No RSS items found.</p> |
|
{:else} |
|
<div class="rss-items-info mb-4"> |
|
<p class="text-fog-text dark:text-fog-dark-text text-sm"> |
|
Showing {paginatedItems.length} of {rssItems.length} items |
|
</p> |
|
</div> |
|
|
|
{#if totalPages > 1} |
|
<div class="pagination pagination-top mb-4"> |
|
<button |
|
class="pagination-button" |
|
disabled={currentPage === 1} |
|
onclick={() => { |
|
if (currentPage > 1) currentPage--; |
|
}} |
|
aria-label="Previous page" |
|
> |
|
Previous |
|
</button> |
|
|
|
<div class="pagination-info"> |
|
<span class="text-fog-text dark:text-fog-dark-text"> |
|
Page {currentPage} of {totalPages} |
|
</span> |
|
</div> |
|
|
|
<button |
|
class="pagination-button" |
|
disabled={currentPage === totalPages} |
|
onclick={() => { |
|
if (currentPage < totalPages) currentPage++; |
|
}} |
|
aria-label="Next page" |
|
> |
|
Next |
|
</button> |
|
</div> |
|
{/if} |
|
|
|
<div class="rss-items"> |
|
{#each paginatedItems as item (item.link + item.pubDate.getTime())} |
|
<article class="rss-item"> |
|
<div class="rss-item-header"> |
|
<div class="rss-item-meta"> |
|
{#if item.feedTitle} |
|
<span class="rss-feed-badge">{item.feedTitle}</span> |
|
{/if} |
|
<span class="rss-item-time">{getRelativeTime(item.pubDate)}</span> |
|
</div> |
|
</div> |
|
<h3 class="rss-item-title"> |
|
<a |
|
href={item.link} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
class="rss-item-link" |
|
> |
|
{item.title} |
|
</a> |
|
</h3> |
|
{#if item.description} |
|
<div class="rss-item-description"> |
|
<MarkdownRenderer content={item.description} /> |
|
</div> |
|
{/if} |
|
{#if sessionManager.isLoggedIn()} |
|
<div class="rss-item-actions flex items-center gap-2 mt-2"> |
|
<IconButton |
|
icon="message-square" |
|
label="Reply" |
|
size={16} |
|
onclick={() => toggleReplyForm(item.link)} |
|
/> |
|
</div> |
|
{/if} |
|
{#if openReplyFormFor === item.link} |
|
<div class="rss-item-reply-form mt-4"> |
|
<RSSCommentForm |
|
url={item.link} |
|
onPublished={() => { |
|
openReplyFormFor = null; |
|
}} |
|
onCancel={() => { |
|
openReplyFormFor = null; |
|
}} |
|
/> |
|
</div> |
|
{/if} |
|
</article> |
|
{/each} |
|
</div> |
|
|
|
{#if totalPages > 1} |
|
<div class="pagination pagination-bottom mt-4"> |
|
<button |
|
class="pagination-button" |
|
disabled={currentPage === 1} |
|
onclick={() => { |
|
if (currentPage > 1) currentPage--; |
|
}} |
|
aria-label="Previous page" |
|
> |
|
Previous |
|
</button> |
|
|
|
<div class="pagination-info"> |
|
<span class="text-fog-text dark:text-fog-dark-text"> |
|
Page {currentPage} of {totalPages} |
|
</span> |
|
</div> |
|
|
|
<button |
|
class="pagination-button" |
|
disabled={currentPage === totalPages} |
|
onclick={() => { |
|
if (currentPage < totalPages) currentPage++; |
|
}} |
|
aria-label="Next page" |
|
> |
|
Next |
|
</button> |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</main> |
|
|
|
<style> |
|
.rss-page { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
padding: 0 1rem; |
|
} |
|
|
|
.rss-setup { |
|
padding: 2rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
} |
|
|
|
:global(.dark) .rss-setup { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
|
|
.rss-info { |
|
padding: 2rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
} |
|
|
|
:global(.dark) .rss-info { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.rss-feed-list { |
|
list-style: none; |
|
padding: 0; |
|
margin: 0; |
|
} |
|
|
|
.rss-feed-item { |
|
padding: 0.75rem; |
|
margin-bottom: 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
} |
|
|
|
:global(.dark) .rss-feed-item { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.rss-feed-link { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: none; |
|
word-break: break-all; |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .rss-feed-link { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.rss-feed-link:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.feed-error { |
|
display: block; |
|
margin-top: 0.5rem; |
|
color: var(--fog-danger, #dc2626); |
|
font-size: 0.75rem; |
|
} |
|
|
|
:global(.dark) .feed-error { |
|
color: var(--fog-dark-danger, #ef4444); |
|
} |
|
|
|
.rss-content { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 2rem; |
|
} |
|
|
|
.rss-items-section { |
|
padding: 2rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
} |
|
|
|
:global(.dark) .rss-items-section { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.rss-items { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.rss-item { |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
} |
|
|
|
:global(.dark) .rss-item { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.rss-item-header { |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
.rss-item-meta { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.rss-feed-badge { |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-accent, #64748b); |
|
color: var(--fog-text, #f1f5f9); |
|
border-radius: 0.25rem; |
|
font-size: 0.75rem; |
|
font-weight: 500; |
|
} |
|
|
|
:global(.dark) .rss-feed-badge { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.rss-item-time { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .rss-item-time { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.rss-item-title { |
|
margin: 0 0 0.5rem 0; |
|
font-size: 1.125rem; |
|
font-weight: 600; |
|
} |
|
|
|
.rss-item-link { |
|
color: var(--fog-text, #475569); |
|
text-decoration: none; |
|
} |
|
|
|
:global(.dark) .rss-item-link { |
|
color: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
.rss-item-link:hover { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.dark) .rss-item-link:hover { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.rss-item-description { |
|
color: var(--fog-text, #475569); |
|
font-size: 0.875rem; |
|
line-height: 1.6; |
|
} |
|
|
|
:global(.dark) .rss-item-description { |
|
color: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
.rss-items-info { |
|
padding: 0.5rem 0; |
|
} |
|
|
|
.pagination { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
gap: 1rem; |
|
margin-top: 2rem; |
|
margin-bottom: 2rem; |
|
} |
|
|
|
.pagination-top { |
|
margin-top: 0; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.pagination-bottom { |
|
margin-top: 2rem; |
|
} |
|
|
|
.pagination-button { |
|
padding: 0.5rem 1rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
background: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1e293b); |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
.pagination-button:hover:not(:disabled) { |
|
background: var(--fog-highlight, #f1f5f9); |
|
border-color: var(--fog-accent, #94a3b8); |
|
} |
|
|
|
.pagination-button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
:global(.dark) .pagination-button { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .pagination-button:hover:not(:disabled) { |
|
background: var(--fog-dark-highlight, #475569); |
|
border-color: var(--fog-dark-accent, #64748b); |
|
} |
|
|
|
.pagination-info { |
|
min-width: 120px; |
|
text-align: center; |
|
} |
|
|
|
</style>
|
|
|