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
+378
View File
@@ -0,0 +1,378 @@
<script lang="ts">
/**
* "Hello, Tom!" — the logged-in dashboard, modelled on the 2007 home page:
* a narrow left rail with the control panel, a main column of Friend Status
* and Bulletin Space, and a right rail of server odds and ends.
*
* Logged out, the same page becomes a front door: what this server is, and
* what its local timeline looks like right now.
*/
import type { Account, Notification, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import {
fetchFollowing,
fetchNotifications,
fetchTimeline,
instanceDomain,
instanceStats,
instanceThumbnail,
} from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
import Composer from '$components/blog/Composer.svelte'
let friendStatus = $state<Status[]>([])
let bulletins = $state<Status[]>([])
let following = $state<Account[]>([])
let notifications = $state<Notification[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
/**
* Per-module failures. Several large servers (mastodon.social among them)
* refuse timeline reads without a token, so "empty" and "not allowed" must
* look different or the page reads as broken.
*/
let friendStatusError = $state<string | null>(null)
let bulletinError = $state<string | null>(null)
const me = $derived(session.me)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
const thumbnail = $derived(instanceThumbnail(session.instance))
const stats = $derived(instanceStats(session.instance))
/** Notification counts by kind, for the "New Messages!" summary. */
const counts = $derived.by(() => {
const table: Record<string, number> = {}
for (const notification of notifications) {
table[notification.type] = (table[notification.type] ?? 0) + 1
}
return table
})
const summary = $derived([
{ label: 'New Messages!', count: counts.mention ?? 0, href: '#/mail/mentions', kind: 'mention' },
{ label: 'New Friend Requests!', count: counts.follow_request ?? 0, href: '#/mail/requests', kind: 'follow_request' },
{ label: 'New Friends!', count: counts.follow ?? 0, href: '#/mail/follows', kind: 'follow' },
{ label: 'New Kudos!', count: counts.favourite ?? 0, href: '#/mail/kudos', kind: 'favourite' },
{ label: 'New Reposts!', count: counts.reblog ?? 0, href: '#/mail/reposts', kind: 'reblog' },
])
$effect(() => {
const host = session.host
const signedIn = session.signedIn
if (!host) {
loading = false
return
}
void load(signedIn)
})
async function load(signedIn: boolean): Promise<void> {
loading = true
error = null
friendStatusError = null
bulletinError = null
try {
// Everything here is independent; a failure in one shouldn't blank the page.
const [statusPage, bulletinPage] = await Promise.all([
signedIn
? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
friendStatusError = messageOf(cause)
return { items: [], links: {} }
})
: Promise.resolve({ items: [], links: {} }),
fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
bulletinError = messageOf(cause)
return { items: [], links: {} }
}),
])
friendStatus = statusPage.items
bulletins = bulletinPage.items
if (signedIn && session.me) {
void fetchFollowing(session.api, session.me.id, { limit: 12 })
.then((page) => (following = page.items))
.catch(() => (following = []))
void fetchNotifications(session.api, { limit: 40 })
.then((page) => (notifications = page.items))
.catch(() => (notifications = []))
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
} finally {
loading = false
}
}
function moodFor(status: Status): string {
return fallbackMood(status.account.id)
}
function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : 'Could not load that.'
}
</script>
<div class="page home-page">
{#if !session.host}
<h1 class="page-title">Welcome to plspace</h1>
<p class="page-subtitle">It&rsquo;s always Pleroma&trade;.</p>
<div class="layout--single">
<Module title="Get started">
<p>
plspace is a web client for Pleroma. Point it at your server to begin.
</p>
<p><a class="button button--primary" href="#/login">Choose your server</a></p>
</Module>
</div>
{:else}
<h1 class="page-title">
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if}
</h1>
{#if me}
<p class="page-subtitle">
My URL: <a href={profilePath(me)}>#/@{me.acct}</a>
&nbsp;&middot;&nbsp; Last login: {shortDate(me.last_status_at ?? new Date())}
</p>
{:else}
<p class="page-subtitle">
Browsing as a guest. <a href="#/login">Sign in</a> to post, follow and read your own feed.
</p>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<div class="layout--dashboard">
<!-- ------------------------------------------------ left: control panel -->
<div class="layout-column layout-column--left">
{#if me}
<Module title="My Profile">
<p class="center">
<Avatar account={me} size="friend" />
</p>
<p class="center">
<a href={profilePath(me)}>{displayNameOf(me)}</a>
</p>
<p class="center muted">
Profile views: {formatCount(me.statuses_count)} entries
</p>
</Module>
<Module title="Updates">
<ul class="mail-summary">
{#each summary as item (item.kind)}
<li class="mail-summary-item" data-kind={item.kind} data-unread={item.count > 0 ? 'true' : 'false'}>
<a href={item.href}>{item.label}</a>
{#if item.count > 0}<span class="mail-folder-count">({item.count})</span>{/if}
</li>
{/each}
</ul>
<div class="mail-summary-buttons">
<a class="button button--small" href="#/mail">inbox</a>
<a class="button button--small" href="#/mail/requests">friend requests</a>
<a class="button button--small" href="#/timeline/home">my feed</a>
<a class="button button--small" href="#/compose">post bulletin</a>
</div>
</Module>
{/if}
<Module title="Control Panel">
<ul class="action-list action-list--single">
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
<a class="action-list-label" href="#/compose">Compose</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">📥</span>
<a class="action-list-label" href="#/mail">Inbox</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🌐</span>
<a class="action-list-label" href="#/timeline/public">The whole network</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🔍</span>
<a class="action-list-label" href="#/browse">Browse people</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🎨</span>
<a class="action-list-label" href="#/settings">Layouts &amp; settings</a>
</li>
</ul>
</Module>
</div>
<!-- --------------------------------------------------- main: the feed -->
<div class="layout-column layout-column--main">
{#if session.signedIn}
<Module title="Post a bulletin">
<Composer placeholder="What are you up to?" submitLabel="Post" onposted={() => void load(true)} />
</Module>
{/if}
<Module title="Friend Status">
{#snippet action()}
<a href="#/timeline/home">view all</a>
{/snippet}
{#if !session.signedIn}
<p class="empty-note"><a href="#/login">Sign in</a> to see what your friends are up to.</p>
{:else if friendStatusError}
<p class="error-note" role="alert">{friendStatusError}</p>
{:else if loading && friendStatus.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if friendStatus.length === 0}
<p class="empty-note">Nothing yet. Add some friends to fill this up.</p>
{:else}
<ul class="status-line-list">
{#each friendStatus as status (status.id)}
{@const entry = status.reblog ?? status}
<li class="status-line" data-account={entry.account.acct}>
<Avatar account={entry.account} />
<div class="status-line-body">
<a class="status-line-author" href={profilePath(entry.account)}>
{displayNameOf(entry.account)}
</a>
<RichText
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
inline
/>
<a class="status-line-time" href={`#/blog/${entry.id}`}>
{relativeTime(entry.created_at)}
</a>
<span class="status-line-mood">Mood: {moodFor(entry)}</span>
</div>
</li>
{/each}
</ul>
{/if}
</Module>
<Module title="Bulletin Space">
{#snippet action()}
<a href="#/timeline/local">view all</a>
{/snippet}
{#if bulletinError}
<p class="error-note" role="alert">
{bulletinError}
{#if !session.signedIn}
<a href="#/login">Signing in</a> usually fixes this &mdash; some servers don't serve
timelines to guests.
{/if}
</p>
{:else if bulletins.length === 0}
<p class="empty-note">{loading ? 'Loading…' : 'No bulletins right now.'}</p>
{:else}
<table class="bulletin-table">
<thead>
<tr>
<th scope="col">From</th>
<th scope="col">Date</th>
<th scope="col">Bulletin</th>
</tr>
</thead>
<tbody>
{#each bulletins as status (status.id)}
{@const entry = status.reblog ?? status}
<tr>
<td class="bulletin-from">
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</a>
</td>
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
<td class="bulletin-subject">
<a href={`#/blog/${entry.id}`}>
{toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
{#if session.signedIn}
<Module title="Friend Space">
{#snippet action()}
<a href={me ? `#/@${me.acct}/friends` : '#/browse'}>view all</a>
{/snippet}
{#if following.length === 0}
<p class="empty-note">You haven't added any friends yet. <a href="#/browse">Find some.</a></p>
{:else}
<p class="friend-count">
You have <span class="friend-count-value">{formatCount(me?.following_count ?? 0)}</span> friends.
</p>
<ul class="friend-grid friend-grid--compact">
{#each following as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}>
<span class="friend-card-name">{displayNameOf(friend)}</span>
<img
class="friend-card-photo"
src={friend.avatar_static || friend.avatar}
alt=""
loading="lazy"
/>
</a>
</li>
{/each}
</ul>
{/if}
</Module>
{/if}
</div>
<!-- ------------------------------------------------- right: the server -->
<div class="layout-column layout-column--right">
<Module title={domain}>
{#if thumbnail}
<p class="center">
<img class="instance-thumbnail" src={thumbnail} alt="" loading="lazy" />
</p>
{/if}
<p class="instance-title"><strong>{session.instance?.title ?? domain}</strong></p>
{#if session.instance?.short_description || session.instance?.description}
<RichText
html={session.instance.short_description || session.instance.description}
class="instance-description"
/>
{/if}
{#if stats.length > 0}
<table class="data-table instance-stats">
<tbody>
{#each stats as stat (stat.label)}
<tr>
<th class="data-table-label" scope="row">{stat.label}</th>
<td class="data-table-value">{formatCount(stat.value)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
<Module title="plspace Tip">
<p>
Name a profile field <code>Music</code>, <code>Movies</code>, <code>Books</code> or
<code>Heroes</code> and it fills in the Interests table on your profile. Name one
<code>Mood</code> and it shows beside your photo.
</p>
<p><a href="#/settings">Customise your layout &rarr;</a></p>
</Module>
</div>
</div>
{/if}
</div>