4 changed files with 302 additions and 22 deletions
@ -0,0 +1,292 @@
@@ -0,0 +1,292 @@
|
||||
<script lang="ts"> |
||||
import { Card, Heading, P, Button, Modal, Avatar } from 'flowbite-svelte'; |
||||
import AAlert from '$lib/a/primitives/AAlert.svelte'; |
||||
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import LazyImage from '$lib/components/util/LazyImage.svelte'; |
||||
import { userBadge } from '$lib/snippets/UserSnippets.svelte'; |
||||
import { basicMarkup } from '$lib/snippets/MarkupSnippets.svelte'; |
||||
import QrCode from '$lib/components/util/QrCode.svelte'; |
||||
import { generateDarkPastelColor } from '$lib/utils/image_utils'; |
||||
import { lnurlpWellKnownUrl, checkCommunity } from '$lib/utils/search_utility'; |
||||
import { bech32 } from 'bech32'; |
||||
import { getNdkContext, activeInboxRelays } from '$lib/ndk'; |
||||
import { toNpub } from '$lib/utils/nostrUtils'; |
||||
import { neventEncode, naddrEncode, nprofileEncode } from '$lib/utils'; |
||||
import { isPubkeyInUserLists, fetchCurrentUserLists } from '$lib/utils/user_lists'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { UserOutline } from 'flowbite-svelte-icons'; |
||||
|
||||
type UserLite = { npub?: string | null }; |
||||
type Profile = { |
||||
name?: string; |
||||
display_name?: string; |
||||
displayName?: string; |
||||
about?: string; |
||||
picture?: string; |
||||
banner?: string; |
||||
website?: string; |
||||
lud16?: string; |
||||
nip05?: string; |
||||
// Optional flags that might come via cached profile data |
||||
isInUserLists?: boolean; |
||||
} | null; |
||||
|
||||
const props = $props<{ |
||||
user?: UserLite; |
||||
profile: Profile; |
||||
loading?: boolean; |
||||
error?: string | null; |
||||
isOwn?: boolean; |
||||
event?: NDKEvent; |
||||
communityStatusMap?: Record<string, boolean>; |
||||
}>(); |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
let lnModalOpen = $state(false); |
||||
let lnurl = $state<string | null>(null); |
||||
let communityStatus = $state<boolean | null>(null); |
||||
let isInUserLists = $state<boolean | null>(null); |
||||
|
||||
function displayName() { |
||||
const p = props.profile; |
||||
const u = props.user; |
||||
return p?.display_name || p?.displayName || p?.name || (u?.npub ? u.npub.slice(0, 10) + '…' : ''); |
||||
} |
||||
|
||||
function shortNpub() { |
||||
const npub = props.user?.npub; |
||||
if (!npub) return ''; |
||||
return npub.slice(0, 12) + '…' + npub.slice(-8); |
||||
} |
||||
|
||||
function hideOnError(e: Event) { |
||||
const img = e.currentTarget as HTMLImageElement | null; |
||||
if (img) { |
||||
img.style.display = 'none'; |
||||
const next = img.nextElementSibling as HTMLElement | null; |
||||
if (next) next.classList.remove('hidden'); |
||||
} |
||||
} |
||||
|
||||
function getIdentifiers(event: NDKEvent, profile: any): { label: string; value: string; link?: string }[] { |
||||
const ids: { label: string; value: string; link?: string }[] = []; |
||||
if (event.kind === 0) { |
||||
const npub = toNpub(event.pubkey); |
||||
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` }); |
||||
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, $activeInboxRelays), link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}` }); |
||||
ids.push({ label: 'nevent', value: neventEncode(event, $activeInboxRelays), link: `/events?id=${neventEncode(event, $activeInboxRelays)}` }); |
||||
ids.push({ label: 'pubkey', value: event.pubkey }); |
||||
} else { |
||||
ids.push({ label: 'nevent', value: neventEncode(event, $activeInboxRelays), link: `/events?id=${neventEncode(event, $activeInboxRelays)}` }); |
||||
try { |
||||
const naddr = naddrEncode(event, $activeInboxRelays); |
||||
ids.push({ label: 'naddr', value: naddr, link: `/events?id=${naddr}` }); |
||||
} catch {} |
||||
ids.push({ label: 'id', value: event.id, link: `/events?id=${event.id}` }); |
||||
} |
||||
return ids; |
||||
} |
||||
|
||||
function navigateToIdentifier(link: string) { |
||||
goto(link); |
||||
} |
||||
|
||||
// Compute LNURL on mount if lud16 exists |
||||
$effect(() => { |
||||
const p = props.profile; |
||||
if (p?.lud16) { |
||||
try { |
||||
const [name, domain] = p.lud16.split('@'); |
||||
const url = lnurlpWellKnownUrl(domain, name); |
||||
const words = bech32.toWords(new TextEncoder().encode(url)); |
||||
lnurl = bech32.encode('lnurl', words); |
||||
} catch { |
||||
lnurl = null; |
||||
} |
||||
} else { |
||||
lnurl = null; |
||||
} |
||||
}); |
||||
|
||||
// Compute community/list status when event changes |
||||
$effect(() => { |
||||
const ev = props.event; |
||||
if (!ev?.pubkey) { |
||||
communityStatus = null; |
||||
isInUserLists = null; |
||||
return; |
||||
} |
||||
|
||||
// isInUserLists: prefer prop.profile hint, else cached profileData, else fetch |
||||
if (props.profile && typeof props.profile.isInUserLists === 'boolean') { |
||||
isInUserLists = props.profile.isInUserLists; |
||||
} else { |
||||
const cachedProfileData = (ev as any).profileData; |
||||
if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') { |
||||
isInUserLists = cachedProfileData.isInUserLists; |
||||
} else { |
||||
fetchCurrentUserLists().then((lists) => { |
||||
isInUserLists = isPubkeyInUserLists(ev.pubkey, lists); |
||||
}).catch(() => { |
||||
isInUserLists = false; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// community status: prefer map if provided, else check |
||||
if (props.communityStatusMap && props.communityStatusMap[ev.pubkey] !== undefined) { |
||||
communityStatus = props.communityStatusMap[ev.pubkey]; |
||||
} else { |
||||
checkCommunity(ev.pubkey).then((status) => { |
||||
communityStatus = status; |
||||
}).catch(() => { |
||||
communityStatus = false; |
||||
}); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<Card size="xl" class="main-leather p-0 overflow-hidden rounded-lg border border-primary-200 dark:border-primary-700"> |
||||
{#if props.profile?.banner} |
||||
{#if props.event} |
||||
<div class="w-full bg-primary-200 dark:bg-primary-800 relative"> |
||||
<LazyImage src={props.profile.banner} alt="Profile banner" eventId={props.event.id} className="w-full h-60 object-cover" /> |
||||
</div> |
||||
{:else} |
||||
<div class="w-full h-60 bg-primary-200 dark:bg-primary-800 relative"> |
||||
<img src={props.profile.banner} alt="Banner" class="w-full h-full object-cover" loading="lazy" onerror={hideOnError} /> |
||||
</div> |
||||
{/if} |
||||
{:else if props.event} |
||||
<div class="w-full h-60" style={`background-color: ${generateDarkPastelColor(props.event.id)};`}></div> |
||||
{/if} |
||||
|
||||
<div class={`p-6 ${props.profile?.banner || props.event ? 'pt-6' : 'pt-6'} flex flex-col gap-4 relative`}> |
||||
<Avatar size="xl" border src={props.profile?.picture} alt="Avatar" class="absolute w-fit top-[-56px]" /> |
||||
|
||||
<div class="min-w-0 mt-14"> |
||||
{#if props.event} |
||||
<div class="flex items-center gap-2 min-w-0"> |
||||
<div class="min-w-0 flex-1"> |
||||
{@render userBadge( |
||||
toNpub(props.event.pubkey) as string, |
||||
props.profile?.displayName || props.profile?.display_name || props.profile?.name || props.event.pubkey, |
||||
ndk, |
||||
)} |
||||
</div> |
||||
{#if communityStatus === true} |
||||
<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 if communityStatus === false} |
||||
<div class="flex-shrink-0 w-4 h-4"></div> |
||||
{/if} |
||||
{#if isInUserLists === true} |
||||
<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> |
||||
{:else if isInUserLists === false} |
||||
<div class="flex-shrink-0 w-4 h-4"></div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<Heading tag="h1" class="h-leather mb-2">{displayName()}</Heading> |
||||
{/if} |
||||
|
||||
<div class="flex flex-row flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-1"> |
||||
{#if props.user?.npub} |
||||
<CopyToClipboard displayText={shortNpub()} copyText={props.user.npub} /> |
||||
{/if} |
||||
{#if props.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">{props.profile.nip05}</span> |
||||
{/if} |
||||
{#if props.profile?.lud16} |
||||
<Button color="alternative" class="!mb-0 !py-0.5 !px-2 rounded" size="xs" onclick={() => (lnModalOpen = true)}>⚡ {props.profile.lud16}</Button> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
{#if props.profile?.about} |
||||
{#if props.event} |
||||
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0"> |
||||
{@render basicMarkup(props.profile.about, ndk)} |
||||
</div> |
||||
{:else} |
||||
<P class="whitespace-pre-wrap break-words leading-relaxed">{props.profile.about}</P> |
||||
{/if} |
||||
{/if} |
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm"> |
||||
{#if props.profile?.website} |
||||
<a href={props.profile.website} rel="noopener" class="text-primary-600 dark:text-primary-400 hover:underline break-all" target="_blank">{props.profile.website}</a> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if props.event} |
||||
<div class="mt-4"> |
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Identifiers:</h4> |
||||
<div class="flex flex-col gap-2 min-w-0"> |
||||
{#each getIdentifiers(props.event, props.profile) as identifier} |
||||
<div class="flex items-center gap-2 min-w-0"> |
||||
<span class="text-gray-600 dark:text-gray-400 flex-shrink-0">{identifier.label}:</span> |
||||
<div class="flex-1 min-w-0 flex items-center gap-2"> |
||||
{#if identifier.link} |
||||
<button class="font-mono text-sm text-primary-700 dark:text-primary-300 hover:text-primary-900 dark:hover:text-primary-100 break-all cursor-pointer bg-transparent border-none p-0 text-left" onclick={() => navigateToIdentifier(identifier.link!)}> |
||||
{identifier.value} |
||||
</button> |
||||
{:else} |
||||
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">{identifier.value}</span> |
||||
{/if} |
||||
<CopyToClipboard displayText="" copyText={identifier.value} /> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if props.isOwn} |
||||
<div class="flex flex-row justify-end gap-4 text-sm"> |
||||
<Button class="!mb-0" size="xs" onclick={() => goto('/profile/notifications')}>Notifications</Button> |
||||
<Button class="!mb-0" size="xs" onclick={() => goto('/profile/my-notes')}>My notes</Button> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if props.loading} |
||||
<AAlert color="primary">Loading profile…</AAlert> |
||||
{/if} |
||||
{#if props.error} |
||||
<AAlert color="red">Error loading profile: {props.error}</AAlert> |
||||
{/if} |
||||
</div> |
||||
</Card> |
||||
|
||||
{#if lnModalOpen} |
||||
<Modal class="modal-leather" title="Lightning Address" bind:open={lnModalOpen} outsideclose size="sm"> |
||||
{#if props.profile?.lud16} |
||||
<div> |
||||
<div class="flex flex-col items-center"> |
||||
{@render userBadge( |
||||
props.event ? (toNpub(props.event.pubkey) as string) : (props.user?.npub || ''), |
||||
props.profile?.displayName || props.profile?.display_name || props.profile?.name || (props.event?.pubkey || ''), |
||||
ndk, |
||||
)} |
||||
<P class="break-all">{props.profile.lud16}</P> |
||||
</div> |
||||
<div class="flex flex-col items-center mt-3 space-y-4"> |
||||
<P>Scan the QR code or copy the address</P> |
||||
{#if lnurl} |
||||
<P class="break-all overflow-wrap-anywhere"> |
||||
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard> |
||||
</P> |
||||
<QrCode value={lnurl} /> |
||||
{:else} |
||||
<P>Couldn't generate address.</P> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</Modal> |
||||
{/if} |
||||
@ -1,24 +1,12 @@
@@ -1,24 +1,12 @@
|
||||
export { default as AInput } from './primitives/AInput.svelte'; |
||||
export { default as ACard } from './primitives/ACard.svelte'; |
||||
export { default as ADetails } from './primitives/ADetails.svelte'; |
||||
export { default as ANostrUser } from './primitives/ANostrUser.svelte'; |
||||
export { default as ANostrBadge } from './primitives/ANostrBadge.svelte'; |
||||
export { default as ANostrBadgeRow } from './primitives/ANostrBadgeRow.svelte'; |
||||
export { default as AThemeToggleMini } from './primitives/AThemeToggleMini.svelte'; |
||||
export { default as AAlert } from './primitives/AAlert.svelte'; |
||||
export { default as APagination } from './primitives/APagination.svelte'; |
||||
|
||||
export { default as AReaderPage } from './reader/AReaderPage.svelte'; |
||||
export { default as AReaderToolbar } from './reader/AReaderToolbar.svelte'; |
||||
export { default as AReaderTOC } from './reader/AReaderTOC.svelte'; |
||||
export { default as ATechToggle } from './reader/ATechToggle.svelte'; |
||||
export { default as ATechBlock } from './reader/ATechBlock.svelte'; |
||||
export { default as ATocNode } from './reader/ATocNode.svelte'; |
||||
|
||||
export { default as ANavbar } from './nav/ANavbar.svelte'; |
||||
export { default as AFooter } from './nav/AFooter.svelte'; |
||||
|
||||
export { default as ASearchForm } from './forms/ASearchForm.svelte'; |
||||
export { default as ACommentForm } from './forms/ACommentForm.svelte'; |
||||
|
||||
export { default as AEventPreview } from './cards/AEventPreview.svelte'; |
||||
export { default as AProfilePreview } from './cards/AProfilePreview.svelte'; |
||||
|
||||
@ -1,6 +0,0 @@
@@ -1,6 +0,0 @@
|
||||
<script lang="ts"> let { class: className = '' } = $props();
</script> |
||||
<div class={`rounded-lg border border-muted/20 bg-surface shadow-sm ${className}`}> |
||||
<slot name="header" /> |
||||
<div class="p-4"><slot /></div> |
||||
<slot name="footer" /> |
||||
</div> |
||||
Loading…
Reference in new issue