Browse Source

Profile page

master
Nuša Pukšič 7 months ago committed by buttercat1791
parent
commit
106a7665e5
  1. 67
      src/lib/a/forms/ACommentForm.svelte
  2. 896
      src/routes/profile/+page.svelte

67
src/lib/a/forms/ACommentForm.svelte

@ -1,17 +1,19 @@ @@ -1,17 +1,19 @@
<script lang="ts">
import { Textarea, Toolbar, ToolbarGroup, ToolbarButton, Button, Label } from "flowbite-svelte";
import { Textarea, Toolbar, ToolbarGroup, ToolbarButton, Button, Label, P } from "flowbite-svelte";
import {
LetterBoldOutline,
LetterItalicOutline
} from "flowbite-svelte-icons";
Bold, Italic, Strikethrough,
Quote, Link2, Image, Hash,
List, ListOrdered
} from "@lucide/svelte";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser.ts";
let {
content = "",
extensions,
profile,
isSubmitting = false,
onSubmit = () => {}
onSubmit = () => {},
} = $props<{
content?: string;
extensions?: any;
@ -20,16 +22,18 @@ @@ -20,16 +22,18 @@
onSubmit?: (content: string) => Promise<void>;
}>();
let preview = $state("");
const markupButtons = [
{ label: "Bold", icon: LetterBoldOutline, action: () => insertMarkup("**", "**") },
{ label: "Italic", icon: LetterItalicOutline, action: () => insertMarkup("_", "_") },
{ label: "Strike", action: () => insertMarkup("~~", "~~") },
{ label: "Link", action: () => insertMarkup("[", "](url)") },
{ label: "Image", action: () => insertMarkup("![", "](url)") },
{ label: "Quote", action: () => insertMarkup("> ", "") },
{ label: "List", action: () => insertMarkup("* ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") },
{ label: "Bold", icon: Bold, action: () => insertMarkup("**", "**") },
{ label: "Italic", icon: Italic, action: () => insertMarkup("_", "_") },
{ label: "Strike", icon: Strikethrough, action: () => insertMarkup("~~", "~~") },
{ label: "Link", icon: Link2, action: () => insertMarkup("[", "](url)") },
{ label: "Image", icon: Image, action: () => insertMarkup("![", "](url)") },
{ label: "Quote", icon: Quote, action: () => insertMarkup("> ", "") },
{ label: "List", icon: List, action: () => insertMarkup("* ", "") },
{ label: "Numbered List", icon: ListOrdered, action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", icon: Hash, action: () => insertMarkup("#", "") },
];
function insertMarkup(prefix: string, suffix: string) {
@ -76,28 +80,41 @@ @@ -76,28 +80,41 @@
e.preventDefault();
await onSubmit(content.trim());
}
$effect(() => {
console.log("Content changed, updating preview...");
if (content.trim() === "") {
preview = "";
return;
}
parseBasicmarkup(content).then((html) => {
preview = html;
});
});
</script>
<form novalidate onsubmit={handleSubmit}>
<form novalidate
onsubmit={handleSubmit}>
<Label for="editor" class="sr-only">Comment</Label>
<Textarea id="editor" rows={8}
<Textarea id="editor" rows={10}
bind:value={content}
class="!m-0 p-4"
innerClass="!m-0 !bg-transparent"
headerClass="!m-0 !bg-transparent"
footerClass="!m-0 !bg-transparent"
addonClass="!m-0 top-3"
addonClass="!m-0 top-3 hidden md:flex"
textareaClass="!m-0 !bg-transparent !border-0 !rounded-none !shadow-none !focus:ring-0"
placeholder="Write a comment">
{#snippet header()}
<Toolbar embedded class="flex-row !m-0">
<ToolbarGroup class="flex-row !m-0">
<ToolbarGroup class="flex-row flex-wrap !m-0">
{#each markupButtons as button}
{#if button.icon}
{@const TheIcon = button.icon}
<ToolbarButton size="xs" onclick={button.action} >
<TheIcon />
<ToolbarButton title={button.label} color="dark" size="md" onclick={button.action} >
<TheIcon size={24} />
</ToolbarButton>
{:else}
<ToolbarButton onclick={button.action}>{button.label}</ToolbarButton>
@ -112,7 +129,7 @@ @@ -112,7 +129,7 @@
{/snippet}
{#snippet footer()}
<div class="flex flex-row justify-between">
<div class="flex flex-row gap-3 !m-0">
<div class="flex flex-row flex-wrap gap-3 !m-0">
<Button size="xs" color="alternative" onclick={removeFormatting} class="!m-0">Remove Formatting</Button>
<Button size="xs" color="alternative" class="!m-0" onclick={clearForm}>Clear</Button>
</div>
@ -131,3 +148,11 @@ @@ -131,3 +148,11 @@
{/snippet}
</Textarea>
</form>
<div class="prose dark:prose-invert max-w-none p-4 border border-primary-500 border-s-4 rounded-lg">
{#if preview}
{@html preview}
{:else}
<P class="text-xs text-gray-500">Preview will appear here...</P>
{/if}
</div>

896
src/routes/profile/+page.svelte

@ -1,36 +1,19 @@ @@ -1,36 +1,19 @@
<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 { AAlert } from "$lib/a";
import CommentBox from "$lib/components/CommentBox.svelte";
import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } 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 { AAlert, AEventPreview } from "$lib/a";
import { userStore } from "$lib/stores/userStore";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { ndkInstance } from "$lib/ndk";
// State
let user = $state($userStore);
let loading = $state(false);
let error = $state<string | null>(null);
let searchValue = $state<string | null>(null);
let dTagValue = $state<string | 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 searchType = $state<string | null>(null);
let searchTerm = $state<string | null>(null);
let profileEvent = $state<NDKEvent | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
@ -41,816 +24,125 @@ @@ -41,816 +24,125 @@
lud16?: string;
nip05?: string;
} | null>(null);
let user = $state($userStore);
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 lastFetchedPubkey: string | null = null;
let userRelayPreference = $state(false); // required by CommentBox
userStore.subscribe((val) => (user = val));
userStore.subscribe(v => user = v);
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
showSidePanel = true;
// Clear search results when showing a single event
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
searchType = null;
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) {
async function fetchProfileEvent(pubkey: string) {
if (!pubkey || pubkey === lastFetchedPubkey) return;
loading = true;
error = null;
try {
profile = JSON.parse(newEvent.content);
} catch {
profile = null;
}
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK not initialized');
}
// Fetch kind 0 event for this author
const evt = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] });
profileEvent = evt || null;
if (evt?.content) {
try { profile = JSON.parse(evt.content); } catch { profile = null; }
} else {
profile = null;
}
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author
if (newEvent.pubkey) {
cacheProfileForPubkey(newEvent.pubkey);
}
}
// AI-NOTE: 2025-01-24 - 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, 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 when $page.url.searchParams.get('id') changes
// Fallback: ensure we have metadata via helper (will cache)
if (!profile && user.npub) {
const meta = await getUserMetadata(user.npub, true);
profile = {
name: meta.name,
display_name: meta.displayName,
about: meta.about,
picture: meta.picture,
banner: meta.banner,
website: meta.website,
lud16: meta.lud16,
nip05: meta.nip05,
};
}
lastFetchedPubkey = pubkey;
} catch (e: any) {
console.error('[profile/+page] Failed to fetch profile event', e);
error = e?.message || 'Failed to load profile';
} finally {
loading = false;
}
}
// Reactive: when user login changes fetch profile event
$effect(() => {
const url = $page.url.searchParams;
const idParam = url.get("id");
const dParam = url.get("d");
if (idParam) {
searchValue = idParam;
dTagValue = null;
} else if (dParam) {
searchValue = null;
dTagValue = dParam.toLowerCase();
} else {
searchValue = null;
dTagValue = null;
}
});
// Add support for t and n parameters
$effect(() => {
const url = $page.url.searchParams;
const tParam = url.get("t");
const nParam = url.get("n");
if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix
const decodedT = decodeURIComponent(tParam);
searchValue = `t:${decodedT}`;
dTagValue = null;
} else if (nParam) {
// Decode the n parameter and set it as searchValue with n: prefix
const decodedN = decodeURIComponent(nParam);
searchValue = `n:${decodedN}`;
dTagValue = null;
}
});
// Handle side panel visibility based on search type
$effect(() => {
const url = $page.url.searchParams;
const hasIdParam = url.get("id");
const hasDParam = url.get("d");
const hasTParam = url.get("t");
const hasNParam = url.get("n");
// Close side panel for searches that return multiple results
if (hasDParam || hasTParam || hasNParam) {
showSidePanel = false;
event = null;
profile = null;
}
});
function handleSearchResults(
results: NDKEvent[],
secondOrder: NDKEvent[] = [],
tTagEvents: NDKEvent[] = [],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
searchTypeParam?: string,
searchTermParam?: string,
) {
searchResults = results;
secondOrderResults = secondOrder;
tTagResults = tTagEvents;
originalEventIds = eventIds;
originalAddresses = addresses;
searchType = searchTypeParam || null;
searchTerm = searchTermParam || null;
// Track search progress
searchInProgress =
loading || (results.length > 0 && secondOrder.length === 0);
// AI-NOTE: 2025-01-08 - 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: 2025-01-24 - Cache profiles for all search results
cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]);
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
}
// AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events
async function cacheProfilesForEvents(events: NDKEvent[]) {
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) {
uniquePubkeys.add(event.pubkey);
}
if (user?.pubkey) fetchProfileEvent(user.pubkey);
});
console.log(`[Events Page] Caching profiles for ${uniquePubkeys.size} unique pubkeys`);
// Cache profiles in parallel
const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey));
await Promise.allSettled(cachePromises);
console.log(`[Events Page] Profile caching complete`);
}
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 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: 2025-01-24 - Function to parse profile content from kind 0 events
function parseProfileContent(event: NDKEvent): {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | 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 getNaddrUrl(event: NDKEvent): string {
return naddrEncode(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 displayName() {
return profile?.display_name || profile?.name || (user?.npub ? user.npub.slice(0, 10) + '…' : '');
}
function onLoadingChange(val: boolean) {
loading = val;
searchInProgress =
val || (searchResults.length > 0 && secondOrderResults.length === 0);
function shortNpub() {
if (!user?.npub) return '';
return user.npub.slice(0, 12) + '…' + user.npub.slice(-8);
}
/**
* 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 };
}
// AI-NOTE: Refactored to avoid blocking $effect with logging operations
// Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Events Page - Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
}
});
</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">
<!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-full lg:w-80 lg:min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
{#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>
<P class="mb-3">
Search and view any event on the Nostr network. Enter an npub, nprofile, nevent, naddr, note,
pubkey, or eventID. You can also search for events by d-tag using the
format "d:tag-name", t-tags with "t:tag-name", or profiles by name with "n:name".
</P>
<P class="mb-3">Visit the
<a href="/events/compose" class="text-primary-600 dark:text-primary-400 hover:underline">Compose page</a> to create new events.
</P>
<EventSearch
{loading}
{error}
{searchValue}
{dTagValue}
{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">
<Heading tag="h2" class="h-leather mb-4 break-words">
{#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 || dTagValue?.toLowerCase() || '';
return term.length > 50 ? term.slice(0, 50) + '...' : term;
})()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
{#each searchResults as result, index}
<AEventPreview
event={result}
index={index}
showPublicationLink={isAddressableEvent(result)}
label={(searchType === "n" ? "Profile " : "Event ") + (index + 1)}
community={result.pubkey && communityStatus[result.pubkey]}
onSelect={handleEventFound}
/>
{@const profileData = parseProfileContent(result)}
{/each}
</div>
</div>
{/if}
{#if secondOrderResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
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">
{#each secondOrderResults as result, index}
{@const profileData = parseProfileContent(result)}
<button
class="w-full 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 overflow-hidden"
onclick={() => handleEventFound(result)}
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>Reference {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#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>
{#if !user || !user.signedIn}
<div class="w-full max-w-3xl mx-auto mt-10 px-4">
<AAlert color="blue">Please log in to view your profile.</AAlert>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{:else}
<div class="w-full flex justify-center">
<div class="flex flex-col w-full max-w-5xl my-6 px-4 mx-auto gap-6">
<div class="main-leather p-0 overflow-hidden rounded-lg border border-primary-200 dark:border-primary-700">
{#if profile?.banner}
<div class="w-full h-40 bg-primary-200 dark:bg-primary-800 relative">
<img src={profile.banner} alt="Banner" class="w-full h-full object-cover" loading="lazy" onerror={(e) => (e.target as HTMLImageElement).style.display='none'} />
{#if profile?.picture}
<img src={profile.picture} alt="Avatar" class="absolute -bottom-10 left-6 w-24 h-24 object-cover rounded-full border-4 border-white dark:border-gray-900 bg-gray-100" onerror={(e) => (e.target as HTMLImageElement).style.display='none'} />
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
profileData?.display_name || profileData?.name,
)}
</span>
<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>
<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"
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>
<div class={`p-6 ${profile?.banner ? 'pt-16' : 'pt-6'} flex flex-col gap-4`}>
{#if !profile?.banner && profile?.picture}
<img src={profile.picture} alt="Avatar" class="w-32 h-32 object-cover rounded-full border border-gray-300 dark:border-gray-600 bg-gray-100" loading="lazy" onerror={(e) => (e.target as HTMLImageElement).style.display='none'} />
{/if}
{#if profileData.about}
<span class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{profileData.about}
</span>
<div>
<Heading tag="h1" class="h-leather mb-2">{displayName()}</Heading>
<div class="flex flex-row flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
{#if user.npub}
<CopyToClipboard displayText={shortNpub()} copyText={user.npub} />
{/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 profile?.nip05}
<span class="px-2 py-0.5 !mb-0 rounded bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 text-xs">{profile.nip05}</span>
{/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 profile?.lud16}
<span class="px-2 py-0.5 !mb-0 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 text-xs">{profile.lud16}</span>
{/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"
>
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
{#if profile?.about}
<P class="whitespace-pre-wrap break-words leading-relaxed">{profile.about}</P>
{/if}
<div class="flex flex-wrap gap-4 text-sm">
{#if profile?.website}
<a href={profile.website} rel="noopener" class="text-primary-600 dark:text-primary-400 hover:underline break-all" target="_blank">{profile.website}</a>
{/if}
</div>
</button>
{/each}
</div>
</div>
{#if loading}
<div class="text-sm text-gray-500 dark:text-gray-400">Loading profile…</div>
{/if}
{#if tTagResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({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">
{#each tTagResults as result, index}
{@const profileData = parseProfileContent(result)}
<button
class="w-full 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 overflow-hidden"
onclick={() => handleEventFound(result)}
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100"
>Tagged Event {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
{#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>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{#if error}
<div class="text-sm text-red-600 dark:text-red-400">{error}</div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
profileData?.display_name || profileData?.name,
)}
</span>
<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"
>
{result.content.slice(0, 200)}{result.content.length >
200
? "..."
: ""}
</div>
{/if}
{/if}
</div>
</button>
{/each}
</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">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
</button>
</div>
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<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} />
{#if profileEvent}
<div class="main-leather flex flex-col space-y-6">
<CommentViewer event={profileEvent} />
<CommentBox event={profileEvent} {userRelayPreference} />
</div>
{/if}
{:else if !loading}
<AAlert color="gray">No profile event (kind 0) found for this user.</AAlert>
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
<CommentViewer {event} />
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
</div>
{/if}
</div>
</div>
{/if}

Loading…
Cancel
Save