mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
351 lines
13 KiB
Svelte
351 lines
13 KiB
Svelte
<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…</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="That’s 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…</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>
|