initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "plspace",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
*.local
.vite/
+111
View File
@@ -0,0 +1,111 @@
# plspace
*It's always Pleroma.*
A static, client-side frontend for Pleroma that looks and behaves like MySpace
circa 2005.
Point it at your server. It talks to that server's REST API directly from the
browser — there is no plspace backend, and `dist/` is a folder of static files
you can host anywhere.
## What it looks like
| MySpace | plspace |
| --- | --- |
| Profile page: photo, headline, vitals, Contacting box, Interests table | `#/@user@server` |
| Latest Blog Entries with "(view more)" | Account statuses, headline list |
| Blurbs: "About me", "Who I'd like to meet" | The bio, split on a heading if you wrote one |
| Friend Space grid | Followers |
| Mail Center, Friend Request Manager | Notifications, follow requests |
| Bulletin Space | The local timeline |
| Kudos | Favourites |
| Custom profile layouts | CSS in a profile field named `css` |
## Running it
```bash
npm install && npm run dev
```
```bash
npm run build
```
`npm run build` type-checks and then bundles to `dist/`. Because routing is
hash-based, the output works from any path — a subdirectory, an S3 bucket,
GitHub Pages — with no server rewrite rules.
## Signing in
plspace registers itself as an OAuth app on your server the first time you sign
in there, then redirects you to that server's own consent screen. Your password
is never entered into plspace; it only ever receives an access token, which is
stored in `localStorage` and used directly from your browser.
PKCE is used where the server supports it, with an automatic fallback for
servers that don't.
You can also browse without signing in — click **Just look around**. Note that
many servers set `restrict_unauthenticated` and refuse timeline reads from
anonymous callers; the suggested servers on the sign-in page are ones verified
to allow it.
## Making it yours
Settings has a CSS editor, five starter layouts, and a full class reference.
Everything about the appearance is overridable — see
[`src/styles/README.md`](src/styles/README.md) for the contract. In short:
no CSS framework, no Svelte scoped styles, no hashed class names, every colour
and metric is a custom property, and user CSS is always last in the cascade so
plain single-class selectors win without `!important`.
There is no built-in dark mode. A dark theme is just token overrides, so it ships
as a preset (**Midnight**, **Terminal**) you can apply, edit or replace, rather
than as a toggle you can't.
To publish a layout other people see on your profile, put CSS in a profile field
named `css` on your server. It is scoped to your profile's subtree and filtered
before it is applied.
Your profile fields also drive the profile page: name one `Music`, `Movies`,
`Television`, `Books` or `Heroes` and it fills the Interests table; name one
`Mood`, `Location`, `Gender` or `Headline` and it fills the block beside your
photo.
## Layout
```
src/
lib/
api/ client (fetch, Link-header pagination), OAuth, endpoints, entity types
stores/ session, Feed (cursor pagination), theme (user + profile CSS)
util/ HTML sanitizing, 2005-flavoured date formats, account -> profile mapping
router.svelte.ts
themes.ts starter layouts, written only in terms of tokens
components/
chrome/ header, nav, footer
common/ Module, RichText, Avatar, Pager, TabBar
profile/ identity, contacting box, interests, details, friend space
blog/ entry, attachments, poll, preview card, composer
people/ person row and list
routes/ Home, Profile, Timeline, StatusPage, Mail, Browse, Search, Compose, Login, Settings
styles/ tokens, base, layout, chrome, module, profile, blog, forms
```
## Safety notes
All HTML from the API — post content, bios, profile field values — is sanitized
with DOMPurify before it reaches `{@html}`, in exactly one component
(`RichText.svelte`) plus the profile-field helper. Mention and hashtag links are
rewritten to in-app routes; every other link gets `target="_blank"` with
`rel="noopener noreferrer"`.
## Compatibility
Written against the standard `/api/v1` REST API and tested against live servers.
Anything implementations differ on degrades rather than fails: `/api/v2/instance`
falls back to v1, `/api/v1/accounts/lookup` falls back to search, pagination
falls back to the last item's id when a server drops the `Link` header, and
per-account privacy flags are read in both their spellings. Other software
speaking the same API therefore works, but Pleroma is what this targets.
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Vite rewrites this to the fingerprinted asset at build time. -->
<link rel="icon" type="image/webp" href="/src/assets/plspace-logo.webp" />
<!-- The theme store replaces this with the user's saved choice on boot;
declaring light here avoids a dark-mode flash before it runs. -->
<meta name="color-scheme" content="light" />
<title>plspace | it's always Pleroma</title>
<meta name="description" content="A MySpace-flavored web client for Pleroma." />
<!--
Style hook: anything a user drops in here (or in a <style> tag they inject
at runtime) wins over the shipped theme, because the app's own stylesheet
is emitted before this point in the cascade and uses single-class
selectors only. See src/styles/README.md for the class contract.
-->
<style id="user-stylesheet"></style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1361
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "plspace",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "A MySpace-flavored static frontend for Pleroma",
"scripts": {
"dev": "vite",
"build": "svelte-check --tsconfig ./tsconfig.json && vite build",
"build:only": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^26.1.2",
"svelte": "^5.56.8",
"svelte-check": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^8.1.5"
},
"dependencies": {
"dompurify": "^3.4.12"
}
}
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
/**
* Shell: restore the session, then render chrome plus whichever route matched.
*/
import { router } from '$lib/router.svelte'
import { session } from '$lib/stores/session.svelte'
import SiteHeader from '$components/chrome/SiteHeader.svelte'
import SiteNav from '$components/chrome/SiteNav.svelte'
import SiteFooter from '$components/chrome/SiteFooter.svelte'
import Home from '$routes/Home.svelte'
import Profile from '$routes/Profile.svelte'
import Timeline from '$routes/Timeline.svelte'
import StatusPage from '$routes/StatusPage.svelte'
import Mail from '$routes/Mail.svelte'
import Browse from '$routes/Browse.svelte'
import Search from '$routes/Search.svelte'
import Login from '$routes/Login.svelte'
import Settings from '$routes/Settings.svelte'
import Compose from '$routes/Compose.svelte'
import NotFound from '$routes/NotFound.svelte'
import type { TimelineKind } from '$lib/api/endpoints'
let booted = $state(false)
$effect(() => {
void (async () => {
const landing = await session.restore()
booted = true
if (landing) {
router.go(landing)
} else if (!session.host && router.current.name !== 'login') {
// No server chosen yet — everything else would 404 against nothing.
router.replace('#/login')
}
})()
})
const route = $derived(router.current)
/** Route names that are usable before a server is chosen. */
const ALWAYS_AVAILABLE = new Set(['login', 'settings', 'notfound'])
const TIMELINE_KINDS: readonly TimelineKind[] = ['home', 'public', 'local']
const timelineKind = $derived<TimelineKind>(
TIMELINE_KINDS.includes(route.params.kind as TimelineKind)
? (route.params.kind as TimelineKind)
: 'public',
)
</script>
<div class="site">
<SiteHeader />
<SiteNav />
{#if !booted}
<div class="page">
<p class="loading-note">Starting up&hellip;</p>
</div>
{:else if !session.host && !ALWAYS_AVAILABLE.has(route.name)}
<Login />
{:else if route.name === 'home'}
<Home />
{:else if route.name === 'profile'}
<Profile acct={route.params.acct} view="profile" />
{:else if route.name === 'profile.blog'}
<Profile acct={route.params.acct} view="blog" />
{:else if route.name === 'profile.friends'}
<Profile acct={route.params.acct} view="friends" />
{:else if route.name === 'profile.pics'}
<Profile acct={route.params.acct} view="pics" />
{:else if route.name === 'timeline'}
<Timeline kind={timelineKind} />
{:else if route.name === 'tag'}
<Timeline kind="tag" tag={route.params.tag} />
{:else if route.name === 'blog.entry'}
<StatusPage id={route.params.id} />
{:else if route.name === 'mail'}
<Mail folder="inbox" />
{:else if route.name === 'mail.folder'}
<Mail folder={route.params.folder} />
{:else if route.name === 'browse'}
<Browse />
{:else if route.name === 'search'}
<Search q={route.query.get('q') ?? ''} />
{:else if route.name === 'compose'}
<Compose to={route.query.get('to') ?? undefined} />
{:else if route.name === 'login'}
<Login />
{:else if route.name === 'settings'}
<Settings />
{:else}
<NotFound />
{/if}
<SiteFooter />
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+85
View File
@@ -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}
+261
View File
@@ -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 cant 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>
+36
View File
@@ -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} />
+186
View File
@@ -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}
+42
View File
@@ -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}
&middot; closed
{:else if poll.expires_at}
&middot; 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>
+36
View File
@@ -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 &mdash; it&rsquo;s always Pleroma&trade;.
{#if session.signedIn && session.me}
Signed in to <strong>{domain}</strong> as
<a href={profilePath(session.me)}>@{session.me.acct}</a>{#if version}&nbsp;({version}){/if}.
{:else if domain}
Browsing <strong>{domain}</strong> as a guest{#if version}&nbsp;({version}){/if}.
<a href="#/login">Sign in</a>
{:else}
Not connected to a server. <a href="#/login">Choose one</a>
{/if}
</p>
</footer>
+91
View File
@@ -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&rsquo;s always Pleroma<span class="site-logo-trademark">&trade;</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>
+56
View File
@@ -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>
+66
View File
@@ -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}
+50
View File
@@ -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>
+51
View File
@@ -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 = 'Thats everything.',
}: Props = $props()
</script>
{#if feed.error}
<p class="error-note" role="alert">
<strong class="error-note-title">Couldnt load this list.</strong>
{feed.error}
</p>
{/if}
{#if !feed.initialized && feed.loading}
<p class="loading-note">Loading&hellip;</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}
+38
View File
@@ -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}
+30
View File
@@ -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}&nbsp;({tab.count}){/if}
</a>
{/each}
</nav>
+22
View File
@@ -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} />
+55
View File
@@ -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 &middot;
{formatCount(account.followers_count)} friends
{#if account.last_status_at}
&middot; active {relativeTime(account.last_status_at)}
{/if}
</p>
</div>
{#if actions}
<div class="person-row-actions">{@render actions()}</div>
{/if}
</li>
+140
View File
@@ -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 didnt 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}
+81
View File
@@ -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&hellip;</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>
+244
View File
@@ -0,0 +1,244 @@
/**
* Thin fetch wrapper for Mastodon-compatible REST APIs.
*
* Deliberately dependency-free and stateless apart from the host/token it is
* constructed with, so it can be reused for logged-out browsing of an arbitrary
* instance as well as for the signed-in session.
*/
export class ApiError extends Error {
readonly status: number
readonly url: string
readonly body: unknown
constructor(status: number, url: string, body: unknown, message?: string) {
super(message ?? `${status} from ${url}`)
this.name = 'ApiError'
this.status = status
this.url = url
this.body = body
}
/** True when re-authenticating is likely to fix it. */
get isAuthFailure(): boolean {
return this.status === 401 || this.status === 403
}
}
/** Cursor links parsed out of the RFC 5988 `Link` response header. */
export interface PageLinks {
/** Older results (`?max_id=…`). */
next?: string
/** Newer results (`?min_id=…`). */
prev?: string
maxId?: string
minId?: string
sinceId?: string
}
export interface Page<T> {
items: T[]
links: PageLinks
}
export type QueryValue = string | number | boolean | undefined | null | string[]
export type Query = Record<string, QueryValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
query?: Query
body?: unknown
/** Send as multipart instead of JSON (media uploads). */
form?: FormData
signal?: AbortSignal
/** Override the instance token for this call. */
token?: string | null
headers?: Record<string, string>
}
/**
* Mastodon paginates with a `Link` header rather than in the body. Pleroma
* emits the same header but occasionally omits `rel="prev"`, and some servers
* behind a CORS proxy strip it entirely callers must cope with an empty
* result here by falling back to the last item's id.
*/
export function parseLinkHeader(header: string | null): PageLinks {
const links: PageLinks = {}
if (!header) return links
for (const part of header.split(/,\s*(?=<)/)) {
const match = /^<([^>]+)>\s*;\s*(.+)$/.exec(part.trim())
if (!match) continue
const [, url, params] = match
const rel = /rel\s*=\s*"?([^";]+)"?/.exec(params)?.[1]
if (rel !== 'next' && rel !== 'prev') continue
links[rel] = url
let parsed: URL
try {
parsed = new URL(url)
} catch {
continue
}
const maxId = parsed.searchParams.get('max_id')
const minId = parsed.searchParams.get('min_id')
const sinceId = parsed.searchParams.get('since_id')
if (rel === 'next' && maxId) links.maxId = maxId
if (rel === 'prev' && minId) links.minId = minId
if (rel === 'prev' && sinceId) links.sinceId = sinceId
}
return links
}
/** `https://example.social/` / `Example.Social` / `@user@example.social` -> `example.social`. */
export function normalizeHost(input: string): string {
let value = input.trim().toLowerCase()
if (!value) return ''
// Accept a full webfinger handle and keep only the domain part.
if (value.includes('@')) value = value.slice(value.lastIndexOf('@') + 1)
value = value.replace(/^https?:\/\//, '')
value = value.replace(/\/.*$/, '')
return value
}
function buildQuery(query: Query | undefined): string {
if (!query) return ''
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
if (Array.isArray(value)) {
for (const entry of value) params.append(`${key}[]`, entry)
} else {
params.set(key, String(value))
}
}
const serialized = params.toString()
return serialized ? `?${serialized}` : ''
}
export class ApiClient {
readonly host: string
private token: string | null
constructor(host: string, token: string | null = null) {
this.host = normalizeHost(host)
this.token = token
}
get origin(): string {
return `https://${this.host}`
}
get authenticated(): boolean {
return Boolean(this.token)
}
setToken(token: string | null): void {
this.token = token
}
withToken(token: string | null): ApiClient {
return new ApiClient(this.host, token)
}
private url(path: string, query?: Query): string {
const suffix = path.startsWith('/') ? path : `/${path}`
return `${this.origin}${suffix}${buildQuery(query)}`
}
private headers(options: RequestOptions): Headers {
const headers = new Headers(options.headers)
headers.set('Accept', 'application/json')
const token = options.token !== undefined ? options.token : this.token
if (token) headers.set('Authorization', `Bearer ${token}`)
if (options.body !== undefined && !options.form) {
headers.set('Content-Type', 'application/json')
}
return headers
}
/** Perform a request and return the parsed body plus the raw response. */
async raw<T>(path: string, options: RequestOptions = {}): Promise<{ data: T; response: Response }> {
const url = this.url(path, options.query)
let response: Response
try {
response = await fetch(url, {
method: options.method ?? 'GET',
headers: this.headers(options),
body: options.form ?? (options.body !== undefined ? JSON.stringify(options.body) : undefined),
signal: options.signal,
// Mastodon tokens ride in the Authorization header; never send cookies
// cross-origin, which would also trip CORS preflight on most servers.
credentials: 'omit',
mode: 'cors',
})
} catch (cause) {
// A CORS rejection and an offline browser are indistinguishable here.
throw new ApiError(
0,
url,
null,
`Could not reach ${this.host}. It may be offline, or it may not allow browser apps to connect (CORS).`,
)
}
const text = await response.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = text
}
}
if (!response.ok) {
const message =
(data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
? data.error
: undefined) ?? `${response.status} ${response.statusText}`
throw new ApiError(response.status, url, data, message)
}
return { data: data as T, response }
}
async get<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'GET', query })
return data
}
async post<T>(path: string, body?: unknown, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'POST', body })
return data
}
async patch<T>(path: string, body?: unknown, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'PATCH', body })
return data
}
async delete<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { data } = await this.raw<T>(path, { ...options, method: 'DELETE' })
return data
}
/** GET a collection endpoint, returning both items and cursor links. */
async page<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<Page<T>> {
const { data, response } = await this.raw<T[]>(path, { ...options, method: 'GET', query })
const items = Array.isArray(data) ? data : []
const links = parseLinkHeader(response.headers.get('Link'))
// Fallback for servers that drop the Link header: derive `max_id` from the
// last item so "see more" keeps working.
if (!links.maxId && items.length > 0) {
const last = items[items.length - 1] as { id?: string }
if (last && typeof last.id === 'string') links.maxId = last.id
}
return { items, links }
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* Typed wrappers around the endpoints this frontend actually uses.
*
* Grouped by the MySpace-era concept they back, because that is how the UI
* thinks about them: friends (follows), blog entries (statuses), comments
* (replies), the mail centre (notifications).
*/
import type { ApiClient, Page, Query } from './client'
import { ApiError } from './client'
import type {
Account,
Context,
CredentialAccount,
InstanceInfo,
MediaAttachment,
Notification,
Relationship,
SearchResults,
Status,
StatusVisibility,
} from './types'
export interface Cursor {
max_id?: string
min_id?: string
since_id?: string
limit?: number
}
/* ------------------------------------------------------------------ server */
export async function fetchInstance(api: ApiClient): Promise<InstanceInfo> {
// v2 is richer but Pleroma/Akkoma only reliably serve v1.
try {
return await api.get<InstanceInfo>('/api/v2/instance')
} catch (cause) {
if (cause instanceof ApiError && cause.status !== 0) {
return api.get<InstanceInfo>('/api/v1/instance')
}
throw cause
}
}
export function instanceDomain(instance: InstanceInfo | null, fallback: string): string {
return instance?.domain ?? instance?.uri ?? fallback
}
export interface InstanceStat {
label: string
value: number
}
/**
* Normalize the two instance shapes into a stat list.
*
* v1 published user/status/domain counts; v2 dropped all of it except monthly
* actives, so the right rail shows whichever the server actually returned
* rather than a row of zeroes.
*/
export function instanceStats(instance: InstanceInfo | null): InstanceStat[] {
if (!instance) return []
if (instance.stats) {
return [
{ label: 'Members', value: instance.stats.user_count },
{ label: 'Entries', value: instance.stats.status_count },
{ label: 'Known servers', value: instance.stats.domain_count },
]
}
const activeMonth = instance.usage?.users?.active_month
return typeof activeMonth === 'number' ? [{ label: 'Active this month', value: activeMonth }] : []
}
export function instanceThumbnail(instance: InstanceInfo | null): string | null {
const thumbnail = instance?.thumbnail
if (!thumbnail) return null
return typeof thumbnail === 'string' ? thumbnail : (thumbnail.url ?? null)
}
/* ------------------------------------------------------------------ people */
export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
}
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
}
/**
* Resolve `user` or `user@host` to an account.
*
* `/api/v1/accounts/lookup` is the fast path but predates Pleroma's
* compatibility work, so fall back to search with `resolve=1`, which also
* pulls in accounts the instance has never seen before.
*/
export async function lookupAccount(api: ApiClient, acct: string): Promise<Account> {
const handle = acct.replace(/^@/, '')
try {
return await api.get<Account>('/api/v1/accounts/lookup', { acct: handle })
} catch (cause) {
if (!(cause instanceof ApiError) || cause.status === 0) throw cause
const results = await api.get<SearchResults>('/api/v2/search', {
q: handle,
type: 'accounts',
resolve: api.authenticated,
limit: 5,
})
const exact = results.accounts.find((account) => account.acct.toLowerCase() === handle.toLowerCase())
if (exact) return exact
if (results.accounts.length > 0) return results.accounts[0]
throw cause
}
}
export function fetchFollowers(api: ApiClient, id: string, cursor: Cursor = {}): Promise<Page<Account>> {
return api.page<Account>(`/api/v1/accounts/${encodeURIComponent(id)}/followers`, { ...cursor })
}
export function fetchFollowing(api: ApiClient, id: string, cursor: Cursor = {}): Promise<Page<Account>> {
return api.page<Account>(`/api/v1/accounts/${encodeURIComponent(id)}/following`, { ...cursor })
}
export async function fetchRelationship(api: ApiClient, id: string): Promise<Relationship | null> {
if (!api.authenticated) return null
// Array values are serialized as `id[]=…`, which is what Mastodon expects.
const rows = await api.get<Relationship[]>('/api/v1/accounts/relationships', { id: [id] })
return rows[0] ?? null
}
export function followAccount(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/follow`)
}
export function unfollowAccount(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/unfollow`)
}
export function blockAccount(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/block`)
}
export function unblockAccount(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/accounts/${encodeURIComponent(id)}/unblock`)
}
/** The instance's opt-in profile directory — the "Browse" page's source. */
export function fetchDirectory(
api: ApiClient,
options: { offset?: number; limit?: number; order?: 'active' | 'new'; local?: boolean } = {},
): Promise<Account[]> {
return api.get<Account[]>('/api/v1/directory', {
offset: options.offset ?? 0,
limit: options.limit ?? 20,
order: options.order ?? 'active',
local: options.local ?? true,
})
}
/* ------------------------------------------------------------ blog entries */
export type TimelineKind = 'home' | 'public' | 'local' | 'tag'
export function fetchTimeline(
api: ApiClient,
kind: TimelineKind,
cursor: Cursor = {},
options: { tag?: string } = {},
): Promise<Page<Status>> {
switch (kind) {
case 'home':
return api.page<Status>('/api/v1/timelines/home', { ...cursor })
case 'local':
return api.page<Status>('/api/v1/timelines/public', { ...cursor, local: true })
case 'tag':
return api.page<Status>(`/api/v1/timelines/tag/${encodeURIComponent(options.tag ?? '')}`, { ...cursor })
case 'public':
default:
return api.page<Status>('/api/v1/timelines/public', { ...cursor })
}
}
export function fetchAccountStatuses(
api: ApiClient,
id: string,
cursor: Cursor = {},
options: { exclude_replies?: boolean; exclude_reblogs?: boolean; only_media?: boolean; pinned?: boolean } = {},
): Promise<Page<Status>> {
return api.page<Status>(`/api/v1/accounts/${encodeURIComponent(id)}/statuses`, { ...cursor, ...options })
}
export function fetchStatus(api: ApiClient, id: string): Promise<Status> {
return api.get<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
}
export function fetchContext(api: ApiClient, id: string): Promise<Context> {
return api.get<Context>(`/api/v1/statuses/${encodeURIComponent(id)}/context`)
}
export interface ComposeOptions {
status: string
in_reply_to_id?: string | null
visibility?: StatusVisibility
spoiler_text?: string
sensitive?: boolean
media_ids?: string[]
language?: string
}
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
const body: Record<string, unknown> = { status: options.status }
if (options.in_reply_to_id) body.in_reply_to_id = options.in_reply_to_id
if (options.visibility) body.visibility = options.visibility
if (options.spoiler_text) {
body.spoiler_text = options.spoiler_text
body.sensitive = true
}
if (options.sensitive) body.sensitive = true
if (options.media_ids?.length) body.media_ids = options.media_ids
if (options.language) body.language = options.language
return api.post<Status>('/api/v1/statuses', body)
}
export function deleteStatus(api: ApiClient, id: string): Promise<Status> {
return api.delete<Status>(`/api/v1/statuses/${encodeURIComponent(id)}`)
}
export function favouriteStatus(api: ApiClient, id: string, on: boolean): Promise<Status> {
const action = on ? 'favourite' : 'unfavourite'
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
}
export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<Status> {
const action = on ? 'reblog' : 'unreblog'
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
}
export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise<MediaAttachment> {
const form = new FormData()
form.set('file', file)
if (description) form.set('description', description)
// v2 returns 202 while transcoding; v1 blocks until ready, which is simpler
// for a client with no job-polling loop.
const { data } = await api.raw<MediaAttachment>('/api/v1/media', { method: 'POST', form })
return data
}
/* ------------------------------------------------------------- mail centre */
export function fetchNotifications(
api: ApiClient,
cursor: Cursor = {},
types?: string[],
): Promise<Page<Notification>> {
const query: Query = { ...cursor }
if (types?.length) query.types = types
return api.page<Notification>('/api/v1/notifications', query)
}
export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise<Page<Account>> {
return api.page<Account>('/api/v1/follow_requests', { ...cursor })
}
export function authorizeFollowRequest(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/follow_requests/${encodeURIComponent(id)}/authorize`)
}
export function rejectFollowRequest(api: ApiClient, id: string): Promise<Relationship> {
return api.post<Relationship>(`/api/v1/follow_requests/${encodeURIComponent(id)}/reject`)
}
/* ----------------------------------------------------------------- search */
export function search(
api: ApiClient,
q: string,
options: { type?: 'accounts' | 'statuses' | 'hashtags'; limit?: number; offset?: number } = {},
): Promise<SearchResults> {
return api.get<SearchResults>('/api/v2/search', {
q,
type: options.type,
limit: options.limit ?? 20,
offset: options.offset,
resolve: api.authenticated,
})
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Browser-side OAuth 2.0 for Mastodon-compatible servers.
*
* A static frontend has no backend to keep a client secret, so it registers a
* throwaway app per instance (`POST /api/v1/apps`) and uses the authorization
* code flow, upgrading to PKCE when the server advertises support (Mastodon
* 4.3+). Pleroma and older Mastodon ignore the PKCE params, hence the retry
* without them rather than a hard requirement.
*
* The registered app credentials and the resulting token live in localStorage;
* they are per-origin and per-instance, and are exactly as sensitive as being
* logged in on this browser.
*/
import { ApiClient, ApiError, normalizeHost } from './client'
import type { OAuthApp, OAuthToken } from './types'
export const APP_NAME = 'plspace'
export const APP_WEBSITE = 'https://github.com/plspace'
export const SCOPES = 'read write follow'
const APP_KEY = 'plspace:oauth:apps'
const PENDING_KEY = 'plspace:oauth:pending'
interface PendingAuth {
host: string
verifier?: string
state: string
/** Route to land on after a successful exchange. */
returnTo: string
}
/**
* The redirect target must match what was registered byte-for-byte. The app
* uses hash routing, so the OAuth code comes back on the query string of the
* document URL and the hash stays free for our own router.
*/
export function redirectUri(): string {
return `${window.location.origin}${window.location.pathname}`
}
function readApps(): Record<string, OAuthApp> {
try {
return JSON.parse(localStorage.getItem(APP_KEY) ?? '{}') as Record<string, OAuthApp>
} catch {
return {}
}
}
function writeApp(host: string, app: OAuthApp): void {
const apps = readApps()
apps[host] = app
localStorage.setItem(APP_KEY, JSON.stringify(apps))
}
/** Register (or reuse) an OAuth app on `host`. */
export async function ensureApp(host: string): Promise<OAuthApp> {
const key = normalizeHost(host)
const cached = readApps()[key]
// Re-register if the deployment moved: a stale redirect_uri fails at /oauth/authorize
// with an opaque error page, which is miserable to debug.
if (cached && cached.redirect_uri === redirectUri()) return cached
const client = new ApiClient(key)
const app = await client.post<OAuthApp>('/api/v1/apps', {
client_name: APP_NAME,
redirect_uris: redirectUri(),
scopes: SCOPES,
website: APP_WEBSITE,
})
writeApp(key, app)
return app
}
function randomString(bytes = 48): string {
const buffer = new Uint8Array(bytes)
crypto.getRandomValues(buffer)
return base64url(buffer)
}
function base64url(buffer: ArrayBuffer | Uint8Array): string {
const view = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
let binary = ''
for (const byte of view) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function challengeFor(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return base64url(digest)
}
/** Kick off the redirect to the instance's consent screen. */
export async function beginLogin(host: string, returnTo = '#/'): Promise<void> {
const key = normalizeHost(host)
const app = await ensureApp(key)
const state = randomString(16)
// crypto.subtle is unavailable on insecure origins; fall back to a plain
// authorization-code flow there rather than failing to log in at all.
const canPkce = Boolean(crypto.subtle)
const verifier = canPkce ? randomString(48) : undefined
const pending: PendingAuth = { host: key, verifier, state, returnTo }
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
const params = new URLSearchParams({
client_id: app.client_id,
redirect_uri: redirectUri(),
response_type: 'code',
scope: SCOPES,
state,
})
if (verifier) {
params.set('code_challenge', await challengeFor(verifier))
params.set('code_challenge_method', 'S256')
}
window.location.assign(`https://${key}/oauth/authorize?${params.toString()}`)
}
export interface CompletedLogin {
host: string
token: string
returnTo: string
}
/**
* Complete the flow if the current URL carries an authorization code.
* Returns null when this is an ordinary page load.
*/
export async function completeLogin(): Promise<CompletedLogin | null> {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
const state = url.searchParams.get('state')
if (!code && !error) return null
const rawPending = sessionStorage.getItem(PENDING_KEY)
sessionStorage.removeItem(PENDING_KEY)
clearOAuthParams()
if (error) {
throw new Error(url.searchParams.get('error_description') ?? `Authorization failed: ${error}`)
}
if (!rawPending) {
throw new Error('No sign-in was in progress in this tab. Please start again.')
}
const pending = JSON.parse(rawPending) as PendingAuth
if (pending.state !== state) {
throw new Error('Sign-in state did not match. Please start again.')
}
const app = readApps()[pending.host]
if (!app) throw new Error('Lost the app registration for this server. Please start again.')
const client = new ApiClient(pending.host)
const body: Record<string, string> = {
grant_type: 'authorization_code',
client_id: app.client_id,
client_secret: app.client_secret,
redirect_uri: redirectUri(),
scope: SCOPES,
code: code!,
}
if (pending.verifier) body.code_verifier = pending.verifier
let token: OAuthToken
try {
token = await client.post<OAuthToken>('/oauth/token', body)
} catch (cause) {
// Servers that don't implement PKCE reject the unexpected code_verifier.
if (pending.verifier && cause instanceof ApiError && cause.status === 400) {
delete body.code_verifier
token = await client.post<OAuthToken>('/oauth/token', body)
} else {
throw cause
}
}
return { host: pending.host, token: token.access_token, returnTo: pending.returnTo || '#/' }
}
/** Strip `?code=…&state=…` so a refresh doesn't try to redeem a spent code. */
function clearOAuthParams(): void {
const url = new URL(window.location.href)
for (const key of ['code', 'state', 'error', 'error_description', 'iss']) {
url.searchParams.delete(key)
}
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
}
/** Best-effort token revocation; failure is not worth blocking sign-out. */
export async function revoke(host: string, token: string): Promise<void> {
const app = readApps()[normalizeHost(host)]
if (!app) return
try {
await new ApiClient(host).post('/oauth/revoke', {
client_id: app.client_id,
client_secret: app.client_secret,
token,
})
} catch {
/* the local token is dropped regardless */
}
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Mastodon REST API entities (v1), typed conservatively.
*
* Everything here is written to survive Pleroma/Akkoma/GoToSocial/Iceshrimp as
* well as Mastodon proper, so anything that is not universally present is
* optional. Two rules of thumb learned the hard way:
*
* - Field presence differs per server *and* per authentication state. A
* logged-out `GET /api/v1/timelines/public` omits `favourited`/`reblogged`
* everywhere, and Pleroma omits several Mastodon-4.x additions entirely.
* - Counters are sometimes hidden (`-1` or `0`) rather than absent when a user
* opts out of showing collections, so never treat 0 as "definitely none".
*/
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct'
export interface CustomEmoji {
shortcode: string
url: string
static_url: string
visible_in_picker: boolean
category?: string | null
}
export interface AccountField {
name: string
/** HTML */
value: string
verified_at?: string | null
}
export interface AccountRole {
id: string
name: string
color: string
}
export interface Account {
id: string
username: string
/** `user` for local accounts, `user@host` for remote ones. */
acct: string
display_name: string
/** HTML bio. */
note: string
url: string
uri?: string
avatar: string
avatar_static: string
header: string
header_static: string
locked: boolean
bot?: boolean
group?: boolean
discoverable?: boolean | null
created_at: string
last_status_at?: string | null
statuses_count: number
followers_count: number
following_count: number
fields: AccountField[]
emojis: CustomEmoji[]
roles?: AccountRole[]
moved?: Account | null
suspended?: boolean
limited?: boolean
hide_collections?: boolean | null
/** Pleroma/Akkoma extension bag. Present only on those servers. */
pleroma?: {
background_image?: string | null
is_admin?: boolean
is_moderator?: boolean
hide_favorites?: boolean
/** Pleroma's equivalent of Mastodon's `hide_collections`. */
hide_followers?: boolean
hide_follows?: boolean
/**
* When set, `followers_count` is reported as 0 rather than withheld so a
* zero here means "not telling you", not "nobody".
*/
hide_followers_count?: boolean
hide_follows_count?: boolean
relationship?: Relationship
/** Some deployments expose the user's own profile CSS here. */
background_color?: string | null
}
}
export interface CredentialAccount extends Account {
source?: {
note: string
fields: AccountField[]
privacy: StatusVisibility
sensitive: boolean
language: string | null
follow_requests_count?: number
}
}
export interface MediaAttachment {
id: string
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
url: string
preview_url: string | null
remote_url?: string | null
description?: string | null
blurhash?: string | null
meta?: {
original?: { width?: number; height?: number; aspect?: number }
small?: { width?: number; height?: number; aspect?: number }
[key: string]: unknown
}
}
export interface StatusMention {
id: string
username: string
url: string
acct: string
}
export interface StatusTag {
name: string
url: string
}
export interface PreviewCard {
url: string
title: string
description: string
type: 'link' | 'photo' | 'video' | 'rich'
image?: string | null
provider_name?: string
author_name?: string
}
export interface PollOption {
title: string
votes_count: number | null
}
export interface Poll {
id: string
expires_at: string | null
expired: boolean
multiple: boolean
votes_count: number
voters_count?: number | null
options: PollOption[]
emojis: CustomEmoji[]
voted?: boolean
own_votes?: number[]
}
export interface Status {
id: string
uri: string
created_at: string
account: Account
/** HTML. Must be sanitized before it goes anywhere near {@html}. */
content: string
visibility: StatusVisibility
sensitive: boolean
spoiler_text: string
language?: string | null
url?: string | null
edited_at?: string | null
in_reply_to_id: string | null
in_reply_to_account_id: string | null
replies_count: number
reblogs_count: number
favourites_count: number
media_attachments: MediaAttachment[]
mentions: StatusMention[]
tags: StatusTag[]
emojis: CustomEmoji[]
card?: PreviewCard | null
poll?: Poll | null
application?: { name: string; website?: string | null } | null
reblog: Status | null
favourited?: boolean
reblogged?: boolean
muted?: boolean
bookmarked?: boolean
pinned?: boolean
pleroma?: {
local?: boolean
conversation_id?: number
content?: Record<string, string>
spoiler_text?: Record<string, string>
}
}
export interface Relationship {
id: string
following: boolean
followed_by: boolean
requested: boolean
blocking: boolean
blocked_by?: boolean
muting: boolean
muting_notifications?: boolean
domain_blocking?: boolean
endorsed?: boolean
note?: string
showing_reblogs?: boolean
notifying?: boolean
}
export type NotificationType =
| 'mention'
| 'status'
| 'reblog'
| 'follow'
| 'follow_request'
| 'favourite'
| 'poll'
| 'update'
| 'admin.sign_up'
| 'admin.report'
| 'pleroma:emoji_reaction'
| 'pleroma:report'
export interface Notification {
id: string
type: NotificationType
created_at: string
account: Account
status?: Status | null
}
export interface Context {
ancestors: Status[]
descendants: Status[]
}
export interface InstanceInfo {
/** v1 shape */
uri?: string
/** v2 shape */
domain?: string
title: string
description?: string
short_description?: string
version: string
thumbnail?: string | { url: string } | null
/** v1 shape. */
stats?: {
user_count: number
status_count: number
domain_count: number
}
/** v2 shape; only monthly actives are published. */
usage?: {
users?: { active_month?: number }
}
configuration?: {
statuses?: {
max_characters?: number
max_media_attachments?: number
}
}
registrations?: boolean | { enabled?: boolean }
contact_account?: Account | null
/** Pleroma reports its "real" upstream here. */
pleroma?: unknown
}
export interface SearchResults {
accounts: Account[]
statuses: Status[]
hashtags: StatusTag[]
}
export interface OAuthApp {
id: string
name: string
website?: string | null
redirect_uri: string
client_id: string
client_secret: string
vapid_key?: string
}
export interface OAuthToken {
access_token: string
token_type: string
scope: string
created_at: number
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Hash router.
*
* Hash routing (rather than the History API) is what makes `dist/` a genuinely
* static bundle: it can be served from a subdirectory, an S3 bucket or a GitHub
* Pages path with no rewrite rules, and the OAuth redirect can land on the
* document URL's query string without colliding with our own routes.
*/
export interface RouteMatch {
name: string
params: Record<string, string>
query: URLSearchParams
/** The raw hash path, e.g. `/@alice@example.social/friends`. */
path: string
}
interface RoutePattern {
name: string
/** `/blog/:id` — `:param` captures one segment, `*rest` captures the remainder. */
pattern: string
}
const ROUTES: RoutePattern[] = [
{ name: 'home', pattern: '/' },
{ name: 'login', pattern: '/login' },
{ name: 'settings', pattern: '/settings' },
{ name: 'browse', pattern: '/browse' },
{ name: 'search', pattern: '/search' },
{ name: 'mail', pattern: '/mail' },
{ name: 'mail.folder', pattern: '/mail/:folder' },
{ name: 'timeline', pattern: '/timeline/:kind' },
{ name: 'tag', pattern: '/tag/:tag' },
{ name: 'blog.entry', pattern: '/blog/:id' },
{ name: 'compose', pattern: '/compose' },
// Account routes come last: `:acct` is greedy enough to shadow the others.
{ name: 'profile.friends', pattern: '/@:acct/friends' },
{ name: 'profile.blog', pattern: '/@:acct/blog' },
{ name: 'profile.pics', pattern: '/@:acct/pics' },
{ name: 'profile', pattern: '/@:acct' },
]
function matchPattern(pattern: string, path: string): Record<string, string> | null {
const patternParts = pattern.split('/').filter(Boolean)
const pathParts = path.split('/').filter(Boolean)
if (patternParts.length !== pathParts.length) return null
const params: Record<string, string> = {}
for (let index = 0; index < patternParts.length; index += 1) {
const expected = patternParts[index]
const actual = pathParts[index]
// `@:acct` — a literal prefix followed by a capture, as in `/@alice@host`.
const prefixed = /^(@?)(:[a-zA-Z]+)$/.exec(expected)
if (prefixed) {
const [, prefix, name] = prefixed
if (prefix && !actual.startsWith(prefix)) return null
const value = decodeURIComponent(actual.slice(prefix.length))
if (!value) return null
params[name.slice(1)] = value
continue
}
if (expected !== actual) return null
}
return params
}
export function parseHash(hash: string): RouteMatch {
const raw = hash.replace(/^#/, '') || '/'
const [pathPart, queryPart = ''] = raw.split('?')
const path = pathPart || '/'
const query = new URLSearchParams(queryPart)
for (const route of ROUTES) {
const params = matchPattern(route.pattern, path)
if (params) return { name: route.name, params, query, path }
}
return { name: 'notfound', params: {}, query, path }
}
class Router {
current = $state<RouteMatch>(parseHash(typeof location === 'undefined' ? '#/' : location.hash))
constructor() {
if (typeof window === 'undefined') return
window.addEventListener('hashchange', () => {
this.current = parseHash(location.hash)
// Matches the old-web expectation that a new "page" starts at the top.
window.scrollTo(0, 0)
})
}
/** Navigate, adding a history entry. */
go(to: string): void {
const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}`
if (location.hash === target) {
this.current = parseHash(target)
return
}
location.hash = target
}
/** Navigate without adding a history entry (search-as-you-type, tab switches). */
replace(to: string): void {
const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}`
history.replaceState({}, '', target)
this.current = parseHash(target)
}
}
export const router = new Router()
/** Build a route string with an encoded query. */
export function routeTo(path: string, query?: Record<string, string | undefined>): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query ?? {})) {
if (value) params.set(key, value)
}
const serialized = params.toString()
return `#${path}${serialized ? `?${serialized}` : ''}`
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Cursor-paginated list state.
*
* Every list in the app timelines, friend lists, notifications, the directory
* is the same shape: fetch a page, remember the cursor, append on demand.
* This wraps that with the two things that bite in practice: de-duplication
* (federated timelines repeat statuses across pages when new posts arrive
* mid-scroll) and out-of-order responses from an impatient "more" button.
*/
import { ApiError, type Page } from '../api/client'
import type { Cursor } from '../api/endpoints'
export interface Identified {
id: string
}
export type Loader<T> = (cursor: Cursor) => Promise<Page<T>>
export class Feed<T extends Identified> {
items = $state<T[]>([])
loading = $state(false)
/** Distinguishes the initial spinner from the "more entries" spinner. */
initialized = $state(false)
error = $state<string | null>(null)
exhausted = $state(false)
private loader: Loader<T>
private pageSize: number
private nextCursor: string | undefined
private seen = new Set<string>()
private generation = 0
constructor(loader: Loader<T>, pageSize = 20) {
this.loader = loader
this.pageSize = pageSize
}
/** Swap in a new loader and reload — used when a route param changes. */
setLoader(loader: Loader<T>): void {
this.loader = loader
void this.reload()
}
async reload(): Promise<void> {
this.generation += 1
this.nextCursor = undefined
this.seen = new Set()
this.items = []
this.exhausted = false
this.initialized = false
await this.run(this.generation, true)
}
async loadMore(): Promise<void> {
if (this.loading || this.exhausted) return
await this.run(this.generation, false)
}
private async run(generation: number, replace: boolean): Promise<void> {
this.loading = true
this.error = null
try {
const page = await this.loader({ max_id: replace ? undefined : this.nextCursor, limit: this.pageSize })
// A newer reload started while this request was in flight; drop it.
if (generation !== this.generation) return
const fresh = page.items.filter((item) => item && !this.seen.has(item.id))
for (const item of fresh) this.seen.add(item.id)
this.items = replace ? fresh : [...this.items, ...fresh]
const previousCursor = this.nextCursor
this.nextCursor = page.links.maxId
// Stop when the server runs out, or when it hands back the same cursor
// (some servers echo the cursor forever on an empty page).
if (page.items.length === 0 || !this.nextCursor || this.nextCursor === previousCursor) {
this.exhausted = true
}
} catch (cause) {
if (generation !== this.generation) return
this.error =
cause instanceof ApiError
? cause.message
: cause instanceof Error
? cause.message
: 'Something went wrong.'
this.exhausted = true
} finally {
if (generation === this.generation) {
this.loading = false
this.initialized = true
}
}
}
/** Replace one item in place, e.g. after a favourite/repost toggle. */
update(id: string, updater: (item: T) => T): void {
this.items = this.items.map((item) => (item.id === id ? updater(item) : item))
}
/** Drop an item, e.g. after deleting a post. */
remove(id: string): void {
this.items = this.items.filter((item) => item.id !== id)
this.seen.delete(id)
}
/** Insert at the top, e.g. after composing. */
prepend(item: T): void {
if (this.seen.has(item.id)) return
this.seen.add(item.id)
this.items = [item, ...this.items]
}
get isEmpty(): boolean {
return this.initialized && this.items.length === 0 && !this.error
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* The signed-in session: which server we're pointed at, the token, and the
* viewer's own account.
*
* Browsing logged-out is a first-class mode you can point plspace at any
* public instance and read its local timeline so `host` is meaningful even
* when `token` is null.
*/
import { ApiClient, ApiError, normalizeHost } from '../api/client'
import { fetchInstance, verifyCredentials } from '../api/endpoints'
import * as oauth from '../api/oauth'
import type { CredentialAccount, InstanceInfo } from '../api/types'
const STORAGE_KEY = 'plspace:session'
interface PersistedSession {
host: string
token: string | null
}
function load(): PersistedSession {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { host: '', token: null }
const parsed = JSON.parse(raw) as PersistedSession
return { host: normalizeHost(parsed.host ?? ''), token: parsed.token ?? null }
} catch {
return { host: '', token: null }
}
}
class Session {
host = $state('')
token = $state<string | null>(null)
me = $state<CredentialAccount | null>(null)
instance = $state<InstanceInfo | null>(null)
/** True until the first `restore()` settles, so routes can hold off. */
loading = $state(true)
error = $state<string | null>(null)
/** A client bound to the current host and token. Recomputed on change. */
readonly api = $derived(new ApiClient(this.host, this.token))
readonly signedIn = $derived(Boolean(this.token && this.me))
readonly connected = $derived(Boolean(this.host))
/**
* Rehydrate from storage and finish any OAuth redirect. Called once at boot.
* Returns the route to land on, if the OAuth flow specified one.
*/
async restore(): Promise<string | null> {
this.loading = true
this.error = null
let landing: string | null = null
try {
const completed = await oauth.completeLogin()
if (completed) {
this.host = completed.host
this.token = completed.token
this.persist()
landing = completed.returnTo
} else {
const stored = load()
this.host = stored.host
this.token = stored.token
}
if (!this.host) return landing
// The instance description is cosmetic; never let it block sign-in.
void this.loadInstance()
if (this.token) {
try {
this.me = await verifyCredentials(this.api)
} catch (cause) {
if (cause instanceof ApiError && cause.isAuthFailure) {
// Token revoked server-side, or the instance was reinstalled.
this.token = null
this.me = null
this.persist()
this.error = 'Your sign-in expired. Please log in again.'
} else {
throw cause
}
}
}
} catch (cause) {
this.error = cause instanceof Error ? cause.message : String(cause)
} finally {
this.loading = false
}
return landing
}
private async loadInstance(): Promise<void> {
try {
this.instance = await fetchInstance(new ApiClient(this.host))
} catch {
this.instance = null
}
}
/** Point at a server without signing in. */
async connect(host: string): Promise<void> {
const normalized = normalizeHost(host)
if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com')
// Probe before committing, so a typo surfaces here rather than on every page.
const probe = new ApiClient(normalized)
const instance = await fetchInstance(probe)
this.host = normalized
this.token = null
this.me = null
this.instance = instance
this.error = null
this.persist()
}
async login(host: string, returnTo = '#/'): Promise<void> {
const normalized = normalizeHost(host)
if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com')
await oauth.beginLogin(normalized, returnTo)
}
async logout(): Promise<void> {
const { host, token } = this
this.token = null
this.me = null
this.persist()
if (host && token) await oauth.revoke(host, token)
}
/** Forget the server entirely and return to the login screen. */
disconnect(): void {
void this.logout()
this.host = ''
this.instance = null
localStorage.removeItem(STORAGE_KEY)
}
private persist(): void {
const payload: PersistedSession = { host: this.host, token: this.token }
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
}
}
export const session = new Session()
+207
View File
@@ -0,0 +1,207 @@
/**
* User styling.
*
* The whole point of a MySpace profile was that you could wreck it with your
* own CSS. Two independent layers exist here:
*
* 1. **Viewer CSS** (`#user-stylesheet`) what *you* set in Settings. Applies
* everywhere you browse and is stored locally.
* 2. **Profile CSS** (`#profile-stylesheet`) what the *account being viewed*
* publishes, read from a profile field named `css` / `style` / `layout`.
* Cleared on navigation so it can never leak onto another page.
*
* Profile CSS is untrusted third-party input, so it is filtered: no `@import`,
* no `url()` pointing anywhere but https/data-images, no escaping the profile
* subtree. It is CSS only there is no path here by which a remote profile can
* run script.
*/
const VIEWER_STYLE_ID = 'user-stylesheet'
const PROFILE_STYLE_ID = 'profile-stylesheet'
const STORAGE_KEY = 'plspace:viewer-css'
/** Root class the profile page carries; all profile CSS is confined to it. */
export const PROFILE_SCOPE = '.profile-page'
/** Field names checked, in order, for a profile's published stylesheet. */
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
/**
* Get (or create) a style element, always moving it to the end of `<head>`.
*
* The relocation is the important part. The app's own stylesheet is injected
* into `<head>` when the bundle loads after the `<style id="user-stylesheet">`
* declared in index.html so a user rule with the same specificity as a
* shipped one would silently lose the tie. Re-appending puts user CSS last in
* document order, which is what makes plain single-class overrides work without
* anyone reaching for `!important`.
*/
function styleElement(id: string): HTMLStyleElement {
let element = document.getElementById(id) as HTMLStyleElement | null
if (!element) {
element = document.createElement('style')
element.id = id
}
document.head.append(element)
return element
}
/**
* Strip constructs a hostile profile could abuse.
*
* This is defence in depth rather than a sandbox: the browser's own CSS parser
* is the real boundary, and CSS cannot execute script in any supported browser.
* What it *can* do is phone home via background images and cover the page, so
* remote resources and fixed positioning are what get removed.
*/
export function sanitizeCss(css: string): string {
return (
css
// Comments first, so they can't hide the patterns below.
.replace(/\/\*[\s\S]*?\*\//g, '')
// `@import` would pull in an unbounded, unfiltered stylesheet.
.replace(/@import[^;]*;?/gi, '')
// Legacy IE vectors, still parsed by nothing but worth removing.
.replace(/expression\s*\(/gi, 'void(')
.replace(/behaviou?r\s*:/gi, '_behavior:')
.replace(/-moz-binding\s*:/gi, '_binding:')
// Only allow images from https or inline data URIs.
.replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (whole, _quote: string, url: string) =>
/^(https:\/\/|data:image\/)/i.test(url.trim()) ? whole : 'none',
)
// Keep the page navigable: no viewport-covering overlays.
.replace(/position\s*:\s*fixed/gi, 'position: static')
)
}
/**
* Prefix every selector so the rules cannot reach outside the profile subtree.
*
* A selector list is anything between a block boundary and the `{` that opens
* its body. Matching after `{` as well as after `}` is what catches rules
* *nested inside* an at-block without it, `@media (…) { .site-nav { … } }`
* would slip through unscoped and let a profile restyle the whole app. At-rule
* preludes themselves are skipped, since `[^{}@]` cannot span the `@`.
*/
export function scopeCss(css: string, scope: string): string {
// `@keyframes` steps are `from`/`to`/`50%`, not selectors — prefixing them
// produces a block the parser discards, silently killing every animation.
// Lift them out, scope everything else, then put them back.
const keyframes: string[] = []
const withoutKeyframes = css.replace(
/@(?:-\w+-)?keyframes\s+[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/gi,
(block) => {
keyframes.push(block)
// The placeholder must end in `}` so the *next* rule still sits on a
// block boundary the scoping regex recognises, and must contain `@` so
// the placeholder itself is never mistaken for a selector.
return `@plspace-keyframes-${keyframes.length - 1}{}`
},
)
const scoped = scopeSelectors(withoutKeyframes, scope)
return scoped.replace(
/@plspace-keyframes-(\d+)\{\}/g,
(_whole, index: string) => keyframes[Number(index)],
)
}
function scopeSelectors(css: string, scope: string): string {
return css.replace(/(^|[{}])([^{}@]+)\{/g, (whole, close: string, selectors: string) => {
const trimmed = selectors.trim()
if (!trimmed) return whole
const scoped = trimmed
.split(',')
.map((selector) => {
const one = selector.trim()
if (!one) return ''
// Let authors restyle the page background by writing `body`/`html`.
if (/^(html|body)$/i.test(one)) return scope
if (one.startsWith(scope)) return one
return `${scope} ${one}`
})
.filter(Boolean)
.join(', ')
return `${close}${scoped}{`
})
}
/**
* There is no built-in light/dark switch, on purpose.
*
* The 2005 palette is the design, and a dark variant is nothing more than a set
* of token overrides which is precisely what a preset already is. Shipping a
* hardcoded toggle would have meant one dark theme nobody could edit, sitting
* beside a styling system built for exactly this. Dark mode is the Midnight
* preset in lib/themes.ts; users can edit it or write their own.
*
* A preset that wants native form controls to follow suit can say
* `:root { color-scheme: dark }` in its own CSS viewer CSS is unscoped.
*/
class Theme {
/** CSS the viewer wrote for themselves. */
viewerCss = $state('')
/** Whether to honour CSS published by the profiles you visit. */
allowProfileCss = $state(true)
constructor() {
if (typeof localStorage === 'undefined') return
this.viewerCss = localStorage.getItem(STORAGE_KEY) ?? ''
this.allowProfileCss = localStorage.getItem('plspace:allow-profile-css') !== 'false'
if (this.viewerCss) this.applyViewerCss()
}
setViewerCss(css: string): void {
this.viewerCss = css
localStorage.setItem(STORAGE_KEY, css)
this.applyViewerCss()
}
setAllowProfileCss(allow: boolean): void {
this.allowProfileCss = allow
localStorage.setItem('plspace:allow-profile-css', String(allow))
if (!allow) this.clearProfileCss()
}
private applyViewerCss(): void {
// Not scoped: this is the viewer's own machine and their own choice.
styleElement(VIEWER_STYLE_ID).textContent = sanitizeCss(this.viewerCss)
}
/** Apply CSS published by the profile currently on screen. */
applyProfileCss(css: string | null | undefined): void {
if (!css || !this.allowProfileCss) {
this.clearProfileCss()
return
}
styleElement(PROFILE_STYLE_ID).textContent = scopeCss(sanitizeCss(css), PROFILE_SCOPE)
// Keep the viewer's own sheet last: their machine, their final say.
if (this.viewerCss) styleElement(VIEWER_STYLE_ID)
}
clearProfileCss(): void {
const element = document.getElementById(PROFILE_STYLE_ID)
if (element) element.textContent = ''
}
}
export const theme = new Theme()
/** Pull a published stylesheet out of an account's profile fields. */
export function profileCssFromFields(
fields: Array<{ name: string; value: string }> | undefined,
): string | null {
if (!fields?.length) return null
for (const field of fields) {
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
// Field values arrive as HTML; take the text and undo entity escaping.
const container = document.createElement('div')
container.innerHTML = field.value
const text = (container.textContent ?? '').trim()
if (text.includes('{')) return text
}
return null
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Starter layouts.
*
* Every preset is written only in terms of the tokens from styles/tokens.css
* no element selectors, no !important which is both the point (they're
* examples of the intended override style) and the reason they compose with
* whatever else a user writes underneath.
*/
export interface ThemePreset {
id: string
name: string
description: string
css: string
}
export const PRESETS: ThemePreset[] = [
{
id: 'classic',
name: 'Classic Blue',
description: 'The default. Navy chrome, cornflower boxes, peach bands.',
css: '',
},
{
id: 'midnight',
name: 'Midnight',
description: 'Dark background, cyan links, the 2 a.m. profile edit.',
css: `:root {
/* Native form controls and scrollbars follow the palette. */
color-scheme: dark;
--ms-page-bg: #0e0e14;
--ms-page-fg: #d8d8e0;
--ms-canvas-bg: #06060a;
--ms-link: #58d7ff;
--ms-link-visited: #b39ddb;
--ms-link-hover: #9beaff;
--ms-chrome-bg: #12121c;
--ms-nav-bg: #1c1c2c;
--ms-nav-active-bg: #58d7ff;
--ms-nav-active-fg: #06060a;
--ms-module-bg: #14141e;
--ms-module-border: #2e2e44;
--ms-module-header-bg: #1c1c2c;
--ms-module-header-fg: #58d7ff;
--ms-band-bg: #1c1c2c;
--ms-band-fg: #58d7ff;
--ms-band-border: #2e2e44;
--ms-heading-fg: #58d7ff;
--ms-table-label-bg: #1a1a26;
--ms-table-label-fg: #a9a9c0;
--ms-table-value-bg: #14141e;
--ms-table-value-fg: #d8d8e0;
--ms-table-stripe-bg: #11111a;
--ms-input-bg: #0a0a10;
--ms-input-fg: #d8d8e0;
--ms-input-border: #2e2e44;
--ms-button-bg: linear-gradient(#24243a, #16161f);
--ms-button-fg: #d8d8e0;
--ms-button-border: #2e2e44;
--ms-muted-fg: #7c7c96;
--ms-hr-color: #24243a;
--ms-avatar-border: #2e2e44;
}`,
},
{
id: 'bubblegum',
name: 'Bubblegum',
description: 'Hot pink, Comic Sans, rounded corners. No apologies.',
css: `:root {
--ms-font-family: 'Comic Sans MS', 'Comic Neue', Verdana, sans-serif;
--ms-font-size: 12px;
--ms-font-size-content: 13px;
--ms-page-bg: #fff4fb;
--ms-canvas-bg: #ffd9f0;
--ms-page-fg: #43102f;
--ms-link: #d6006e;
--ms-link-visited: #a3005c;
--ms-link-hover: #ff2e9a;
--ms-chrome-bg: #ff2e9a;
--ms-nav-bg: #ff85c2;
--ms-nav-active-bg: #ffd400;
--ms-nav-active-fg: #43102f;
--ms-module-bg: #fffafd;
--ms-module-border: #ff85c2;
--ms-module-header-bg: #ff2e9a;
--ms-module-radius: 10px;
--ms-band-bg: #ffe27a;
--ms-band-fg: #b3005e;
--ms-band-border: #ffb800;
--ms-heading-fg: #d6006e;
--ms-table-label-bg: #ffd9f0;
--ms-table-value-bg: #fffafd;
--ms-table-stripe-bg: #fff0f8;
--ms-avatar-radius: 8px;
--ms-avatar-border: #ff85c2;
--ms-button-bg: linear-gradient(#fff, #ffd9f0);
--ms-button-border: #ff85c2;
}`,
},
{
id: 'terminal',
name: 'Terminal',
description: 'Green on black, monospace, no decoration.',
css: `:root {
color-scheme: dark;
--ms-font-family: 'Courier New', Courier, monospace;
--ms-font-family-heading: 'Courier New', Courier, monospace;
--ms-font-size: 13px;
--ms-font-size-content: 13px;
--ms-page-bg: #000000;
--ms-canvas-bg: #000000;
--ms-page-fg: #33ff66;
--ms-link: #99ff99;
--ms-link-visited: #66cc66;
--ms-link-hover: #ffffff;
--ms-link-decoration: underline;
--ms-chrome-bg: #001a00;
--ms-chrome-fg: #33ff66;
--ms-nav-bg: #002600;
--ms-nav-active-bg: #33ff66;
--ms-nav-active-fg: #000000;
--ms-module-bg: #000000;
--ms-module-border: #1f7a33;
--ms-module-header-bg: #001a00;
--ms-module-header-fg: #33ff66;
--ms-band-bg: #001a00;
--ms-band-fg: #99ff99;
--ms-band-border: #1f7a33;
--ms-heading-fg: #99ff99;
--ms-table-label-bg: #001a00;
--ms-table-label-fg: #33ff66;
--ms-table-value-bg: #000000;
--ms-table-value-fg: #33ff66;
--ms-table-stripe-bg: #000d00;
--ms-table-border: #1f7a33;
--ms-input-bg: #000000;
--ms-input-fg: #33ff66;
--ms-input-border: #1f7a33;
--ms-button-bg: #001a00;
--ms-button-fg: #33ff66;
--ms-button-border: #1f7a33;
--ms-muted-fg: #1f7a33;
--ms-avatar-border: #1f7a33;
--ms-hr-color: #1f7a33;
}`,
},
{
id: 'sunset',
name: 'Sunset',
description: 'Warm oranges and browns, wider text, gentler on the eyes.',
css: `:root {
--ms-font-size: 12px;
--ms-font-size-content: 14px;
--ms-line-height: 1.55;
--ms-page-bg: #fffaf3;
--ms-canvas-bg: #f0dcc4;
--ms-page-fg: #3a2a1c;
--ms-link: #b5451b;
--ms-link-visited: #8a3714;
--ms-link-hover: #e0642f;
--ms-chrome-bg: #7a3410;
--ms-nav-bg: #b5451b;
--ms-nav-active-bg: #ffb845;
--ms-nav-active-fg: #3a2a1c;
--ms-module-bg: #fffaf3;
--ms-module-border: #dcae7a;
--ms-module-header-bg: #c96a2c;
--ms-band-bg: #ffe0b8;
--ms-band-fg: #a8410f;
--ms-band-border: #dcae7a;
--ms-heading-fg: #b5451b;
--ms-table-label-bg: #f5e2cb;
--ms-table-value-bg: #fffaf3;
--ms-table-stripe-bg: #fdf2e5;
--ms-table-border: #dcae7a;
--ms-avatar-border: #dcae7a;
--ms-hr-color: #e6cfae;
--ms-page-width-wide: 900px;
}`,
},
]
/**
* A worked example, shown in Settings, that goes past recolouring: it shows
* the class hooks and the data attributes rather than just the token layer.
*/
export const EXAMPLE_CSS = `/* Restyle just your own blog entries */
.blog-entry[data-mine='true'] {
background: #fffbe6;
border-left: 4px solid #ffb845;
padding-left: 8px;
}
/* Make private entries obvious */
.blog-entry[data-visibility='private'] {
background: #fff0f0;
}
/* Round the friend-space photos */
.friend-card-photo {
border-radius: 50%;
}
/* A background image behind the whole page */
.profile-page {
background-image: url(https://example.com/stars.png);
background-attachment: fixed;
}`
+195
View File
@@ -0,0 +1,195 @@
/**
* Turning server HTML into something safe to hand to `{@html}`.
*
* Status content, account bios and profile field values all arrive as HTML from
* arbitrary federated servers, so every one of them goes through DOMPurify
* before rendering. On top of sanitizing we rewrite links so mentions and
* hashtags navigate inside the app instead of bouncing the user to the remote
* web UI.
*/
import DOMPurify from 'dompurify'
import type { CustomEmoji, StatusMention, StatusTag } from '../api/types'
const ALLOWED_TAGS = [
'p',
'br',
'span',
'a',
'del',
'pre',
'code',
'em',
'strong',
'b',
'i',
'u',
's',
'sub',
'sup',
'blockquote',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'ruby',
'rt',
'rp',
'img',
]
const ALLOWED_ATTR = ['href', 'rel', 'class', 'title', 'lang', 'src', 'alt', 'draggable', 'data-plspace-to']
/**
* Rewrite outbound anchors:
* - mentions/hashtags we can resolve locally become in-app hash routes
* - everything else keeps its href but gains `target`/`rel` hardening
*/
function installHooks(): void {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return
const classes = node.getAttribute('class') ?? ''
const href = node.getAttribute('href') ?? ''
if (classes.includes('mention') || classes.includes('hashtag') || href.startsWith('#/')) {
// Left as a same-document link; the router picks it up.
node.removeAttribute('target')
node.setAttribute('rel', 'nofollow noopener')
return
}
if (href) {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'nofollow noopener noreferrer')
}
})
}
installHooks()
export interface RenderOptions {
mentions?: StatusMention[]
tags?: StatusTag[]
emojis?: CustomEmoji[]
/** Strip block structure down to a single line (used in previews). */
inline?: boolean
}
/** Escape for interpolation into an HTML string we build ourselves. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Replace `:shortcode:` with the instance's custom emoji images.
*
* Runs on the *sanitized* string and only injects `<img>` with a URL taken from
* the emoji list, so it cannot reintroduce markup from the original content.
*/
function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
if (!emojis?.length) return html
const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji]))
// Skip anything inside a tag by only matching between '>' boundaries.
return html.replace(/(^|>)([^<]*)/g, (_match, boundary: string, text: string) => {
const replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => {
const emoji = table.get(shortcode)
if (!emoji) return whole
return `<img class="custom-emoji" src="${escapeHtml(emoji.url)}" alt=":${escapeHtml(
shortcode,
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
})
return boundary + replaced
})
}
/**
* Point mention/hashtag anchors at our own routes.
*
* Mastodon marks mentions with `class="u-url mention"` and gives the matching
* `mentions[]` entry, but the anchor text is only `@user` (no domain), so the
* href is matched against the mention list instead of the label.
*/
function rewriteLinks(html: string, options: RenderOptions): string {
if (!options.mentions?.length && !options.tags?.length) return html
const container = document.createElement('div')
container.innerHTML = html
for (const anchor of Array.from(container.querySelectorAll('a'))) {
const href = anchor.getAttribute('href') ?? ''
const classes = anchor.getAttribute('class') ?? ''
const mention = options.mentions?.find((entry) => entry.url === href || href.endsWith(`/@${entry.username}`))
if (mention) {
anchor.setAttribute('href', `#/@${mention.acct}`)
anchor.setAttribute('class', `${classes} mention`.trim())
anchor.removeAttribute('target')
continue
}
if (classes.includes('hashtag') || /\/tags?\//.test(href)) {
const name = (anchor.textContent ?? '').replace(/^#/, '').trim()
if (name) {
anchor.setAttribute('href', `#/tag/${encodeURIComponent(name)}`)
anchor.setAttribute('class', `${classes} hashtag`.trim())
anchor.removeAttribute('target')
}
}
}
return container.innerHTML
}
/** Sanitize + emojify + relink server HTML. Always use this before `{@html}`. */
export function renderHtml(source: string | null | undefined, options: RenderOptions = {}): string {
if (!source) return ''
let html = DOMPurify.sanitize(source, {
ALLOWED_TAGS: options.inline ? ALLOWED_TAGS.filter((tag) => tag !== 'img') : ALLOWED_TAGS,
ALLOWED_ATTR,
// Blocks `javascript:` and friends without also killing `#/` routes.
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|xmpp:|#|\/)/i,
})
html = rewriteLinks(html, options)
html = applyEmojis(html, options.emojis)
if (options.inline) {
html = html
.replace(/<\/p>\s*<p>/g, ' ')
.replace(/<\/?p>/g, '')
.replace(/<br\s*\/?>/g, ' ')
}
return html
}
/**
* Plain-text projection, for teasers, subjects and `document.title`.
*
* Block boundaries become spaces first: stripping tags outright would weld
* `<p>RE: https://…</p><p>Congratulations…` into one unreadable run, which is
* exactly the shape a Mastodon quote-post takes.
*/
export function toPlainText(source: string | null | undefined): string {
if (!source) return ''
const spaced = source.replace(/<(?:br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|\/pre)\s*\/?>/gi, ' $& ')
const container = document.createElement('div')
container.innerHTML = DOMPurify.sanitize(spaced, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
return (container.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Emoji-substituted display name, safe for `{@html}`. */
export function renderDisplayName(name: string, emojis: CustomEmoji[] | undefined): string {
return applyEmojis(escapeHtml(name), emojis)
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Mapping ActivityPub accounts onto a 2005 profile page.
*
* A MySpace profile had a fixed vocabulary a headline in quotes, a mood, an
* "Interests" table with General/Music/Movies/Television/Books/Heroes rows, and
* blurbs for "About me" and "Who I'd like to meet". Mastodon has none of that;
* it has a bio and up to four free-form key/value fields.
*
* The mapping is: profile fields whose name matches a known MySpace row fill
* that row, leftover fields land in a generic details table, and the bio is
* split into the two blurbs on a "who I'd like to meet"-ish heading if the user
* wrote one. Users opt in to the richer layout simply by naming their fields
* `Music`, `Movies`, `Mood` and so on.
*/
import type { Account } from '../api/types'
import { renderHtml, toPlainText } from './html'
/** The interest rows a MySpace profile shipped with, in their original order. */
export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const
export type InterestRow = (typeof INTEREST_ROWS)[number]
/** Field names that feed the chrome rather than a table row. */
const CHROME_FIELDS = new Set(['headline', 'mood', 'status', 'location', 'city', 'gender', 'pronouns', 'age'])
const ALIASES: Record<string, InterestRow> = {
general: 'General',
interests: 'General',
about: 'General',
music: 'Music',
bands: 'Music',
'now playing': 'Music',
movies: 'Movies',
film: 'Movies',
films: 'Movies',
television: 'Television',
tv: 'Television',
shows: 'Television',
books: 'Books',
reading: 'Books',
heroes: 'Heroes',
hero: 'Heroes',
inspiration: 'Heroes',
}
export interface ProfileField {
name: string
/** Sanitized HTML. */
value: string
verified: boolean
}
export interface InterestEntry {
row: InterestRow
/** Sanitized HTML. */
value: string
}
export interface ProfileView {
account: Account
/** The quoted line beside the photo. */
headline: string
mood: string | null
location: string | null
gender: string | null
/** Age in years, from the account creation date unless a field overrides it. */
age: number | null
/** "About me" blurb, sanitized HTML. */
about: string
/** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */
wantsToMeet: string
interests: InterestEntry[]
details: ProfileField[]
}
/** Split a bio on a "who I'd like to meet" style heading, if one exists. */
function splitBio(noteHtml: string): { about: string; meet: string } {
const marker = /(?:^|\n|<br\s*\/?>|<\/p>\s*<p>)\s*(?:who\s+i(?:'|)?d\s+like\s+to\s+meet|looking\s+for)\s*:?/i
const match = marker.exec(noteHtml)
if (!match || match.index === undefined) return { about: noteHtml, meet: '' }
return {
about: noteHtml.slice(0, match.index),
meet: noteHtml.slice(match.index + match[0].length),
}
}
function findField(account: Account, names: string[]): string | null {
for (const field of account.fields ?? []) {
if (names.includes(field.name.trim().toLowerCase())) {
const text = toPlainText(field.value)
if (text) return text
}
}
return null
}
/**
* A stable, silly mood per account MySpace always showed one, and an empty
* "Mood:" line reads as a bug. Derived from the account id so it doesn't
* flicker between renders.
*/
const MOODS = [
'busy :-)',
'productive :)',
'awake',
'jubilant',
'chill',
'working',
'contemplative',
'amused ;)',
'nostalgic',
'bouncy',
'sleepy',
'accomplished',
]
export function fallbackMood(seed: string): string {
let hash = 0
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
}
return MOODS[hash % MOODS.length]
}
export function buildProfileView(account: Account): ProfileView {
const noteHtml = renderHtml(account.note, { emojis: account.emojis })
const { about, meet } = splitBio(noteHtml)
const interests: InterestEntry[] = []
const details: ProfileField[] = []
for (const field of account.fields ?? []) {
const key = field.name.trim().toLowerCase()
if (CHROME_FIELDS.has(key)) continue
const value = renderHtml(field.value, { emojis: account.emojis })
const row = ALIASES[key]
if (row) {
interests.push({ row, value })
} else {
details.push({
name: field.name,
value,
verified: Boolean(field.verified_at),
})
}
}
// Keep the canonical MySpace ordering rather than the user's field order.
interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row))
const headlineField = findField(account, ['headline', 'status'])
const headline = headlineField ?? firstSentence(toPlainText(account.note)) ?? '"..."'
const ageField = findField(account, ['age'])
const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN
return {
account,
headline,
mood: findField(account, ['mood']) ?? fallbackMood(account.id || account.acct),
location: findField(account, ['location', 'city']),
gender: findField(account, ['gender', 'pronouns']),
age: Number.isFinite(parsedAge) ? parsedAge : null,
about,
wantsToMeet: meet,
interests,
details,
}
}
function firstSentence(text: string): string | null {
if (!text) return null
const match = /^.{0,120}?[.!?](?:\s|$)/.exec(text)
const sentence = (match?.[0] ?? text.slice(0, 120)).trim()
return sentence || null
}
/** `15,672,442` — friend counts were the whole point. */
export function formatCount(value: number | null | undefined): string {
if (value === null || value === undefined || value < 0) return '0'
return value.toLocaleString('en-US')
}
/** `@user@host`, always with the domain so remote accounts are unambiguous. */
export function fullHandle(account: Account, localHost: string): string {
return account.acct.includes('@') ? `@${account.acct}` : `@${account.acct}@${localHost}`
}
/**
* Whether an account's follower list is withheld.
*
* Mastodon signals this with `hide_collections`; Pleroma and Akkoma use
* `pleroma.hide_followers` instead. Both then return an empty list, which is
* indistinguishable from having no followers unless you check the flag.
*/
export function followersHidden(account: Account): boolean {
return Boolean(account.hide_collections || account.pleroma?.hide_followers)
}
/**
* Whether the follower *count* is withheld.
*
* Pleroma reports `followers_count: 0` when `hide_followers_count` is set, so
* rendering that zero would state a privacy setting as fact.
*/
export function followerCountHidden(account: Account): boolean {
return Boolean(account.pleroma?.hide_followers_count)
}
/** As above, for the accounts someone follows. */
export function followingCountHidden(account: Account): boolean {
return Boolean(account.pleroma?.hide_follows_count)
}
/** The route this app uses for an account. */
export function profilePath(account: Account): string {
return `#/@${account.acct}`
}
export function displayNameOf(account: Account): string {
return account.display_name?.trim() || account.username
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Date formatting in the vernacular of a 2005 profile page.
*
* MySpace showed `Last Login: 05/10/2005` and posted entries as
* `Wednesday, September 12, 2007` no relative timestamps, no tooltips. The
* relative helper exists anyway because a federated timeline is unreadable
* without one.
*/
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
function toDate(value: string | Date | null | undefined): Date | null {
if (!value) return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/** `05/10/2005` — the "Last Login" format. */
export function shortDate(value: string | Date | null | undefined): string {
const date = toDate(value)
if (!date) return '--/--/----'
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())}/${date.getFullYear()}`
}
/** `Wednesday, September 12, 2007` — the blog-entry heading format. */
export function longDate(value: string | Date | null | undefined): string {
const date = toDate(value)
if (!date) return ''
return `${DAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
}
/** `Sep 12, 2007 1:30 PM` — comment and bulletin stamps. */
export function stampDate(value: string | Date | null | undefined): string {
const date = toDate(value)
if (!date) return ''
const hours = date.getHours()
const hour12 = hours % 12 === 0 ? 12 : hours % 12
const meridiem = hours < 12 ? 'AM' : 'PM'
return `${MONTHS[date.getMonth()].slice(0, 3)} ${date.getDate()}, ${date.getFullYear()} ${hour12}:${pad(
date.getMinutes(),
)} ${meridiem}`
}
/** `4 months ago` — used for "Last active". */
export function relativeTime(value: string | Date | null | undefined, now: Date = new Date()): string {
const date = toDate(value)
if (!date) return 'a while ago'
const seconds = Math.round((now.getTime() - date.getTime()) / 1000)
if (seconds < 45) return 'just now'
const units: Array<[label: string, seconds: number]> = [
['year', 31_536_000],
['month', 2_592_000],
['week', 604_800],
['day', 86_400],
['hour', 3_600],
['minute', 60],
]
for (const [label, size] of units) {
const count = Math.floor(seconds / size)
if (count >= 1) return `${count} ${label}${count === 1 ? '' : 's'} ago`
}
return 'just now'
}
/** Whole-year age from a date, or null when it isn't parseable. */
export function yearsSince(value: string | Date | null | undefined, now: Date = new Date()): number | null {
const date = toDate(value)
if (!date) return null
let years = now.getFullYear() - date.getFullYear()
const monthDelta = now.getMonth() - date.getMonth()
if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < date.getDate())) years -= 1
return years < 0 ? 0 : years
}
/** Machine-readable value for `<time datetime>`. */
export function isoDate(value: string | Date | null | undefined): string {
return toDate(value)?.toISOString() ?? ''
}
+11
View File
@@ -0,0 +1,11 @@
import { mount } from 'svelte'
import './styles/index.css'
// Imported for its side effects: resolving the saved colour scheme and
// injecting the viewer's own CSS before the first paint.
import '$lib/stores/theme.svelte'
import App from './App.svelte'
const target = document.getElementById('app')
if (!target) throw new Error('Missing #app mount point')
export default mount(App, { target })
+90
View File
@@ -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>
+41
View File
@@ -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>
+378
View File
@@ -0,0 +1,378 @@
<script lang="ts">
/**
* "Hello, Tom!" — the logged-in dashboard, modelled on the 2007 home page:
* a narrow left rail with the control panel, a main column of Friend Status
* and Bulletin Space, and a right rail of server odds and ends.
*
* Logged out, the same page becomes a front door: what this server is, and
* what its local timeline looks like right now.
*/
import type { Account, Notification, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import {
fetchFollowing,
fetchNotifications,
fetchTimeline,
instanceDomain,
instanceStats,
instanceThumbnail,
} from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
import Composer from '$components/blog/Composer.svelte'
let friendStatus = $state<Status[]>([])
let bulletins = $state<Status[]>([])
let following = $state<Account[]>([])
let notifications = $state<Notification[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
/**
* Per-module failures. Several large servers (mastodon.social among them)
* refuse timeline reads without a token, so "empty" and "not allowed" must
* look different or the page reads as broken.
*/
let friendStatusError = $state<string | null>(null)
let bulletinError = $state<string | null>(null)
const me = $derived(session.me)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
const thumbnail = $derived(instanceThumbnail(session.instance))
const stats = $derived(instanceStats(session.instance))
/** Notification counts by kind, for the "New Messages!" summary. */
const counts = $derived.by(() => {
const table: Record<string, number> = {}
for (const notification of notifications) {
table[notification.type] = (table[notification.type] ?? 0) + 1
}
return table
})
const summary = $derived([
{ label: 'New Messages!', count: counts.mention ?? 0, href: '#/mail/mentions', kind: 'mention' },
{ label: 'New Friend Requests!', count: counts.follow_request ?? 0, href: '#/mail/requests', kind: 'follow_request' },
{ label: 'New Friends!', count: counts.follow ?? 0, href: '#/mail/follows', kind: 'follow' },
{ label: 'New Kudos!', count: counts.favourite ?? 0, href: '#/mail/kudos', kind: 'favourite' },
{ label: 'New Reposts!', count: counts.reblog ?? 0, href: '#/mail/reposts', kind: 'reblog' },
])
$effect(() => {
const host = session.host
const signedIn = session.signedIn
if (!host) {
loading = false
return
}
void load(signedIn)
})
async function load(signedIn: boolean): Promise<void> {
loading = true
error = null
friendStatusError = null
bulletinError = null
try {
// Everything here is independent; a failure in one shouldn't blank the page.
const [statusPage, bulletinPage] = await Promise.all([
signedIn
? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
friendStatusError = messageOf(cause)
return { items: [], links: {} }
})
: Promise.resolve({ items: [], links: {} }),
fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
bulletinError = messageOf(cause)
return { items: [], links: {} }
}),
])
friendStatus = statusPage.items
bulletins = bulletinPage.items
if (signedIn && session.me) {
void fetchFollowing(session.api, session.me.id, { limit: 12 })
.then((page) => (following = page.items))
.catch(() => (following = []))
void fetchNotifications(session.api, { limit: 40 })
.then((page) => (notifications = page.items))
.catch(() => (notifications = []))
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
} finally {
loading = false
}
}
function moodFor(status: Status): string {
return fallbackMood(status.account.id)
}
function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : 'Could not load that.'
}
</script>
<div class="page home-page">
{#if !session.host}
<h1 class="page-title">Welcome to plspace</h1>
<p class="page-subtitle">It&rsquo;s always Pleroma&trade;.</p>
<div class="layout--single">
<Module title="Get started">
<p>
plspace is a web client for Pleroma. Point it at your server to begin.
</p>
<p><a class="button button--primary" href="#/login">Choose your server</a></p>
</Module>
</div>
{:else}
<h1 class="page-title">
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if}
</h1>
{#if me}
<p class="page-subtitle">
My URL: <a href={profilePath(me)}>#/@{me.acct}</a>
&nbsp;&middot;&nbsp; Last login: {shortDate(me.last_status_at ?? new Date())}
</p>
{:else}
<p class="page-subtitle">
Browsing as a guest. <a href="#/login">Sign in</a> to post, follow and read your own feed.
</p>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<div class="layout--dashboard">
<!-- ------------------------------------------------ left: control panel -->
<div class="layout-column layout-column--left">
{#if me}
<Module title="My Profile">
<p class="center">
<Avatar account={me} size="friend" />
</p>
<p class="center">
<a href={profilePath(me)}>{displayNameOf(me)}</a>
</p>
<p class="center muted">
Profile views: {formatCount(me.statuses_count)} entries
</p>
</Module>
<Module title="Updates">
<ul class="mail-summary">
{#each summary as item (item.kind)}
<li class="mail-summary-item" data-kind={item.kind} data-unread={item.count > 0 ? 'true' : 'false'}>
<a href={item.href}>{item.label}</a>
{#if item.count > 0}<span class="mail-folder-count">({item.count})</span>{/if}
</li>
{/each}
</ul>
<div class="mail-summary-buttons">
<a class="button button--small" href="#/mail">inbox</a>
<a class="button button--small" href="#/mail/requests">friend requests</a>
<a class="button button--small" href="#/timeline/home">my feed</a>
<a class="button button--small" href="#/compose">post bulletin</a>
</div>
</Module>
{/if}
<Module title="Control Panel">
<ul class="action-list action-list--single">
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
<a class="action-list-label" href="#/compose">Compose</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">📥</span>
<a class="action-list-label" href="#/mail">Inbox</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🌐</span>
<a class="action-list-label" href="#/timeline/public">The whole network</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🔍</span>
<a class="action-list-label" href="#/browse">Browse people</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🎨</span>
<a class="action-list-label" href="#/settings">Layouts &amp; settings</a>
</li>
</ul>
</Module>
</div>
<!-- --------------------------------------------------- main: the feed -->
<div class="layout-column layout-column--main">
{#if session.signedIn}
<Module title="Post a bulletin">
<Composer placeholder="What are you up to?" submitLabel="Post" onposted={() => void load(true)} />
</Module>
{/if}
<Module title="Friend Status">
{#snippet action()}
<a href="#/timeline/home">view all</a>
{/snippet}
{#if !session.signedIn}
<p class="empty-note"><a href="#/login">Sign in</a> to see what your friends are up to.</p>
{:else if friendStatusError}
<p class="error-note" role="alert">{friendStatusError}</p>
{:else if loading && friendStatus.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if friendStatus.length === 0}
<p class="empty-note">Nothing yet. Add some friends to fill this up.</p>
{:else}
<ul class="status-line-list">
{#each friendStatus as status (status.id)}
{@const entry = status.reblog ?? status}
<li class="status-line" data-account={entry.account.acct}>
<Avatar account={entry.account} />
<div class="status-line-body">
<a class="status-line-author" href={profilePath(entry.account)}>
{displayNameOf(entry.account)}
</a>
<RichText
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
inline
/>
<a class="status-line-time" href={`#/blog/${entry.id}`}>
{relativeTime(entry.created_at)}
</a>
<span class="status-line-mood">Mood: {moodFor(entry)}</span>
</div>
</li>
{/each}
</ul>
{/if}
</Module>
<Module title="Bulletin Space">
{#snippet action()}
<a href="#/timeline/local">view all</a>
{/snippet}
{#if bulletinError}
<p class="error-note" role="alert">
{bulletinError}
{#if !session.signedIn}
<a href="#/login">Signing in</a> usually fixes this &mdash; some servers don't serve
timelines to guests.
{/if}
</p>
{:else if bulletins.length === 0}
<p class="empty-note">{loading ? 'Loading…' : 'No bulletins right now.'}</p>
{:else}
<table class="bulletin-table">
<thead>
<tr>
<th scope="col">From</th>
<th scope="col">Date</th>
<th scope="col">Bulletin</th>
</tr>
</thead>
<tbody>
{#each bulletins as status (status.id)}
{@const entry = status.reblog ?? status}
<tr>
<td class="bulletin-from">
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</a>
</td>
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
<td class="bulletin-subject">
<a href={`#/blog/${entry.id}`}>
{toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
{#if session.signedIn}
<Module title="Friend Space">
{#snippet action()}
<a href={me ? `#/@${me.acct}/friends` : '#/browse'}>view all</a>
{/snippet}
{#if following.length === 0}
<p class="empty-note">You haven't added any friends yet. <a href="#/browse">Find some.</a></p>
{:else}
<p class="friend-count">
You have <span class="friend-count-value">{formatCount(me?.following_count ?? 0)}</span> friends.
</p>
<ul class="friend-grid friend-grid--compact">
{#each following as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}>
<span class="friend-card-name">{displayNameOf(friend)}</span>
<img
class="friend-card-photo"
src={friend.avatar_static || friend.avatar}
alt=""
loading="lazy"
/>
</a>
</li>
{/each}
</ul>
{/if}
</Module>
{/if}
</div>
<!-- ------------------------------------------------- right: the server -->
<div class="layout-column layout-column--right">
<Module title={domain}>
{#if thumbnail}
<p class="center">
<img class="instance-thumbnail" src={thumbnail} alt="" loading="lazy" />
</p>
{/if}
<p class="instance-title"><strong>{session.instance?.title ?? domain}</strong></p>
{#if session.instance?.short_description || session.instance?.description}
<RichText
html={session.instance.short_description || session.instance.description}
class="instance-description"
/>
{/if}
{#if stats.length > 0}
<table class="data-table instance-stats">
<tbody>
{#each stats as stat (stat.label)}
<tr>
<th class="data-table-label" scope="row">{stat.label}</th>
<td class="data-table-value">{formatCount(stat.value)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
<Module title="plspace Tip">
<p>
Name a profile field <code>Music</code>, <code>Movies</code>, <code>Books</code> or
<code>Heroes</code> and it fills in the Interests table on your profile. Name one
<code>Mood</code> and it shows beside your photo.
</p>
<p><a href="#/settings">Customise your layout &rarr;</a></p>
</Module>
</div>
</div>
{/if}
</div>
+140
View File
@@ -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 &mdash; 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>
+241
View File
@@ -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 didnt 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&ndash;{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>
+21
View File
@@ -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> &middot;
<a href="#/browse">Browse people</a> &middot;
<a href="#/search">Search</a>
</p>
</Module>
</div>
</div>
+350
View File
@@ -0,0 +1,350 @@
<script lang="ts">
/**
* The profile page — the whole point of the exercise.
*
* Left rail: photo, vitals, contacting box, URL, interests, details.
* Main column: latest blog entries, blurbs, friend space.
*
* The account's own published CSS (a profile field named `css`) is applied
* while this page is mounted and torn down on unmount, scoped to
* `.profile-page` — see lib/stores/theme.svelte.ts.
*/
import { untrack } from 'svelte'
import type { Account, Relationship, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import { theme, profileCssFromFields } from '$lib/stores/theme.svelte'
import {
fetchAccountStatuses,
fetchFollowers,
fetchRelationship,
lookupAccount,
} from '$lib/api/endpoints'
import {
buildProfileView,
displayNameOf,
followerCountHidden,
followersHidden,
followingCountHidden,
formatCount,
} from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte'
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
import ContactBox from '$components/profile/ContactBox.svelte'
import InterestsTable from '$components/profile/InterestsTable.svelte'
import DetailsTable from '$components/profile/DetailsTable.svelte'
import FriendSpace from '$components/profile/FriendSpace.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import Pager from '$components/common/Pager.svelte'
interface Props {
acct: string
/** Which sub-page: the profile itself, or a full list. */
view?: 'profile' | 'blog' | 'friends' | 'pics'
}
let { acct, view = 'profile' }: Props = $props()
let account = $state<Account | null>(null)
let relationship = $state<Relationship | null>(null)
let loading = $state(true)
let error = $state<string | null>(null)
let friends = $state<Account[]>([])
let friendsLoading = $state(false)
// Recreated whenever the account changes, so the feed never shows one
// person's entries under another's name.
let entries = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
const profile = $derived(account ? buildProfileView(account) : null)
const firstName = $derived(account ? displayNameOf(account).split(/\s+/)[0] : '')
const listHidden = $derived(account ? followersHidden(account) : false)
const countHidden = $derived(account ? followerCountHidden(account) : false)
const followsCountHidden = $derived(account ? followingCountHidden(account) : false)
/** Route prefix for this profile; snippets can't see the null-narrowing. */
const base = $derived(account ? `#/@${account.acct}` : '#/')
/** How many friend tiles the compact grid shows before "[view all]". */
const FRIEND_PREVIEW = 12
$effect(() => {
// Track the inputs that change what's on screen; the load itself is
// untracked so reading state inside it can't retrigger this effect.
const handle = acct
const currentView = view
const host = session.host
if (!host) return
untrack(() => void load(handle, currentView))
})
async function load(handle: string, currentView: Props['view']): Promise<void> {
loading = true
error = null
account = null
relationship = null
friends = []
try {
const found = await lookupAccount(session.api, handle)
// A newer navigation won the race.
if (acct !== handle) return
account = found
document.title = `${displayNameOf(found)} | plspace`
theme.applyProfileCss(profileCssFromFields(found.fields))
entries = new Feed<Status>(
(cursor) =>
fetchAccountStatuses(session.api, found.id, cursor, {
// The profile page mirrors "Latest Blog Entries": top-level posts.
exclude_replies: currentView !== 'blog',
}),
currentView === 'blog' ? 20 : 10,
)
void entries.reload()
void loadFriends(found, currentView)
void loadRelationship(found)
} catch (cause) {
if (acct !== handle) return
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
} finally {
if (acct === handle) loading = false
}
}
async function loadFriends(target: Account, currentView: Props['view']): Promise<void> {
if (followersHidden(target)) return
friendsLoading = true
try {
const page = await fetchFollowers(session.api, target.id, {
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
})
if (account?.id === target.id) friends = page.items
} catch {
// Hidden or unavailable follower lists are normal; the count still shows.
if (account?.id === target.id) friends = []
} finally {
friendsLoading = false
}
}
async function loadRelationship(target: Account): Promise<void> {
try {
const found = await fetchRelationship(session.api, target.id)
if (account?.id === target.id) relationship = found
} catch {
/* relationships need auth; absence is fine */
}
}
$effect(() => () => theme.clearProfileCss())
/** One line of an entry, for the headline list on the profile page. */
function teaserFor(entry: Status): string {
const text = toPlainText(entry.spoiler_text || entry.content)
if (text) return text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text
if (entry.media_attachments.length > 0) {
const count = entry.media_attachments.length
return `(${count} photo${count === 1 ? '' : 's'})`
}
return '(no text)'
}
</script>
<div class="page profile-page" data-account={account?.acct ?? acct} data-view={view}>
{#if loading}
<p class="loading-note">Loading profile&hellip;</p>
{:else if error}
<p class="error-note" role="alert">
<strong class="error-note-title">Profile not found.</strong>
{error}
</p>
{:else if account && profile}
<h1 class="page-title profile-name">{displayNameOf(account)}</h1>
{#if account.moved}
<p class="profile-moved">
This account has moved to
<a href={`#/@${account.moved.acct}`}>@{account.moved.acct}</a>.
</p>
{/if}
<div class="layout--split">
<div class="layout-column layout-column--left">
<ProfileIdentity {profile} />
<ContactBox
{account}
{relationship}
onrelationship={(next) => (relationship = next)}
/>
<Module title="plspace URL">
<p class="profile-url">
<a href={`#/@${account.acct}`}>{location.origin}{location.pathname}#/@{account.acct}</a>
</p>
</Module>
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
<Module title={`${firstName}'s Stats`} flush>
<table class="data-table stats-table">
<tbody>
<tr>
<th class="data-table-label" scope="row">Blog entries</th>
<td class="data-table-value">{formatCount(account.statuses_count)}</td>
</tr>
<!-- Pleroma zeroes these counts when the user hides them, so the
flag has to be checked before the number is believed. -->
<tr>
<th class="data-table-label" scope="row">Friends</th>
<td class="data-table-value" data-private={countHidden ? 'true' : 'false'}>
{#if countHidden}
<span class="muted">private</span>
{:else}
<a href={`#/@${account.acct}/friends`}>{formatCount(account.followers_count)}</a>
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Friend of</th>
<td class="data-table-value" data-private={followsCountHidden ? 'true' : 'false'}>
{#if followsCountHidden}
<span class="muted">private</span>
{:else}
{formatCount(account.following_count)}
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Joined</th>
<td class="data-table-value">{new Date(account.created_at).getFullYear()}</td>
</tr>
</tbody>
</table>
</Module>
</div>
<div class="layout-column layout-column--main">
{#if view === 'friends'}
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
{friends}
total={account.followers_count}
viewAllHref={`#/@${account.acct}`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
/>
{:else if view === 'blog'}
<Module title={`${firstName}'s Blog`} variant="band">
{#snippet action()}
<a href={base}>[Back to Profile]</a>
{/snippet}
<ul class="blog-list">
{#each entries.items as status (status.id)}
<li class="blog-list-item">
<BlogEntry
{status}
compact
longFormDate
onupdate={(next) => entries.update(status.id, () => next)}
ondelete={(id) => entries.remove(id)}
/>
</li>
{/each}
</ul>
<Pager
feed={entries}
emptyText="There are no Blog Entries yet."
endText="Thats the whole blog."
/>
</Module>
{:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band">
<p class="empty-note">
Photos appear here as they're attached to blog entries.
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context.
</p>
</Module>
{:else}
<!--
The profile page lists entry headlines with "(view more)", exactly
as the 2005 page did. Full entries live on /blog — otherwise ten
posts of media push the Blurbs and Friend Space off the bottom,
which is the wrong shape for a profile.
-->
<Module title={`${firstName}'s Latest Blog Entries`} variant="band">
{#snippet action()}
<a href={`${base}/blog`}>[View Blog]</a>
{/snippet}
{#if entries.loading && entries.items.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if entries.items.length === 0}
<p class="empty-note">There are no Blog Entries yet.</p>
{:else}
<ul class="entry-teaser-list">
{#each entries.items.slice(0, 6) as status (status.id)}
{@const entry = status.reblog ?? status}
<li class="entry-teaser" data-status-id={entry.id}>
<span class="entry-teaser-text">{teaserFor(entry)}</span>
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
</li>
{/each}
</ul>
<p class="entry-teaser-all">
<a href={`${base}/blog`}>[View All Blog Entries]</a>
</p>
{/if}
</Module>
<Module title={`${firstName}'s Blurbs`} variant="band">
<h3 class="section-heading">About me:</h3>
{#if profile.about}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.about}
</div>
{:else}
<p class="empty-note">{firstName} hasn't written an About me yet.</p>
{/if}
<h3 class="section-heading">Who I'd like to meet:</h3>
{#if profile.wantsToMeet}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.wantsToMeet}
</div>
{:else}
<p class="blurb-body">
People who educate, inspire or entertain me. And you, apparently.
</p>
{/if}
</Module>
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
friends={friends.slice(0, FRIEND_PREVIEW)}
total={account.followers_count}
viewAllHref={`#/@${account.acct}/friends`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
compact
/>
{/if}
</div>
</div>
{/if}
</div>
+143
View File
@@ -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&hellip;</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>
+216
View File
@@ -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 pages 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 youve 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 &mdash; 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>
+214
View File
@@ -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&hellip;</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>
+96
View File
@@ -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>
+130
View File
@@ -0,0 +1,130 @@
# The styling contract
These stylesheets are the public API of plspace's appearance. They are written
so that a user's own CSS can override any of it without fighting the cascade.
## Rules the app follows
1. **No CSS framework, no utility classes.** Every class names the *thing* it
styles, not how it looks. `.friend-card-photo`, not `.w-16.rounded`.
2. **No Svelte scoped styles.** Not one component has a `<style>` block, so
Svelte never appends a `.svelte-1a2b3c` hash to a class. What you see in the
DOM inspector is what you write in your selector, permanently.
3. **Single-class selectors.** Almost nothing here is more specific than one
class. No IDs, no `!important`, no long descendant chains. A plain
`.blog-entry { … }` in your CSS ties on specificity and wins on order.
4. **User CSS goes last.** The theme store re-appends `#user-stylesheet` to the
end of `<head>` whenever it changes, so an equal-specificity tie always
resolves in your favour. This is why you never need `!important`.
5. **Everything visual is a custom property.** No rule hard-codes a colour, font
or size. Retinting the entire app is a matter of overriding tokens.
## Layer 1: tokens
`tokens.css` declares every colour, font, border and metric on `:root`. This is
the intended entry point — it changes the whole app coherently, including parts
you haven't looked at.
```css
:root {
--ms-chrome-bg: #2d0b3a;
--ms-module-header-bg: #4a1a5c;
--ms-link: #ff77cc;
--ms-font-family: 'Comic Sans MS', Verdana, sans-serif;
}
```
The five presets in `src/lib/themes.ts` are written *entirely* at this layer —
read them as worked examples.
Token groups: typography (`--ms-font-*`), page (`--ms-page-*`, `--ms-canvas-*`),
links (`--ms-link*`), chrome (`--ms-chrome-*`, `--ms-nav-*`), modules
(`--ms-module-*`, `--ms-band-*`), tables (`--ms-table-*`), forms
(`--ms-input-*`, `--ms-button-*`), avatars (`--ms-avatar-*`), layout
(`--ms-page-width*`, `--ms-column-*`).
## Layer 2: classes
When tokens aren't enough, target classes directly.
| Area | Classes |
| --- | --- |
| Page skeleton | `.page`, `.page-title`, `.page-subtitle`, `.layout--split`, `.layout--dashboard`, `.layout--single`, `.layout-column--left`, `.layout-column--main`, `.layout-column--right` |
| Chrome | `.site-header`, `.site-logo`, `.site-nav`, `.site-nav-link`, `.site-footer` |
| Boxes | `.module`, `.module-header`, `.module-body`, `.module--band`, `.module--plain`, `.section-heading` |
| Profile | `.profile-page`, `.profile-photo`, `.profile-headline`, `.profile-vitals`, `.profile-mood`, `.profile-badge` |
| Tables | `.data-table`, `.data-table-label`, `.data-table-value`, `.interests-table`, `.details-table` |
| Friends | `.friend-grid`, `.friend-card`, `.friend-card-name`, `.friend-card-photo`, `.person-row` |
| Entries | `.blog-list`, `.blog-entry`, `.blog-entry-header`, `.blog-entry-body`, `.blog-entry-actions`, `.blog-action`, `.entry-teaser` |
| Comments | `.comment-list`, `.comment`, `.comment-author`, `.comment-body` |
| Media | `.attachment`, `.attachment-media`, `.attachment-caption`, `.preview-card`, `.poll` |
| Mail | `.mail-folders`, `.mail-table`, `.mail-row`, `.mail-summary` |
| Forms | `.button`, `.field`, `.field-label`, `.link-button`, `.tab-bar`, `.tab` |
## Layer 3: state attributes
Rather than inventing a modifier class per combination, elements carry data
attributes describing what they *are*. Style by state with attribute selectors:
```css
.blog-entry[data-mine='true'] { background: #fffbe6; }
.blog-entry[data-visibility='private'] { background: #fff0f0; }
.blog-entry[data-boosted='true'] { opacity: 0.85; }
.blog-entry[data-sensitive='true'] { border-left: 3px solid red; }
.comment[data-depth='2'] { font-size: 11px; }
.mail-row[data-kind='follow_request'] { background: #ffffcc; }
.attachment[data-type='video'] { border-color: purple; }
.blog-action[aria-pressed='true'] { font-weight: 700; }
.site-nav-link[aria-current='page'] { background: orange; }
```
Available attributes: `data-account` (on entries, cards, rows), `data-status-id`,
`data-visibility`, `data-boosted`, `data-reply`, `data-sensitive`, `data-mine`,
`data-compact`, `data-depth`, `data-kind`, `data-type`, `data-verified`,
`data-timeline`, `data-view`, `data-folder`, `data-row`, `data-badge`,
`data-private`, `data-session`.
## There is no dark mode
Deliberately. A dark theme is a set of token overrides and nothing more, so it
lives at layer 1 like any other theme — see the **Midnight** and **Terminal**
presets in `src/lib/themes.ts`. Shipping a hardcoded toggle would have meant one
dark theme nobody could edit, next to a styling system built for exactly this.
Two things a dark theme must remember:
- Set `color-scheme: dark` on `:root` so native form controls and scrollbars
follow. Viewer CSS is unscoped, so this works.
- Recolour `--ms-chrome-bg`. The logo sits on the chrome band and is knocked
out to white by `--ms-logo-filter`, so any dark band works as-is. If you theme
the band to a *light* colour, set `--ms-logo-filter: none` to get the mark's
own navy back.
## Publishing a layout on your profile
Put CSS in a profile field named `css` (or `style`, `layout`, `stylesheet`) on
your server. Anyone viewing your profile in plspace gets it. Because it's an
ordinary profile field it federates normally — other clients just show it as
text.
Published CSS is **scoped to `.profile-page`** and filtered before it is applied:
- `@import` is removed (it would pull in an unbounded external stylesheet).
- `url()` is allowed only for `https:` and `data:image/` (no tracking pixels).
- `position: fixed` becomes `static` (no viewport-covering overlays).
- Every selector is prefixed with `.profile-page`, including rules nested inside
`@media` / `@supports`. Writing `body` or `html` targets the profile page
itself, which is the useful interpretation.
- `@keyframes` blocks pass through untouched, so animations still work.
The net effect: you can do anything to your own page, and nothing to anyone
else's or to the surrounding app. Viewers can switch the whole feature off in
Settings.
## Adding styles to the app itself
Add the rule to the file that owns that area — `chrome.css`, `layout.css`,
`module.css`, `profile.css`, `blog.css`, `forms.css` — and register any new
colour or metric as a token in `tokens.css` first. Do not introduce a `<style>`
block in a component; it would create a hashed class the contract above promises
doesn't exist.
+215
View File
@@ -0,0 +1,215 @@
/*
* Element defaults.
*
* Selectors here are deliberately low-specificity (single element or single
* class, never an id, never a chain) so that user CSS overrides win without
* anyone having to reach for !important.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
background: var(--ms-canvas-bg);
}
body {
margin: 0;
padding: 0;
background: var(--ms-canvas-bg);
color: var(--ms-page-fg);
font-family: var(--ms-font-family);
font-size: var(--ms-font-size);
line-height: var(--ms-line-height);
-webkit-text-size-adjust: 100%;
}
a {
color: var(--ms-link);
text-decoration: var(--ms-link-decoration);
}
a:visited {
color: var(--ms-link-visited);
}
a:hover,
a:focus-visible {
color: var(--ms-link-hover);
text-decoration: var(--ms-link-decoration-hover);
}
:focus-visible {
outline: 2px solid var(--ms-focus-ring);
outline-offset: 1px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--ms-font-family-heading);
margin: 0 0 0.4em;
line-height: 1.2;
}
h1 {
font-size: var(--ms-font-size-title);
}
h2 {
font-size: var(--ms-font-size-heading);
}
h3,
h4,
h5,
h6 {
font-size: var(--ms-font-size);
}
p {
margin: 0 0 0.75em;
}
p:last-child {
margin-bottom: 0;
}
ul,
ol {
margin: 0 0 0.75em;
padding-left: 1.6em;
}
hr {
border: 0;
border-top: 1px solid var(--ms-hr-color);
margin: 8px 0;
}
img {
max-width: 100%;
border: 0;
}
blockquote {
margin: 0 0 0.75em;
padding-left: 8px;
border-left: 3px solid var(--ms-module-border);
color: var(--ms-muted-fg);
}
pre,
code {
font-family: var(--ms-font-family-mono);
font-size: var(--ms-font-size-content);
}
pre {
overflow-x: auto;
padding: 6px;
background: var(--ms-table-stripe-bg);
border: 1px solid var(--ms-hr-color);
}
table {
border-collapse: collapse;
}
/* ------------------------------------------------------------- utilities */
/* Visually hidden but available to assistive tech. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.muted {
color: var(--ms-muted-fg);
}
.nowrap {
white-space: nowrap;
}
.center {
text-align: center;
}
/* Custom emoji injected into sanitized HTML by lib/util/html.ts. */
.custom-emoji {
width: var(--ms-emoji-size);
height: var(--ms-emoji-size);
vertical-align: text-bottom;
object-fit: contain;
}
/* Sanitized server HTML — bios, statuses, field values. */
.rich-text {
font-size: var(--ms-font-size-content);
word-wrap: break-word;
overflow-wrap: anywhere;
}
.rich-text :last-child {
margin-bottom: 0;
}
.rich-text .invisible {
/* Mastodon wraps the elided parts of a URL in this class. */
font-size: 0;
line-height: 0;
display: inline-block;
width: 0;
height: 0;
position: absolute;
overflow: hidden;
}
.rich-text .ellipsis::after {
content: '…';
}
/*
* Inline mode: a bio squeezed into a list row.
*
* `renderHtml({inline})` flattens paragraphs and line breaks, but bios are
* regularly whole documents headings, bullet lists, link directories so the
* remaining block elements are laid down inline here too. Anything still too
* tall is clamped by the row that contains it.
*/
.rich-text--inline p,
.rich-text--inline ul,
.rich-text--inline ol,
.rich-text--inline li,
.rich-text--inline h1,
.rich-text--inline h2,
.rich-text--inline h3,
.rich-text--inline h4,
.rich-text--inline blockquote {
display: inline;
margin: 0;
padding: 0;
border: 0;
font-size: inherit;
font-weight: inherit;
}
.rich-text--inline li + li::before {
content: ' · ';
color: var(--ms-muted-fg);
}
+419
View File
@@ -0,0 +1,419 @@
/*
* Blog entries (statuses), comments (replies) and bulletins.
*
* Every entry carries data attributes describing what it is
* `data-visibility`, `data-boosted`, `data-reply`, `data-sensitive` so a user
* stylesheet can restyle by kind without the app inventing a modifier class for
* each combination:
*
* .blog-entry[data-visibility='private'] { background: #ffe9e9; }
*/
.blog-list {
list-style: none;
margin: 0;
padding: 0;
}
.blog-entry {
padding: 8px 2px 12px;
border-bottom: 1px solid var(--ms-hr-color);
}
.blog-entry:last-child {
border-bottom: 0;
}
/* "Tom reposted" attribution above a boost. */
.blog-entry-attribution {
font-size: var(--ms-font-size-small);
color: var(--ms-muted-fg);
margin-bottom: 4px;
}
.blog-entry-header {
display: flex;
gap: 8px;
align-items: flex-start;
margin-bottom: 6px;
}
.blog-entry-avatar {
flex: 0 0 auto;
}
.blog-entry-byline {
min-width: 0;
flex: 1;
}
.blog-entry-author {
font-weight: 700;
font-size: var(--ms-font-size-content);
}
.blog-entry-handle {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
word-break: break-all;
}
/* "Wednesday, September 12, 2007" */
.blog-entry-date {
display: block;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
.blog-entry-date a {
color: var(--ms-muted-fg);
}
.blog-entry-body {
margin-left: calc(var(--ms-avatar-size) + 8px);
}
.blog-entry[data-compact='true'] .blog-entry-body {
margin-left: 0;
}
/* Content warning: a summary the reader opens. */
.content-warning {
margin-bottom: 6px;
}
.content-warning-summary {
cursor: pointer;
padding: 4px 6px;
background: var(--ms-notice-bg);
border: 1px solid var(--ms-notice-border);
font-weight: 700;
font-size: var(--ms-font-size);
}
.content-warning-summary::marker {
color: var(--ms-band-fg);
}
.content-warning[open] .content-warning-summary {
margin-bottom: 6px;
}
/* ------------------------------------------------------- entry headlines */
/* The profile page's "Latest Blog Entries" list: one line per entry with a
"(view more)" link, as on the 2005 page. */
.entry-teaser-list {
list-style: none;
margin: 0;
padding: 0;
}
.entry-teaser {
padding: 4px 0;
font-size: var(--ms-font-size-content);
}
.entry-teaser-text {
margin-right: 4px;
}
.entry-teaser-link {
white-space: nowrap;
}
.entry-teaser-all {
margin: 8px 0 0;
}
/* ------------------------------------------------------------ attachments */
.attachment-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 6px;
}
.attachment {
margin: 0;
border: 1px solid var(--ms-avatar-border);
background: var(--ms-table-stripe-bg);
overflow: hidden;
}
.attachment-media {
display: block;
width: 100%;
max-height: 320px;
object-fit: contain;
background: var(--ms-canvas-bg);
}
.attachment-caption {
padding: 3px 5px;
font-size: var(--ms-font-size-small);
color: var(--ms-muted-fg);
}
.attachment[data-sensitive='true'] .attachment-media {
filter: blur(18px);
}
.attachment[data-revealed='true'] .attachment-media {
filter: none;
}
.attachment-reveal {
display: block;
width: 100%;
padding: 3px;
font-size: var(--ms-font-size-small);
}
/* ----------------------------------------------------------- link preview */
.preview-card {
display: flex;
gap: 8px;
margin-top: 8px;
border: 1px solid var(--ms-module-border);
background: var(--ms-table-stripe-bg);
text-decoration: none;
color: inherit;
}
.preview-card:hover {
text-decoration: none;
background: var(--ms-highlight-bg);
}
.preview-card-image {
flex: 0 0 auto;
width: 90px;
height: 68px;
object-fit: cover;
}
.preview-card-body {
padding: 5px 6px;
min-width: 0;
}
.preview-card-title {
font-weight: 700;
font-size: var(--ms-font-size);
margin: 0 0 2px;
}
.preview-card-description {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.preview-card-host {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
/* ------------------------------------------------------------------ polls */
.poll {
margin-top: 8px;
border: 1px solid var(--ms-module-border);
padding: 6px;
}
.poll-option {
margin-bottom: 5px;
}
.poll-option-label {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: var(--ms-font-size);
}
.poll-option-bar {
height: 10px;
background: var(--ms-table-label-bg);
border: 1px solid var(--ms-table-border);
margin-top: 2px;
}
.poll-option-fill {
display: block;
height: 100%;
background: var(--ms-nav-bg);
}
.poll-meta {
font-size: var(--ms-font-size-small);
color: var(--ms-muted-fg);
}
/* ---------------------------------------------------------------- actions */
/* "Kudos (12) | Repost | Comment" */
.blog-entry-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
margin-top: 8px;
font-size: var(--ms-font-size);
}
.blog-action {
display: inline-flex;
align-items: center;
gap: 4px;
}
.blog-action[aria-pressed='true'] {
color: var(--ms-band-fg);
font-weight: 700;
}
.blog-action-count {
color: var(--ms-muted-fg);
}
.blog-action[aria-pressed='true'] .blog-action-count {
color: inherit;
}
/* --------------------------------------------------------------- comments */
.comment-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
border-top: 1px solid var(--ms-hr-color);
}
.comment {
display: flex;
gap: 8px;
padding: 8px 4px;
border-bottom: 1px solid var(--ms-hr-color);
}
.comment:nth-child(even) {
background: var(--ms-table-stripe-bg);
}
.comment-avatar {
flex: 0 0 auto;
}
.comment-body {
min-width: 0;
flex: 1;
}
.comment-author {
font-weight: 700;
}
.comment-date {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
margin-left: 4px;
}
/* Depth of a reply within a thread, capped so deep threads stay readable. */
.comment[data-depth='1'] {
padding-left: 16px;
}
.comment[data-depth='2'] {
padding-left: 32px;
}
.comment[data-depth='3'] {
padding-left: 44px;
}
/* -------------------------------------------------------------- bulletins */
/* The compact date/from/subject table from the 2007 dashboard. */
.bulletin-table {
width: 100%;
font-size: var(--ms-font-size-small);
}
.bulletin-table th {
text-align: left;
font-weight: 700;
border-bottom: 1px solid var(--ms-hr-color);
padding: 2px 4px;
color: var(--ms-muted-fg);
}
.bulletin-table td {
padding: 2px 4px;
border-bottom: 1px solid var(--ms-hr-color);
vertical-align: top;
}
.bulletin-table tr:last-child td {
border-bottom: 0;
}
.bulletin-from {
font-weight: 700;
white-space: nowrap;
}
.bulletin-date {
color: var(--ms-muted-fg);
white-space: nowrap;
}
.bulletin-subject {
width: 100%;
}
/* ----------------------------------------------------------- friend status */
/* "Tom is working on myspace plans! Update. / Mood: productive" */
.status-line {
display: flex;
gap: 6px;
padding: 5px 2px;
border-bottom: 1px solid var(--ms-hr-color);
align-items: flex-start;
}
.status-line:last-child {
border-bottom: 0;
}
.status-line-body {
min-width: 0;
flex: 1;
}
.status-line-author {
font-weight: 700;
}
.status-line-time {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
.status-line-mood {
display: block;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
+260
View File
@@ -0,0 +1,260 @@
/*
* Site chrome: the navy header band, the nav row, the footer.
*
* Structure mirrors the 2005 page: a navy band holding the white logo lockup
* with search and account links to its right, then a lighter nav row of
* pipe-separated links beneath it.
*/
.site {
min-height: 100vh;
background: var(--ms-canvas-bg);
display: flex;
flex-direction: column;
}
/* ------------------------------------------------------------ header band */
/* The navy band carries the logo, the search box and the account links
one bar, as on the 2005 page. */
.site-header {
background: var(--ms-chrome-bg);
color: var(--ms-chrome-fg);
border-bottom: 1px solid var(--ms-chrome-border);
}
.site-header-inner {
max-width: var(--ms-page-width-wide);
margin: 0 auto;
padding: 8px var(--ms-page-padding);
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.site-header a {
color: var(--ms-chrome-link);
}
/* ---------------------------------------------------------------- the logo */
.site-logo {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--ms-chrome-fg);
flex: 0 0 auto;
}
.site-logo:hover {
text-decoration: none;
color: var(--ms-chrome-fg);
}
/*
* The mark ships as a solid navy image on a transparent background, so it is
* knocked out to white rather than swapped for a second file: `brightness(0)`
* flattens it to black, `invert(1)` lifts it to white, and alpha survives both.
* Themed via a token so a light chrome colour can drop the filter entirely.
*/
.site-logo-image {
display: block;
height: var(--ms-logo-height);
width: auto;
flex: 0 0 auto;
filter: var(--ms-logo-filter);
}
.site-logo-text {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.site-logo-mark {
font-size: var(--ms-font-size-title);
font-weight: 700;
letter-spacing: -0.5px;
}
/* Tucked under the wordmark and trailing to its right edge, as in the original. */
.site-logo-tagline {
font-size: var(--ms-font-size-small);
opacity: 0.85;
text-align: right;
white-space: nowrap;
}
/* Superscript, and smaller still than the tagline around it. */
.site-logo-trademark {
font-size: 0.75em;
vertical-align: super;
line-height: 0;
}
/* Which server you're reading, and whether you're signed in to it. */
.site-connection {
margin: 0;
font-size: var(--ms-font-size-small);
opacity: 0.85;
align-self: flex-end;
padding-bottom: 3px;
}
.site-connection-guest {
opacity: 0.8;
}
/* --------------------------------------------------------- search + links */
.site-search {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.site-search-label {
white-space: nowrap;
}
.site-search-input {
width: 200px;
}
.site-account-links {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
margin: 0;
}
/* ---------------------------------------------------------------- nav row */
.site-nav {
background: var(--ms-nav-bg);
color: var(--ms-nav-fg);
border-top: 1px solid var(--ms-chrome-border);
border-bottom: 1px solid var(--ms-chrome-border);
}
.site-nav-inner {
max-width: var(--ms-page-width-wide);
margin: 0 auto;
padding: 0 var(--ms-page-padding);
}
.site-nav-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.site-nav-item {
display: flex;
}
/* The pipe separators between nav entries. */
.site-nav-item + .site-nav-item::before {
content: '|';
color: var(--ms-nav-separator);
align-self: center;
}
.site-nav-link {
display: block;
padding: 4px 10px;
color: var(--ms-nav-link);
text-decoration: none;
font-weight: 700;
}
.site-nav-link:visited {
color: var(--ms-nav-link);
}
.site-nav-link:hover {
color: var(--ms-nav-link);
text-decoration: underline;
}
.site-nav-link[aria-current='page'] {
background: var(--ms-nav-active-bg);
color: var(--ms-nav-active-fg);
}
/* ----------------------------------------------------------------- footer */
.site-footer {
margin-top: auto;
padding: 14px var(--ms-page-padding) 24px;
text-align: center;
font-size: var(--ms-font-size-small);
color: var(--ms-muted-fg);
background: var(--ms-page-bg);
border-top: 1px solid var(--ms-hr-color);
}
.site-footer-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
margin-bottom: 6px;
}
.site-footer-links a::after {
content: ' |';
color: var(--ms-hr-color);
}
.site-footer-links a:last-child::after {
content: '';
}
/* --------------------------------------------------------- ad-shaped slot */
/* The banner that ran above the nav. Kept as a slot for announcements; it
never loads a third-party resource. */
.site-banner {
max-width: var(--ms-page-width-wide);
margin: 6px auto 0;
padding: 6px 10px;
background: var(--ms-notice-bg);
border: 1px solid var(--ms-notice-border);
font-size: var(--ms-font-size-small);
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
text-align: center;
}
@media (max-width: 640px) {
.site-header-inner {
gap: 6px;
}
.site-search {
order: 3;
width: 100%;
margin-left: 0;
}
.site-search-input {
flex: 1;
width: auto;
}
/* Search drops to its own row, so the account links take the space the
search box vacated rather than crowding the brand. */
.site-account-links {
margin-left: auto;
}
}
+367
View File
@@ -0,0 +1,367 @@
/*
* Form controls and the mail centre.
*
* Buttons keep the beveled Windows-XP silhouette of the era without resorting
* to images: a light gradient, a hairline border, and a pressed state that
* actually moves.
*/
input,
select,
textarea,
button {
font-family: inherit;
font-size: var(--ms-font-size);
color: var(--ms-input-fg);
}
input[type='text'],
input[type='search'],
input[type='url'],
input[type='email'],
input[type='password'],
select,
textarea {
padding: 2px 4px;
background: var(--ms-input-bg);
color: var(--ms-input-fg);
border: 1px solid var(--ms-input-border);
border-radius: 0;
max-width: 100%;
}
textarea {
width: 100%;
min-height: 80px;
resize: vertical;
font-size: var(--ms-font-size-content);
line-height: var(--ms-line-height);
}
.button {
display: inline-block;
padding: 2px 10px;
background: var(--ms-button-bg);
color: var(--ms-button-fg);
border: 1px solid var(--ms-button-border);
border-radius: 0;
cursor: pointer;
font-size: var(--ms-font-size);
line-height: 1.6;
text-decoration: none;
white-space: nowrap;
}
.button:hover {
text-decoration: none;
border-color: var(--ms-chrome-bg);
}
.button:active {
background: var(--ms-button-active-bg);
transform: translateY(1px);
}
.button:disabled,
.button[aria-disabled='true'] {
color: var(--ms-muted-fg);
cursor: default;
opacity: 0.7;
transform: none;
}
.button--primary {
font-weight: 700;
}
.button--small {
padding: 0 6px;
font-size: var(--ms-font-size-small);
}
.button--wide {
display: block;
width: 100%;
text-align: center;
}
.field {
margin-bottom: 10px;
}
.field-label {
display: block;
font-weight: 700;
margin-bottom: 3px;
}
.field-hint {
display: block;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
margin-top: 3px;
}
.field-input {
width: 100%;
}
.field-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.checkbox-field {
display: flex;
gap: 6px;
align-items: flex-start;
margin-bottom: 8px;
}
/* --------------------------------------------------------------- composer */
.composer-toolbar {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin-top: 6px;
}
.composer-counter {
margin-left: auto;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
.composer-counter[data-over='true'] {
color: var(--ms-error-fg);
font-weight: 700;
}
.composer-attachments {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.composer-attachment {
position: relative;
border: 1px solid var(--ms-avatar-border);
}
.composer-attachment img {
display: block;
width: 78px;
height: 78px;
object-fit: cover;
}
.composer-body {
width: 100%;
}
.composer-upload {
position: relative;
display: inline-block;
}
/* The CSS editor in Settings wants to be a code surface, not prose. */
.css-editor {
width: 100%;
font-family: var(--ms-font-family-mono);
font-size: 12px;
line-height: 1.45;
tab-size: 2;
white-space: pre;
min-height: 220px;
}
.preset-list {
list-style: none;
margin: 0;
padding: 0;
}
.preset {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
flex-wrap: wrap;
}
.preset-button {
min-width: 130px;
text-align: center;
}
.hooks-table {
margin-bottom: 10px;
}
.hooks-table th {
width: 45%;
}
.hooks-table code {
font-size: 11px;
word-break: break-all;
}
/* ------------------------------------------------------------ mail centre */
/* The left rail of folders: Inbox, Saved, Sent, Friend Requests. */
.mail-folders {
list-style: none;
margin: 0;
padding: 0;
}
.mail-folder {
border-bottom: 1px solid var(--ms-hr-color);
}
.mail-folder:last-child {
border-bottom: 0;
}
.mail-folder-link {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
text-decoration: none;
font-weight: 700;
}
.mail-folder-link[aria-current='page'] {
background: var(--ms-table-label-bg);
color: var(--ms-table-label-fg);
}
.mail-folder-count {
margin-left: auto;
color: var(--ms-muted-fg);
font-weight: 400;
font-size: var(--ms-font-size-small);
}
/* The message table. */
.mail-table {
width: 100%;
}
.mail-table thead th {
background: var(--ms-table-label-bg);
color: var(--ms-table-label-fg);
text-align: left;
padding: var(--ms-table-cell-padding);
border: 1px solid var(--ms-table-border);
font-size: var(--ms-font-size);
}
.mail-table td {
padding: var(--ms-table-cell-padding);
border: 1px solid var(--ms-table-border);
vertical-align: top;
}
.mail-table-date {
white-space: nowrap;
width: 92px;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
.mail-table-from {
width: 88px;
}
.mail-table-from img {
display: block;
width: 74px;
height: 74px;
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
}
.mail-table-actions {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
/* Row tinting by notification kind. */
.mail-row[data-kind='follow'],
.mail-row[data-kind='follow_request'] {
background: var(--ms-highlight-bg);
}
.mail-row[data-kind='mention'] {
background: var(--ms-module-bg);
}
/* The "New Messages! / New Friend Requests!" summary box. */
.mail-summary {
list-style: none;
margin: 0;
padding: 0;
}
.mail-summary-item {
padding: 1px 0;
}
.mail-summary-item a {
font-weight: 700;
}
.mail-summary-item[data-unread='true'] a {
color: var(--ms-error-fg);
}
.mail-summary-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
margin-top: 8px;
}
/* ---------------------------------------------------------------- tab bar */
.tab-bar {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--ms-table-border);
margin-bottom: var(--ms-module-gap);
flex-wrap: wrap;
}
.tab {
padding: 3px 10px;
border: 1px solid var(--ms-table-border);
border-bottom: 0;
background: var(--ms-table-label-bg);
color: var(--ms-table-label-fg);
text-decoration: none;
font-size: var(--ms-font-size);
}
.tab:hover {
text-decoration: none;
}
.tab[aria-current='page'] {
background: var(--ms-module-bg);
color: var(--ms-page-fg);
font-weight: 700;
position: relative;
top: 1px;
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Stylesheet entry point.
*
* Order matters: tokens first (so everything below can reference them), then
* element defaults, then layout, then components. Nothing is scoped by Svelte
* every rule here is global and every selector is a plain class, which is
* what makes user CSS able to override it. See ./README.md for the contract.
*/
@import './tokens.css';
@import './base.css';
@import './layout.css';
@import './chrome.css';
@import './module.css';
@import './profile.css';
@import './blog.css';
@import './forms.css';
+236
View File
@@ -0,0 +1,236 @@
/*
* Page skeletons.
*
* MySpace was a two-column table: a fixed-width left rail of boxes and a fluid
* main column. The 2007 home page added a third. Both are grids here, and both
* collapse to a single column on narrow screens the one concession to the
* present day, because the alternative is a 990px page in a 390px viewport.
*/
.page {
max-width: var(--ms-page-width-wide);
margin: 0 auto;
padding: var(--ms-page-padding);
background: var(--ms-page-bg);
flex: 1;
width: 100%;
}
/* Page title in the top-left, above the columns: "Tom", "Hello, Tom!". */
.page-title {
font-size: var(--ms-font-size-title);
font-weight: 700;
margin: 0 0 8px;
}
.page-subtitle {
font-size: var(--ms-font-size);
color: var(--ms-muted-fg);
margin: -4px 0 10px;
}
/* Two-column: left rail + main. */
.layout--split {
display: grid;
grid-template-columns: var(--ms-column-left-width) minmax(0, 1fr);
gap: var(--ms-column-gap);
align-items: start;
}
/* Three-column: the 2007 dashboard. */
.layout--dashboard {
display: grid;
grid-template-columns: 200px minmax(0, 1fr) 230px;
gap: var(--ms-column-gap);
align-items: start;
}
/* Single wide column: search results, settings, sign-in. */
.layout--single {
display: block;
max-width: var(--ms-page-width);
margin: 0 auto;
}
.layout-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--ms-module-gap);
}
.layout-column--left {
/* Named for styling; the grid places it. */
}
.layout-column--main {
}
.layout-column--right {
}
@media (max-width: 900px) {
.layout--dashboard {
grid-template-columns: minmax(0, 1fr) 230px;
}
.layout--dashboard .layout-column--left {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.layout--split,
.layout--dashboard {
grid-template-columns: minmax(0, 1fr);
}
.layout--dashboard .layout-column--left {
grid-column: auto;
}
:root {
--ms-column-left-width: 100%;
}
}
/* --------------------------------------------------------- shared blocks */
.stack {
display: flex;
flex-direction: column;
gap: var(--ms-module-gap);
}
.row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.row--between {
justify-content: space-between;
}
.loading-note,
.empty-note {
padding: 10px 6px;
color: var(--ms-muted-fg);
font-style: italic;
}
.error-note {
padding: 8px 10px;
background: var(--ms-error-bg);
border: 1px solid var(--ms-error-border);
color: var(--ms-error-fg);
margin-bottom: var(--ms-module-gap);
}
.error-note-title {
font-weight: 700;
display: block;
margin-bottom: 2px;
}
.notice {
padding: 8px 10px;
background: var(--ms-notice-bg);
border: 1px solid var(--ms-notice-border);
margin-bottom: var(--ms-module-gap);
}
/* Simple lists that appear in more than one place. */
.tag-list,
.server-suggestions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
}
.server-suggestion {
display: flex;
align-items: baseline;
gap: 4px;
}
.tag-list-item {
font-weight: 700;
}
.status-line-list,
.blog-list,
.person-list {
list-style: none;
margin: 0;
padding: 0;
}
.instance-thumbnail {
max-width: 100%;
border: 1px solid var(--ms-avatar-border);
}
.instance-stats {
margin-top: 8px;
}
.mail-listing-count {
font-weight: 700;
margin-bottom: 6px;
}
.mail-table-excerpt {
margin: 4px 0 0;
color: var(--ms-muted-fg);
}
.blurb-body {
margin-bottom: 8px;
}
.profile-handle-line {
margin: 8px 0 0;
font-size: var(--ms-font-size-small);
}
.profile-handle {
font-family: var(--ms-font-family-mono);
word-break: break-all;
}
.contact-note {
margin: 8px 0 0;
font-size: var(--ms-font-size-small);
}
.browse-controls {
gap: 14px;
}
.search-input {
min-width: 260px;
flex: 1;
}
.site-footer-note {
margin: 0;
}
/* "Next >" / "more entries" control at the end of a paginated list. */
.pager {
display: flex;
justify-content: center;
padding: 8px 0;
}
.pager-status {
color: var(--ms-muted-fg);
font-style: italic;
padding: 8px 0;
text-align: center;
}
+217
View File
@@ -0,0 +1,217 @@
/*
* Modules the bordered boxes everything lives inside.
*
* Two variants, straight off the 2005 page:
* .module solid blue caption bar (left rail: "Contacting Tom")
* .module--band peach caption bar, no side borders (main column:
* "Tom's Blurbs", "Tom's Friend Space")
*
* A module is <section class="module"><h2 class="module-header">
* <div class="module-body">, so `.module-header` is styleable on its own and
* the heading level stays semantic.
*/
.module {
background: var(--ms-module-bg);
border: var(--ms-module-border-width) solid var(--ms-module-border);
border-radius: var(--ms-module-radius);
}
.module-header {
margin: 0;
padding: var(--ms-module-header-padding);
background: var(--ms-module-header-bg);
color: var(--ms-module-header-fg);
font-size: var(--ms-module-header-font-size);
font-weight: var(--ms-module-header-weight);
line-height: 1.3;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.module-header a {
color: var(--ms-module-header-fg);
}
/* "[view all]" style link on the right of a caption bar. */
.module-header-action {
font-weight: 400;
font-size: var(--ms-font-size-small);
white-space: nowrap;
}
.module-body {
padding: var(--ms-module-body-padding);
}
.module-body--flush {
padding: 0;
}
.module-footer {
padding: 4px var(--ms-module-body-padding);
border-top: 1px solid var(--ms-hr-color);
font-size: var(--ms-font-size-small);
text-align: right;
}
/* --------------------------------------------------- peach band variant */
.module--band {
border: 0;
background: transparent;
}
.module--band > .module-header {
background: var(--ms-band-bg);
color: var(--ms-band-fg);
border: 1px solid var(--ms-band-border);
padding: var(--ms-band-padding);
}
.module--band > .module-header a {
color: var(--ms-band-fg);
}
.module--band > .module-body {
padding: 8px 2px;
}
/* ------------------------------------------------------- plain variant */
/* No chrome at all for stacking content that needs the module rhythm
without the box. */
.module--plain {
border: 0;
background: transparent;
}
.module--plain > .module-header {
background: transparent;
color: var(--ms-heading-fg);
padding: 0 0 4px;
font-size: var(--ms-font-size-heading);
}
/* ------------------------------------------------------ inner headings */
/* "About me:", "Who I'd like to meet:" — orange run-in headings. */
.section-heading {
color: var(--ms-heading-fg);
font-size: var(--ms-font-size-content);
font-weight: 700;
margin: 10px 0 2px;
}
.section-heading:first-child {
margin-top: 0;
}
/* ------------------------------------------------------- generic tables */
/* The label/value table used for Interests and Details. */
.data-table {
width: 100%;
border: 1px solid var(--ms-table-border);
table-layout: fixed;
}
.data-table th,
.data-table td {
padding: var(--ms-table-cell-padding);
border: 1px solid var(--ms-table-border);
vertical-align: top;
text-align: left;
font-size: var(--ms-font-size);
font-weight: 400;
}
.data-table th,
.data-table .data-table-label {
width: 30%;
background: var(--ms-table-label-bg);
color: var(--ms-table-label-fg);
font-weight: 700;
}
.data-table td,
.data-table .data-table-value {
background: var(--ms-table-value-bg);
color: var(--ms-table-value-fg);
word-wrap: break-word;
overflow-wrap: anywhere;
}
/* A verified profile field (Mastodon's link ownership check). */
.data-table-value[data-verified='true'] {
background: var(--ms-highlight-bg);
}
.verified-mark {
color: var(--ms-online-fg);
font-weight: 700;
}
/* ------------------------------------------------------------ link list */
/* Two-column icon-and-label list: "Add to Friends", "Send Message", … */
.action-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 10px;
}
.action-list--single {
grid-template-columns: minmax(0, 1fr);
}
.action-list-item {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.action-list-icon {
flex: 0 0 auto;
width: 14px;
text-align: center;
font-size: var(--ms-font-size-content);
line-height: 1;
}
.action-list-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* An action rendered as a button but styled as a link, so the markup stays
honest about what is a navigation and what is a mutation. */
.link-button {
appearance: none;
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
color: var(--ms-link);
cursor: pointer;
text-align: left;
}
.link-button:hover {
color: var(--ms-link-hover);
text-decoration: underline;
}
.link-button:disabled {
color: var(--ms-muted-fg);
cursor: default;
text-decoration: none;
}
+362
View File
@@ -0,0 +1,362 @@
/*
* The profile page.
*
* `.profile-page` is also the scope every published profile stylesheet is
* rewritten into (see lib/stores/theme.svelte.ts), which is why the class sits
* on the page root rather than on an inner wrapper.
*/
.profile-page {
/* Local overrides land here when a profile publishes a stylesheet. */
}
/* ------------------------------------------------------------- identity */
.profile-identity {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 10px;
margin-bottom: var(--ms-module-gap);
}
.profile-photo {
display: block;
width: 100%;
max-width: var(--ms-avatar-size-large);
aspect-ratio: 1 / 1;
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
border-radius: var(--ms-avatar-radius);
background: var(--ms-table-stripe-bg);
}
.profile-photo-link {
display: block;
}
.profile-photo-caption {
display: block;
margin-top: 4px;
text-align: center;
font-size: var(--ms-font-size-small);
}
/* The quoted line beside the photo. */
.profile-headline {
font-size: var(--ms-font-size-content);
margin: 0 0 10px;
quotes: '"' '"';
}
.profile-headline::before {
content: open-quote;
}
.profile-headline::after {
content: close-quote;
}
/* Gender / age / location / last active — one fact per line, as in 2005. */
.profile-vitals {
margin: 0 0 10px;
font-size: var(--ms-font-size);
}
.profile-vitals dt {
display: none;
}
.profile-vitals dd {
margin: 0 0 8px;
}
.profile-vitals dd:last-child {
margin-bottom: 0;
}
.profile-mood {
margin: 8px 0;
font-size: var(--ms-font-size);
}
.profile-mood-value {
font-weight: 700;
}
/* "View my: Blog | Forum Topics" */
.profile-viewlinks {
margin: 8px 0 0;
font-size: var(--ms-font-size);
}
.profile-viewlinks-label {
font-weight: 700;
}
.profile-url {
font-size: var(--ms-font-size);
word-break: break-all;
}
.profile-badge {
display: inline-block;
padding: 0 4px;
margin-left: 4px;
font-size: var(--ms-font-size-small);
background: var(--ms-table-label-bg);
color: var(--ms-table-label-fg);
border: 1px solid var(--ms-table-border);
vertical-align: middle;
}
.profile-badge[data-badge='bot'] {
background: var(--ms-notice-bg);
border-color: var(--ms-notice-border);
}
.profile-badge[data-badge='locked'] {
background: var(--ms-band-bg);
border-color: var(--ms-band-border);
color: var(--ms-band-fg);
}
/* An account that has moved elsewhere. */
.profile-moved {
padding: 6px 8px;
background: var(--ms-notice-bg);
border: 1px solid var(--ms-notice-border);
margin-bottom: var(--ms-module-gap);
}
/* -------------------------------------------------------- header banner */
.profile-banner {
display: block;
width: 100%;
max-height: 160px;
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
margin-bottom: var(--ms-module-gap);
}
/* ----------------------------------------------------------- stat strip */
/* "Tom has 527 friends." and the counts row. */
.profile-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
font-size: var(--ms-font-size);
}
.profile-stat {
display: flex;
gap: 4px;
}
.profile-stat-value {
font-weight: 700;
color: var(--ms-band-fg);
}
.profile-stat-label {
color: var(--ms-muted-fg);
}
/* ---------------------------------------------------------- friend space */
.friend-count {
margin: 0 0 8px;
font-size: var(--ms-font-size-content);
font-weight: 700;
}
.friend-count-value {
color: var(--ms-band-fg);
}
.friend-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--ms-avatar-size-friend), 1fr));
gap: 10px 8px;
}
/* The preview grid on a profile — larger tiles, fewer per row, as in 2005. */
.friend-grid--compact {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px 6px;
}
.friend-card {
text-align: center;
min-width: 0;
}
.friend-card-link {
display: block;
text-decoration: none;
}
.friend-card-name {
display: block;
margin-bottom: 3px;
font-weight: 700;
font-size: var(--ms-font-size);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-card-photo {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
background: var(--ms-table-stripe-bg);
}
.friend-card-handle {
display: block;
margin-top: 2px;
font-size: var(--ms-font-size-small);
color: var(--ms-muted-fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A richer row used on the full friends list and search results. */
.person-list {
list-style: none;
margin: 0;
padding: 0;
}
.person-row {
display: flex;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid var(--ms-hr-color);
align-items: flex-start;
}
.person-row:last-child {
border-bottom: 0;
}
.person-row:nth-child(even) {
background: var(--ms-table-stripe-bg);
}
.person-row-photo {
flex: 0 0 auto;
width: var(--ms-avatar-size-friend);
height: var(--ms-avatar-size-friend);
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
background: var(--ms-table-stripe-bg);
}
.person-row-body {
min-width: 0;
flex: 1;
}
.person-row-name {
font-weight: 700;
font-size: var(--ms-font-size-content);
}
.person-row-handle {
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
word-break: break-all;
}
/* Bios vary from one line to an essay; clamp so one long one can't dominate
the list. */
.person-row-note {
margin-top: 4px;
color: var(--ms-page-fg);
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.person-row-meta {
margin-top: 4px;
color: var(--ms-muted-fg);
font-size: var(--ms-font-size-small);
}
.person-row-actions {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
/* --------------------------------------------------------- avatar helper */
.avatar {
display: block;
width: var(--ms-avatar-size);
height: var(--ms-avatar-size);
object-fit: cover;
border: 1px solid var(--ms-avatar-border);
border-radius: var(--ms-avatar-radius);
background: var(--ms-table-stripe-bg);
}
.avatar--large {
width: var(--ms-avatar-size-large);
height: var(--ms-avatar-size-large);
}
.avatar--friend {
width: var(--ms-avatar-size-friend);
height: var(--ms-avatar-size-friend);
}
/* Monogram shown when an account has no avatar, or it fails to load.
`--avatar-hue` is set per account so the fallback stays recognisable. */
.avatar--placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1em;
background: hsl(var(--avatar-hue, 210) 45% 78%);
color: hsl(var(--avatar-hue, 210) 60% 20%);
}
.avatar--placeholder.avatar--large,
.avatar--placeholder.avatar--friend {
font-size: 2.4em;
}
.avatar-link {
display: block;
text-decoration: none;
}
@media (max-width: 700px) {
.profile-identity {
grid-template-columns: minmax(0, 1fr);
}
.profile-photo {
max-width: 220px;
margin: 0 auto;
}
}
+135
View File
@@ -0,0 +1,135 @@
/*
* Design tokens.
*
* Every colour, border, font and metric the app uses is declared here as a
* custom property. Nothing further down the cascade hard-codes a hex value, so
* a complete retheme is possible by overriding this block alone:
*
* .profile-page { --ms-module-header-bg: #2d0b3a; --ms-link: #ff77cc; }
*
* Palette is the classic 2003-2007 MySpace default: navy chrome, cornflower
* module headers, peach blurb bars, and Verdana at a size that would horrify a
* modern accessibility audit.
*/
:root {
/* ----------------------------------------------------------- typography */
--ms-font-family: Verdana, Geneva, Tahoma, Arial, Helvetica, sans-serif;
--ms-font-family-heading: Verdana, Geneva, Tahoma, Arial, Helvetica, sans-serif;
--ms-font-family-mono: 'Courier New', Courier, monospace;
/** Chrome text: nav bars, table labels, captions. */
--ms-font-size: 11px;
/** Prose: bios, blog entries, comments. */
--ms-font-size-content: 12px;
--ms-font-size-small: 10px;
--ms-font-size-heading: 14px;
--ms-font-size-title: 20px;
--ms-line-height: 1.4;
/* --------------------------------------------------------------- colour */
--ms-page-bg: #ffffff;
--ms-page-fg: #000000;
--ms-canvas-bg: #e5e5e5;
--ms-link: #003399;
--ms-link-visited: #003399;
--ms-link-hover: #0055cc;
--ms-link-decoration: none;
--ms-link-decoration-hover: underline;
/** The navy band across the top and the primary nav strip. */
--ms-chrome-bg: #003399;
--ms-chrome-fg: #ffffff;
--ms-chrome-link: #ffffff;
--ms-chrome-border: #000000;
/** The secondary (lighter) nav row. */
--ms-nav-bg: #6699cc;
--ms-nav-fg: #ffffff;
--ms-nav-link: #ffffff;
--ms-nav-active-bg: #ff9900;
--ms-nav-active-fg: #ffffff;
--ms-nav-separator: rgba(255, 255, 255, 0.55);
/* ------------------------------------------------------------- modules */
/* The bordered boxes: "Contacting Tom", "Tom's Interests", "My Mail". */
--ms-module-bg: #ffffff;
--ms-module-border: #6699cc;
--ms-module-border-width: 1px;
--ms-module-radius: 0;
--ms-module-header-bg: #6699cc;
--ms-module-header-fg: #ffffff;
--ms-module-header-font-size: 11px;
--ms-module-header-weight: 700;
--ms-module-header-padding: 3px 6px;
--ms-module-body-padding: 6px;
--ms-module-gap: 10px;
/* The peach "…'s Blurbs" / "…'s Friend Space" bars in the main column. */
--ms-band-bg: #ffcc99;
--ms-band-fg: #ff6600;
--ms-band-border: #ff9933;
--ms-band-padding: 3px 8px;
/** Section headings inside blurbs: "About me:", "Who I'd like to meet:". */
--ms-heading-fg: #ff6600;
/* --------------------------------------------------------------- tables */
--ms-table-border: #6699cc;
--ms-table-label-bg: #b3c7e6;
--ms-table-label-fg: #000000;
--ms-table-value-bg: #ffffff;
--ms-table-value-fg: #000000;
--ms-table-cell-padding: 4px 6px;
--ms-table-stripe-bg: #f2f5fa;
/* --------------------------------------------------------------- forms */
--ms-input-bg: #ffffff;
--ms-input-fg: #000000;
--ms-input-border: #7f9db9;
--ms-button-bg: linear-gradient(#ffffff, #e3e3e3);
--ms-button-fg: #000000;
--ms-button-border: #999999;
--ms-button-active-bg: #d4d4d4;
--ms-focus-ring: #ff6600;
/* -------------------------------------------------------------- status */
--ms-muted-fg: #666666;
--ms-online-fg: #cc6600;
--ms-error-bg: #ffeeee;
--ms-error-border: #cc0000;
--ms-error-fg: #990000;
--ms-notice-bg: #ffffcc;
--ms-notice-border: #e6c200;
--ms-highlight-bg: #ffffcc;
/* -------------------------------------------------------------- layout */
--ms-page-width: 800px;
--ms-page-width-wide: 990px;
--ms-column-left-width: 300px;
--ms-column-gap: 10px;
--ms-page-padding: 8px;
/* Height of the mark in the header lockup; width follows its 3100:2120 ratio. */
--ms-logo-height: 34px;
/*
* The mark ships as solid navy on transparent, so it is knocked out to white
* to sit on the chrome band. Set this to `none` if you theme the band to a
* light colour and want the artwork's own navy back.
*/
--ms-logo-filter: brightness(0) invert(1);
/* -------------------------------------------------------------- avatars */
--ms-avatar-size: 42px;
--ms-avatar-size-large: 190px;
--ms-avatar-size-friend: 74px;
--ms-avatar-border: #999999;
--ms-avatar-radius: 0;
/* --------------------------------------------------------------- misc */
--ms-hr-color: #cccccc;
--ms-emoji-size: 1.1em;
--ms-transition: none;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
compilerOptions: {
runes: true,
},
}
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler",
"types": ["svelte", "vite/client"],
"baseUrl": ".",
"paths": {
"$lib/*": ["src/lib/*"],
"$components/*": ["src/components/*"],
"$routes/*": ["src/routes/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { fileURLToPath, URL } from 'node:url'
// Fully static output: the app talks to a Mastodon/Pleroma server directly from
// the browser, so `dist/` can be dropped on any static host (or file://-ish CDN).
export default defineConfig({
base: './',
plugins: [svelte()],
resolve: {
alias: {
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
$components: fileURLToPath(new URL('./src/components', import.meta.url)),
$routes: fileURLToPath(new URL('./src/routes', import.meta.url)),
},
},
build: {
target: 'es2022',
cssCodeSplit: false,
},
})