bpist-squalk-forum/src/routes/profile/[id]/+page.svelte
2026-08-26 15:08:13 +01:00

257 lines
7.5 KiB
Svelte

<script lang="ts">
import { page } from "$app/state";
import { MODE } from "$lib/config";
import { getGroupName } from "$lib/group.svelte";
import {
profileStore,
ensureProfile,
entryFromUser,
type ProfileEntry,
} from "$lib/profiles.svelte";
import {
userPostsStore,
loadUserPosts,
loadMoreUserPosts,
} from "$lib/userPosts.svelte";
import type {
UserPost,
UserPostKind,
UserPostPage,
} from "$lib/forum/userPosts";
import Meta from "$lib/components/Meta.svelte";
import { summarize } from "$lib/seo";
const pubkey = $derived<string | null>(page.data.pubkey ?? null);
$effect(() => {
if (!pubkey) return;
ensureProfile(pubkey);
loadUserPosts(pubkey);
});
// Server-rendered profile, used until the live one is fetched.
const entry = $derived.by<ProfileEntry | undefined>(() => {
if (!pubkey) return undefined;
const live = profileStore.profiles.get(pubkey);
if (live) return live;
const u = page.data.profiles?.[pubkey];
return u ? entryFromUser(u) : undefined;
});
const posts = $derived(
pubkey && userPostsStore.pubkey === pubkey
? userPostsStore.posts
: (page.data.posts ?? null),
);
const npub = $derived(entry?.npub ?? page.params.id ?? "");
const name = $derived(
entry?.displayName || entry?.name || `${npub.slice(0, 12)}…`,
);
const initial = $derived(name[0]?.toUpperCase() ?? "?");
function websiteHref(url: string): string {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function websiteLabel(url: string): string {
return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
}
function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function postHref(p: UserPost): string {
return p.id === p.threadId
? `/thread/${p.threadId}`
: `/thread/${p.threadId}#post-${p.id}`;
}
const jsonLd = $derived(
pubkey
? {
"@context": "https://schema.org",
"@type": "Person",
name,
url: page.url.origin + page.url.pathname,
...(entry?.picture ? { image: entry.picture } : {}),
...(entry?.about ? { description: summarize(entry.about, 160) } : {}),
}
: undefined,
);
</script>
<Meta
title={name}
description={entry?.about ? summarize(entry.about, 160) : ""}
image={entry?.picture}
{jsonLd}
/>
{#snippet postList(kind: UserPostKind, list: UserPostPage, empty: string)}
{#if list.items.length === 0}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">{empty}</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each list.items as p (p.id)}
{@const text = summarize(p.content, 200)}
<li class="py-3">
<a href={postHref(p)} class="group block">
{#if p.title}
<p
class="group-hover:text-accent line-clamp-2 text-lg leading-5 text-neutral-900 dark:text-neutral-100"
>
{p.title}
</p>
{/if}
{#if text}
<p
class="mt-1 text-sm leading-5 text-neutral-600 dark:text-neutral-400"
>
{text}
</p>
{/if}
</a>
<div
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
>
{#if MODE === "full"}
<a href="/room/{p.groupId}" class="hover:text-accent truncate"
>{getGroupName(p.groupId)}</a
>
<span aria-hidden="true">·</span>
{/if}
<time datetime={new Date(p.createdAt * 1000).toISOString()}
>{formatDate(p.createdAt)}</time
>
</div>
</li>
{/each}
</ul>
{#if !list.done}
{@const busy = userPostsStore.loadingMore[kind]}
<div class="flex justify-center py-6">
<button
onclick={() => loadMoreUserPosts(kind)}
disabled={busy}
aria-busy={busy}
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{busy ? "Loading…" : "Show more"}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="mx-auto max-w-6xl">
{#if !pubkey}
<h1 class="text-accent py-2 text-[1.65rem]">Profile</h1>
<p class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Profile not found.
</p>
{:else}
<h1 class="text-accent py-2 text-[1.65rem]">{name}</h1>
<div class="mt-2 flex items-start gap-6">
<div class="min-w-0 flex-1">
{#if entry?.nip05}
<p class="truncate text-neutral-400 dark:text-neutral-500">
{entry.nip05}
</p>
{/if}
{#if entry?.about}
<p
class="mt-2 whitespace-pre-line text-neutral-600 dark:text-neutral-400"
>
{entry.about}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
{#if entry?.website}
<a
href={websiteHref(entry.website)}
target="_blank"
rel="noopener noreferrer"
class="text-accent truncate hover:underline"
>
{websiteLabel(entry.website)}
</a>
{/if}
{#if entry?.lud16}
<span
class="text-neutral-500 dark:text-neutral-400"
title="Lightning address"
>
⚡ {entry.lud16}
</span>
{/if}
<a
href="https://njump.me/{npub}"
target="_blank"
rel="noopener noreferrer"
class="text-accent text-sm hover:underline"
>
View on njump ↗
</a>
</div>
</div>
{#if entry?.picture}
<img
src={entry.picture}
alt=""
class="h-24 w-24 shrink-0 rounded-full object-cover sm:h-32 sm:w-32"
/>
{:else}
<span
class="flex h-24 w-24 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-3xl font-semibold text-neutral-500 sm:h-32 sm:w-32 dark:bg-neutral-700 dark:text-neutral-400"
aria-hidden="true"
>
{initial}
</span>
{/if}
</div>
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
<section aria-labelledby="profile-discussions">
<h2
id="profile-discussions"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Discussions
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList("roots", posts.roots, "No discussions yet.")}
{/if}
</section>
<section aria-labelledby="profile-replies">
<h2
id="profile-replies"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Replies
</h2>
{#if !posts}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
Loading…
</p>
{:else}
{@render postList("replies", posts.replies, "No replies yet.")}
{/if}
</section>
</div>
{/if}
</div>