initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+350
View File
@@ -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&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>