mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Media on a status.
|
||||
*
|
||||
* Sensitive media is blurred rather than hidden so the layout doesn't jump
|
||||
* when it's revealed, and the reveal is per-attachment because a single post
|
||||
* can mix flagged and unflagged media.
|
||||
*/
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
attachments: MediaAttachment[]
|
||||
sensitive?: boolean
|
||||
}
|
||||
|
||||
let { attachments, sensitive = false }: Props = $props()
|
||||
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
function isRevealed(id: string): boolean {
|
||||
return !sensitive || revealed[id] === true
|
||||
}
|
||||
|
||||
function toggle(id: string): void {
|
||||
revealed = { ...revealed, [id]: !revealed[id] }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
<ul class="attachment-list">
|
||||
{#each attachments as media (media.id)}
|
||||
<li
|
||||
class="attachment"
|
||||
data-type={media.type}
|
||||
data-sensitive={sensitive ? 'true' : 'false'}
|
||||
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
|
||||
>
|
||||
<figure class="attachment-figure">
|
||||
{#if media.type === 'video' || media.type === 'gifv'}
|
||||
<video
|
||||
class="attachment-media"
|
||||
src={media.url}
|
||||
poster={media.preview_url ?? undefined}
|
||||
controls
|
||||
playsinline
|
||||
loop={media.type === 'gifv'}
|
||||
preload="none"
|
||||
>
|
||||
<!-- Remote media carries no caption track; declared so the
|
||||
requirement is explicit rather than merely unmet. -->
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if media.type === 'audio'}
|
||||
<audio class="attachment-media attachment-media--audio" src={media.url} controls preload="none"
|
||||
></audio>
|
||||
{:else if media.type === 'image'}
|
||||
<a href={media.url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
class="attachment-media"
|
||||
src={media.preview_url ?? media.url}
|
||||
alt={media.description ?? ''}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<a class="attachment-media attachment-media--file" href={media.url} target="_blank" rel="noopener noreferrer">
|
||||
Attachment
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if media.description}
|
||||
<figcaption class="attachment-caption">{media.description}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
{#if sensitive}
|
||||
<button type="button" class="button button--small attachment-reveal" onclick={() => toggle(media.id)}>
|
||||
{isRevealed(media.id) ? 'Hide' : 'Show'} sensitive media
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One status, dressed as a MySpace blog entry.
|
||||
*
|
||||
* Vocabulary mapping, applied consistently across the app:
|
||||
* favourite -> Kudos (MySpace blogs really did call them that)
|
||||
* reblog -> Repost
|
||||
* reply -> Comment
|
||||
*
|
||||
* Toggling kudos/repost updates optimistically and rolls back on failure —
|
||||
* a federated round trip is slow enough that waiting feels broken.
|
||||
*/
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints'
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
|
||||
interface Props {
|
||||
status: Status
|
||||
/** Called with the updated status after an action succeeds. */
|
||||
onupdate?: (status: Status) => void
|
||||
/** Called after the viewer deletes their own entry. */
|
||||
ondelete?: (id: string) => void
|
||||
/** Drop the avatar gutter — used inside threads. */
|
||||
compact?: boolean
|
||||
/** Show the full date rather than a short stamp. */
|
||||
longFormDate?: boolean
|
||||
}
|
||||
|
||||
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
|
||||
|
||||
/** The status actually being displayed; a boost renders its target. */
|
||||
const entry = $derived(status.reblog ?? status)
|
||||
const booster = $derived(status.reblog ? status.account : null)
|
||||
|
||||
const author = $derived(entry.account)
|
||||
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
|
||||
const handle = $derived(fullHandle(author, session.host))
|
||||
const permalink = $derived(`#/blog/${entry.id}`)
|
||||
const isMine = $derived(session.me?.id === entry.account.id)
|
||||
|
||||
let busy = $state(false)
|
||||
let actionError = $state<string | null>(null)
|
||||
|
||||
const VISIBILITY_ICON: Record<string, string> = {
|
||||
public: '🌐',
|
||||
unlisted: '🔓',
|
||||
private: '🔒',
|
||||
direct: '✉',
|
||||
}
|
||||
|
||||
const VISIBILITY_LABEL: Record<string, string> = {
|
||||
public: 'Public',
|
||||
unlisted: 'Unlisted',
|
||||
private: 'Friends only',
|
||||
direct: 'Private message',
|
||||
}
|
||||
|
||||
async function toggleKudos(): Promise<void> {
|
||||
if (!session.signedIn || busy) return
|
||||
const next = !entry.favourited
|
||||
busy = true
|
||||
actionError = null
|
||||
|
||||
// Optimistic: reflect the new state before the request settles.
|
||||
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
|
||||
|
||||
try {
|
||||
const updated = await favouriteStatus(session.api, entry.id, next)
|
||||
onupdate?.(rewrap(status, updated))
|
||||
} catch (cause) {
|
||||
onupdate?.(status)
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRepost(): Promise<void> {
|
||||
if (!session.signedIn || busy) return
|
||||
const next = !entry.reblogged
|
||||
busy = true
|
||||
actionError = null
|
||||
|
||||
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
|
||||
|
||||
try {
|
||||
const updated = await reblogStatus(session.api, entry.id, next)
|
||||
// Reblogging returns the *wrapper* status; unwrap to the original.
|
||||
onupdate?.(rewrap(status, updated.reblog ?? updated))
|
||||
} catch (cause) {
|
||||
onupdate?.(status)
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!isMine || busy) return
|
||||
if (!confirm('Delete this entry? This cannot be undone.')) return
|
||||
busy = true
|
||||
try {
|
||||
await deleteStatus(session.api, entry.id)
|
||||
ondelete?.(status.id)
|
||||
} catch (cause) {
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Patch the inner status, preserving the boost wrapper if there is one. */
|
||||
function applyLocal(wrapper: Status, patch: Partial<Status>): Status {
|
||||
if (wrapper.reblog) return { ...wrapper, reblog: { ...wrapper.reblog, ...patch } }
|
||||
return { ...wrapper, ...patch }
|
||||
}
|
||||
|
||||
function rewrap(wrapper: Status, fresh: Status): Status {
|
||||
return wrapper.reblog ? { ...wrapper, reblog: fresh } : fresh
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="blog-entry"
|
||||
data-status-id={entry.id}
|
||||
data-account={author.acct}
|
||||
data-visibility={entry.visibility}
|
||||
data-boosted={booster ? 'true' : 'false'}
|
||||
data-reply={entry.in_reply_to_id ? 'true' : 'false'}
|
||||
data-sensitive={entry.sensitive ? 'true' : 'false'}
|
||||
data-compact={compact ? 'true' : 'false'}
|
||||
data-mine={isMine ? 'true' : 'false'}
|
||||
>
|
||||
{#if booster}
|
||||
<p class="blog-entry-attribution">
|
||||
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<header class="blog-entry-header">
|
||||
{#if !compact}
|
||||
<div class="blog-entry-avatar">
|
||||
<Avatar account={author} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="blog-entry-byline">
|
||||
<a class="blog-entry-author" href={profilePath(author)}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
|
||||
{@html authorName}
|
||||
</a>
|
||||
<span class="blog-entry-handle">{handle}</span>
|
||||
<span class="blog-entry-date">
|
||||
<a href={permalink}>
|
||||
<time datetime={isoDate(entry.created_at)}>
|
||||
{longFormDate ? longDate(entry.created_at) : stampDate(entry.created_at)}
|
||||
</time>
|
||||
</a>
|
||||
<span class="blog-entry-visibility" title={VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}>
|
||||
{VISIBILITY_ICON[entry.visibility] ?? ''}
|
||||
<span class="visually-hidden">{VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}</span>
|
||||
</span>
|
||||
{#if entry.edited_at}
|
||||
<span class="blog-entry-edited">(edited)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-entry-body">
|
||||
{#if entry.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
||||
<RichText
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if entry.poll}
|
||||
<PollView poll={entry.poll} />
|
||||
{/if}
|
||||
|
||||
{#if entry.card && entry.media_attachments.length === 0}
|
||||
<PreviewCardView card={entry.card} />
|
||||
{/if}
|
||||
|
||||
{#if actionError}
|
||||
<p class="error-note" role="alert">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
<footer class="blog-entry-actions">
|
||||
<a class="blog-action blog-action--comment" href={permalink}>
|
||||
Comment <span class="blog-action-count">({formatCount(entry.replies_count)})</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="link-button blog-action blog-action--kudos"
|
||||
aria-pressed={entry.favourited ? 'true' : 'false'}
|
||||
disabled={!session.signedIn || busy}
|
||||
title={session.signedIn ? 'Give kudos' : 'Sign in to give kudos'}
|
||||
onclick={toggleKudos}
|
||||
>
|
||||
{entry.favourited ? 'Kudos given' : 'Kudos'}
|
||||
<span class="blog-action-count">({formatCount(entry.favourites_count)})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="link-button blog-action blog-action--repost"
|
||||
aria-pressed={entry.reblogged ? 'true' : 'false'}
|
||||
disabled={!session.signedIn || busy || entry.visibility === 'direct' || entry.visibility === 'private'}
|
||||
title={entry.visibility === 'private' || entry.visibility === 'direct'
|
||||
? 'This entry can’t be reposted'
|
||||
: 'Repost to your friends'}
|
||||
onclick={toggleRepost}
|
||||
>
|
||||
{entry.reblogged ? 'Reposted' : 'Repost'}
|
||||
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
|
||||
</button>
|
||||
|
||||
{#if entry.url}
|
||||
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
Original
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if isMine}
|
||||
<button type="button" class="link-button blog-action blog-action--delete" disabled={busy} onclick={remove}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
/** A `Feed<Status>` rendered as a list of blog entries, plus its pager. */
|
||||
import type { Status } from '$lib/api/types'
|
||||
import type { Feed } from '$lib/stores/feed.svelte'
|
||||
import BlogEntry from './BlogEntry.svelte'
|
||||
import Pager from '../common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Status>
|
||||
emptyText?: string
|
||||
label?: string
|
||||
longFormDate?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
emptyText = 'There are no Blog Entries yet.',
|
||||
label = 'View More Entries',
|
||||
longFormDate = false,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="blog-list">
|
||||
{#each feed.items as status (status.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
{status}
|
||||
{longFormDate}
|
||||
onupdate={(next) => feed.update(status.id, () => next)}
|
||||
ondelete={(id) => feed.remove(id)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Pager {feed} {emptyText} {label} />
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Post a blog entry, or a comment on someone else's.
|
||||
*
|
||||
* Character limits vary per server (500 on Mastodon, often 5000 on Pleroma),
|
||||
* so the counter reads `configuration.statuses.max_characters` from the
|
||||
* instance and only falls back to 500 when the server doesn't say.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { postStatus, uploadMedia } from '$lib/api/endpoints'
|
||||
|
||||
interface Props {
|
||||
/** Set to reply to an existing entry. */
|
||||
inReplyTo?: Status | null
|
||||
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
||||
initialText?: string
|
||||
placeholder?: string
|
||||
submitLabel?: string
|
||||
onposted?: (status: Status) => void
|
||||
}
|
||||
|
||||
let {
|
||||
inReplyTo = null,
|
||||
initialText = '',
|
||||
placeholder = 'What are you up to?',
|
||||
submitLabel = 'Post Entry',
|
||||
onposted,
|
||||
}: Props = $props()
|
||||
|
||||
// Seeded once from the prop; afterwards the textarea owns the value.
|
||||
let text = $state(untrack(() => initialText))
|
||||
let warning = $state('')
|
||||
let showWarning = $state(false)
|
||||
let visibility = $state<StatusVisibility>('public')
|
||||
let attachments = $state<MediaAttachment[]>([])
|
||||
let busy = $state(false)
|
||||
let uploading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
|
||||
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
|
||||
const remaining = $derived(maxCharacters - text.length - warning.length)
|
||||
const canPost = $derived(
|
||||
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
||||
)
|
||||
|
||||
// Default replies to the visibility of what they answer, so a private thread
|
||||
// doesn't accidentally get a public reply.
|
||||
$effect(() => {
|
||||
if (inReplyTo) visibility = inReplyTo.visibility
|
||||
})
|
||||
|
||||
async function onFiles(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
if (files.length === 0) return
|
||||
|
||||
uploading = true
|
||||
error = null
|
||||
try {
|
||||
for (const file of files.slice(0, maxAttachments - attachments.length)) {
|
||||
const media = await uploadMedia(session.api, file)
|
||||
attachments = [...attachments, media]
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Upload failed.'
|
||||
} finally {
|
||||
uploading = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(id: string): void {
|
||||
attachments = attachments.filter((media) => media.id !== id)
|
||||
}
|
||||
|
||||
async function submit(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (!canPost) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
const created = await postStatus(session.api, {
|
||||
status: text,
|
||||
in_reply_to_id: inReplyTo?.id ?? null,
|
||||
visibility,
|
||||
spoiler_text: showWarning ? warning : undefined,
|
||||
media_ids: attachments.map((media) => media.id),
|
||||
})
|
||||
text = ''
|
||||
warning = ''
|
||||
showWarning = false
|
||||
attachments = []
|
||||
onposted?.(created)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if session.signedIn}
|
||||
<form class="composer" onsubmit={submit}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if showWarning}
|
||||
<div class="field">
|
||||
<label class="field-label" for="composer-warning">Content warning</label>
|
||||
<input
|
||||
id="composer-warning"
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={warning}
|
||||
placeholder="What should readers know first?"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="visually-hidden" for="composer-body">Entry text</label>
|
||||
<textarea id="composer-body" class="composer-body" bind:value={text} {placeholder} rows="4"></textarea>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
<ul class="composer-attachments">
|
||||
{#each attachments as media (media.id)}
|
||||
<li class="composer-attachment">
|
||||
<img src={media.preview_url ?? media.url} alt={media.description ?? ''} />
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
onclick={() => removeAttachment(media.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<label class="button button--small composer-upload">
|
||||
{uploading ? 'Uploading…' : 'Add photo'}
|
||||
<input
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
disabled={uploading || attachments.length >= maxAttachments}
|
||||
onchange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
aria-pressed={showWarning ? 'true' : 'false'}
|
||||
onclick={() => (showWarning = !showWarning)}
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
|
||||
<label class="visually-hidden" for="composer-visibility">Who can see this</label>
|
||||
<select id="composer-visibility" bind:value={visibility}>
|
||||
<option value="public">Everyone</option>
|
||||
<option value="unlisted">Everyone (off the public timeline)</option>
|
||||
<option value="private">Friends only</option>
|
||||
<option value="direct">Mentioned people only</option>
|
||||
</select>
|
||||
|
||||
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
|
||||
|
||||
<button class="button button--primary" type="submit" disabled={!canPost}>
|
||||
{busy ? 'Posting…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="empty-note">
|
||||
<a href="#/login">Sign in</a> to post.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
poll: Poll
|
||||
}
|
||||
|
||||
let { poll }: Props = $props()
|
||||
|
||||
const total = $derived(poll.votes_count || 0)
|
||||
|
||||
function share(votes: number | null): number {
|
||||
if (!total || votes === null) return 0
|
||||
return Math.round((votes / total) * 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
|
||||
{#each poll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">{option.title}</span>
|
||||
<span class="poll-option-share">{share(option.votes_count)}%</span>
|
||||
</div>
|
||||
<div class="poll-option-bar">
|
||||
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<p class="poll-meta">
|
||||
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
||||
{#if poll.expired}
|
||||
· closed
|
||||
{:else if poll.expires_at}
|
||||
· closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
/** The link preview a server attaches to a status. */
|
||||
import type { PreviewCard } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
card: PreviewCard
|
||||
}
|
||||
|
||||
let { card }: Props = $props()
|
||||
|
||||
const host = $derived.by(() => {
|
||||
try {
|
||||
return new URL(card.url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return card.provider_name ?? ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<a class="preview-card" href={card.url} target="_blank" rel="noopener noreferrer" data-card-type={card.type}>
|
||||
{#if card.image}
|
||||
<img class="preview-card-image" src={card.image} alt="" loading="lazy" decoding="async" />
|
||||
{/if}
|
||||
<span class="preview-card-body">
|
||||
<span class="preview-card-title">{card.title}</span>
|
||||
{#if card.description}
|
||||
<span class="preview-card-description">{card.description}</span>
|
||||
{/if}
|
||||
<span class="preview-card-host">{host}</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : null)
|
||||
const version = $derived(session.instance?.version ?? null)
|
||||
</script>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p class="site-footer-links">
|
||||
<a href="#/">Home</a>
|
||||
<a href="#/browse">Browse</a>
|
||||
<a href="#/search">Search</a>
|
||||
<a href="#/settings">Settings</a>
|
||||
{#if domain}
|
||||
<a href={`https://${session.host}/about`} target="_blank" rel="noopener noreferrer">About this server</a>
|
||||
{/if}
|
||||
</p>
|
||||
<!--
|
||||
"Connected to X" read as though you had an account there, even when
|
||||
browsing anonymously. Say which of the two it is.
|
||||
-->
|
||||
<p class="site-footer-note" data-session={session.signedIn ? 'signed-in' : session.host ? 'guest' : 'none'}>
|
||||
plspace — it’s always Pleroma™.
|
||||
{#if session.signedIn && session.me}
|
||||
Signed in to <strong>{domain}</strong> as
|
||||
<a href={profilePath(session.me)}>@{session.me.acct}</a>{#if version} ({version}){/if}.
|
||||
{:else if domain}
|
||||
Browsing <strong>{domain}</strong> as a guest{#if version} ({version}){/if}.
|
||||
<a href="#/login">Sign in</a>
|
||||
{:else}
|
||||
Not connected to a server. <a href="#/login">Choose one</a>
|
||||
{/if}
|
||||
</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The navy utility bar and the boxed logo strip beneath it.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router, routeTo } from '$lib/router.svelte'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
// Imported rather than referenced by path so Vite fingerprints it and the
|
||||
// relative `base` still resolves when hosted from a subdirectory.
|
||||
import logoUrl from '../../assets/plspace-logo.webp'
|
||||
|
||||
let query = $state('')
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
function submitSearch(event: SubmitEvent): void {
|
||||
event.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
router.go(routeTo('/search', { q: trimmed }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header-inner">
|
||||
<!--
|
||||
The logo lockup sits inside the navy band and is knocked out to white,
|
||||
as on the 2005 page — mark, wordmark, and the tagline tucked beneath the
|
||||
wordmark. `width`/`height` carry the intrinsic ratio so the bar doesn't
|
||||
reflow while the image decodes.
|
||||
-->
|
||||
<a class="site-logo" href="#/">
|
||||
<img
|
||||
class="site-logo-image"
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width="3100"
|
||||
height="2120"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="site-logo-text">
|
||||
<span class="site-logo-mark">plspace</span>
|
||||
<span class="site-logo-tagline">
|
||||
It’s always Pleroma<span class="site-logo-trademark">™</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if domain}
|
||||
<!-- A bare domain read as "you have an account on this server".
|
||||
Mark the guest case so the two are never confused. -->
|
||||
<p class="site-connection">
|
||||
{domain}
|
||||
{#if !session.signedIn}
|
||||
<span class="site-connection-guest" title="You are not signed in">(guest)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="site-search" role="search" onsubmit={submitSearch}>
|
||||
<label class="site-search-label" for="site-search-input">Search Users:</label>
|
||||
<input
|
||||
id="site-search-input"
|
||||
class="site-search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
bind:value={query}
|
||||
placeholder="name or @user@server"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="button button--small" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<p class="site-account-links">
|
||||
<a href="#/settings">Settings</a>
|
||||
{#if session.signedIn}
|
||||
<span aria-hidden="true">|</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-button site-header-logout"
|
||||
onclick={() => void session.logout()}
|
||||
>
|
||||
LogOut
|
||||
</button>
|
||||
{:else}
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="#/login">LogIn</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The pipe-separated nav strip.
|
||||
*
|
||||
* Entries that need a token disappear when browsing logged out rather than
|
||||
* erroring on click.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
/** Route names that should light this entry up. */
|
||||
matches: string[]
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
|
||||
const items = $derived<NavItem[]>([
|
||||
{ label: 'Home', href: '#/', matches: ['home'] },
|
||||
{ label: 'Browse', href: '#/browse', matches: ['browse'] },
|
||||
{ label: 'Search', href: '#/search', matches: ['search'] },
|
||||
{ label: 'Mail', href: '#/mail', matches: ['mail', 'mail.folder'], requiresAuth: true },
|
||||
{ label: 'Blog', href: '#/timeline/home', matches: ['timeline', 'tag', 'blog.entry'] },
|
||||
{
|
||||
label: 'My Profile',
|
||||
href: session.me ? profilePath(session.me) : '#/login',
|
||||
matches: ['profile', 'profile.friends', 'profile.blog', 'profile.pics'],
|
||||
requiresAuth: true,
|
||||
},
|
||||
{ label: 'Post', href: '#/compose', matches: ['compose'], requiresAuth: true },
|
||||
{ label: 'Settings', href: '#/settings', matches: ['settings'] },
|
||||
])
|
||||
|
||||
const visible = $derived(items.filter((item) => !item.requiresAuth || session.signedIn))
|
||||
const currentName = $derived(router.current.name)
|
||||
</script>
|
||||
|
||||
<nav class="site-nav" aria-label="Main">
|
||||
<div class="site-nav-inner">
|
||||
<ul class="site-nav-list">
|
||||
{#each visible as item (item.label)}
|
||||
<li class="site-nav-item">
|
||||
<a
|
||||
class="site-nav-link"
|
||||
href={item.href}
|
||||
aria-current={item.matches.includes(currentName) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* An account's photo, linked to their profile.
|
||||
*
|
||||
* Falls back to a generated monogram when the avatar 404s or the account has
|
||||
* none — a broken-image icon in a friend grid ruins the whole effect.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
size?: 'default' | 'large' | 'friend'
|
||||
/** Render without the surrounding link (when an ancestor is already one). */
|
||||
plain?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { account, size = 'default', plain = false, class: extraClass = '' }: Props = $props()
|
||||
|
||||
let failed = $state(false)
|
||||
|
||||
const name = $derived(displayNameOf(account))
|
||||
const sizeClass = $derived(size === 'large' ? 'avatar--large' : size === 'friend' ? 'avatar--friend' : '')
|
||||
const src = $derived(account.avatar_static || account.avatar)
|
||||
const initial = $derived((name.match(/\p{L}|\p{N}/u)?.[0] ?? '?').toUpperCase())
|
||||
|
||||
/**
|
||||
* A stable hue per account, so the placeholder is at least recognisable.
|
||||
*/
|
||||
const hue = $derived.by(() => {
|
||||
let hash = 0
|
||||
const seed = account.acct || account.id
|
||||
for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
||||
return hash % 360
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet image()}
|
||||
{#if src && !failed}
|
||||
<img
|
||||
class="avatar {sizeClass} {extraClass}"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => (failed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="avatar avatar--placeholder {sizeClass} {extraClass}"
|
||||
style="--avatar-hue: {hue}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if plain}
|
||||
{@render image()}
|
||||
{:else}
|
||||
<a class="avatar-link" href={profilePath(account)} title={name} data-account={account.acct}>
|
||||
{@render image()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A bordered box with a caption bar — the unit every page is assembled from.
|
||||
*
|
||||
* `variant` picks the era-correct chrome:
|
||||
* panel blue caption bar (left rail)
|
||||
* band peach caption bar (main column)
|
||||
* plain no chrome, just the heading
|
||||
*/
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
variant?: 'panel' | 'band' | 'plain'
|
||||
/** Right-aligned link in the caption bar, e.g. "[view all]". */
|
||||
action?: Snippet
|
||||
/** Remove body padding, for tables that should meet the border. */
|
||||
flush?: boolean
|
||||
/** Extra classes, so callers can add their own styling hook. */
|
||||
class?: string
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
variant = 'panel',
|
||||
action,
|
||||
flush = false,
|
||||
class: extraClass = '',
|
||||
children,
|
||||
}: Props = $props()
|
||||
|
||||
const variantClass = $derived(
|
||||
variant === 'band' ? 'module--band' : variant === 'plain' ? 'module--plain' : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<section class="module {variantClass} {extraClass}" data-variant={variant}>
|
||||
{#if title}
|
||||
<h2 class="module-header">
|
||||
<span class="module-header-title">{title}</span>
|
||||
{#if action}
|
||||
<span class="module-header-action">{@render action()}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
{/if}
|
||||
<div class="module-body" class:module-body--flush={flush}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The end-of-list control for a `Feed`: a "more entries" button, the loading
|
||||
* note, the empty state and any error, in one place so every list behaves the
|
||||
* same way.
|
||||
*/
|
||||
import type { Feed, Identified } from '$lib/stores/feed.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Identified>
|
||||
/** Label for the load-more button. */
|
||||
label?: string
|
||||
/** Shown when the list came back empty. */
|
||||
emptyText?: string
|
||||
/** Shown once everything has loaded, if there was anything at all. */
|
||||
endText?: string
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
label = 'View More Entries',
|
||||
emptyText = 'There are no entries yet.',
|
||||
endText = 'That’s everything.',
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if feed.error}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Couldn’t load this list.</strong>
|
||||
{feed.error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !feed.initialized && feed.loading}
|
||||
<p class="loading-note">Loading…</p>
|
||||
{:else if feed.isEmpty}
|
||||
<p class="empty-note">{emptyText}</p>
|
||||
{:else if !feed.exhausted}
|
||||
<div class="pager">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
onclick={() => feed.loadMore()}
|
||||
disabled={feed.loading}
|
||||
>
|
||||
{feed.loading ? 'Loading…' : label}
|
||||
</button>
|
||||
</div>
|
||||
{:else if feed.items.length > 0 && endText}
|
||||
<p class="pager-status">{endText}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The only place in the app that calls `{@html}` on server content.
|
||||
*
|
||||
* Keeping it to one component means the sanitizer can never be accidentally
|
||||
* skipped: callers pass raw HTML from the API and get a sanitized,
|
||||
* emoji-substituted, link-rewritten render.
|
||||
*/
|
||||
import { renderHtml } from '$lib/util/html'
|
||||
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
html: string | null | undefined
|
||||
emojis?: CustomEmoji[]
|
||||
mentions?: StatusMention[]
|
||||
tags?: StatusTag[]
|
||||
/** Collapse to a single line, for previews. */
|
||||
inline?: boolean
|
||||
class?: string
|
||||
lang?: string | null
|
||||
}
|
||||
|
||||
let { html, emojis, mentions, tags, inline = false, class: extraClass = '', lang }: Props = $props()
|
||||
|
||||
const rendered = $derived(renderHtml(html, { emojis, mentions, tags, inline }))
|
||||
</script>
|
||||
|
||||
{#if rendered}
|
||||
<div
|
||||
class="rich-text {extraClass}"
|
||||
class:rich-text--inline={inline}
|
||||
lang={lang ?? undefined}
|
||||
dir="auto"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderHtml -->
|
||||
{@html rendered}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/** A row of page tabs, rendered as links so they're navigable and shareable. */
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
/** Optional count shown in parentheses. */
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[]
|
||||
/** Route path of the active tab, compared against each `href`. */
|
||||
current: string
|
||||
label: string
|
||||
}
|
||||
|
||||
let { tabs, current, label }: Props = $props()
|
||||
</script>
|
||||
|
||||
<nav class="tab-bar" aria-label={label}>
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
class="tab"
|
||||
href={tab.href}
|
||||
aria-current={tab.href === current ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}{#if tab.count != null} ({tab.count}){/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/** A `Feed<Account>` rendered as person rows, plus its pager. */
|
||||
import type { Account } from '$lib/api/types'
|
||||
import type { Feed } from '$lib/stores/feed.svelte'
|
||||
import PersonRow from './PersonRow.svelte'
|
||||
import Pager from '../common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Account>
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
let { feed, emptyText = 'Nobody here yet.' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="person-list">
|
||||
{#each feed.items as account (account.id)}
|
||||
<PersonRow {account} />
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Pager {feed} label="View More People" {emptyText} />
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
/** A person in a list: friends, search results, the browse directory. */
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
/** Extra controls on the right, e.g. Approve/Deny on friend requests. */
|
||||
actions?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { account, actions }: Props = $props()
|
||||
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
</script>
|
||||
|
||||
<li class="person-row" data-account={account.acct} data-bot={account.bot ? 'true' : 'false'}>
|
||||
<a href={profilePath(account)} class="person-row-photo-link">
|
||||
<img
|
||||
class="person-row-photo"
|
||||
src={account.avatar_static || account.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="person-row-body">
|
||||
<a class="person-row-name" href={profilePath(account)}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
|
||||
{@html name}
|
||||
</a>
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
|
||||
{#if account.note}
|
||||
<RichText class="person-row-note" html={account.note} emojis={account.emojis} inline />
|
||||
{/if}
|
||||
|
||||
<p class="person-row-meta">
|
||||
{formatCount(account.statuses_count)} entries ·
|
||||
{formatCount(account.followers_count)} friends
|
||||
{#if account.last_status_at}
|
||||
· active {relativeTime(account.last_status_at)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
<div class="person-row-actions">{@render actions()}</div>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Contacting Tom" — the two-column action list.
|
||||
*
|
||||
* Follow state comes from `/api/v1/accounts/relationships`, which is only
|
||||
* available when signed in; logged out, the actions become sign-in prompts
|
||||
* rather than disappearing, so the box keeps its shape.
|
||||
*/
|
||||
import type { Account, Relationship } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
relationship: Relationship | null
|
||||
onrelationship?: (relationship: Relationship) => void
|
||||
}
|
||||
|
||||
let { account, relationship, onrelationship }: Props = $props()
|
||||
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const firstName = $derived(displayNameOf(account).split(/\s+/)[0])
|
||||
const isSelf = $derived(session.me?.id === account.id)
|
||||
const following = $derived(relationship?.following ?? false)
|
||||
const requested = $derived(relationship?.requested ?? false)
|
||||
const blocking = $derived(relationship?.blocking ?? false)
|
||||
|
||||
const followLabel = $derived(
|
||||
blocking
|
||||
? 'Unblock User'
|
||||
: following
|
||||
? 'Remove from Friends'
|
||||
: requested
|
||||
? 'Cancel Friend Request'
|
||||
: account.locked
|
||||
? 'Request to Add'
|
||||
: 'Add to Friends',
|
||||
)
|
||||
|
||||
async function run(action: () => Promise<Relationship>): Promise<void> {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
onrelationship?.(await action())
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFollow(): void {
|
||||
if (blocking) {
|
||||
void run(() => unblockAccount(session.api, account.id))
|
||||
} else if (following || requested) {
|
||||
void run(() => unfollowAccount(session.api, account.id))
|
||||
} else {
|
||||
void run(() => followAccount(session.api, account.id))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBlock(): void {
|
||||
void run(() =>
|
||||
blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Module title={`Contacting ${firstName}`}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<ul class="action-list">
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">✉</span>
|
||||
{#if session.signedIn}
|
||||
<a class="action-list-label" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>Send Message</a>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Send Message</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">👤</span>
|
||||
{#if isSelf}
|
||||
<span class="action-list-label muted">This is you</span>
|
||||
{:else if session.signedIn}
|
||||
<button
|
||||
type="button"
|
||||
class="link-button action-list-label"
|
||||
disabled={busy}
|
||||
aria-pressed={following ? 'true' : 'false'}
|
||||
onclick={toggleFollow}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Add to Friends</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">★</span>
|
||||
<a class="action-list-label" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
View on {account.acct.includes('@') ? account.acct.split('@')[1] : session.host}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">➦</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/friends`}>Forward to Friend</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">💬</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/blog`}>Instant Message</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">⛔</span>
|
||||
{#if isSelf || !session.signedIn}
|
||||
<span class="action-list-label muted">Block User</span>
|
||||
{:else}
|
||||
<button type="button" class="link-button action-list-label" disabled={busy} onclick={toggleBlock}>
|
||||
{blocking ? 'Unblock User' : 'Block User'}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{#if relationship?.followed_by && !isSelf}
|
||||
<p class="contact-note muted">{firstName} has you on their friends list.</p>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Details" — profile fields that didn't map to an interests row.
|
||||
*
|
||||
* Mastodon's link verification is surfaced here: a field whose URL proved
|
||||
* ownership is highlighted, matching what every other client does, because
|
||||
* an unverified link that looks verified is a phishing surface.
|
||||
*/
|
||||
import type { ProfileField } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
fields: ProfileField[]
|
||||
}
|
||||
|
||||
let { title, fields }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if fields.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table details-table">
|
||||
<tbody>
|
||||
{#each fields as field (field.name)}
|
||||
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
||||
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
||||
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
||||
{#if field.verified}
|
||||
<span class="verified-mark" title="Ownership of this link is verified">✓</span>
|
||||
{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html field.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Friend Space" — the grid of tiny avatars with names above them.
|
||||
*
|
||||
* Mastodon lets an account hide its follower/following lists, and remote
|
||||
* accounts often return an empty list rather than an error, so the count and
|
||||
* the grid are allowed to disagree; the count is authoritative.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** The subject, used in "Tom has 527 friends." */
|
||||
ownerName: string
|
||||
friends: Account[]
|
||||
total: number
|
||||
viewAllHref: string
|
||||
loading?: boolean
|
||||
/** The list is withheld by the account's privacy settings. */
|
||||
hidden?: boolean
|
||||
/** The count is withheld too, so `total` is not meaningful. */
|
||||
countHidden?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
ownerName,
|
||||
friends,
|
||||
total,
|
||||
viewAllHref,
|
||||
loading = false,
|
||||
hidden = false,
|
||||
countHidden = false,
|
||||
compact = false,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Module {title} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={viewAllHref}>[view all]</a>
|
||||
{/snippet}
|
||||
|
||||
<!-- Never render a withheld count as "0 friends" — that reports a privacy
|
||||
setting as a fact about the person. -->
|
||||
{#if countHidden}
|
||||
<p class="friend-count">{ownerName} keeps their friend count private.</p>
|
||||
{:else}
|
||||
<p class="friend-count">
|
||||
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
|
||||
friend{total === 1 ? '' : 's'}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<p class="empty-note">This friends list is private.</p>
|
||||
{:else if loading && friends.length === 0}
|
||||
<p class="loading-note">Loading friends…</p>
|
||||
{:else if friends.length === 0}
|
||||
<p class="empty-note">No friends to show yet.</p>
|
||||
{:else}
|
||||
<ul class="friend-grid" class:friend-grid--compact={compact}>
|
||||
{#each friends 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"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Interests" — the label/value table.
|
||||
*
|
||||
* Rows only appear when the account has a matching profile field, so a bare
|
||||
* Mastodon account gets a compact box rather than six empty rows.
|
||||
*/
|
||||
import type { InterestEntry } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
interests: InterestEntry[]
|
||||
}
|
||||
|
||||
let { title, interests }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if interests.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table interests-table">
|
||||
<tbody>
|
||||
{#each interests as entry (entry.row)}
|
||||
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
||||
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
||||
<td class="data-table-value interests-value">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html entry.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The photo-and-vitals block at the top of a profile.
|
||||
*
|
||||
* MySpace showed gender, age, location and last-login as bare lines beside
|
||||
* the photo. Mastodon publishes none of those, so the ones a user hasn't put
|
||||
* in a profile field are simply omitted — except "age", which falls back to
|
||||
* how long the account has existed, because a profile with no numbers at all
|
||||
* doesn't read as a profile.
|
||||
*/
|
||||
import type { ProfileView } from '$lib/util/profile'
|
||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView
|
||||
}
|
||||
|
||||
let { profile }: Props = $props()
|
||||
|
||||
const account = $derived(profile.account)
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
const handle = $derived(fullHandle(account, session.host))
|
||||
const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
|
||||
const photo = $derived(account.avatar || account.avatar_static)
|
||||
</script>
|
||||
|
||||
<div class="profile-identity">
|
||||
<div class="profile-photo-wrap">
|
||||
{#if photo}
|
||||
<a class="profile-photo-link" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
<img class="profile-photo" src={photo} alt={displayNameOf(account)} decoding="async" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="profile-photo profile-photo--empty" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<a class="profile-photo-caption" href={`#/@${account.acct}/pics`}>View more pics</a>
|
||||
</div>
|
||||
|
||||
<div class="profile-vitals-wrap">
|
||||
<p class="profile-headline">{profile.headline}</p>
|
||||
|
||||
<dl class="profile-vitals">
|
||||
{#if profile.gender}
|
||||
<dt>Gender</dt>
|
||||
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
|
||||
{/if}
|
||||
|
||||
{#if accountAge !== null}
|
||||
<dt>Age</dt>
|
||||
<dd class="profile-vital profile-vital--age">
|
||||
{accountAge} years old
|
||||
{#if profile.age === null}
|
||||
<span class="muted">(on this server)</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
{#if profile.location}
|
||||
<dt>Location</dt>
|
||||
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>Last active</dt>
|
||||
<dd class="profile-vital profile-vital--active">
|
||||
Last active:<br />
|
||||
{account.last_status_at ? relativeTime(account.last_status_at) : 'unknown'}
|
||||
</dd>
|
||||
|
||||
<dt>Member since</dt>
|
||||
<dd class="profile-vital profile-vital--joined">
|
||||
Member since: {shortDate(account.created_at)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if profile.mood}
|
||||
<p class="profile-mood">
|
||||
Mood: <span class="profile-mood-value">{profile.mood}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="profile-viewlinks">
|
||||
<span class="profile-viewlinks-label">View my:</span>
|
||||
<a href={`#/@${account.acct}/blog`}>Blog</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/friends`}>Friends</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/pics`}>Pics</a>
|
||||
</p>
|
||||
|
||||
<p class="profile-handle-line">
|
||||
<span class="visually-hidden">Handle:</span>
|
||||
<code class="profile-handle">{handle}</code>
|
||||
{#if account.bot}
|
||||
<span class="profile-badge" data-badge="bot">bot</span>
|
||||
{/if}
|
||||
{#if account.locked}
|
||||
<span class="profile-badge" data-badge="locked">private</span>
|
||||
{/if}
|
||||
{#each account.roles ?? [] as role (role.id)}
|
||||
<span class="profile-badge" data-badge="role">{role.name}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user