mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user