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.
1418 lines
64 KiB
1418 lines
64 KiB
<script lang="ts"> |
|
import { Heading, P } from "flowbite-svelte"; |
|
import { page } from "$app/stores"; |
|
import { goto } from "$app/navigation"; |
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
|
import EventSearch from "$lib/components/EventSearch.svelte"; |
|
import EventDetails from "$lib/components/EventDetails.svelte"; |
|
import RelayActions from "$lib/components/RelayActions.svelte"; |
|
import CommentBox from "$lib/components/CommentBox.svelte"; |
|
import CommentViewer from "$lib/components/CommentViewer.svelte"; |
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
|
import { |
|
getMatchingTags, |
|
toNpub, |
|
getUserMetadata, |
|
} from "$lib/utils/nostrUtils"; |
|
import EventInput from "$lib/components/EventInput.svelte"; |
|
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; |
|
import { neventEncode, naddrEncode } from "$lib/utils"; |
|
import { activeInboxRelays, getNdkContext } from "$lib/ndk"; |
|
import { getEventType } from "$lib/utils/mime"; |
|
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; |
|
import { checkCommunity } from "$lib/utils/search_utility"; |
|
import { repostContent, quotedContent } from "$lib/snippets/EmbeddedSnippets.svelte"; |
|
import { repostKinds } from "$lib/consts"; |
|
import { userStore } from "$lib/stores/userStore"; |
|
import { |
|
fetchCurrentUserLists, |
|
isPubkeyInUserLists, |
|
} from "$lib/utils/user_lists"; |
|
import { UserOutline } from "flowbite-svelte-icons"; |
|
import type { UserProfile } from "$lib/models/user_profile"; |
|
import type { SearchType } from "$lib/models/search_type"; |
|
import { clearAllCaches } from "$lib/utils/cache_manager"; |
|
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
|
|
|
// AI-NOTE: Add cache clearing function for testing second-order search |
|
// This can be called from browser console: window.clearCache() |
|
if (typeof window !== 'undefined') { |
|
(window as any).clearCache = () => { |
|
console.log('Clearing all caches for testing...'); |
|
clearAllCaches(); |
|
console.log('Caches cleared. Try searching again to test second-order search.'); |
|
}; |
|
|
|
// AI-NOTE: Add function to clear specific search cache |
|
// Usage: window.clearSearchCache('n', 'silberengel') |
|
(window as any).clearSearchCache = (searchType: string, searchTerm: string) => { |
|
console.log(`Clearing search cache for ${searchType}:${searchTerm}...`); |
|
// Import searchCache dynamically |
|
import('$lib/utils/searchCache').then(({ searchCache }) => { |
|
searchCache.clear(); |
|
console.log('Search cache cleared. Try searching again to test second-order search.'); |
|
}); |
|
}; |
|
} |
|
|
|
let loading = $state(false); |
|
let error = $state<string | null>(null); |
|
let searchValue = $state<string | null>(null); |
|
let searchType = $state<SearchType | null>(null); |
|
let event = $state<NDKEvent | null>(null); |
|
let searchResults = $state<NDKEvent[]>([]); |
|
let secondOrderResults = $state<NDKEvent[]>([]); |
|
let tTagResults = $state<NDKEvent[]>([]); |
|
let originalEventIds = $state<Set<string>>(new Set()); |
|
let originalAddresses = $state<Set<string>>(new Set()); |
|
let searchTerm = $state<string | null>(null); |
|
let profile = $state<UserProfile | null>(null); |
|
let userRelayPreference = $state(false); |
|
let showSidePanel = $state(false); |
|
let searchInProgress = $state(false); |
|
let secondOrderSearchMessage = $state<string | null>(null); |
|
let communityStatus = $state<Record<string, boolean>>({}); |
|
let searchResultsCollapsed = $state(false); |
|
let user = $state<any>(null); |
|
|
|
userStore.subscribe((val) => (user = val)); |
|
|
|
// Get NDK context during component initialization |
|
const ndk = getNdkContext(); |
|
|
|
// AI-NOTE: Event navigation and comment feed update issue |
|
// When navigating to events via e-tags, the CommentViewer component may experience |
|
// timing issues that cause comment feed problems. This function is called when |
|
// a new event is found, and it triggers the CommentViewer to update. |
|
// The CommentViewer has been updated with better state management to handle |
|
// these race conditions. |
|
function handleEventFound(newEvent: NDKEvent) { |
|
event = newEvent; |
|
showSidePanel = true; |
|
// searchInProgress = false; |
|
// secondOrderSearchMessage = null; |
|
|
|
// AI-NOTE: Properly parse profile data for kind 0 events |
|
if (newEvent.kind === 0) { |
|
try { |
|
const parsedProfile = parseProfileContent(newEvent); |
|
if (parsedProfile) { |
|
// Check if we already have user list information from the search results |
|
const existingProfileData = (newEvent as any).profileData; |
|
if (existingProfileData && typeof existingProfileData.isInUserLists === "boolean") { |
|
// Use the existing user list status from search results |
|
profile = { ...parsedProfile, isInUserLists: existingProfileData.isInUserLists } as any; |
|
console.log(`[Events Page] Using existing user list status for ${newEvent.pubkey}: ${existingProfileData.isInUserLists}`); |
|
} else { |
|
// Set initial profile and fetch user list information |
|
profile = parsedProfile; |
|
|
|
// Fetch user list information |
|
fetchCurrentUserLists(undefined, ndk) |
|
.then((userLists) => { |
|
const isInLists = isPubkeyInUserLists( |
|
newEvent.pubkey, |
|
userLists, |
|
); |
|
// Update the profile with user list information |
|
profile = { ...parsedProfile, isInUserLists: isInLists } as any; |
|
// Also update the event's profileData for consistency |
|
(newEvent as any).profileData = { |
|
...parsedProfile, |
|
isInUserLists: isInLists, |
|
}; |
|
console.log(`[Events Page] Updated user list status for ${newEvent.pubkey}: ${isInLists}`); |
|
}) |
|
.catch(() => { |
|
profile = { ...parsedProfile, isInUserLists: false } as any; |
|
(newEvent as any).profileData = { |
|
...parsedProfile, |
|
isInUserLists: false, |
|
}; |
|
console.log(`[Events Page] Set default user list status for ${newEvent.pubkey}: false`); |
|
}); |
|
} |
|
} else { |
|
console.warn( |
|
"[Events Page] Failed to parse profile content for event:", |
|
newEvent.id, |
|
); |
|
profile = null; |
|
} |
|
} catch (error) { |
|
console.error("[Events Page] Error parsing profile content:", error); |
|
profile = null; |
|
} |
|
} else { |
|
profile = null; |
|
} |
|
|
|
// AI-NOTE: Ensure profile is cached for the event author |
|
if (newEvent.pubkey) { |
|
cacheProfileForPubkey(newEvent.pubkey); |
|
|
|
// Also check community status for the individual event if not already cached |
|
if (!communityStatus[newEvent.pubkey]) { |
|
checkCommunity(newEvent.pubkey) |
|
.then((status) => { |
|
communityStatus = { ...communityStatus, [newEvent.pubkey]: status }; |
|
console.log(`[Events Page] Updated community status for ${newEvent.pubkey}: ${status}`); |
|
}) |
|
.catch(() => { |
|
communityStatus = { ...communityStatus, [newEvent.pubkey]: false }; |
|
console.log(`[Events Page] Set default community status for ${newEvent.pubkey}: false`); |
|
}); |
|
} |
|
} |
|
} |
|
|
|
// AI-NOTE: Function to ensure profile is cached for a pubkey |
|
async function cacheProfileForPubkey(pubkey: string) { |
|
try { |
|
const npub = toNpub(pubkey); |
|
if (npub) { |
|
// Force fetch to ensure profile is cached |
|
await getUserMetadata(npub, ndk, true); |
|
console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`); |
|
} |
|
} catch (error) { |
|
console.warn( |
|
`[Events Page] Failed to cache profile for ${pubkey}:`, |
|
error, |
|
); |
|
} |
|
} |
|
|
|
|
|
|
|
// Use Svelte 5 idiomatic effect to update searchValue and searchType based on URL parameters |
|
$effect(() => { |
|
// Ensure we have the full URL object to trigger reactivity |
|
const url = $page.url; |
|
const searchParams = url.searchParams; |
|
|
|
const idParam = searchParams.get("id"); |
|
const dParam = searchParams.get("d"); |
|
const tParam = searchParams.get("t"); |
|
const nParam = searchParams.get("n"); |
|
const qParam = searchParams.get("q"); |
|
|
|
if (idParam) { |
|
searchValue = idParam; |
|
searchType = "id"; |
|
} else if (dParam) { |
|
searchValue = dParam.toLowerCase(); |
|
searchType = "d"; |
|
} else if (tParam) { |
|
searchValue = decodeURIComponent(tParam); |
|
searchType = "t"; |
|
} else if (nParam) { |
|
searchValue = decodeURIComponent(nParam); |
|
searchType = "n"; |
|
} else if (qParam) { |
|
searchValue = decodeURIComponent(qParam); |
|
searchType = "q"; |
|
} else { |
|
searchValue = null; |
|
searchType = null; |
|
} |
|
}); |
|
|
|
// Handle side panel visibility based on search type |
|
$effect(() => { |
|
// Close side panel for searches that return multiple results (d-tag, t-tag, name searches, general searches) |
|
if ( |
|
searchType === "d" || |
|
searchType === "t" || |
|
searchType === "n" || |
|
searchType === "q" |
|
) { |
|
showSidePanel = false; |
|
event = null; |
|
profile = null; |
|
} |
|
}); |
|
|
|
// AI-NOTE: Function to ensure events have created_at property |
|
// This fixes the "Unknown date" issue when events are retrieved from cache |
|
function ensureEventProperties(events: NDKEvent[]): NDKEvent[] { |
|
return events.map((event) => { |
|
if (event && typeof event === "object") { |
|
// Ensure created_at is set |
|
if (!event.created_at && event.created_at !== 0) { |
|
console.warn( |
|
"[Events Page] Event missing created_at, setting to 0:", |
|
event.id, |
|
); |
|
(event as any).created_at = 0; |
|
} |
|
} |
|
return event; |
|
}); |
|
} |
|
|
|
function handleSearchResults( |
|
results: NDKEvent[], |
|
secondOrder: NDKEvent[] = [], |
|
tTagEvents: NDKEvent[] = [], |
|
eventIds: Set<string> = new Set(), |
|
addresses: Set<string> = new Set(), |
|
searchTypeParam?: string, |
|
searchTermParam?: string, |
|
) { |
|
// AI-NOTE: Ensure all events have proper properties |
|
const processedResults = ensureEventProperties(results); |
|
const processedSecondOrder = ensureEventProperties(secondOrder); |
|
const processedTTagEvents = ensureEventProperties(tTagEvents); |
|
|
|
searchResults = processedResults; |
|
secondOrderResults = processedSecondOrder; |
|
tTagResults = processedTTagEvents; |
|
originalEventIds = eventIds; |
|
originalAddresses = addresses; |
|
searchType = searchTypeParam as SearchType | null; |
|
searchTerm = searchTermParam || null; |
|
|
|
// Track search progress |
|
searchInProgress = |
|
loading || (results.length > 0 && secondOrder.length === 0); |
|
|
|
// AI-NOTE: Only show second-order search message if we're actually searching |
|
// Don't show it for cached results that have no second-order events |
|
if ( |
|
results.length > 0 && |
|
secondOrder.length === 0 && |
|
searchTypeParam === "n" && |
|
!loading // Only show message if we're actively searching, not for cached results |
|
) { |
|
secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`; |
|
} else if ( |
|
results.length > 0 && |
|
secondOrder.length === 0 && |
|
searchTypeParam === "d" && |
|
!loading // Only show message if we're actively searching, not for cached results |
|
) { |
|
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`; |
|
} else if (secondOrder.length > 0) { |
|
secondOrderSearchMessage = null; |
|
} else { |
|
// Clear message if we have results but no second-order search is happening |
|
secondOrderSearchMessage = null; |
|
} |
|
|
|
// Check community status for all search results |
|
if (results.length > 0) { |
|
checkCommunityStatusForResults(results); |
|
} |
|
if (secondOrder.length > 0) { |
|
checkCommunityStatusForResults(secondOrder); |
|
} |
|
if (tTagEvents.length > 0) { |
|
checkCommunityStatusForResults(tTagEvents); |
|
} |
|
|
|
// AI-NOTE: Profile data is now handled in subscription_search.ts |
|
// No need to cache profiles here as they're already attached to events |
|
} |
|
|
|
|
|
|
|
function handleClear() { |
|
searchType = null; |
|
searchTerm = null; |
|
searchResults = []; |
|
secondOrderResults = []; |
|
tTagResults = []; |
|
originalEventIds = new Set(); |
|
originalAddresses = new Set(); |
|
event = null; |
|
profile = null; |
|
showSidePanel = false; |
|
searchInProgress = false; |
|
secondOrderSearchMessage = null; |
|
communityStatus = {}; |
|
goto("/events", { replaceState: true }); |
|
} |
|
|
|
function closeSidePanel() { |
|
showSidePanel = false; |
|
event = null; |
|
profile = null; |
|
searchInProgress = false; |
|
secondOrderSearchMessage = null; |
|
} |
|
|
|
function toggleSearchResults() { |
|
searchResultsCollapsed = !searchResultsCollapsed; |
|
} |
|
|
|
function navigateToPublication(dTag: string) { |
|
goto(`/publications?d=${encodeURIComponent(dTag.toLowerCase())}`); |
|
} |
|
|
|
function getSummary(event: NDKEvent): string | undefined { |
|
return getMatchingTags(event, "summary")[0]?.[1]; |
|
} |
|
|
|
function getDeferralNaddr(event: NDKEvent): string | undefined { |
|
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...'] |
|
return getMatchingTags(event, "deferral")[0]?.[1]; |
|
} |
|
|
|
function getReferenceType( |
|
event: NDKEvent, |
|
originalEventIds: Set<string>, |
|
originalAddresses: Set<string>, |
|
): string { |
|
const eTags = event.getMatchingTags("e"); |
|
const aTags = event.getMatchingTags("a"); |
|
|
|
if (eTags.length > 0) { |
|
const referencedEventId = eTags[eTags.length - 1][1]; |
|
if (originalEventIds.has(referencedEventId)) { |
|
return "Reply"; |
|
} |
|
} |
|
|
|
if (aTags.length > 0) { |
|
const referencedAddress = aTags[aTags.length - 1][1]; |
|
if (originalAddresses.has(referencedAddress)) { |
|
return "Quote"; |
|
} |
|
} |
|
|
|
return "Reference"; |
|
} |
|
|
|
// AI-NOTE: Function to parse profile content from kind 0 events |
|
function parseProfileContent(event: NDKEvent): UserProfile | null { |
|
if (event.kind !== 0 || !event.content) { |
|
return null; |
|
} |
|
|
|
try { |
|
return JSON.parse(event.content); |
|
} catch (error) { |
|
console.warn("Failed to parse profile content:", error); |
|
return null; |
|
} |
|
} |
|
|
|
function getNeventUrl(event: NDKEvent): string { |
|
if (event.kind === 0) { |
|
return neventEncode(event, $activeInboxRelays); |
|
} |
|
return neventEncode(event, $activeInboxRelays); |
|
} |
|
|
|
function isAddressableEvent(event: NDKEvent): boolean { |
|
return getEventType(event.kind || 0) === "addressable"; |
|
} |
|
|
|
function getNaddrAddress(event: NDKEvent): string | null { |
|
if (!isAddressableEvent(event)) { |
|
return null; |
|
} |
|
try { |
|
return naddrEncode(event, $activeInboxRelays); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function getViewPublicationNaddr(event: NDKEvent): string | null { |
|
// For deferred events, use the deferral naddr instead of the event's own naddr |
|
const deferralNaddr = getDeferralNaddr(event); |
|
if (deferralNaddr) { |
|
return deferralNaddr; |
|
} |
|
|
|
// Otherwise, use the event's own naddr if it's addressable |
|
return getNaddrAddress(event); |
|
} |
|
|
|
function shortenAddress(addr: string, head = 10, tail = 10): string { |
|
if (!addr || addr.length <= head + tail + 3) return addr; |
|
return addr.slice(0, head) + "…" + addr.slice(-tail); |
|
} |
|
|
|
function onLoadingChange(val: boolean) { |
|
loading = val; |
|
searchInProgress = |
|
val || (searchResults.length > 0 && secondOrderResults.length === 0); |
|
} |
|
|
|
/** |
|
* Check community status for all search results |
|
*/ |
|
async function checkCommunityStatusForResults(events: NDKEvent[]) { |
|
const newCommunityStatus: Record<string, boolean> = {}; |
|
|
|
for (const event of events) { |
|
if (event.pubkey && !communityStatus[event.pubkey]) { |
|
try { |
|
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey); |
|
} catch (error) { |
|
console.error( |
|
"Error checking community status for", |
|
event.pubkey, |
|
error, |
|
); |
|
newCommunityStatus[event.pubkey] = false; |
|
} |
|
} else if (event.pubkey) { |
|
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey]; |
|
} |
|
} |
|
|
|
communityStatus = { ...communityStatus, ...newCommunityStatus }; |
|
} |
|
</script> |
|
|
|
<div class="w-full flex justify-center"> |
|
<div |
|
class="flex flex-col lg:flex-row w-full max-w-7xl my-6 px-4 mx-auto gap-6 overflow-hidden" |
|
> |
|
<!-- Left Panel: Search and Results --> |
|
<div |
|
class={showSidePanel |
|
? "w-full lg:w-80 lg:min-w-80" |
|
: "w-full max-w-4xl mx-auto lg:max-w-4xl"} |
|
class:min-w-0={true} |
|
> |
|
<div class="main-leather flex flex-col space-y-6 w-full"> |
|
<div class="flex justify-between items-center"> |
|
<Heading tag="h1" class="h-leather mb-2">Events</Heading> |
|
<div class="flex items-center gap-2"> |
|
{#if showSidePanel && (searchResults.length > 0 || secondOrderResults.length > 0 || tTagResults.length > 0)} |
|
<button |
|
class="lg:hidden text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded px-2 py-1" |
|
onclick={toggleSearchResults} |
|
> |
|
{searchResultsCollapsed ? "Show Results" : "Hide Results"} |
|
</button> |
|
{/if} |
|
{#if showSidePanel} |
|
<button |
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200" |
|
onclick={closeSidePanel} |
|
> |
|
Close Details |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<P class="mb-3"> |
|
Search and explore Nostr events across the network. Find events by: |
|
</P> |
|
<ul |
|
class="mb-3 list-disc list-inside space-y-1 text-sm text-gray-700 dark:text-gray-300" |
|
> |
|
<li> |
|
<strong>Event identifiers:</strong> nevent, note, naddr, npub, nprofile, |
|
pubkey, or event ID |
|
</li> |
|
<li><strong>NIP-05 addresses:</strong> username@domain.com</li> |
|
<li> |
|
<strong>Profile names:</strong> Search by display name or username (use |
|
"n:" prefix for exact matches) |
|
</li> |
|
<li> |
|
<strong>D-tags:</strong> Find events with specific d-tags using "d:tag-name" |
|
</li> |
|
<li> |
|
<strong>T-tags:</strong> Find events tagged with specific topics using |
|
"t:topic" |
|
</li> |
|
</ul> |
|
<P class="mb-3 text-sm text-gray-600 dark:text-gray-400"> |
|
The page shows primary search results, second-order references |
|
(replies, quotes, mentions), and related tagged events. Click any |
|
event to view details, comments, and relay information. |
|
</P> |
|
|
|
<EventSearch |
|
{loading} |
|
{error} |
|
{searchValue} |
|
{searchType} |
|
{event} |
|
onEventFound={handleEventFound} |
|
onSearchResults={handleSearchResults} |
|
onClear={handleClear} |
|
{onLoadingChange} |
|
/> |
|
|
|
{#if secondOrderSearchMessage} |
|
<div |
|
class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg" |
|
> |
|
{secondOrderSearchMessage} |
|
</div> |
|
{/if} |
|
|
|
{#if searchResults.length > 0} |
|
<div class="mt-8"> |
|
<div |
|
class={showSidePanel && searchResultsCollapsed |
|
? "lg:block hidden" |
|
: "block"} |
|
> |
|
<Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden"> |
|
{#if searchType === "n"} |
|
Search Results for name: "{searchTerm && |
|
searchTerm.length > 50 |
|
? searchTerm.slice(0, 50) + "..." |
|
: searchTerm || ""}" ({searchResults.length} profiles) |
|
{:else if searchType === "t"} |
|
Search Results for t-tag: "{searchTerm && |
|
searchTerm.length > 50 |
|
? searchTerm.slice(0, 50) + "..." |
|
: searchTerm || ""}" ({searchResults.length} |
|
events) |
|
{:else} |
|
Search Results for d-tag: "{(() => { |
|
const term = |
|
searchTerm || |
|
(searchType === "d" ? searchValue : "") || |
|
""; |
|
return term.length > 50 ? term.slice(0, 50) + "..." : term; |
|
})()}" ({searchResults.length} events) |
|
{/if} |
|
</Heading> |
|
<div class="space-y-4 w-full max-w-full"> |
|
{#each searchResults as result, index} |
|
{@const profileData = |
|
(result as any).profileData || parseProfileContent(result)} |
|
<button |
|
class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors" |
|
onclick={() => handleEventFound(result)} |
|
> |
|
<div class="flex flex-col gap-1 responsive-card-content"> |
|
<div class="flex items-center gap-2 mb-1 min-w-0"> |
|
<span |
|
class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0" |
|
>{searchType === "n" ? "Profile" : "Event"} |
|
{index + 1}</span |
|
> |
|
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0" |
|
>Kind: {result.kind}</span |
|
> |
|
<div class="flex items-center gap-2 ml-auto flex-shrink-0"> |
|
<!-- Indicators --> |
|
{#if profileData?.isInUserLists} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center" |
|
title="In your lists (follows, etc.)" |
|
> |
|
<svg |
|
class="w-3 h-3 text-red-600 dark:text-red-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if result.pubkey && communityStatus[result.pubkey]} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" |
|
title="Has posted to the community" |
|
> |
|
<svg |
|
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])} |
|
<div class="flex-shrink-0 w-4 h-4"></div> |
|
{/if} |
|
|
|
<!-- Profile picture --> |
|
{#if profileData?.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = "none"; |
|
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden"); |
|
}} |
|
/> |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{:else} |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{/if} |
|
|
|
<!-- User badge --> |
|
<span class="text-xs text-gray-600 dark:text-gray-400"> |
|
{@render userBadge( |
|
toNpub(result.pubkey) as string, |
|
profileData?.display_name || profileData?.name, |
|
ndk, |
|
)} |
|
</span> |
|
</div> |
|
<span |
|
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" |
|
> |
|
{result.created_at |
|
? new Date( |
|
result.created_at * 1000, |
|
).toLocaleDateString() |
|
: "Unknown date"} |
|
</span> |
|
</div> |
|
{#if result.kind === 0 && profileData} |
|
<div class="flex items-center gap-3 mb-2"> |
|
{#if profileData.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = |
|
"none"; |
|
( |
|
e.target as HTMLImageElement |
|
).nextElementSibling?.classList.remove( |
|
"hidden", |
|
); |
|
}} |
|
/> |
|
<div |
|
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 hidden flex-shrink-0" |
|
> |
|
<UserOutline |
|
class="w-6 h-6 text-gray-600 dark:text-gray-300" |
|
/> |
|
</div> |
|
{:else} |
|
<div |
|
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
> |
|
<UserOutline |
|
class="w-6 h-6 text-gray-600 dark:text-gray-300" |
|
/> |
|
</div> |
|
{/if} |
|
<div class="flex flex-col min-w-0 flex-1 overflow-hidden"> |
|
{#if profileData.display_name || profileData.name} |
|
<span |
|
class="font-medium text-gray-900 dark:text-gray-100 truncate" |
|
> |
|
{profileData.display_name || profileData.name} |
|
</span> |
|
{/if} |
|
{#if profileData.about} |
|
<span |
|
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 break-words" |
|
> |
|
{profileData.about} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else} |
|
{#if getSummary(result)} |
|
<div |
|
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2" |
|
> |
|
{getSummary(result)} |
|
</div> |
|
{/if} |
|
{#if getDeferralNaddr(result)} |
|
<div |
|
class="text-xs text-primary-800 dark:text-primary-300 mb-1" |
|
> |
|
Read |
|
<span |
|
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
}} |
|
onkeydown={(e) => { |
|
if (e.key === "Enter" || e.key === " ") { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
} |
|
}} |
|
tabindex="0" |
|
role="button" |
|
> |
|
{getDeferralNaddr(result)} |
|
</span> |
|
</div> |
|
{/if} |
|
{#if isAddressableEvent(result)} |
|
<div |
|
class="text-xs text-blue-600 dark:text-blue-400 mb-1" |
|
> |
|
<ViewPublicationLink event={result} /> |
|
</div> |
|
{/if} |
|
{#if result.content} |
|
<div |
|
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" |
|
> |
|
{#if repostKinds.includes(result.kind)} |
|
<!-- Repost content - parse stringified JSON --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
{result.kind === 6 ? 'Repost:' : 'Generic repost:'} |
|
</div> |
|
{@render repostContent(result.content)} |
|
</div> |
|
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0} |
|
<!-- Quote repost content --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Quote repost: |
|
</div> |
|
{@render quotedContent(result, [], ndk)} |
|
{#if result.content && result.content.trim()} |
|
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Comment: |
|
</div> |
|
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<!-- Regular content --> |
|
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)} |
|
{/if} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
{#if secondOrderResults.length > 0} |
|
<div class="mt-8"> |
|
<div |
|
class={showSidePanel && searchResultsCollapsed |
|
? "lg:block hidden" |
|
: "block"} |
|
> |
|
<Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden"> |
|
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length} |
|
events) |
|
</Heading> |
|
{#if (searchType === "n" || searchType === "d") && secondOrderResults.length === 100} |
|
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> |
|
Showing the 100 newest events. More results may be available. |
|
</P> |
|
{/if} |
|
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> |
|
Events that reference, reply to, highlight, or quote the |
|
original events. |
|
</P> |
|
<div class="space-y-4 w-full max-w-full"> |
|
{#each secondOrderResults as result, index} |
|
{@const profileData = |
|
(result as any).profileData || parseProfileContent(result)} |
|
<button |
|
class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors" |
|
onclick={() => handleEventFound(result)} |
|
> |
|
<div class="flex flex-col gap-1 responsive-card-content"> |
|
<div class="flex items-center gap-2 mb-1 min-w-0"> |
|
<span |
|
class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0" |
|
>Reference {index + 1}</span |
|
> |
|
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0" |
|
>Kind: {result.kind}</span |
|
> |
|
<div class="flex items-center gap-2 ml-auto flex-shrink-0"> |
|
<!-- Indicators --> |
|
{#if profileData?.isInUserLists} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center" |
|
title="In your lists (follows, etc.)" |
|
> |
|
<svg |
|
class="w-3 h-3 text-red-600 dark:text-red-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if result.pubkey && communityStatus[result.pubkey]} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" |
|
title="Has posted to the community" |
|
> |
|
<svg |
|
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])} |
|
<div class="flex-shrink-0 w-4 h-4"></div> |
|
{/if} |
|
|
|
<!-- Profile picture --> |
|
{#if profileData?.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = "none"; |
|
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden"); |
|
}} |
|
/> |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{:else} |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{/if} |
|
|
|
<!-- User badge --> |
|
<span class="text-xs text-gray-600 dark:text-gray-400"> |
|
{@render userBadge( |
|
toNpub(result.pubkey) as string, |
|
profileData?.display_name || profileData?.name, |
|
ndk, |
|
)} |
|
</span> |
|
</div> |
|
<span |
|
class="text-xs text-gray-500 dark:text-gray-400 ml-auto flex-shrink-0" |
|
> |
|
{result.created_at |
|
? new Date( |
|
result.created_at * 1000, |
|
).toLocaleDateString() |
|
: "Unknown date"} |
|
</span> |
|
</div> |
|
<div |
|
class="text-xs text-blue-600 dark:text-blue-400 mb-1" |
|
> |
|
{getReferenceType( |
|
result, |
|
originalEventIds, |
|
originalAddresses, |
|
)} |
|
</div> |
|
{#if result.kind === 0 && profileData} |
|
<div class="flex items-center gap-3 mb-2"> |
|
{#if profileData.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = |
|
"none"; |
|
}} |
|
/> |
|
{:else} |
|
<div |
|
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
> |
|
<span |
|
class="text-lg font-medium text-gray-600 dark:text-gray-300" |
|
> |
|
{( |
|
profileData.display_name || |
|
profileData.name || |
|
result.pubkey.slice(0, 1) |
|
).toUpperCase()} |
|
</span> |
|
</div> |
|
{/if} |
|
<div class="flex flex-col min-w-0 flex-1 overflow-hidden"> |
|
{#if profileData.display_name || profileData.name} |
|
<span |
|
class="font-medium text-gray-900 dark:text-gray-100 truncate" |
|
> |
|
{profileData.display_name || profileData.name} |
|
</span> |
|
{/if} |
|
{#if profileData.about} |
|
<span |
|
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 break-words" |
|
> |
|
{profileData.about} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else} |
|
{#if getSummary(result)} |
|
<div |
|
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2" |
|
> |
|
{getSummary(result)} |
|
</div> |
|
{/if} |
|
{#if getDeferralNaddr(result)} |
|
<div |
|
class="text-xs text-primary-800 dark:text-primary-300 mb-1" |
|
> |
|
Read |
|
<span |
|
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
}} |
|
onkeydown={(e) => { |
|
if (e.key === "Enter" || e.key === " ") { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
} |
|
}} |
|
tabindex="0" |
|
role="button" |
|
> |
|
{getDeferralNaddr(result)} |
|
</span> |
|
</div> |
|
{/if} |
|
{#if isAddressableEvent(result)} |
|
<div |
|
class="text-xs text-blue-600 dark:text-blue-400 mb-1" |
|
> |
|
<ViewPublicationLink event={result} /> |
|
</div> |
|
{/if} |
|
{#if result.content} |
|
<div |
|
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" |
|
> |
|
{#if repostKinds.includes(result.kind)} |
|
<!-- Repost content - parse stringified JSON --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
{result.kind === 6 ? 'Repost:' : 'Generic repost:'} |
|
</div> |
|
{@render repostContent(result.content)} |
|
</div> |
|
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0} |
|
<!-- Quote repost content --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Quote repost: |
|
</div> |
|
{@render quotedContent(result, [], ndk)} |
|
{#if result.content && result.content.trim()} |
|
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Comment: |
|
</div> |
|
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<!-- Regular content --> |
|
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)} |
|
{/if} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
{#if tTagResults.length > 0} |
|
<div class="mt-8"> |
|
<div |
|
class={showSidePanel && searchResultsCollapsed |
|
? "lg:block hidden" |
|
: "block"} |
|
> |
|
<Heading tag="h2" class="h-leather mb-4 break-words overflow-hidden"> |
|
Search Results for t-tag: "{searchTerm || |
|
(searchType === "t" ? searchValue : "")}" ({tTagResults.length} |
|
events) |
|
</Heading> |
|
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> |
|
Events that are tagged with the t-tag. |
|
</P> |
|
<div class="space-y-4 w-full max-w-full"> |
|
{#each tTagResults as result, index} |
|
{@const profileData = |
|
(result as any).profileData || parseProfileContent(result)} |
|
<button |
|
class="responsive-card text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors" |
|
onclick={() => handleEventFound(result)} |
|
> |
|
<div class="flex flex-col gap-1 responsive-card-content"> |
|
<div class="flex items-center gap-2 mb-1 min-w-0"> |
|
<span |
|
class="font-medium text-gray-800 dark:text-gray-100 flex-shrink-0" |
|
>Tagged Event {index + 1}</span |
|
> |
|
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0" |
|
>Kind: {result.kind}</span |
|
> |
|
<div class="flex items-center gap-2 ml-auto flex-shrink-0"> |
|
<!-- Indicators --> |
|
{#if profileData?.isInUserLists} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center" |
|
title="In your lists (follows, etc.)" |
|
> |
|
<svg |
|
class="w-3 h-3 text-red-600 dark:text-red-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if result.pubkey && communityStatus[result.pubkey]} |
|
<div |
|
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" |
|
title="Has posted to the community" |
|
> |
|
<svg |
|
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" |
|
fill="currentColor" |
|
viewBox="0 0 24 24" |
|
> |
|
<path |
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" |
|
/> |
|
</svg> |
|
</div> |
|
{/if} |
|
{#if !profileData?.isInUserLists && !(result.pubkey && communityStatus[result.pubkey])} |
|
<div class="flex-shrink-0 w-4 h-4"></div> |
|
{/if} |
|
|
|
<!-- Profile picture --> |
|
{#if profileData?.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-6 h-6 rounded-full object-cover border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = "none"; |
|
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden"); |
|
}} |
|
/> |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0 hidden" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{:else} |
|
<div |
|
class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600 flex-shrink-0" |
|
> |
|
<UserOutline class="w-3 h-3 text-gray-600 dark:text-gray-300" /> |
|
</div> |
|
{/if} |
|
|
|
<!-- User badge --> |
|
<span class="text-xs text-gray-600 dark:text-gray-400"> |
|
{@render userBadge( |
|
toNpub(result.pubkey) as string, |
|
profileData?.display_name || profileData?.name, |
|
ndk, |
|
)} |
|
</span> |
|
</div> |
|
<span |
|
class="text-xs text-gray-500 dark:text-gray-400 ml-auto" |
|
> |
|
{result.created_at |
|
? new Date( |
|
result.created_at * 1000, |
|
).toLocaleDateString() |
|
: "Unknown date"} |
|
</span> |
|
</div> |
|
{#if result.kind === 0 && profileData} |
|
<div class="flex items-center gap-3 mb-2"> |
|
{#if profileData.picture} |
|
<img |
|
src={profileData.picture} |
|
alt="Profile" |
|
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600" |
|
onerror={(e) => { |
|
(e.target as HTMLImageElement).style.display = |
|
"none"; |
|
}} |
|
/> |
|
{:else} |
|
<div |
|
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600" |
|
> |
|
<span |
|
class="text-lg font-medium text-gray-600 dark:text-gray-300" |
|
> |
|
{( |
|
profileData.display_name || |
|
profileData.name || |
|
result.pubkey.slice(0, 1) |
|
).toUpperCase()} |
|
</span> |
|
</div> |
|
{/if} |
|
<div class="flex flex-col min-w-0 flex-1"> |
|
{#if profileData.display_name || profileData.name} |
|
<span |
|
class="font-medium text-gray-900 dark:text-gray-100 truncate" |
|
> |
|
{profileData.display_name || profileData.name} |
|
</span> |
|
{/if} |
|
{#if profileData.about} |
|
<span |
|
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2" |
|
> |
|
{profileData.about} |
|
</span> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else} |
|
{#if getSummary(result)} |
|
<div |
|
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2" |
|
> |
|
{getSummary(result)} |
|
</div> |
|
{/if} |
|
{#if getDeferralNaddr(result)} |
|
<div |
|
class="text-xs text-primary-800 dark:text-primary-300 mb-1" |
|
> |
|
Read |
|
<span |
|
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
}} |
|
onkeydown={(e) => { |
|
if (e.key === "Enter" || e.key === " ") { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
navigateToPublication( |
|
getDeferralNaddr(result) || "", |
|
); |
|
} |
|
}} |
|
tabindex="0" |
|
role="button" |
|
> |
|
{getDeferralNaddr(result)} |
|
</span> |
|
</div> |
|
{/if} |
|
{#if isAddressableEvent(result)} |
|
<div |
|
class="text-xs text-blue-600 dark:text-blue-400 mb-1" |
|
> |
|
<ViewPublicationLink event={result} /> |
|
</div> |
|
{/if} |
|
{#if result.content} |
|
<div |
|
class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words" |
|
> |
|
{#if repostKinds.includes(result.kind)} |
|
<!-- Repost content - parse stringified JSON --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
{result.kind === 6 ? 'Repost:' : 'Generic repost:'} |
|
</div> |
|
{@render repostContent(result.content)} |
|
</div> |
|
{:else if result.kind === 1 && result.getMatchingTags("q").length > 0} |
|
<!-- Quote repost content --> |
|
<div class="border-l-2 border-primary-300 dark:border-primary-600 pl-2"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Quote repost: |
|
</div> |
|
{@render quotedContent(result, [], ndk)} |
|
{#if result.content && result.content.trim()} |
|
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700"> |
|
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> |
|
Comment: |
|
</div> |
|
{@render basicMarkup(result.content.slice(0, 100) + (result.content.length > 100 ? "..." : ""), ndk)} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
<!-- Regular content --> |
|
{@render basicMarkup(result.content.slice(0, 200) + (result.content.length > 200 ? "..." : ""), ndk)} |
|
{/if} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !searchInProgress} |
|
<div class="mt-8 w-full"> |
|
<Heading tag="h2" class="h-leather mb-4" |
|
>Publish Nostr Event</Heading |
|
> |
|
<P class="mb-4"> |
|
Create and publish new Nostr events to the network. This form |
|
supports various event kinds including: |
|
</P> |
|
<ul |
|
class="mb-6 list-disc list-inside space-y-1 text-sm text-gray-700 dark:text-gray-300" |
|
> |
|
<li> |
|
<strong>Kind 30040:</strong> Publication indexes that organize AsciiDoc |
|
content into structured publications |
|
</li> |
|
<li> |
|
<strong>Kind 30041:</strong> Individual section content for publications |
|
</li> |
|
<li> |
|
<strong>Other kinds:</strong> Standard Nostr events with custom tags |
|
and content |
|
</li> |
|
</ul> |
|
<div class="w-full flex justify-center"> |
|
<EventInput /> |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<!-- Right Panel: Event Details --> |
|
{#if showSidePanel && event} |
|
<div |
|
class="w-full lg:flex-1 lg:min-w-0 main-leather flex flex-col space-y-6 overflow-hidden" |
|
> |
|
<div class="flex justify-between items-center min-w-0"> |
|
<Heading tag="h2" class="h-leather mb-2 break-words" |
|
>Event Details</Heading |
|
> |
|
<button |
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex-shrink-0" |
|
onclick={closeSidePanel} |
|
> |
|
✕ |
|
</button> |
|
</div> |
|
|
|
{#if event.kind !== 0} |
|
<div class="flex flex-col gap-2 mb-4 break-all min-w-0"> |
|
<CopyToClipboard |
|
displayText={shortenAddress(getNeventUrl(event))} |
|
copyText={getNeventUrl(event)} |
|
/> |
|
{#if isAddressableEvent(event)} |
|
{@const naddrAddress = getViewPublicationNaddr(event)} |
|
{#if naddrAddress} |
|
<CopyToClipboard |
|
displayText={shortenAddress(naddrAddress)} |
|
copyText={naddrAddress} |
|
/> |
|
<div class="mt-2"> |
|
<ViewPublicationLink {event} /> |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<div class="min-w-0 overflow-hidden"> |
|
<EventDetails {event} {profile} communityStatusMap={communityStatus} /> |
|
</div> |
|
<div class="min-w-0 overflow-hidden"> |
|
<RelayActions {event} /> |
|
</div> |
|
|
|
<div class="min-w-0 overflow-hidden"> |
|
<CommentViewer {event} /> |
|
</div> |
|
|
|
{#if user?.signedIn} |
|
<div class="mt-8 min-w-0 overflow-hidden"> |
|
<Heading tag="h3" class="h-leather mb-4 break-words" |
|
>Add Comment</Heading |
|
> |
|
<CommentBox {event} {userRelayPreference} /> |
|
</div> |
|
{:else} |
|
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg min-w-0"> |
|
<P>Please sign in to add comments.</P> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</div>
|
|
|