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,90 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Browse — the profile directory.
|
||||
*
|
||||
* `/api/v1/directory` is opt-in per user and disabled entirely on some
|
||||
* servers, so an empty result is a normal outcome and says so rather than
|
||||
* looking broken.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { fetchDirectory, instanceDomain } from '$lib/api/endpoints'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PersonList from '$components/people/PersonList.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
|
||||
let order = $state<'active' | 'new'>('active')
|
||||
let localOnly = $state(true)
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
let feed = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
|
||||
|
||||
$effect(() => {
|
||||
const currentOrder = order
|
||||
const currentLocal = localOnly
|
||||
const host = session.host
|
||||
if (!host) return
|
||||
|
||||
untrack(() => {
|
||||
// The directory paginates by offset, not by cursor, so the loader keeps
|
||||
// its own running offset rather than using the Link header.
|
||||
let offset = 0
|
||||
feed = new Feed<Account>(async (cursor) => {
|
||||
if (!cursor.max_id) offset = 0
|
||||
const limit = cursor.limit ?? 20
|
||||
const items = await fetchDirectory(session.api, {
|
||||
offset,
|
||||
limit,
|
||||
order: currentOrder,
|
||||
local: currentLocal,
|
||||
})
|
||||
offset += items.length
|
||||
return {
|
||||
items,
|
||||
// Synthesise a cursor so `Feed` knows whether more may exist.
|
||||
links: items.length === limit ? { maxId: String(offset) } : {},
|
||||
}
|
||||
})
|
||||
void feed.reload()
|
||||
document.title = 'Browse | plspace'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page browse-page">
|
||||
<h1 class="page-title">Browse</h1>
|
||||
<p class="page-subtitle">People who've listed themselves in {domain}'s directory.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Find people" variant="band">
|
||||
<div class="row browse-controls">
|
||||
<label class="field-row">
|
||||
<span>Sort by</span>
|
||||
<select bind:value={order}>
|
||||
<option value="active">Recently active</option>
|
||||
<option value="new">Newest members</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field browse-scope">
|
||||
<input type="checkbox" bind:checked={localOnly} />
|
||||
<span>Only people on {domain}</span>
|
||||
</label>
|
||||
|
||||
<button type="button" class="button" onclick={() => router.go('#/search')}>
|
||||
Search instead
|
||||
</button>
|
||||
</div>
|
||||
</Module>
|
||||
|
||||
<Module title="Members" variant="band">
|
||||
<PersonList
|
||||
{feed}
|
||||
emptyText="Nobody is listed in this server's directory. Try the search page instead."
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
/** A full-page composer, reachable from the nav and from "Send Message". */
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Handle to address the entry to, from `?to=`. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
let { to }: Props = $props()
|
||||
|
||||
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
|
||||
</script>
|
||||
|
||||
<div class="page compose-page">
|
||||
<h1 class="page-title">{to ? 'Send a Message' : 'Post a Blog Entry'}</h1>
|
||||
{#if to}
|
||||
<p class="page-subtitle">
|
||||
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
|
||||
audience to <em>Mentioned people only</em> to keep it private.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title={to ? 'New message' : 'New entry'}>
|
||||
<Composer
|
||||
initialText={prefill}
|
||||
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
||||
submitLabel={to ? 'Send' : 'Post Entry'}
|
||||
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
||||
/>
|
||||
</Module>
|
||||
|
||||
{#if !session.signedIn}
|
||||
<p class="notice"><a href="#/login">Sign in</a> to post.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Sign in, or browse a server anonymously.
|
||||
*
|
||||
* Two paths on purpose: signing in registers an OAuth app on the target
|
||||
* server and redirects to its consent screen, while "just look around" only
|
||||
* needs the host and works on any server that allows anonymous API reads.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { normalizeHost } from '$lib/api/client'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
|
||||
let host = $state(session.host)
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
/**
|
||||
* Servers to try, all verified to serve `/api/v1/timelines/public` without a
|
||||
* token so that "Just look around" actually shows something.
|
||||
*
|
||||
* That is the bar for being on this list. Plenty of instances set Pleroma's
|
||||
* `restrict_unauthenticated` (or Mastodon's equivalent) and answer 401 to
|
||||
* anonymous timeline reads; suggesting one of those hands a first-time
|
||||
* visitor an empty page. Re-check before adding to this list.
|
||||
*/
|
||||
const SUGGESTIONS = ['pleroma.soykaf.com', 'lain.com', 'spinster.xyz']
|
||||
|
||||
async function signIn(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await session.login(host, '#/')
|
||||
// On success the browser has already navigated away.
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not start sign-in.'
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function browseOnly(): Promise<void> {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await session.connect(host)
|
||||
router.go('#/')
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not reach that server.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page login-page">
|
||||
<h1 class="page-title">Sign in</h1>
|
||||
<p class="page-subtitle">plspace works with any Pleroma server.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
{#if session.error}
|
||||
<p class="notice">{session.error}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Module title="Your server">
|
||||
<form onsubmit={signIn}>
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-host">Server address</label>
|
||||
<input
|
||||
id="login-host"
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={host}
|
||||
placeholder="pleroma.soykaf.com"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="field-hint">
|
||||
The domain of the server your account lives on. You can paste your full
|
||||
<code>@you@server</code> handle instead.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<button class="button button--primary" type="submit" disabled={busy || !normalizeHost(host)}>
|
||||
{busy ? 'Redirecting…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
disabled={busy || !normalizeHost(host)}
|
||||
onclick={() => void browseOnly()}
|
||||
>
|
||||
Just look around
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Module>
|
||||
|
||||
<Module title="Not sure where to start?">
|
||||
<p>A few servers to try. All of these let you look around without signing in:</p>
|
||||
<ul class="server-suggestions">
|
||||
{#each SUGGESTIONS as suggestion (suggestion)}
|
||||
<li class="server-suggestion">
|
||||
<button type="button" class="link-button" onclick={() => (host = suggestion)}>
|
||||
{suggestion}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="What happens when you sign in">
|
||||
<p>
|
||||
plspace registers itself as an application on your server, then sends you there to approve
|
||||
it. Your password is never typed into plspace — you enter it on your own server, and
|
||||
plspace only ever receives an access token.
|
||||
</p>
|
||||
<p>
|
||||
That token is stored in this browser's local storage and used directly from your browser.
|
||||
There is no plspace backend; nothing you read or post passes through anyone else's server.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Forget this server
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The Mail Center — notifications as an inbox, plus the Friend Request
|
||||
* Manager with its Approve / Deny buttons.
|
||||
*
|
||||
* Folders map onto notification types. The "requests" folder is a different
|
||||
* endpoint (`/api/v1/follow_requests`) because pending requests aren't
|
||||
* notifications once they've been read.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Account, Notification } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import {
|
||||
authorizeFollowRequest,
|
||||
fetchFollowRequests,
|
||||
fetchNotifications,
|
||||
rejectFollowRequest,
|
||||
} from '$lib/api/endpoints'
|
||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
folder?: string
|
||||
}
|
||||
|
||||
let { folder = 'inbox' }: Props = $props()
|
||||
|
||||
interface Folder {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
/** Notification types this folder shows; empty means everything. */
|
||||
types: string[]
|
||||
}
|
||||
|
||||
const FOLDERS: Folder[] = [
|
||||
{ key: 'inbox', label: 'Inbox', icon: '📥', types: [] },
|
||||
{ key: 'mentions', label: 'Messages', icon: '✉', types: ['mention'] },
|
||||
{ key: 'requests', label: 'Friend Requests', icon: '➕', types: [] },
|
||||
{ key: 'follows', label: 'New Friends', icon: '👥', types: ['follow'] },
|
||||
{ key: 'kudos', label: 'Kudos', icon: '★', types: ['favourite'] },
|
||||
{ key: 'reposts', label: 'Reposts', icon: '↻', types: ['reblog'] },
|
||||
]
|
||||
|
||||
const active = $derived(FOLDERS.find((entry) => entry.key === folder) ?? FOLDERS[0])
|
||||
const isRequests = $derived(active.key === 'requests')
|
||||
|
||||
let notifications = $state<Feed<Notification>>(new Feed<Notification>(async () => ({ items: [], links: {} })))
|
||||
let requests = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
|
||||
let busyIds = $state<Record<string, boolean>>({})
|
||||
let actionError = $state<string | null>(null)
|
||||
|
||||
const VERB: Record<string, string> = {
|
||||
mention: 'sent you a message',
|
||||
follow: 'added you as a friend',
|
||||
follow_request: 'wants to be your friend',
|
||||
favourite: 'gave your entry kudos',
|
||||
reblog: 'reposted your entry',
|
||||
poll: 'closed a poll you voted in',
|
||||
status: 'posted a new entry',
|
||||
update: 'edited an entry you interacted with',
|
||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const key = active.key
|
||||
const types = active.types
|
||||
if (!session.signedIn) return
|
||||
|
||||
untrack(() => {
|
||||
if (key === 'requests') {
|
||||
requests = new Feed<Account>((cursor) => fetchFollowRequests(session.api, cursor))
|
||||
void requests.reload()
|
||||
} else {
|
||||
notifications = new Feed<Notification>((cursor) =>
|
||||
fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined),
|
||||
)
|
||||
void notifications.reload()
|
||||
}
|
||||
document.title = `${key === 'requests' ? 'Friend Requests' : 'Mail Center'} | plspace`
|
||||
})
|
||||
})
|
||||
|
||||
async function respond(account: Account, approve: boolean): Promise<void> {
|
||||
if (busyIds[account.id]) return
|
||||
busyIds = { ...busyIds, [account.id]: true }
|
||||
actionError = null
|
||||
try {
|
||||
await (approve ? authorizeFollowRequest : rejectFollowRequest)(session.api, account.id)
|
||||
requests.remove(account.id)
|
||||
} catch (cause) {
|
||||
actionError = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||
} finally {
|
||||
busyIds = { ...busyIds, [account.id]: false }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page mail-page" data-folder={active.key}>
|
||||
<h1 class="page-title">Mail Center</h1>
|
||||
<p class="page-subtitle">
|
||||
{isRequests ? 'Friend Request Manager' : active.label}
|
||||
</p>
|
||||
|
||||
{#if !session.signedIn}
|
||||
<p class="notice">
|
||||
<a href="#/login">Sign in</a> to read your mail.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="layout--split">
|
||||
<div class="layout-column layout-column--left">
|
||||
<Module title="Folders" flush>
|
||||
<ul class="mail-folders">
|
||||
{#each FOLDERS as entry (entry.key)}
|
||||
<li class="mail-folder">
|
||||
<a
|
||||
class="mail-folder-link"
|
||||
href={entry.key === 'inbox' ? '#/mail' : `#/mail/${entry.key}`}
|
||||
aria-current={entry.key === active.key ? 'page' : undefined}
|
||||
>
|
||||
<span class="action-list-icon" aria-hidden="true">{entry.icon}</span>
|
||||
<span class="mail-folder-label">{entry.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
</div>
|
||||
|
||||
<div class="layout-column layout-column--main">
|
||||
{#if actionError}
|
||||
<p class="error-note" role="alert">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
{#if isRequests}
|
||||
<Module title="Approve or Deny Your Friend Requests" variant="band">
|
||||
{#if requests.items.length > 0}
|
||||
<p class="mail-listing-count">
|
||||
Listing 1–{requests.items.length} of {requests.items.length}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<table class="mail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Confirmation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each requests.items as account (account.id)}
|
||||
<tr class="mail-row" data-kind="follow_request" data-account={account.acct}>
|
||||
<td class="mail-table-from">
|
||||
<a href={profilePath(account)}>
|
||||
<img src={account.avatar_static || account.avatar} alt="" loading="lazy" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<a href={profilePath(account)}>{displayNameOf(account)}</a>
|
||||
</strong>
|
||||
wants to be your friend!
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
<div class="mail-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
disabled={busyIds[account.id]}
|
||||
onclick={() => void respond(account, true)}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
disabled={busyIds[account.id]}
|
||||
onclick={() => void respond(account, false)}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<a class="button" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>
|
||||
Send Message
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pager feed={requests} label="View More Requests" emptyText="No pending friend requests." />
|
||||
</Module>
|
||||
{:else}
|
||||
<Module title={active.label} variant="band">
|
||||
<table class="mail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Subject</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each notifications.items as item (item.id)}
|
||||
<tr class="mail-row" data-kind={item.type} data-account={item.account.acct}>
|
||||
<td class="mail-table-date">{stampDate(item.created_at)}</td>
|
||||
<td class="mail-table-from">
|
||||
<a href={profilePath(item.account)}>
|
||||
<img src={item.account.avatar_static || item.account.avatar} alt="" loading="lazy" />
|
||||
</a>
|
||||
</td>
|
||||
<td class="mail-table-subject">
|
||||
<strong>
|
||||
<a href={profilePath(item.account)}>{displayNameOf(item.account)}</a>
|
||||
</strong>
|
||||
{VERB[item.type] ?? item.type}
|
||||
{#if item.status}
|
||||
<p class="mail-table-excerpt">
|
||||
<a href={`#/blog/${item.status.id}`}>
|
||||
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
'(no text)'}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pager feed={notifications} label="View More Mail" emptyText="Your inbox is empty." />
|
||||
</Module>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
</script>
|
||||
|
||||
<div class="page notfound-page">
|
||||
<h1 class="page-title">Page not found</h1>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Sorry!">
|
||||
<p>
|
||||
There's nothing at <code>{router.current.path}</code>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="#/">Go home</a> ·
|
||||
<a href="#/browse">Browse people</a> ·
|
||||
<a href="#/search">Search</a>
|
||||
</p>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Search across people, entries and hashtags.
|
||||
*
|
||||
* `resolve` is only sent when signed in — it makes the server fetch unknown
|
||||
* remote accounts, which anonymous callers aren't allowed to trigger.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { SearchResults } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { search } from '$lib/api/endpoints'
|
||||
import { router, routeTo } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PersonRow from '$components/people/PersonRow.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
q?: string
|
||||
}
|
||||
|
||||
let { q = '' }: Props = $props()
|
||||
|
||||
// Seeded from the route, then kept in sync by the effect below.
|
||||
let input = $state(untrack(() => q))
|
||||
let results = $state<SearchResults | null>(null)
|
||||
let loading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
const query = q
|
||||
input = query
|
||||
if (!query || !session.host) {
|
||||
results = null
|
||||
return
|
||||
}
|
||||
untrack(() => void run(query))
|
||||
})
|
||||
|
||||
async function run(query: string): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const found = await search(session.api, query, { limit: 20 })
|
||||
if (q === query) results = found
|
||||
} catch (cause) {
|
||||
if (q === query) error = cause instanceof Error ? cause.message : 'Search failed.'
|
||||
} finally {
|
||||
if (q === query) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: SubmitEvent): void {
|
||||
event.preventDefault()
|
||||
const trimmed = input.trim()
|
||||
if (trimmed) router.go(routeTo('/search', { q: trimmed }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page search-page">
|
||||
<h1 class="page-title">Search</h1>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Search plspace">
|
||||
<form class="search-form" onsubmit={submit}>
|
||||
<div class="field-row">
|
||||
<label class="visually-hidden" for="search-input">Search terms</label>
|
||||
<input
|
||||
id="search-input"
|
||||
class="field-input search-input"
|
||||
type="search"
|
||||
bind:value={input}
|
||||
placeholder="A name, @user@server, #hashtag, or a link to a post"
|
||||
/>
|
||||
<button class="button button--primary" type="submit">Search</button>
|
||||
</div>
|
||||
<span class="field-hint">
|
||||
Paste a full <code>@user@server</code> handle or a post URL to pull it in from another server.
|
||||
</span>
|
||||
</form>
|
||||
</Module>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="loading-note">Searching…</p>
|
||||
{:else if results}
|
||||
<Module title={`People (${formatCount(results.accounts.length)})`} variant="band">
|
||||
{#if results.accounts.length === 0}
|
||||
<p class="empty-note">No people matched.</p>
|
||||
{:else}
|
||||
<ul class="person-list">
|
||||
{#each results.accounts as account (account.id)}
|
||||
<PersonRow {account} />
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`Hashtags (${formatCount(results.hashtags.length)})`} variant="band">
|
||||
{#if results.hashtags.length === 0}
|
||||
<p class="empty-note">No hashtags matched.</p>
|
||||
{:else}
|
||||
<ul class="tag-list">
|
||||
{#each results.hashtags as tag (tag.name)}
|
||||
<li class="tag-list-item">
|
||||
<a href={`#/tag/${encodeURIComponent(tag.name)}`}>#{tag.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`Blog Entries (${formatCount(results.statuses.length)})`} variant="band">
|
||||
{#if results.statuses.length === 0}
|
||||
<p class="empty-note">No entries matched. Many servers only search entries you wrote.</p>
|
||||
{:else}
|
||||
<ul class="blog-list">
|
||||
{#each results.statuses as status (status.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
{status}
|
||||
onupdate={(next) => {
|
||||
if (results) {
|
||||
results = {
|
||||
...results,
|
||||
statuses: results.statuses.map((item) => (item.id === next.id ? next : item)),
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
{:else}
|
||||
<p class="empty-note">Enter something to search for.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Settings — mostly the layout editor, which is the feature this whole app
|
||||
* exists to have.
|
||||
*
|
||||
* Two things are editable: the CSS applied to *your* view of plspace (stored
|
||||
* locally), and whether the CSS other people publish on their profiles is
|
||||
* honoured when you visit them.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { theme, CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte'
|
||||
import { PRESETS, EXAMPLE_CSS } from '$lib/themes'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
|
||||
let draft = $state(theme.viewerCss)
|
||||
let saved = $state(false)
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
/** Class hooks worth documenting, grouped the way pages are built. */
|
||||
const HOOKS: Array<{ group: string; entries: Array<[string, string]> }> = [
|
||||
{
|
||||
group: 'Page skeletons',
|
||||
entries: [
|
||||
['.page', 'Every page’s outer container'],
|
||||
['.profile-page', 'The profile page — also the scope for published profile CSS'],
|
||||
['.layout--split', 'Two-column pages (left rail + main)'],
|
||||
['.layout--dashboard', 'The three-column home page'],
|
||||
['.layout-column--left / --main / --right', 'The individual columns'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Boxes',
|
||||
entries: [
|
||||
['.module', 'A bordered box'],
|
||||
['.module-header', 'Its caption bar'],
|
||||
['.module-body', 'Its contents'],
|
||||
['.module--band', 'The peach-bar variant used in the main column'],
|
||||
['.section-heading', '“About me:” style orange headings'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Profile',
|
||||
entries: [
|
||||
['.profile-photo', 'The big photo'],
|
||||
['.profile-headline', 'The quoted line beside it'],
|
||||
['.profile-vitals', 'Gender / age / location / last active'],
|
||||
['.interests-table, .interests-label, .interests-value', 'The Interests table'],
|
||||
['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Blog entries',
|
||||
entries: [
|
||||
['.blog-entry', 'One entry'],
|
||||
['.blog-entry[data-mine="true"]', 'Entries you wrote'],
|
||||
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
||||
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
||||
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function save(): void {
|
||||
theme.setViewerCss(draft)
|
||||
saved = true
|
||||
setTimeout(() => (saved = false), 2000)
|
||||
}
|
||||
|
||||
function applyPreset(css: string): void {
|
||||
draft = css
|
||||
theme.setViewerCss(css)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
draft = ''
|
||||
theme.setViewerCss('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page settings-page">
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<p class="page-subtitle">Everything here is stored in this browser only.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Your account">
|
||||
{#if session.signedIn && session.me}
|
||||
<p>
|
||||
Signed in as
|
||||
<a href={profilePath(session.me)}>{displayNameOf(session.me)}</a>
|
||||
on <strong>{domain}</strong>.
|
||||
</p>
|
||||
<p class="field-row">
|
||||
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Forget this server
|
||||
</button>
|
||||
</p>
|
||||
{:else if session.host}
|
||||
<p>Browsing <strong>{domain}</strong> as a guest.</p>
|
||||
<p class="field-row">
|
||||
<a class="button button--primary" href="#/login">Sign in</a>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Choose a different server
|
||||
</button>
|
||||
</p>
|
||||
{:else}
|
||||
<p>Not connected to a server. <a href="#/login">Choose one</a>.</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Pick a layout" variant="band">
|
||||
<ul class="preset-list">
|
||||
{#each PRESETS as preset (preset.id)}
|
||||
<li class="preset">
|
||||
<button type="button" class="button preset-button" onclick={() => applyPreset(preset.css)}>
|
||||
{preset.name}
|
||||
</button>
|
||||
<span class="preset-description muted">{preset.description}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="Your CSS" variant="band">
|
||||
<p>
|
||||
This is applied to every page you view in plspace. Overriding the custom properties in
|
||||
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go
|
||||
further.
|
||||
</p>
|
||||
|
||||
<label class="visually-hidden" for="viewer-css">Your CSS</label>
|
||||
<textarea
|
||||
id="viewer-css"
|
||||
class="css-editor"
|
||||
bind:value={draft}
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder={EXAMPLE_CSS}
|
||||
></textarea>
|
||||
|
||||
<div class="field-row">
|
||||
<button type="button" class="button button--primary" onclick={save}>Save CSS</button>
|
||||
<button type="button" class="button" onclick={reset}>Reset to default</button>
|
||||
<button type="button" class="button" onclick={() => (draft = EXAMPLE_CSS)}>
|
||||
Load the example
|
||||
</button>
|
||||
{#if saved}<span class="muted">Saved.</span>{/if}
|
||||
</div>
|
||||
|
||||
<p class="field-hint">
|
||||
<code>@import</code> and non-HTTPS <code>url()</code> values are stripped before your CSS is
|
||||
applied.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Other people's layouts" variant="band">
|
||||
<label class="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={theme.allowProfileCss}
|
||||
onchange={(event) => theme.setAllowProfileCss(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show profile layouts published by the people I visit
|
||||
</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
A profile can publish a stylesheet by putting CSS in a profile field named
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
|
||||
Their rules are rewritten to apply only inside <code>{PROFILE_SCOPE}</code>, so a profile
|
||||
can restyle its own page but not the rest of plspace.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Publish your own layout" variant="band">
|
||||
<p>
|
||||
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and
|
||||
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since
|
||||
it's an ordinary profile field, it survives elsewhere too — other clients just show it
|
||||
as text.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<a
|
||||
class="button"
|
||||
href={`https://${session.host}/settings/profile`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit your profile on {domain}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Class reference" variant="band">
|
||||
{#each HOOKS as section (section.group)}
|
||||
<h3 class="section-heading">{section.group}</h3>
|
||||
<table class="data-table hooks-table">
|
||||
<tbody>
|
||||
{#each section.entries as [selector, description] (selector)}
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row"><code>{selector}</code></th>
|
||||
<td class="data-table-value">{description}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/each}
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A single blog entry with its comment thread.
|
||||
*
|
||||
* `/context` returns ancestors and descendants flat; the descendants are
|
||||
* re-nested here so replies-to-replies indent, capped at three levels because
|
||||
* a 2005 layout has nowhere to put the fourth.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { fetchContext, fetchStatus } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import { stampDate, isoDate } from '$lib/util/time'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
let { id }: Props = $props()
|
||||
|
||||
let status = $state<Status | null>(null)
|
||||
let ancestors = $state<Status[]>([])
|
||||
let descendants = $state<Status[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
interface ThreadedReply {
|
||||
status: Status
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** Flatten the descendant tree depth-first so it renders as one list. */
|
||||
const thread = $derived.by<ThreadedReply[]>(() => {
|
||||
if (!status) return []
|
||||
|
||||
const byParent = new Map<string, Status[]>()
|
||||
for (const reply of descendants) {
|
||||
const parent = reply.in_reply_to_id ?? status.id
|
||||
const bucket = byParent.get(parent) ?? []
|
||||
bucket.push(reply)
|
||||
byParent.set(parent, bucket)
|
||||
}
|
||||
|
||||
const out: ThreadedReply[] = []
|
||||
const walk = (parentId: string, depth: number): void => {
|
||||
for (const reply of byParent.get(parentId) ?? []) {
|
||||
out.push({ status: reply, depth: Math.min(depth, 3) })
|
||||
walk(reply.id, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(status.id, 0)
|
||||
|
||||
// Anything whose parent fell outside the context still deserves showing.
|
||||
const seen = new Set(out.map((entry) => entry.status.id))
|
||||
for (const reply of descendants) {
|
||||
if (!seen.has(reply.id)) out.push({ status: reply, depth: 0 })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** Prefill a comment with the mentions a reply conventionally carries. */
|
||||
const replyPrefill = $derived.by(() => {
|
||||
if (!status) return ''
|
||||
const handles = new Set<string>()
|
||||
if (status.account.id !== session.me?.id) handles.add(status.account.acct)
|
||||
for (const mention of status.mentions) {
|
||||
if (mention.id !== session.me?.id) handles.add(mention.acct)
|
||||
}
|
||||
return handles.size > 0 ? `${[...handles].map((acct) => `@${acct}`).join(' ')} ` : ''
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const currentId = id
|
||||
const host = session.host
|
||||
if (!host) return
|
||||
untrack(() => void load(currentId))
|
||||
})
|
||||
|
||||
async function load(currentId: string): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const [entry, context] = await Promise.all([
|
||||
fetchStatus(session.api, currentId),
|
||||
fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })),
|
||||
])
|
||||
if (id !== currentId) return
|
||||
|
||||
status = entry
|
||||
ancestors = context.ancestors
|
||||
descendants = context.descendants
|
||||
document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace`
|
||||
} catch (cause) {
|
||||
if (id !== currentId) return
|
||||
error = cause instanceof Error ? cause.message : 'Could not load that entry.'
|
||||
} finally {
|
||||
if (id === currentId) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPosted(created: Status): void {
|
||||
descendants = [...descendants, created]
|
||||
if (status) status = { ...status, replies_count: status.replies_count + 1 }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page entry-page">
|
||||
{#if loading}
|
||||
<p class="loading-note">Loading entry…</p>
|
||||
{:else if error}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Entry not available.</strong>
|
||||
{error}
|
||||
</p>
|
||||
{:else if status}
|
||||
<h1 class="page-title">
|
||||
<a href={profilePath(status.account)}>{displayNameOf(status.account)}</a>'s Blog
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
|
||||
</p>
|
||||
|
||||
<div class="layout--single">
|
||||
{#if ancestors.length > 0}
|
||||
<Module title="Earlier in this thread" variant="band">
|
||||
<ul class="blog-list">
|
||||
{#each ancestors as ancestor (ancestor.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
status={ancestor}
|
||||
compact
|
||||
onupdate={(next) =>
|
||||
(ancestors = ancestors.map((item) => (item.id === next.id ? next : item)))}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title="Blog Entry" variant="band">
|
||||
<BlogEntry
|
||||
{status}
|
||||
longFormDate
|
||||
onupdate={(next) => (status = next)}
|
||||
ondelete={() => history.back()}
|
||||
/>
|
||||
</Module>
|
||||
|
||||
<Module title={`Comments (${status.replies_count})`} variant="band">
|
||||
{#if session.signedIn}
|
||||
<Composer
|
||||
inReplyTo={status}
|
||||
initialText={replyPrefill}
|
||||
placeholder="Leave a comment…"
|
||||
submitLabel="Post Comment"
|
||||
onposted={onPosted}
|
||||
/>
|
||||
{:else}
|
||||
<p class="empty-note"><a href="#/login">Sign in</a> to leave a comment.</p>
|
||||
{/if}
|
||||
|
||||
{#if thread.length === 0}
|
||||
<p class="empty-note">No comments yet. Be the first.</p>
|
||||
{:else}
|
||||
<ul class="comment-list">
|
||||
{#each thread as reply (reply.status.id)}
|
||||
<li class="comment" data-depth={reply.depth} data-account={reply.status.account.acct}>
|
||||
<div class="comment-avatar">
|
||||
<Avatar account={reply.status.account} />
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<a class="comment-author" href={profilePath(reply.status.account)}>
|
||||
{displayNameOf(reply.status.account)}
|
||||
</a>
|
||||
<a class="comment-date" href={`#/blog/${reply.status.id}`}>
|
||||
<time datetime={isoDate(reply.status.created_at)}>
|
||||
{stampDate(reply.status.created_at)}
|
||||
</time>
|
||||
</a>
|
||||
{#if reply.status.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
||||
<RichText
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
tags={reply.status.tags}
|
||||
/>
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
tags={reply.status.tags}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A timeline, presented as a blog: "My Blog" (home), "This Server" (local),
|
||||
* "The Whole Network" (federated), or a hashtag.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import type { TimelineKind } from '$lib/api/endpoints'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { fetchTimeline, instanceDomain } from '$lib/api/endpoints'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import TabBar from '$components/common/TabBar.svelte'
|
||||
import BlogList from '$components/blog/BlogList.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
kind: TimelineKind
|
||||
tag?: string
|
||||
}
|
||||
|
||||
let { kind, tag }: Props = $props()
|
||||
|
||||
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
const title = $derived(
|
||||
kind === 'home'
|
||||
? 'My Blog'
|
||||
: kind === 'local'
|
||||
? `Blogs on ${domain}`
|
||||
: kind === 'tag'
|
||||
? `#${tag}`
|
||||
: 'The Whole Network',
|
||||
)
|
||||
|
||||
const tabs = $derived([
|
||||
...(session.signedIn ? [{ label: 'My Blog', href: '#/timeline/home' }] : []),
|
||||
{ label: 'This Server', href: '#/timeline/local' },
|
||||
{ label: 'Whole Network', href: '#/timeline/public' },
|
||||
])
|
||||
|
||||
const currentTab = $derived(kind === 'tag' ? '' : `#/timeline/${kind}`)
|
||||
|
||||
$effect(() => {
|
||||
const currentKind = kind
|
||||
const currentTag = tag
|
||||
const host = session.host
|
||||
const authed = session.signedIn
|
||||
if (!host) return
|
||||
|
||||
untrack(() => {
|
||||
// Home needs a token; fall back rather than showing a 401.
|
||||
const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind
|
||||
feed = new Feed<Status>((cursor) =>
|
||||
fetchTimeline(session.api, resolved, cursor, { tag: currentTag }),
|
||||
)
|
||||
void feed.reload()
|
||||
document.title = `${title} | plspace`
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||
<h1 class="page-title">{title}</h1>
|
||||
|
||||
{#if kind !== 'tag'}
|
||||
<TabBar {tabs} current={currentTab} label="Timelines" />
|
||||
{/if}
|
||||
|
||||
{#if kind === 'home' && !session.signedIn}
|
||||
<p class="notice">
|
||||
You're browsing as a guest, so this is <strong>{domain}</strong>'s local timeline.
|
||||
<a href="#/login">Sign in</a> to read your own.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--single">
|
||||
{#if kind === 'home' && session.signedIn}
|
||||
<Module title="Post a new entry">
|
||||
<Composer onposted={(status) => feed.prepend(status)} />
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title={title} variant="band">
|
||||
<BlogList
|
||||
{feed}
|
||||
emptyText={kind === 'tag'
|
||||
? `Nobody has posted with #${tag} that this server knows about.`
|
||||
: 'There are no Blog Entries yet.'}
|
||||
longFormDate
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user