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,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’s always Pleroma™.</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>
|
||||
· 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 & 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…</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 — 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 →</a></p>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user