Files
plspace/src/routes/Profile.svelte
T
2026-07-29 09:16:38 +09:00

351 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
/**
* The profile page — the whole point of the exercise.
*
* Left rail: photo, vitals, contacting box, URL, interests, details.
* Main column: latest blog entries, blurbs, friend space.
*
* The account's own published CSS (a profile field named `css`) is applied
* while this page is mounted and torn down on unmount, scoped to
* `.profile-page` — see lib/stores/theme.svelte.ts.
*/
import { untrack } from 'svelte'
import type { Account, Relationship, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import { theme, profileCssFromFields } from '$lib/stores/theme.svelte'
import {
fetchAccountStatuses,
fetchFollowers,
fetchRelationship,
lookupAccount,
} from '$lib/api/endpoints'
import {
buildProfileView,
displayNameOf,
followerCountHidden,
followersHidden,
followingCountHidden,
formatCount,
} from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte'
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
import ContactBox from '$components/profile/ContactBox.svelte'
import InterestsTable from '$components/profile/InterestsTable.svelte'
import DetailsTable from '$components/profile/DetailsTable.svelte'
import FriendSpace from '$components/profile/FriendSpace.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import Pager from '$components/common/Pager.svelte'
interface Props {
acct: string
/** Which sub-page: the profile itself, or a full list. */
view?: 'profile' | 'blog' | 'friends' | 'pics'
}
let { acct, view = 'profile' }: Props = $props()
let account = $state<Account | null>(null)
let relationship = $state<Relationship | null>(null)
let loading = $state(true)
let error = $state<string | null>(null)
let friends = $state<Account[]>([])
let friendsLoading = $state(false)
// Recreated whenever the account changes, so the feed never shows one
// person's entries under another's name.
let entries = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
const profile = $derived(account ? buildProfileView(account) : null)
const firstName = $derived(account ? displayNameOf(account).split(/\s+/)[0] : '')
const listHidden = $derived(account ? followersHidden(account) : false)
const countHidden = $derived(account ? followerCountHidden(account) : false)
const followsCountHidden = $derived(account ? followingCountHidden(account) : false)
/** Route prefix for this profile; snippets can't see the null-narrowing. */
const base = $derived(account ? `#/@${account.acct}` : '#/')
/** How many friend tiles the compact grid shows before "[view all]". */
const FRIEND_PREVIEW = 12
$effect(() => {
// Track the inputs that change what's on screen; the load itself is
// untracked so reading state inside it can't retrigger this effect.
const handle = acct
const currentView = view
const host = session.host
if (!host) return
untrack(() => void load(handle, currentView))
})
async function load(handle: string, currentView: Props['view']): Promise<void> {
loading = true
error = null
account = null
relationship = null
friends = []
try {
const found = await lookupAccount(session.api, handle)
// A newer navigation won the race.
if (acct !== handle) return
account = found
document.title = `${displayNameOf(found)} | plspace`
theme.applyProfileCss(profileCssFromFields(found.fields))
entries = new Feed<Status>(
(cursor) =>
fetchAccountStatuses(session.api, found.id, cursor, {
// The profile page mirrors "Latest Blog Entries": top-level posts.
exclude_replies: currentView !== 'blog',
}),
currentView === 'blog' ? 20 : 10,
)
void entries.reload()
void loadFriends(found, currentView)
void loadRelationship(found)
} catch (cause) {
if (acct !== handle) return
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
} finally {
if (acct === handle) loading = false
}
}
async function loadFriends(target: Account, currentView: Props['view']): Promise<void> {
if (followersHidden(target)) return
friendsLoading = true
try {
const page = await fetchFollowers(session.api, target.id, {
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
})
if (account?.id === target.id) friends = page.items
} catch {
// Hidden or unavailable follower lists are normal; the count still shows.
if (account?.id === target.id) friends = []
} finally {
friendsLoading = false
}
}
async function loadRelationship(target: Account): Promise<void> {
try {
const found = await fetchRelationship(session.api, target.id)
if (account?.id === target.id) relationship = found
} catch {
/* relationships need auth; absence is fine */
}
}
$effect(() => () => theme.clearProfileCss())
/** One line of an entry, for the headline list on the profile page. */
function teaserFor(entry: Status): string {
const text = toPlainText(entry.spoiler_text || entry.content)
if (text) return text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text
if (entry.media_attachments.length > 0) {
const count = entry.media_attachments.length
return `(${count} photo${count === 1 ? '' : 's'})`
}
return '(no text)'
}
</script>
<div class="page profile-page" data-account={account?.acct ?? acct} data-view={view}>
{#if loading}
<p class="loading-note">Loading profile&hellip;</p>
{:else if error}
<p class="error-note" role="alert">
<strong class="error-note-title">Profile not found.</strong>
{error}
</p>
{:else if account && profile}
<h1 class="page-title profile-name">{displayNameOf(account)}</h1>
{#if account.moved}
<p class="profile-moved">
This account has moved to
<a href={`#/@${account.moved.acct}`}>@{account.moved.acct}</a>.
</p>
{/if}
<div class="layout--split">
<div class="layout-column layout-column--left">
<ProfileIdentity {profile} />
<ContactBox
{account}
{relationship}
onrelationship={(next) => (relationship = next)}
/>
<Module title="plspace URL">
<p class="profile-url">
<a href={`#/@${account.acct}`}>{location.origin}{location.pathname}#/@{account.acct}</a>
</p>
</Module>
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
<Module title={`${firstName}'s Stats`} flush>
<table class="data-table stats-table">
<tbody>
<tr>
<th class="data-table-label" scope="row">Blog entries</th>
<td class="data-table-value">{formatCount(account.statuses_count)}</td>
</tr>
<!-- Pleroma zeroes these counts when the user hides them, so the
flag has to be checked before the number is believed. -->
<tr>
<th class="data-table-label" scope="row">Friends</th>
<td class="data-table-value" data-private={countHidden ? 'true' : 'false'}>
{#if countHidden}
<span class="muted">private</span>
{:else}
<a href={`#/@${account.acct}/friends`}>{formatCount(account.followers_count)}</a>
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Friend of</th>
<td class="data-table-value" data-private={followsCountHidden ? 'true' : 'false'}>
{#if followsCountHidden}
<span class="muted">private</span>
{:else}
{formatCount(account.following_count)}
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Joined</th>
<td class="data-table-value">{new Date(account.created_at).getFullYear()}</td>
</tr>
</tbody>
</table>
</Module>
</div>
<div class="layout-column layout-column--main">
{#if view === 'friends'}
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
{friends}
total={account.followers_count}
viewAllHref={`#/@${account.acct}`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
/>
{:else if view === 'blog'}
<Module title={`${firstName}'s Blog`} variant="band">
{#snippet action()}
<a href={base}>[Back to Profile]</a>
{/snippet}
<ul class="blog-list">
{#each entries.items as status (status.id)}
<li class="blog-list-item">
<BlogEntry
{status}
compact
longFormDate
onupdate={(next) => entries.update(status.id, () => next)}
ondelete={(id) => entries.remove(id)}
/>
</li>
{/each}
</ul>
<Pager
feed={entries}
emptyText="There are no Blog Entries yet."
endText="Thats the whole blog."
/>
</Module>
{:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band">
<p class="empty-note">
Photos appear here as they're attached to blog entries.
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context.
</p>
</Module>
{:else}
<!--
The profile page lists entry headlines with "(view more)", exactly
as the 2005 page did. Full entries live on /blog — otherwise ten
posts of media push the Blurbs and Friend Space off the bottom,
which is the wrong shape for a profile.
-->
<Module title={`${firstName}'s Latest Blog Entries`} variant="band">
{#snippet action()}
<a href={`${base}/blog`}>[View Blog]</a>
{/snippet}
{#if entries.loading && entries.items.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if entries.items.length === 0}
<p class="empty-note">There are no Blog Entries yet.</p>
{:else}
<ul class="entry-teaser-list">
{#each entries.items.slice(0, 6) as status (status.id)}
{@const entry = status.reblog ?? status}
<li class="entry-teaser" data-status-id={entry.id}>
<span class="entry-teaser-text">{teaserFor(entry)}</span>
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
</li>
{/each}
</ul>
<p class="entry-teaser-all">
<a href={`${base}/blog`}>[View All Blog Entries]</a>
</p>
{/if}
</Module>
<Module title={`${firstName}'s Blurbs`} variant="band">
<h3 class="section-heading">About me:</h3>
{#if profile.about}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.about}
</div>
{:else}
<p class="empty-note">{firstName} hasn't written an About me yet.</p>
{/if}
<h3 class="section-heading">Who I'd like to meet:</h3>
{#if profile.wantsToMeet}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.wantsToMeet}
</div>
{:else}
<p class="blurb-body">
People who educate, inspire or entertain me. And you, apparently.
</p>
{/if}
</Module>
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
friends={friends.slice(0, FRIEND_PREVIEW)}
total={account.followers_count}
viewAllHref={`#/@${account.acct}/friends`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
compact
/>
{/if}
</div>
</div>
{/if}
</div>