Add the profile page for users
This commit is contained in:
parent
bcbc70ecb8
commit
f40680cbf9
13 changed files with 517 additions and 76 deletions
236
src/routes/profile/[id]/+page.svelte
Normal file
236
src/routes/profile/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<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 } from "$lib/userPosts.svelte";
|
||||
import type { UserPost } 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(items: UserPost[], empty: string)}
|
||||
{#if 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 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}
|
||||
{/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(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(posts.replies, "No replies yet.")}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
24
src/routes/profile/[id]/+page.ts
Normal file
24
src/routes/profile/[id]/+page.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { redirect } from "@sveltejs/kit";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import { decodeProfileId } from "$lib/forum/profiles";
|
||||
import { snapshot, type Profiles } from "$lib/forum/snapshot";
|
||||
import type { UserPosts } from "$lib/forum/userPosts";
|
||||
|
||||
// nprofile and hex forms redirect to the canonical npub URL. An invalid id
|
||||
// is a server 404 (via the endpoint) and an in-page message on the client.
|
||||
export async function load({ fetch, params }) {
|
||||
const pubkey = decodeProfileId(params.id);
|
||||
if (pubkey) {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
if (params.id !== npub) redirect(301, `/profile/${npub}`);
|
||||
}
|
||||
const data = await snapshot<{ posts: UserPosts; profiles: Profiles }>(
|
||||
fetch,
|
||||
`/api/profile/${params.id}`,
|
||||
);
|
||||
return {
|
||||
pubkey,
|
||||
posts: data?.posts ?? null,
|
||||
profiles: data?.profiles ?? null,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue