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.
676 lines
22 KiB
676 lines
22 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { config } from '../../services/nostr/config.js'; |
|
import FeedPost from './FeedPost.svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { onMount } from 'svelte'; |
|
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; |
|
import { getRecentFeedEvents } from '../../services/cache/event-cache.js'; |
|
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; |
|
import { browser } from '$app/environment'; |
|
|
|
interface Props { |
|
singleRelay?: string; |
|
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }; |
|
} |
|
|
|
let { singleRelay, filterResult = { type: null, value: null } }: Props = $props(); |
|
|
|
// Expose API for parent component via component reference |
|
// Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive |
|
export { loadOlderEvents, loadingMore, hasMoreEvents, waitingRoomEvents, loadWaitingRoomEvents }; |
|
|
|
// Core state |
|
let allEvents = $state<NostrEvent[]>([]); |
|
let loading = $state(true); |
|
let relayError = $state<string | null>(null); |
|
|
|
// Waiting room for new events |
|
let waitingRoomEvents = $state<NostrEvent[]>([]); |
|
|
|
// Pagination |
|
let oldestTimestamp = $state<number | null>(null); |
|
let loadingMore = $state(false); |
|
let hasMoreEvents = $state(true); |
|
|
|
// Subscription |
|
let subscriptionId: string | null = $state(null); |
|
let isMounted = $state(true); |
|
let initialLoadComplete = $state(false); |
|
let loadInProgress = $state(false); |
|
|
|
// Preloaded referenced events (e, a, q tags) - eventId -> referenced event |
|
let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map()); |
|
|
|
// Filtered events based on filterResult |
|
let filteredEvents = $derived.by(() => { |
|
if (!filterResult.value) { |
|
return allEvents; |
|
} |
|
|
|
const queryLower = filterResult.value.toLowerCase(); |
|
|
|
if (filterResult.type === 'pubkey') { |
|
// Filter by exact pubkey match |
|
return allEvents.filter((event: NostrEvent) => { |
|
if (event.pubkey.toLowerCase() === queryLower) return true; |
|
// Check p tags |
|
if (event.tags.some(tag => tag[0] === 'p' && tag[1]?.toLowerCase() === queryLower)) return true; |
|
// Check q tags |
|
if (event.tags.some(tag => tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase() === queryLower))) return true; |
|
return false; |
|
}); |
|
} else if (filterResult.type === 'text') { |
|
// Filter by text search in pubkey, p tags, q tags, and content |
|
return allEvents.filter((event: NostrEvent) => { |
|
const pubkeyMatch = event.pubkey.toLowerCase().includes(queryLower); |
|
const pTagMatch = event.tags.some(tag => |
|
tag[0] === 'p' && tag[1]?.toLowerCase().includes(queryLower) |
|
); |
|
const qTagMatch = event.tags.some(tag => |
|
tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase().includes(queryLower)) |
|
); |
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
return pubkeyMatch || pTagMatch || qTagMatch || contentMatch; |
|
}); |
|
} |
|
|
|
return allEvents; |
|
}); |
|
|
|
// Use filteredEvents for display |
|
let events = $derived(filteredEvents); |
|
|
|
// Get preloaded referenced event for a post (from e, a, or q tag) |
|
function getReferencedEventForPost(event: NostrEvent): NostrEvent | null { |
|
// Check q tag first |
|
const qTag = event.tags.find(t => t[0] === 'q' && t[1]); |
|
if (qTag && qTag[1]) { |
|
return preloadedReferencedEvents.get(qTag[1]) || null; |
|
} |
|
|
|
// Check e tag |
|
const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id); |
|
if (eTag && eTag[1]) { |
|
return preloadedReferencedEvents.get(eTag[1]) || null; |
|
} |
|
|
|
// Check a tag - need to match by kind+pubkey+d-tag |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]); |
|
if (aTag && aTag[1]) { |
|
const parts = aTag[1].split(':'); |
|
if (parts.length >= 2) { |
|
const kind = parseInt(parts[0]); |
|
const pubkey = parts[1]; |
|
const dTag = parts[2] || ''; |
|
|
|
// Find matching event in preloaded events |
|
for (const [eventId, refEvent] of preloadedReferencedEvents.entries()) { |
|
if (refEvent.kind === kind && refEvent.pubkey === pubkey) { |
|
if (dTag) { |
|
const eventDTag = refEvent.tags.find(t => t[0] === 'd' && t[1]); |
|
if (eventDTag && eventDTag[1] === dTag) { |
|
return refEvent; |
|
} |
|
} else { |
|
// No d-tag, just match kind and pubkey |
|
return refEvent; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
// Load waiting room events into feed |
|
function loadWaitingRoomEvents() { |
|
if (waitingRoomEvents.length === 0) return; |
|
|
|
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e])); |
|
for (const event of waitingRoomEvents) { |
|
eventMap.set(event.id, event); |
|
} |
|
|
|
allEvents = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); |
|
waitingRoomEvents = []; |
|
} |
|
|
|
// Load older events (pagination) |
|
// svelte-ignore non_reactive_update |
|
async function loadOlderEvents() { |
|
if (loadingMore || !hasMoreEvents) return; |
|
|
|
const untilTimestamp = oldestTimestamp ?? Math.floor(Date.now() / 1000); |
|
if (!untilTimestamp) return; |
|
|
|
loadingMore = true; |
|
try { |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(k => ({ |
|
kinds: [k], |
|
limit: config.feedLimit, |
|
until: untilTimestamp - 1 |
|
})); |
|
|
|
const fetched = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: 'relay-first', |
|
cacheResults: true, |
|
timeout: config.standardTimeout |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
const filtered = fetched.filter(e => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
if (filtered.length === 0) { |
|
hasMoreEvents = false; |
|
return; |
|
} |
|
|
|
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e])); |
|
for (const event of filtered) { |
|
eventMap.set(event.id, event); |
|
} |
|
|
|
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); |
|
allEvents = sorted; |
|
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at)); |
|
|
|
// Batch fetch referenced events for paginated events too |
|
await batchFetchReferencedEvents(filtered); |
|
} catch (error) { |
|
console.error('Error loading older events:', error); |
|
} finally { |
|
loadingMore = false; |
|
} |
|
} |
|
|
|
// Initial feed load |
|
async function loadFeed() { |
|
if (!isMounted || loadInProgress) return; |
|
|
|
loadInProgress = true; |
|
loading = true; |
|
relayError = null; |
|
|
|
try { |
|
// Load from cache first (fast) |
|
if (!singleRelay) { |
|
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD); |
|
const cached = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, config.feedLimit); |
|
const filtered = cached.filter(e => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
if (filtered.length > 0 && isMounted) { |
|
const unique = Array.from(new Map(filtered.map((e: NostrEvent) => [e.id, e])).values()); |
|
allEvents = unique.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); |
|
oldestTimestamp = Math.min(...allEvents.map((e: NostrEvent) => e.created_at)); |
|
loading = false; // Show cached content immediately |
|
} |
|
} |
|
|
|
// Fetch fresh data from relays (can be slow) |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
|
|
if (singleRelay) { |
|
const relay = await nostrClient.getRelay(singleRelay); |
|
if (!relay) { |
|
relayError = `Relay ${singleRelay} is unavailable.`; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit })); |
|
|
|
const fetched = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
singleRelay ? { |
|
useCache: false, |
|
cacheResults: false, |
|
timeout: config.singleRelayTimeout |
|
} : { |
|
useCache: 'relay-first', |
|
cacheResults: true, |
|
timeout: config.standardTimeout |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
const filtered = fetched.filter(e => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e])); |
|
for (const event of filtered) { |
|
eventMap.set(event.id, event); |
|
} |
|
|
|
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at); |
|
allEvents = sorted; |
|
|
|
if (sorted.length > 0) { |
|
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at)); |
|
} |
|
|
|
// Batch fetch referenced events (e, a, q tags) after main events are loaded |
|
await batchFetchReferencedEvents(sorted); |
|
} catch (error) { |
|
console.error('Error loading feed:', error); |
|
if (!events.length) { |
|
relayError = 'Failed to load feed.'; |
|
} |
|
} finally { |
|
loading = false; |
|
initialLoadComplete = true; |
|
loadInProgress = false; |
|
} |
|
} |
|
|
|
// Collect and batch fetch all referenced events from e, a, q tags |
|
async function batchFetchReferencedEvents(events: NostrEvent[]) { |
|
if (!isMounted || events.length === 0) return; |
|
|
|
const eventIds = new Set<string>(); // For e and q tags |
|
const aTagGroups = new Map<string, { kind: number; pubkey: string; dTag?: string }>(); // For a tags |
|
|
|
// Collect all references |
|
for (const event of events) { |
|
// Check q tag (quote) |
|
const qTag = event.tags.find(t => t[0] === 'q' && t[1]); |
|
if (qTag && qTag[1] && qTag[1] !== event.id) { |
|
eventIds.add(qTag[1]); |
|
} |
|
|
|
// Check e tag (reply) - skip if it's the event's own ID |
|
const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id); |
|
if (eTag && eTag[1]) { |
|
eventIds.add(eTag[1]); |
|
} |
|
|
|
// Check a tag (addressable event) |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]); |
|
if (aTag && aTag[1]) { |
|
const parts = aTag[1].split(':'); |
|
if (parts.length >= 2) { |
|
const kind = parseInt(parts[0]); |
|
const pubkey = parts[1]; |
|
const dTag = parts[2] || undefined; |
|
const groupKey = `${kind}:${pubkey}:${dTag || ''}`; |
|
if (!aTagGroups.has(groupKey)) { |
|
aTagGroups.set(groupKey, { kind, pubkey, dTag }); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Remove event IDs that are already in the feed (no need to fetch them) |
|
const feedEventIds = new Set(events.map(e => e.id)); |
|
const eventIdsToFetch = Array.from(eventIds).filter(id => !feedEventIds.has(id)); |
|
|
|
if (eventIdsToFetch.length === 0 && aTagGroups.size === 0) { |
|
return; // Nothing to fetch |
|
} |
|
|
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
const fetchedEvents = new Map<string, NostrEvent>(); |
|
|
|
try { |
|
// Fetch events by ID (e and q tags) in batches |
|
if (eventIdsToFetch.length > 0) { |
|
const batchSize = 100; |
|
for (let i = 0; i < eventIdsToFetch.length; i += batchSize) { |
|
const batch = eventIdsToFetch.slice(i, i + batchSize); |
|
const events = await nostrClient.fetchEvents( |
|
[{ ids: batch, limit: batch.length }], |
|
relays, |
|
{ |
|
useCache: true, |
|
cacheResults: true, |
|
priority: 'low', // Low priority - don't block main feed |
|
timeout: config.standardTimeout |
|
} |
|
); |
|
|
|
for (const event of events) { |
|
fetchedEvents.set(event.id, event); |
|
} |
|
} |
|
} |
|
|
|
// Fetch addressable events (a tags) - group by kind+pubkey+d-tag |
|
if (aTagGroups.size > 0) { |
|
const aTagFilters: any[] = []; |
|
const filterToATag = new Map<number, string>(); // filter index -> a-tag string |
|
|
|
for (const [groupKey, group] of aTagGroups.entries()) { |
|
const filter: any = { |
|
kinds: [group.kind], |
|
authors: [group.pubkey], |
|
limit: 100 |
|
}; |
|
|
|
if (group.dTag) { |
|
filter['#d'] = [group.dTag]; |
|
} |
|
|
|
const filterIndex = aTagFilters.length; |
|
aTagFilters.push(filter); |
|
filterToATag.set(filterIndex, groupKey); |
|
} |
|
|
|
if (aTagFilters.length > 0) { |
|
const aTagEvents = await nostrClient.fetchEvents( |
|
aTagFilters, |
|
relays, |
|
{ |
|
useCache: true, |
|
cacheResults: true, |
|
priority: 'low', |
|
timeout: config.standardTimeout |
|
} |
|
); |
|
|
|
// Map a-tag events back to their a-tag strings |
|
for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) { |
|
const filter = aTagFilters[filterIndex]; |
|
const groupKey = filterToATag.get(filterIndex); |
|
if (!groupKey) continue; |
|
|
|
const [, pubkey, dTag] = groupKey.split(':'); |
|
const kind = filter.kinds[0]; |
|
|
|
// Find matching events |
|
const matchingEvents = aTagEvents.filter(e => |
|
e.kind === kind && |
|
e.pubkey === pubkey && |
|
(!dTag || e.tags.find(t => t[0] === 'd' && t[1] === dTag)) |
|
); |
|
|
|
for (const event of matchingEvents) { |
|
// Store by event ID (will be matched by ReferencedEventPreview) |
|
fetchedEvents.set(event.id, event); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Update preloaded events map (merge with existing) |
|
if (fetchedEvents.size > 0 && isMounted) { |
|
const merged = new Map(preloadedReferencedEvents); |
|
for (const [id, event] of fetchedEvents.entries()) { |
|
merged.set(id, event); |
|
} |
|
preloadedReferencedEvents = merged; |
|
} |
|
} catch (error) { |
|
console.debug('[FeedPage] Error batch fetching referenced events:', error); |
|
// Don't block on errors - components will fetch individually if needed |
|
} |
|
} |
|
|
|
// Setup subscription (only adds to waiting room) |
|
function setupSubscription() { |
|
if (subscriptionId || singleRelay) return; |
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit })); |
|
|
|
subscriptionId = nostrClient.subscribe( |
|
filters, |
|
relays, |
|
(event: NostrEvent) => { |
|
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || !initialLoadComplete) return; |
|
|
|
// Add to waiting room if not already in feed or waiting room |
|
const eventIds = new Set([...allEvents.map((e: NostrEvent) => e.id), ...waitingRoomEvents.map((e: NostrEvent) => e.id)]); |
|
if (!eventIds.has(event.id)) { |
|
waitingRoomEvents = [...waitingRoomEvents, event].sort((a, b) => b.created_at - a.created_at); |
|
} |
|
}, |
|
() => {} |
|
); |
|
} |
|
|
|
onMount(() => { |
|
isMounted = true; |
|
loadInProgress = false; |
|
|
|
// Use a small delay to ensure previous page cleanup completes |
|
const initTimeout = setTimeout(() => { |
|
if (!isMounted) return; |
|
|
|
nostrClient.initialize().then(() => { |
|
if (isMounted && !loadInProgress) { |
|
loadFeed().then(() => { |
|
if (isMounted) { |
|
setupSubscription(); |
|
} |
|
}); |
|
} |
|
}); |
|
}, 50); |
|
|
|
// Register j/k navigation shortcuts |
|
if (browser) { |
|
const unregisterJ = keyboardShortcuts.register('j', (e) => { |
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) { |
|
return; // Don't interfere with typing |
|
} |
|
e.preventDefault(); |
|
navigateToNextPost(); |
|
return false; |
|
}); |
|
|
|
const unregisterK = keyboardShortcuts.register('k', (e) => { |
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) { |
|
return; // Don't interfere with typing |
|
} |
|
e.preventDefault(); |
|
navigateToPreviousPost(); |
|
return false; |
|
}); |
|
|
|
return () => { |
|
clearTimeout(initTimeout); |
|
isMounted = false; |
|
loadInProgress = false; |
|
if (subscriptionId) { |
|
nostrClient.unsubscribe(subscriptionId); |
|
subscriptionId = null; |
|
} |
|
unregisterJ(); |
|
unregisterK(); |
|
}; |
|
} |
|
|
|
return () => { |
|
clearTimeout(initTimeout); |
|
isMounted = false; |
|
loadInProgress = false; |
|
if (subscriptionId) { |
|
nostrClient.unsubscribe(subscriptionId); |
|
subscriptionId = null; |
|
} |
|
}; |
|
}); |
|
|
|
function navigateToNextPost() { |
|
const posts = Array.from(document.querySelectorAll('[data-post-id]')); |
|
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement)); |
|
const nextIndex = currentIndex < posts.length - 1 ? currentIndex + 1 : 0; |
|
if (posts[nextIndex]) { |
|
(posts[nextIndex] as HTMLElement).focus(); |
|
posts[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
|
} |
|
} |
|
|
|
function navigateToPreviousPost() { |
|
const posts = Array.from(document.querySelectorAll('[data-post-id]')); |
|
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement)); |
|
const prevIndex = currentIndex > 0 ? currentIndex - 1 : posts.length - 1; |
|
if (posts[prevIndex]) { |
|
(posts[prevIndex] as HTMLElement).focus(); |
|
posts[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
|
} |
|
} |
|
|
|
</script> |
|
|
|
<div class="feed-page"> |
|
{#if singleRelay} |
|
<div class="relay-info"> |
|
<p class="relay-info-text">Showing feed from: <code class="relay-url">{singleRelay}</code></p> |
|
</div> |
|
{/if} |
|
|
|
{#if loading} |
|
<div class="loading-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p> |
|
</div> |
|
{:else if relayError} |
|
<div class="error-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text error-message">{relayError}</p> |
|
</div> |
|
{:else if events.length === 0} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p> |
|
</div> |
|
{:else} |
|
<div class="feed-posts"> |
|
{#each events as event (event.id)} |
|
{@const referencedEvent = getReferencedEventForPost(event)} |
|
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} /> |
|
{/each} |
|
</div> |
|
|
|
<div class="load-more-section"> |
|
<button |
|
onclick={loadOlderEvents} |
|
disabled={loadingMore || !hasMoreEvents} |
|
class="see-more-events-btn" |
|
> |
|
{loadingMore ? 'Loading...' : 'See more events'} |
|
</button> |
|
</div> |
|
|
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.feed-page { |
|
max-width: 100%; |
|
} |
|
|
|
.loading-state, |
|
.empty-state, |
|
.error-state { |
|
padding: 2rem; |
|
text-align: center; |
|
} |
|
|
|
.error-message { |
|
font-weight: 600; |
|
color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .error-message { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.feed-posts { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.relay-info { |
|
margin-bottom: 1.5rem; |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
} |
|
|
|
:global(.dark) .relay-info { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.relay-info-text { |
|
margin: 0; |
|
font-size: 0.875rem; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .relay-info-text { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.relay-url { |
|
font-family: monospace; |
|
font-size: 0.875rem; |
|
background: var(--fog-post, #ffffff); |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .relay-url { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.see-more-events-btn { |
|
padding: 0.75rem 1.5rem; |
|
background: var(--fog-accent, #64748b); |
|
color: white; |
|
border: none; |
|
border-radius: 0.375rem; |
|
font-size: 0.875rem; |
|
font-weight: 500; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
.see-more-events-btn:hover:not(:disabled) { |
|
background: var(--fog-text, #475569); |
|
} |
|
|
|
.see-more-events-btn:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
:global(.dark) .see-more-events-btn { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .see-more-events-btn:hover:not(:disabled) { |
|
background: var(--fog-dark-text, #cbd5e1); |
|
} |
|
|
|
.load-more-section { |
|
padding: 2rem; |
|
text-align: center; |
|
} |
|
</style>
|
|
|