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.
722 lines
24 KiB
722 lines
24 KiB
<script lang="ts"> |
|
import { indexKind } from "$lib/consts"; |
|
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; |
|
import { filterValidIndexEvents, debounceAsync } from "$lib/utils"; |
|
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; |
|
import ArticleHeader from "./PublicationHeader.svelte"; |
|
import { onMount, onDestroy } from "svelte"; |
|
import { |
|
getMatchingTags, |
|
toNpub, |
|
} from "$lib/utils/nostrUtils"; |
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
|
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import { searchCache } from "$lib/utils/searchCache"; |
|
import { indexEventCache } from "$lib/utils/indexEventCache"; |
|
import { isValidNip05Address } from "$lib/utils/search_utility"; |
|
import { userStore } from "$lib/stores/userStore.ts"; |
|
import { nip19 } from "nostr-tools"; |
|
|
|
const props = $props<{ |
|
searchQuery?: string; |
|
showOnlyMyPublications?: boolean; |
|
onEventCountUpdate?: (counts: { displayed: number; total: number }) => void; |
|
}>(); |
|
|
|
const ndk = getNdkContext(); |
|
|
|
// Component state |
|
let eventsInView: NDKEvent[] = $state([]); |
|
let loadingMore: boolean = $state(false); |
|
let endOfFeed: boolean = $state(false); |
|
let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>({}); |
|
let loading: boolean = $state(true); |
|
let hasInitialized = $state(false); |
|
let fallbackTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let gridContainer: HTMLElement; |
|
|
|
// Relay management |
|
let allRelays: string[] = $state([]); |
|
|
|
// Event management |
|
let allIndexEvents: NDKEvent[] = $state([]); |
|
|
|
// Calculate the number of columns based on window width |
|
let columnCount = $state(1); |
|
let publicationsToDisplay = $state(10); |
|
|
|
// Update column count and publications when window resizes |
|
$effect(() => { |
|
if (typeof window !== 'undefined') { |
|
const width = window.innerWidth; |
|
let newColumnCount = 1; |
|
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4 |
|
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3 |
|
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2 |
|
|
|
if (columnCount !== newColumnCount) { |
|
columnCount = newColumnCount; |
|
publicationsToDisplay = newColumnCount * 10; |
|
|
|
// Update the view immediately when column count changes |
|
if (allIndexEvents.length > 0) { |
|
let source = allIndexEvents; |
|
|
|
// Apply user filter first |
|
source = filterEventsByUser(source); |
|
|
|
// Then apply search filter if query exists |
|
if (props.searchQuery?.trim()) { |
|
source = filterEventsBySearch(source); |
|
} |
|
|
|
eventsInView = source.slice(0, publicationsToDisplay); |
|
endOfFeed = eventsInView.length >= source.length; |
|
} |
|
} |
|
} |
|
}); |
|
|
|
// Initialize relays and fetch events |
|
// AI-NOTE: This function is called when the component mounts and when relay configuration changes |
|
// It ensures that events are fetched from the current set of active relays |
|
async function initializeAndFetch() { |
|
if (!ndk) { |
|
console.debug('[PublicationFeed] No NDK instance available'); |
|
return; |
|
} |
|
|
|
// Get relays from active stores |
|
const inboxRelays = $activeInboxRelays; |
|
const outboxRelays = $activeOutboxRelays; |
|
const newRelays = [...inboxRelays, ...outboxRelays]; |
|
|
|
console.debug('[PublicationFeed] Available relays:', { |
|
inboxCount: inboxRelays.length, |
|
outboxCount: outboxRelays.length, |
|
totalCount: newRelays.length, |
|
relays: newRelays |
|
}); |
|
|
|
if (newRelays.length === 0) { |
|
console.debug('[PublicationFeed] No relays available, waiting...'); |
|
// Set up a retry mechanism when relays become available |
|
const unsubscribe = activeInboxRelays.subscribe((relays) => { |
|
if (relays.length > 0 && !hasInitialized) { |
|
console.debug('[PublicationFeed] Relays now available, retrying initialization'); |
|
unsubscribe(); |
|
setTimeout(() => { |
|
hasInitialized = true; |
|
initializeAndFetch(); |
|
}, 1000); |
|
} |
|
}); |
|
return; |
|
} |
|
|
|
// Update allRelays if different |
|
const currentRelaysString = allRelays.sort().join(','); |
|
const newRelaysString = newRelays.sort().join(','); |
|
|
|
if (currentRelaysString !== newRelaysString) { |
|
allRelays = newRelays; |
|
console.debug('[PublicationFeed] Relays updated, fetching events'); |
|
await fetchAllIndexEventsFromRelays(); |
|
} |
|
} |
|
|
|
// Watch for relay store changes and user authentication state |
|
$effect(() => { |
|
const inboxRelays = $activeInboxRelays; |
|
const outboxRelays = $activeOutboxRelays; |
|
const newRelays = [...inboxRelays, ...outboxRelays]; |
|
const userState = $userStore; |
|
|
|
if (newRelays.length > 0 && !hasInitialized) { |
|
console.debug('[PublicationFeed] Relays available, initializing'); |
|
hasInitialized = true; |
|
if (fallbackTimeout) { |
|
clearTimeout(fallbackTimeout); |
|
fallbackTimeout = null; |
|
} |
|
setTimeout(() => initializeAndFetch(), 0); |
|
} else if (newRelays.length === 0 && !hasInitialized) { |
|
console.debug('[PublicationFeed] No relays available, setting up fallback'); |
|
if (!fallbackTimeout) { |
|
fallbackTimeout = setTimeout(() => { |
|
console.debug('[PublicationFeed] Fallback timeout reached, retrying'); |
|
hasInitialized = true; |
|
initializeAndFetch(); |
|
}, 3000); |
|
} |
|
} else if (hasInitialized && newRelays.length > 0) { |
|
// AI-NOTE: Re-fetch events when user authentication state changes or relays are updated |
|
// This ensures that when a user logs in and their relays are loaded, we fetch events from those relays |
|
const currentRelaysString = allRelays.sort().join(','); |
|
const newRelaysString = newRelays.sort().join(','); |
|
|
|
if (currentRelaysString !== newRelaysString) { |
|
console.debug('[PublicationFeed] Relay configuration changed, re-fetching events'); |
|
// Clear cache to force fresh fetch from new relays |
|
indexEventCache.clear(); |
|
setTimeout(() => initializeAndFetch(), 0); |
|
} |
|
} |
|
}); |
|
|
|
async function fetchAllIndexEventsFromRelays() { |
|
console.debug('[PublicationFeed] fetchAllIndexEventsFromRelays called with relays:', { |
|
allRelaysCount: allRelays.length, |
|
allRelays: allRelays |
|
}); |
|
|
|
if (!ndk) { |
|
console.error('[PublicationFeed] No NDK instance available'); |
|
loading = false; |
|
return; |
|
} |
|
|
|
if (allRelays.length === 0) { |
|
console.debug('[PublicationFeed] No relays available for fetching'); |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Check cache first |
|
const cachedEvents = indexEventCache.get(allRelays); |
|
if (cachedEvents) { |
|
console.log( |
|
`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`, |
|
); |
|
allIndexEvents = cachedEvents; |
|
eventsInView = allIndexEvents.slice(0, publicationsToDisplay); |
|
endOfFeed = allIndexEvents.length <= publicationsToDisplay; |
|
loading = false; |
|
return; |
|
} |
|
|
|
loading = true; |
|
relayStatuses = Object.fromEntries( |
|
allRelays.map((r: string) => [r, "pending"]), |
|
); |
|
let allEvents: NDKEvent[] = []; |
|
const eventMap = new Map<string, NDKEvent>(); |
|
|
|
// Helper to fetch from a single relay with timeout |
|
async function fetchFromRelay(relay: string): Promise<void> { |
|
try { |
|
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`); |
|
|
|
// Use WebSocketPool to get a pooled connection |
|
const ws = await WebSocketPool.instance.acquire(relay); |
|
const subId = crypto.randomUUID(); |
|
|
|
// Create a promise that resolves with the events |
|
const eventPromise = new Promise<Set<NDKEvent>>((resolve, reject) => { |
|
const events = new Set<NDKEvent>(); |
|
|
|
const messageHandler = (ev: MessageEvent) => { |
|
try { |
|
const data = JSON.parse(ev.data); |
|
|
|
if (data[0] === "EVENT" && data[1] === subId) { |
|
const event = new NDKEvent(ndk, data[2]); |
|
events.add(event); |
|
} else if (data[0] === "EOSE" && data[1] === subId) { |
|
resolve(events); |
|
} |
|
} catch (error) { |
|
console.error(`[PublicationFeed] Error parsing message from ${relay}:`, error); |
|
} |
|
}; |
|
|
|
const errorHandler = (ev: Event) => { |
|
reject(new Error(`WebSocket error for ${relay}: ${ev}`)); |
|
}; |
|
|
|
ws.addEventListener("message", messageHandler); |
|
ws.addEventListener("error", errorHandler); |
|
|
|
// Send the subscription request |
|
ws.send(JSON.stringify([ |
|
"REQ", |
|
subId, |
|
{ kinds: [indexKind], limit: 1000 } |
|
])); |
|
|
|
// Set up cleanup |
|
setTimeout(() => { |
|
ws.removeEventListener("message", messageHandler); |
|
ws.removeEventListener("error", errorHandler); |
|
WebSocketPool.instance.release(ws); |
|
resolve(events); |
|
}, 5000); |
|
}); |
|
|
|
let eventSet = await eventPromise; |
|
|
|
console.debug(`[PublicationFeed] Raw events from ${relay}:`, eventSet.size); |
|
eventSet = filterValidIndexEvents(eventSet); |
|
console.debug(`[PublicationFeed] Valid events from ${relay}:`, eventSet.size); |
|
|
|
relayStatuses = { ...relayStatuses, [relay]: "found" }; |
|
|
|
// Add new events to the map and update the view immediately |
|
const newEvents: NDKEvent[] = []; |
|
for (const event of eventSet) { |
|
const tagAddress = event.tagAddress(); |
|
if (!eventMap.has(tagAddress)) { |
|
eventMap.set(tagAddress, event); |
|
newEvents.push(event); |
|
} |
|
} |
|
|
|
if (newEvents.length > 0) { |
|
// Update allIndexEvents with new events |
|
allIndexEvents = Array.from(eventMap.values()); |
|
// Sort by created_at descending |
|
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); |
|
|
|
// Update the view immediately with new events |
|
eventsInView = allIndexEvents.slice(0, publicationsToDisplay); |
|
endOfFeed = allIndexEvents.length <= publicationsToDisplay; |
|
|
|
console.debug(`[PublicationFeed] Updated view with ${newEvents.length} new events from ${relay}, total: ${allIndexEvents.length}`); |
|
} |
|
} catch (err) { |
|
console.error(`[PublicationFeed] Error fetching from relay ${relay}:`, err); |
|
relayStatuses = { ...relayStatuses, [relay]: "notfound" }; |
|
} |
|
} |
|
|
|
// Fetch from all relays in parallel, return events as they arrive |
|
console.debug(`[PublicationFeed] Starting fetch from ${allRelays.length} relays`); |
|
|
|
// Start all relay fetches in parallel |
|
const fetchPromises = allRelays.map(fetchFromRelay); |
|
|
|
// Wait for all to complete (but events are shown as they arrive) |
|
await Promise.allSettled(fetchPromises); |
|
|
|
console.debug(`[PublicationFeed] All relays completed, final event count:`, allIndexEvents.length); |
|
|
|
// Cache the fetched events |
|
indexEventCache.set(allRelays, allIndexEvents); |
|
|
|
// Final update to ensure we have the latest view |
|
eventsInView = allIndexEvents.slice(0, publicationsToDisplay); |
|
endOfFeed = allIndexEvents.length <= publicationsToDisplay; |
|
loading = false; |
|
} |
|
|
|
// Function to convert various Nostr identifiers to npub using the utility function |
|
const convertToNpub = (input: string): string | null => { |
|
const result = toNpub(input); |
|
if (!result) { |
|
console.debug("[PublicationFeed] Failed to convert to npub:", input); |
|
} |
|
return result; |
|
}; |
|
|
|
// Function to filter events by npub (author or p tags) |
|
const filterEventsByNpub = (events: NDKEvent[], npub: string): NDKEvent[] => { |
|
try { |
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
console.debug("[PublicationFeed] Invalid npub format:", npub); |
|
return events; |
|
} |
|
|
|
const pubkey = decoded.data.toLowerCase(); |
|
console.debug("[PublicationFeed] Filtering events for npub:", npub, "pubkey:", pubkey); |
|
|
|
const filtered = events.filter((event) => { |
|
// Check if user is the author of the event |
|
const eventPubkey = event.pubkey.toLowerCase(); |
|
const isAuthor = eventPubkey === pubkey; |
|
|
|
// Check if user is listed in "p" tags (participants/contributors) |
|
const pTags = getMatchingTags(event, "p"); |
|
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === pubkey); |
|
|
|
const matches = isAuthor || isInPTags; |
|
|
|
if (matches) { |
|
console.debug("[PublicationFeed] Event matches npub filter:", { |
|
id: event.id, |
|
eventPubkey, |
|
searchPubkey: pubkey, |
|
isAuthor, |
|
isInPTags, |
|
pTags: pTags.map(tag => tag[1]) |
|
}); |
|
} |
|
return matches; |
|
}); |
|
|
|
console.debug("[PublicationFeed] Events after npub filtering:", filtered.length); |
|
return filtered; |
|
} catch (error) { |
|
console.debug("[PublicationFeed] Error filtering by npub:", npub, error); |
|
return events; |
|
} |
|
}; |
|
|
|
// Function to filter events by current user's pubkey |
|
const filterEventsByUser = (events: NDKEvent[]) => { |
|
if (!props.showOnlyMyPublications) return events; |
|
|
|
const currentUser = $userStore; |
|
if (!currentUser.signedIn || !currentUser.pubkey) { |
|
console.debug("[PublicationFeed] User not signed in or no pubkey, showing all events"); |
|
return events; |
|
} |
|
|
|
const userPubkey = currentUser.pubkey.toLowerCase(); |
|
console.debug("[PublicationFeed] Filtering events for user:", userPubkey); |
|
|
|
const filtered = events.filter((event) => { |
|
// Check if user is the author of the event |
|
const eventPubkey = event.pubkey.toLowerCase(); |
|
const isAuthor = eventPubkey === userPubkey; |
|
|
|
// Check if user is listed in "p" tags (participants/contributors) |
|
const pTags = getMatchingTags(event, "p"); |
|
const isInPTags = pTags.some(tag => tag[1]?.toLowerCase() === userPubkey); |
|
|
|
const matches = isAuthor || isInPTags; |
|
|
|
if (matches) { |
|
console.debug("[PublicationFeed] Event matches user filter:", { |
|
id: event.id, |
|
eventPubkey, |
|
userPubkey, |
|
isAuthor, |
|
isInPTags, |
|
pTags: pTags.map(tag => tag[1]) |
|
}); |
|
} |
|
return matches; |
|
}); |
|
|
|
console.debug("[PublicationFeed] Events after user filtering:", filtered.length); |
|
return filtered; |
|
}; |
|
|
|
// Function to filter events based on search query |
|
const filterEventsBySearch = (events: NDKEvent[]) => { |
|
if (!props.searchQuery) return events; |
|
const query = props.searchQuery.trim(); |
|
console.debug( |
|
"[PublicationFeed] Filtering events with query:", |
|
query, |
|
"Total events before filter:", |
|
events.length, |
|
); |
|
|
|
// Check cache first for publication search |
|
const cachedResult = searchCache.get("publication", query); |
|
if (cachedResult) { |
|
console.log( |
|
`[PublicationFeed] Using cached results for publication search: ${query}`, |
|
); |
|
return cachedResult.events; |
|
} |
|
|
|
// AI-NOTE: Check if the query is a Nostr identifier (npub, hex, nprofile) |
|
const npub = convertToNpub(query); |
|
if (npub) { |
|
console.debug("[PublicationFeed] Query is a Nostr identifier, filtering by npub:", npub); |
|
const filtered = filterEventsByNpub(events, npub); |
|
|
|
// Cache the filtered results |
|
const result = { |
|
events: filtered, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: new Set<string>(), |
|
addresses: new Set<string>(), |
|
searchType: "publication", |
|
searchTerm: query, |
|
}; |
|
searchCache.set("publication", query, result); |
|
|
|
return filtered; |
|
} |
|
|
|
// Check if the query is a NIP-05 address |
|
const isNip05Query = isValidNip05Address(query); |
|
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query); |
|
|
|
const filtered = events.filter((event) => { |
|
const title = |
|
getMatchingTags(event, "title")[0]?.[1]?.toLowerCase() ?? ""; |
|
const authorName = |
|
getMatchingTags(event, "author")[0]?.[1]?.toLowerCase() ?? ""; |
|
const authorPubkey = event.pubkey.toLowerCase(); |
|
const nip05 = |
|
getMatchingTags(event, "nip05")[0]?.[1]?.toLowerCase() ?? ""; |
|
|
|
// For NIP-05 queries, only match against NIP-05 tags |
|
if (isNip05Query) { |
|
const matches = nip05 === query.toLowerCase(); |
|
if (matches) { |
|
console.debug("[PublicationFeed] Event matches NIP-05 search:", { |
|
id: event.id, |
|
nip05, |
|
authorPubkey, |
|
}); |
|
} |
|
return matches; |
|
} |
|
|
|
// For regular queries, match against all fields |
|
const queryLower = query.toLowerCase(); |
|
const matches = |
|
title.includes(queryLower) || |
|
authorName.includes(queryLower) || |
|
authorPubkey.includes(queryLower) || |
|
nip05.includes(queryLower); |
|
if (matches) { |
|
console.debug("[PublicationFeed] Event matches search:", { |
|
id: event.id, |
|
title, |
|
authorName, |
|
authorPubkey, |
|
nip05, |
|
}); |
|
} |
|
return matches; |
|
}); |
|
|
|
// Cache the filtered results |
|
const result = { |
|
events: filtered, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: new Set<string>(), |
|
addresses: new Set<string>(), |
|
searchType: "publication", |
|
searchTerm: query, |
|
}; |
|
searchCache.set("publication", query, result); |
|
|
|
console.debug("[PublicationFeed] Events after filtering:", filtered.length); |
|
return filtered; |
|
}; |
|
|
|
// Debounced search function |
|
const debouncedSearch = debounceAsync(async (query: string) => { |
|
console.debug("[PublicationFeed] Search query or user filter changed:", query); |
|
let filtered = allIndexEvents; |
|
|
|
// Apply user filter first |
|
filtered = filterEventsByUser(filtered); |
|
|
|
// Then apply search filter if query exists |
|
if (query && query.trim()) { |
|
filtered = filterEventsBySearch(filtered); |
|
} |
|
|
|
eventsInView = filtered.slice(0, publicationsToDisplay); |
|
endOfFeed = filtered.length <= publicationsToDisplay; |
|
}, 300); |
|
|
|
// AI-NOTE: Watch for changes in search query and user filter |
|
$effect(() => { |
|
// Trigger search when either search query or user filter changes |
|
// Also watch for changes in user store to update filter when user logs in/out |
|
debouncedSearch(props.searchQuery); |
|
}); |
|
|
|
// AI-NOTE: Watch for user authentication state changes to re-fetch events when user logs in/out |
|
$effect(() => { |
|
const userState = $userStore; |
|
|
|
if (hasInitialized && userState.signedIn) { |
|
console.debug('[PublicationFeed] User signed in, checking if we need to re-fetch events'); |
|
// Check if we have user-specific relays that we haven't fetched from yet |
|
const inboxRelays = $activeInboxRelays; |
|
const outboxRelays = $activeOutboxRelays; |
|
const newRelays = [...inboxRelays, ...outboxRelays]; |
|
|
|
if (newRelays.length > 0) { |
|
const currentRelaysString = allRelays.sort().join(','); |
|
const newRelaysString = newRelays.sort().join(','); |
|
|
|
if (currentRelaysString !== newRelaysString) { |
|
console.debug('[PublicationFeed] User logged in with new relays, re-fetching events'); |
|
// Clear cache to force fresh fetch from user's relays |
|
indexEventCache.clear(); |
|
setTimeout(() => initializeAndFetch(), 0); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
// AI-NOTE: Watch for changes in the user filter checkbox |
|
$effect(() => { |
|
// Trigger filtering when the user filter checkbox changes |
|
// Access both props to ensure the effect runs when either changes |
|
const searchQuery = props.searchQuery; |
|
const showOnlyMyPublications = props.showOnlyMyPublications; |
|
debouncedSearch(searchQuery); |
|
}); |
|
|
|
// Emit event count updates |
|
$effect(() => { |
|
if (props.onEventCountUpdate) { |
|
props.onEventCountUpdate({ |
|
displayed: eventsInView.length, |
|
total: allIndexEvents.length |
|
}); |
|
} |
|
}); |
|
|
|
async function loadMorePublications() { |
|
loadingMore = true; |
|
const current = eventsInView.length; |
|
let source = allIndexEvents; |
|
|
|
// Apply user filter first |
|
source = filterEventsByUser(source); |
|
|
|
// Then apply search filter if query exists |
|
if (props.searchQuery.trim()) { |
|
source = filterEventsBySearch(source); |
|
} |
|
|
|
eventsInView = source.slice(0, current + publicationsToDisplay); |
|
endOfFeed = eventsInView.length >= source.length; |
|
loadingMore = false; |
|
} |
|
|
|
function getSkeletonIds(): string[] { |
|
// Only access window on client-side |
|
if (typeof window === 'undefined') { |
|
return ['skeleton-0', 'skeleton-1', 'skeleton-2']; // Default fallback for SSR |
|
} |
|
|
|
const skeletonHeight = 192; // The height of the card component in pixels (h-48 = 12rem = 192px). |
|
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; |
|
const skeletonIds = []; |
|
for (let i = 0; i < skeletonCount; i++) { |
|
skeletonIds.push(`skeleton-${i}`); |
|
} |
|
return skeletonIds; |
|
} |
|
|
|
function getCacheStats(): string { |
|
const indexStats = indexEventCache.getStats(); |
|
const searchStats = searchCache.size(); |
|
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`; |
|
} |
|
|
|
// Cleanup function for fallback timeout |
|
function cleanup() { |
|
if (fallbackTimeout) { |
|
clearTimeout(fallbackTimeout); |
|
fallbackTimeout = null; |
|
} |
|
} |
|
|
|
// Cleanup on component destruction |
|
onDestroy(() => { |
|
cleanup(); |
|
}); |
|
|
|
onMount(() => { |
|
console.debug('[PublicationFeed] onMount called'); |
|
// The effect will handle fetching when relays become available |
|
|
|
// Add window resize listener for responsive updates |
|
const handleResize = () => { |
|
if (typeof window !== 'undefined') { |
|
const width = window.innerWidth; |
|
let newColumnCount = 1; |
|
if (width >= 1280) newColumnCount = 4; // xl:grid-cols-4 |
|
else if (width >= 1024) newColumnCount = 3; // lg:grid-cols-3 |
|
else if (width >= 768) newColumnCount = 2; // md:grid-cols-2 |
|
|
|
if (columnCount !== newColumnCount) { |
|
columnCount = newColumnCount; |
|
publicationsToDisplay = newColumnCount * 10; |
|
|
|
// Update the view immediately when column count changes |
|
if (allIndexEvents.length > 0) { |
|
let source = allIndexEvents; |
|
|
|
// Apply user filter first |
|
source = filterEventsByUser(source); |
|
|
|
// Then apply search filter if query exists |
|
if (props.searchQuery?.trim()) { |
|
source = filterEventsBySearch(source); |
|
} |
|
|
|
eventsInView = source.slice(0, publicationsToDisplay); |
|
endOfFeed = eventsInView.length >= source.length; |
|
} |
|
} |
|
} |
|
}; |
|
|
|
window.addEventListener('resize', handleResize); |
|
|
|
// Initial calculation |
|
handleResize(); |
|
|
|
// Cleanup function |
|
return () => { |
|
window.removeEventListener('resize', handleResize); |
|
}; |
|
}); |
|
</script> |
|
|
|
<div class="flex flex-col space-y-4"> |
|
<div |
|
bind:this={gridContainer} |
|
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full" |
|
> |
|
{#if loading && eventsInView.length === 0} |
|
{#each getSkeletonIds() as id} |
|
<Skeleton divClass="skeleton-leather w-full" size="lg" /> |
|
{/each} |
|
{:else if eventsInView.length > 0} |
|
{#each eventsInView as event} |
|
<ArticleHeader {event} /> |
|
{/each} |
|
{:else} |
|
<div class="col-span-full"> |
|
<p class="text-center">No publications found.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
{#if !loadingMore && !endOfFeed} |
|
<div class="flex justify-center mt-4 mb-8"> |
|
<Button |
|
outline |
|
class="w-full max-w-md" |
|
onclick={async () => { |
|
await loadMorePublications(); |
|
}} |
|
> |
|
Show more publications |
|
</Button> |
|
</div> |
|
{:else if loadingMore} |
|
<div class="flex justify-center mt-4 mb-8"> |
|
<Button outline disabled class="w-full max-w-md"> |
|
<Spinner class="mr-3 text-gray-600 dark:text-gray-300" size="4" /> |
|
Loading... |
|
</Button> |
|
</div> |
|
{:else} |
|
<div class="flex justify-center mt-4 mb-8"> |
|
<P class="text-sm text-gray-700 dark:text-gray-300" |
|
>You've reached the end of the feed.</P |
|
> |
|
</div> |
|
{/if} |
|
</div>
|
|
|