mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "plspace",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
.vite/
|
||||
@@ -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
@@ -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>
|
||||
Generated
+1361
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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…</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 |
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Media on a status.
|
||||
*
|
||||
* Sensitive media is blurred rather than hidden so the layout doesn't jump
|
||||
* when it's revealed, and the reveal is per-attachment because a single post
|
||||
* can mix flagged and unflagged media.
|
||||
*/
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
attachments: MediaAttachment[]
|
||||
sensitive?: boolean
|
||||
}
|
||||
|
||||
let { attachments, sensitive = false }: Props = $props()
|
||||
|
||||
let revealed = $state<Record<string, boolean>>({})
|
||||
|
||||
function isRevealed(id: string): boolean {
|
||||
return !sensitive || revealed[id] === true
|
||||
}
|
||||
|
||||
function toggle(id: string): void {
|
||||
revealed = { ...revealed, [id]: !revealed[id] }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
<ul class="attachment-list">
|
||||
{#each attachments as media (media.id)}
|
||||
<li
|
||||
class="attachment"
|
||||
data-type={media.type}
|
||||
data-sensitive={sensitive ? 'true' : 'false'}
|
||||
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
|
||||
>
|
||||
<figure class="attachment-figure">
|
||||
{#if media.type === 'video' || media.type === 'gifv'}
|
||||
<video
|
||||
class="attachment-media"
|
||||
src={media.url}
|
||||
poster={media.preview_url ?? undefined}
|
||||
controls
|
||||
playsinline
|
||||
loop={media.type === 'gifv'}
|
||||
preload="none"
|
||||
>
|
||||
<!-- Remote media carries no caption track; declared so the
|
||||
requirement is explicit rather than merely unmet. -->
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{:else if media.type === 'audio'}
|
||||
<audio class="attachment-media attachment-media--audio" src={media.url} controls preload="none"
|
||||
></audio>
|
||||
{:else if media.type === 'image'}
|
||||
<a href={media.url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
class="attachment-media"
|
||||
src={media.preview_url ?? media.url}
|
||||
alt={media.description ?? ''}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<a class="attachment-media attachment-media--file" href={media.url} target="_blank" rel="noopener noreferrer">
|
||||
Attachment
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if media.description}
|
||||
<figcaption class="attachment-caption">{media.description}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
{#if sensitive}
|
||||
<button type="button" class="button button--small attachment-reveal" onclick={() => toggle(media.id)}>
|
||||
{isRevealed(media.id) ? 'Hide' : 'Show'} sensitive media
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One status, dressed as a MySpace blog entry.
|
||||
*
|
||||
* Vocabulary mapping, applied consistently across the app:
|
||||
* favourite -> Kudos (MySpace blogs really did call them that)
|
||||
* reblog -> Repost
|
||||
* reply -> Comment
|
||||
*
|
||||
* Toggling kudos/repost updates optimistically and rolls back on failure —
|
||||
* a federated round trip is slow enough that waiting feels broken.
|
||||
*/
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints'
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
|
||||
interface Props {
|
||||
status: Status
|
||||
/** Called with the updated status after an action succeeds. */
|
||||
onupdate?: (status: Status) => void
|
||||
/** Called after the viewer deletes their own entry. */
|
||||
ondelete?: (id: string) => void
|
||||
/** Drop the avatar gutter — used inside threads. */
|
||||
compact?: boolean
|
||||
/** Show the full date rather than a short stamp. */
|
||||
longFormDate?: boolean
|
||||
}
|
||||
|
||||
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
|
||||
|
||||
/** The status actually being displayed; a boost renders its target. */
|
||||
const entry = $derived(status.reblog ?? status)
|
||||
const booster = $derived(status.reblog ? status.account : null)
|
||||
|
||||
const author = $derived(entry.account)
|
||||
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
|
||||
const handle = $derived(fullHandle(author, session.host))
|
||||
const permalink = $derived(`#/blog/${entry.id}`)
|
||||
const isMine = $derived(session.me?.id === entry.account.id)
|
||||
|
||||
let busy = $state(false)
|
||||
let actionError = $state<string | null>(null)
|
||||
|
||||
const VISIBILITY_ICON: Record<string, string> = {
|
||||
public: '🌐',
|
||||
unlisted: '🔓',
|
||||
private: '🔒',
|
||||
direct: '✉',
|
||||
}
|
||||
|
||||
const VISIBILITY_LABEL: Record<string, string> = {
|
||||
public: 'Public',
|
||||
unlisted: 'Unlisted',
|
||||
private: 'Friends only',
|
||||
direct: 'Private message',
|
||||
}
|
||||
|
||||
async function toggleKudos(): Promise<void> {
|
||||
if (!session.signedIn || busy) return
|
||||
const next = !entry.favourited
|
||||
busy = true
|
||||
actionError = null
|
||||
|
||||
// Optimistic: reflect the new state before the request settles.
|
||||
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
|
||||
|
||||
try {
|
||||
const updated = await favouriteStatus(session.api, entry.id, next)
|
||||
onupdate?.(rewrap(status, updated))
|
||||
} catch (cause) {
|
||||
onupdate?.(status)
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRepost(): Promise<void> {
|
||||
if (!session.signedIn || busy) return
|
||||
const next = !entry.reblogged
|
||||
busy = true
|
||||
actionError = null
|
||||
|
||||
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
|
||||
|
||||
try {
|
||||
const updated = await reblogStatus(session.api, entry.id, next)
|
||||
// Reblogging returns the *wrapper* status; unwrap to the original.
|
||||
onupdate?.(rewrap(status, updated.reblog ?? updated))
|
||||
} catch (cause) {
|
||||
onupdate?.(status)
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!isMine || busy) return
|
||||
if (!confirm('Delete this entry? This cannot be undone.')) return
|
||||
busy = true
|
||||
try {
|
||||
await deleteStatus(session.api, entry.id)
|
||||
ondelete?.(status.id)
|
||||
} catch (cause) {
|
||||
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Patch the inner status, preserving the boost wrapper if there is one. */
|
||||
function applyLocal(wrapper: Status, patch: Partial<Status>): Status {
|
||||
if (wrapper.reblog) return { ...wrapper, reblog: { ...wrapper.reblog, ...patch } }
|
||||
return { ...wrapper, ...patch }
|
||||
}
|
||||
|
||||
function rewrap(wrapper: Status, fresh: Status): Status {
|
||||
return wrapper.reblog ? { ...wrapper, reblog: fresh } : fresh
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="blog-entry"
|
||||
data-status-id={entry.id}
|
||||
data-account={author.acct}
|
||||
data-visibility={entry.visibility}
|
||||
data-boosted={booster ? 'true' : 'false'}
|
||||
data-reply={entry.in_reply_to_id ? 'true' : 'false'}
|
||||
data-sensitive={entry.sensitive ? 'true' : 'false'}
|
||||
data-compact={compact ? 'true' : 'false'}
|
||||
data-mine={isMine ? 'true' : 'false'}
|
||||
>
|
||||
{#if booster}
|
||||
<p class="blog-entry-attribution">
|
||||
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<header class="blog-entry-header">
|
||||
{#if !compact}
|
||||
<div class="blog-entry-avatar">
|
||||
<Avatar account={author} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="blog-entry-byline">
|
||||
<a class="blog-entry-author" href={profilePath(author)}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
|
||||
{@html authorName}
|
||||
</a>
|
||||
<span class="blog-entry-handle">{handle}</span>
|
||||
<span class="blog-entry-date">
|
||||
<a href={permalink}>
|
||||
<time datetime={isoDate(entry.created_at)}>
|
||||
{longFormDate ? longDate(entry.created_at) : stampDate(entry.created_at)}
|
||||
</time>
|
||||
</a>
|
||||
<span class="blog-entry-visibility" title={VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}>
|
||||
{VISIBILITY_ICON[entry.visibility] ?? ''}
|
||||
<span class="visually-hidden">{VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}</span>
|
||||
</span>
|
||||
{#if entry.edited_at}
|
||||
<span class="blog-entry-edited">(edited)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-entry-body">
|
||||
{#if entry.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
||||
<RichText
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
lang={entry.language}
|
||||
/>
|
||||
{#if entry.media_attachments.length > 0}
|
||||
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if entry.poll}
|
||||
<PollView poll={entry.poll} />
|
||||
{/if}
|
||||
|
||||
{#if entry.card && entry.media_attachments.length === 0}
|
||||
<PreviewCardView card={entry.card} />
|
||||
{/if}
|
||||
|
||||
{#if actionError}
|
||||
<p class="error-note" role="alert">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
<footer class="blog-entry-actions">
|
||||
<a class="blog-action blog-action--comment" href={permalink}>
|
||||
Comment <span class="blog-action-count">({formatCount(entry.replies_count)})</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="link-button blog-action blog-action--kudos"
|
||||
aria-pressed={entry.favourited ? 'true' : 'false'}
|
||||
disabled={!session.signedIn || busy}
|
||||
title={session.signedIn ? 'Give kudos' : 'Sign in to give kudos'}
|
||||
onclick={toggleKudos}
|
||||
>
|
||||
{entry.favourited ? 'Kudos given' : 'Kudos'}
|
||||
<span class="blog-action-count">({formatCount(entry.favourites_count)})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="link-button blog-action blog-action--repost"
|
||||
aria-pressed={entry.reblogged ? 'true' : 'false'}
|
||||
disabled={!session.signedIn || busy || entry.visibility === 'direct' || entry.visibility === 'private'}
|
||||
title={entry.visibility === 'private' || entry.visibility === 'direct'
|
||||
? 'This entry can’t be reposted'
|
||||
: 'Repost to your friends'}
|
||||
onclick={toggleRepost}
|
||||
>
|
||||
{entry.reblogged ? 'Reposted' : 'Repost'}
|
||||
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
|
||||
</button>
|
||||
|
||||
{#if entry.url}
|
||||
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
Original
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if isMine}
|
||||
<button type="button" class="link-button blog-action blog-action--delete" disabled={busy} onclick={remove}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
/** A `Feed<Status>` rendered as a list of blog entries, plus its pager. */
|
||||
import type { Status } from '$lib/api/types'
|
||||
import type { Feed } from '$lib/stores/feed.svelte'
|
||||
import BlogEntry from './BlogEntry.svelte'
|
||||
import Pager from '../common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Status>
|
||||
emptyText?: string
|
||||
label?: string
|
||||
longFormDate?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
emptyText = 'There are no Blog Entries yet.',
|
||||
label = 'View More Entries',
|
||||
longFormDate = false,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="blog-list">
|
||||
{#each feed.items as status (status.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
{status}
|
||||
{longFormDate}
|
||||
onupdate={(next) => feed.update(status.id, () => next)}
|
||||
ondelete={(id) => feed.remove(id)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Pager {feed} {emptyText} {label} />
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Post a blog entry, or a comment on someone else's.
|
||||
*
|
||||
* Character limits vary per server (500 on Mastodon, often 5000 on Pleroma),
|
||||
* so the counter reads `configuration.statuses.max_characters` from the
|
||||
* instance and only falls back to 500 when the server doesn't say.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { postStatus, uploadMedia } from '$lib/api/endpoints'
|
||||
|
||||
interface Props {
|
||||
/** Set to reply to an existing entry. */
|
||||
inReplyTo?: Status | null
|
||||
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
||||
initialText?: string
|
||||
placeholder?: string
|
||||
submitLabel?: string
|
||||
onposted?: (status: Status) => void
|
||||
}
|
||||
|
||||
let {
|
||||
inReplyTo = null,
|
||||
initialText = '',
|
||||
placeholder = 'What are you up to?',
|
||||
submitLabel = 'Post Entry',
|
||||
onposted,
|
||||
}: Props = $props()
|
||||
|
||||
// Seeded once from the prop; afterwards the textarea owns the value.
|
||||
let text = $state(untrack(() => initialText))
|
||||
let warning = $state('')
|
||||
let showWarning = $state(false)
|
||||
let visibility = $state<StatusVisibility>('public')
|
||||
let attachments = $state<MediaAttachment[]>([])
|
||||
let busy = $state(false)
|
||||
let uploading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
|
||||
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
|
||||
const remaining = $derived(maxCharacters - text.length - warning.length)
|
||||
const canPost = $derived(
|
||||
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
||||
)
|
||||
|
||||
// Default replies to the visibility of what they answer, so a private thread
|
||||
// doesn't accidentally get a public reply.
|
||||
$effect(() => {
|
||||
if (inReplyTo) visibility = inReplyTo.visibility
|
||||
})
|
||||
|
||||
async function onFiles(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
if (files.length === 0) return
|
||||
|
||||
uploading = true
|
||||
error = null
|
||||
try {
|
||||
for (const file of files.slice(0, maxAttachments - attachments.length)) {
|
||||
const media = await uploadMedia(session.api, file)
|
||||
attachments = [...attachments, media]
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Upload failed.'
|
||||
} finally {
|
||||
uploading = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(id: string): void {
|
||||
attachments = attachments.filter((media) => media.id !== id)
|
||||
}
|
||||
|
||||
async function submit(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (!canPost) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
const created = await postStatus(session.api, {
|
||||
status: text,
|
||||
in_reply_to_id: inReplyTo?.id ?? null,
|
||||
visibility,
|
||||
spoiler_text: showWarning ? warning : undefined,
|
||||
media_ids: attachments.map((media) => media.id),
|
||||
})
|
||||
text = ''
|
||||
warning = ''
|
||||
showWarning = false
|
||||
attachments = []
|
||||
onposted?.(created)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if session.signedIn}
|
||||
<form class="composer" onsubmit={submit}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if showWarning}
|
||||
<div class="field">
|
||||
<label class="field-label" for="composer-warning">Content warning</label>
|
||||
<input
|
||||
id="composer-warning"
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={warning}
|
||||
placeholder="What should readers know first?"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="visually-hidden" for="composer-body">Entry text</label>
|
||||
<textarea id="composer-body" class="composer-body" bind:value={text} {placeholder} rows="4"></textarea>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
<ul class="composer-attachments">
|
||||
{#each attachments as media (media.id)}
|
||||
<li class="composer-attachment">
|
||||
<img src={media.preview_url ?? media.url} alt={media.description ?? ''} />
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
onclick={() => removeAttachment(media.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<label class="button button--small composer-upload">
|
||||
{uploading ? 'Uploading…' : 'Add photo'}
|
||||
<input
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
disabled={uploading || attachments.length >= maxAttachments}
|
||||
onchange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
aria-pressed={showWarning ? 'true' : 'false'}
|
||||
onclick={() => (showWarning = !showWarning)}
|
||||
>
|
||||
Warning
|
||||
</button>
|
||||
|
||||
<label class="visually-hidden" for="composer-visibility">Who can see this</label>
|
||||
<select id="composer-visibility" bind:value={visibility}>
|
||||
<option value="public">Everyone</option>
|
||||
<option value="unlisted">Everyone (off the public timeline)</option>
|
||||
<option value="private">Friends only</option>
|
||||
<option value="direct">Mentioned people only</option>
|
||||
</select>
|
||||
|
||||
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
|
||||
|
||||
<button class="button button--primary" type="submit" disabled={!canPost}>
|
||||
{busy ? 'Posting…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="empty-note">
|
||||
<a href="#/login">Sign in</a> to post.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
poll: Poll
|
||||
}
|
||||
|
||||
let { poll }: Props = $props()
|
||||
|
||||
const total = $derived(poll.votes_count || 0)
|
||||
|
||||
function share(votes: number | null): number {
|
||||
if (!total || votes === null) return 0
|
||||
return Math.round((votes / total) * 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
|
||||
{#each poll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">{option.title}</span>
|
||||
<span class="poll-option-share">{share(option.votes_count)}%</span>
|
||||
</div>
|
||||
<div class="poll-option-bar">
|
||||
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<p class="poll-meta">
|
||||
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
||||
{#if poll.expired}
|
||||
· closed
|
||||
{:else if poll.expires_at}
|
||||
· closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
/** The link preview a server attaches to a status. */
|
||||
import type { PreviewCard } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
card: PreviewCard
|
||||
}
|
||||
|
||||
let { card }: Props = $props()
|
||||
|
||||
const host = $derived.by(() => {
|
||||
try {
|
||||
return new URL(card.url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return card.provider_name ?? ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<a class="preview-card" href={card.url} target="_blank" rel="noopener noreferrer" data-card-type={card.type}>
|
||||
{#if card.image}
|
||||
<img class="preview-card-image" src={card.image} alt="" loading="lazy" decoding="async" />
|
||||
{/if}
|
||||
<span class="preview-card-body">
|
||||
<span class="preview-card-title">{card.title}</span>
|
||||
{#if card.description}
|
||||
<span class="preview-card-description">{card.description}</span>
|
||||
{/if}
|
||||
<span class="preview-card-host">{host}</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : null)
|
||||
const version = $derived(session.instance?.version ?? null)
|
||||
</script>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p class="site-footer-links">
|
||||
<a href="#/">Home</a>
|
||||
<a href="#/browse">Browse</a>
|
||||
<a href="#/search">Search</a>
|
||||
<a href="#/settings">Settings</a>
|
||||
{#if domain}
|
||||
<a href={`https://${session.host}/about`} target="_blank" rel="noopener noreferrer">About this server</a>
|
||||
{/if}
|
||||
</p>
|
||||
<!--
|
||||
"Connected to X" read as though you had an account there, even when
|
||||
browsing anonymously. Say which of the two it is.
|
||||
-->
|
||||
<p class="site-footer-note" data-session={session.signedIn ? 'signed-in' : session.host ? 'guest' : 'none'}>
|
||||
plspace — it’s always Pleroma™.
|
||||
{#if session.signedIn && session.me}
|
||||
Signed in to <strong>{domain}</strong> as
|
||||
<a href={profilePath(session.me)}>@{session.me.acct}</a>{#if version} ({version}){/if}.
|
||||
{:else if domain}
|
||||
Browsing <strong>{domain}</strong> as a guest{#if version} ({version}){/if}.
|
||||
<a href="#/login">Sign in</a>
|
||||
{:else}
|
||||
Not connected to a server. <a href="#/login">Choose one</a>
|
||||
{/if}
|
||||
</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The navy utility bar and the boxed logo strip beneath it.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router, routeTo } from '$lib/router.svelte'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
// Imported rather than referenced by path so Vite fingerprints it and the
|
||||
// relative `base` still resolves when hosted from a subdirectory.
|
||||
import logoUrl from '../../assets/plspace-logo.webp'
|
||||
|
||||
let query = $state('')
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
function submitSearch(event: SubmitEvent): void {
|
||||
event.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
router.go(routeTo('/search', { q: trimmed }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header-inner">
|
||||
<!--
|
||||
The logo lockup sits inside the navy band and is knocked out to white,
|
||||
as on the 2005 page — mark, wordmark, and the tagline tucked beneath the
|
||||
wordmark. `width`/`height` carry the intrinsic ratio so the bar doesn't
|
||||
reflow while the image decodes.
|
||||
-->
|
||||
<a class="site-logo" href="#/">
|
||||
<img
|
||||
class="site-logo-image"
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width="3100"
|
||||
height="2120"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="site-logo-text">
|
||||
<span class="site-logo-mark">plspace</span>
|
||||
<span class="site-logo-tagline">
|
||||
It’s always Pleroma<span class="site-logo-trademark">™</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if domain}
|
||||
<!-- A bare domain read as "you have an account on this server".
|
||||
Mark the guest case so the two are never confused. -->
|
||||
<p class="site-connection">
|
||||
{domain}
|
||||
{#if !session.signedIn}
|
||||
<span class="site-connection-guest" title="You are not signed in">(guest)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="site-search" role="search" onsubmit={submitSearch}>
|
||||
<label class="site-search-label" for="site-search-input">Search Users:</label>
|
||||
<input
|
||||
id="site-search-input"
|
||||
class="site-search-input"
|
||||
type="search"
|
||||
name="q"
|
||||
bind:value={query}
|
||||
placeholder="name or @user@server"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="button button--small" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<p class="site-account-links">
|
||||
<a href="#/settings">Settings</a>
|
||||
{#if session.signedIn}
|
||||
<span aria-hidden="true">|</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-button site-header-logout"
|
||||
onclick={() => void session.logout()}
|
||||
>
|
||||
LogOut
|
||||
</button>
|
||||
{:else}
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="#/login">LogIn</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The pipe-separated nav strip.
|
||||
*
|
||||
* Entries that need a token disappear when browsing logged out rather than
|
||||
* erroring on click.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
/** Route names that should light this entry up. */
|
||||
matches: string[]
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
|
||||
const items = $derived<NavItem[]>([
|
||||
{ label: 'Home', href: '#/', matches: ['home'] },
|
||||
{ label: 'Browse', href: '#/browse', matches: ['browse'] },
|
||||
{ label: 'Search', href: '#/search', matches: ['search'] },
|
||||
{ label: 'Mail', href: '#/mail', matches: ['mail', 'mail.folder'], requiresAuth: true },
|
||||
{ label: 'Blog', href: '#/timeline/home', matches: ['timeline', 'tag', 'blog.entry'] },
|
||||
{
|
||||
label: 'My Profile',
|
||||
href: session.me ? profilePath(session.me) : '#/login',
|
||||
matches: ['profile', 'profile.friends', 'profile.blog', 'profile.pics'],
|
||||
requiresAuth: true,
|
||||
},
|
||||
{ label: 'Post', href: '#/compose', matches: ['compose'], requiresAuth: true },
|
||||
{ label: 'Settings', href: '#/settings', matches: ['settings'] },
|
||||
])
|
||||
|
||||
const visible = $derived(items.filter((item) => !item.requiresAuth || session.signedIn))
|
||||
const currentName = $derived(router.current.name)
|
||||
</script>
|
||||
|
||||
<nav class="site-nav" aria-label="Main">
|
||||
<div class="site-nav-inner">
|
||||
<ul class="site-nav-list">
|
||||
{#each visible as item (item.label)}
|
||||
<li class="site-nav-item">
|
||||
<a
|
||||
class="site-nav-link"
|
||||
href={item.href}
|
||||
aria-current={item.matches.includes(currentName) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* An account's photo, linked to their profile.
|
||||
*
|
||||
* Falls back to a generated monogram when the avatar 404s or the account has
|
||||
* none — a broken-image icon in a friend grid ruins the whole effect.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
size?: 'default' | 'large' | 'friend'
|
||||
/** Render without the surrounding link (when an ancestor is already one). */
|
||||
plain?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { account, size = 'default', plain = false, class: extraClass = '' }: Props = $props()
|
||||
|
||||
let failed = $state(false)
|
||||
|
||||
const name = $derived(displayNameOf(account))
|
||||
const sizeClass = $derived(size === 'large' ? 'avatar--large' : size === 'friend' ? 'avatar--friend' : '')
|
||||
const src = $derived(account.avatar_static || account.avatar)
|
||||
const initial = $derived((name.match(/\p{L}|\p{N}/u)?.[0] ?? '?').toUpperCase())
|
||||
|
||||
/**
|
||||
* A stable hue per account, so the placeholder is at least recognisable.
|
||||
*/
|
||||
const hue = $derived.by(() => {
|
||||
let hash = 0
|
||||
const seed = account.acct || account.id
|
||||
for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
||||
return hash % 360
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet image()}
|
||||
{#if src && !failed}
|
||||
<img
|
||||
class="avatar {sizeClass} {extraClass}"
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={() => (failed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="avatar avatar--placeholder {sizeClass} {extraClass}"
|
||||
style="--avatar-hue: {hue}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if plain}
|
||||
{@render image()}
|
||||
{:else}
|
||||
<a class="avatar-link" href={profilePath(account)} title={name} data-account={account.acct}>
|
||||
{@render image()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A bordered box with a caption bar — the unit every page is assembled from.
|
||||
*
|
||||
* `variant` picks the era-correct chrome:
|
||||
* panel blue caption bar (left rail)
|
||||
* band peach caption bar (main column)
|
||||
* plain no chrome, just the heading
|
||||
*/
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
variant?: 'panel' | 'band' | 'plain'
|
||||
/** Right-aligned link in the caption bar, e.g. "[view all]". */
|
||||
action?: Snippet
|
||||
/** Remove body padding, for tables that should meet the border. */
|
||||
flush?: boolean
|
||||
/** Extra classes, so callers can add their own styling hook. */
|
||||
class?: string
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
variant = 'panel',
|
||||
action,
|
||||
flush = false,
|
||||
class: extraClass = '',
|
||||
children,
|
||||
}: Props = $props()
|
||||
|
||||
const variantClass = $derived(
|
||||
variant === 'band' ? 'module--band' : variant === 'plain' ? 'module--plain' : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<section class="module {variantClass} {extraClass}" data-variant={variant}>
|
||||
{#if title}
|
||||
<h2 class="module-header">
|
||||
<span class="module-header-title">{title}</span>
|
||||
{#if action}
|
||||
<span class="module-header-action">{@render action()}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
{/if}
|
||||
<div class="module-body" class:module-body--flush={flush}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The end-of-list control for a `Feed`: a "more entries" button, the loading
|
||||
* note, the empty state and any error, in one place so every list behaves the
|
||||
* same way.
|
||||
*/
|
||||
import type { Feed, Identified } from '$lib/stores/feed.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Identified>
|
||||
/** Label for the load-more button. */
|
||||
label?: string
|
||||
/** Shown when the list came back empty. */
|
||||
emptyText?: string
|
||||
/** Shown once everything has loaded, if there was anything at all. */
|
||||
endText?: string
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
label = 'View More Entries',
|
||||
emptyText = 'There are no entries yet.',
|
||||
endText = 'That’s everything.',
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if feed.error}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Couldn’t load this list.</strong>
|
||||
{feed.error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !feed.initialized && feed.loading}
|
||||
<p class="loading-note">Loading…</p>
|
||||
{:else if feed.isEmpty}
|
||||
<p class="empty-note">{emptyText}</p>
|
||||
{:else if !feed.exhausted}
|
||||
<div class="pager">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
onclick={() => feed.loadMore()}
|
||||
disabled={feed.loading}
|
||||
>
|
||||
{feed.loading ? 'Loading…' : label}
|
||||
</button>
|
||||
</div>
|
||||
{:else if feed.items.length > 0 && endText}
|
||||
<p class="pager-status">{endText}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The only place in the app that calls `{@html}` on server content.
|
||||
*
|
||||
* Keeping it to one component means the sanitizer can never be accidentally
|
||||
* skipped: callers pass raw HTML from the API and get a sanitized,
|
||||
* emoji-substituted, link-rewritten render.
|
||||
*/
|
||||
import { renderHtml } from '$lib/util/html'
|
||||
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
|
||||
|
||||
interface Props {
|
||||
html: string | null | undefined
|
||||
emojis?: CustomEmoji[]
|
||||
mentions?: StatusMention[]
|
||||
tags?: StatusTag[]
|
||||
/** Collapse to a single line, for previews. */
|
||||
inline?: boolean
|
||||
class?: string
|
||||
lang?: string | null
|
||||
}
|
||||
|
||||
let { html, emojis, mentions, tags, inline = false, class: extraClass = '', lang }: Props = $props()
|
||||
|
||||
const rendered = $derived(renderHtml(html, { emojis, mentions, tags, inline }))
|
||||
</script>
|
||||
|
||||
{#if rendered}
|
||||
<div
|
||||
class="rich-text {extraClass}"
|
||||
class:rich-text--inline={inline}
|
||||
lang={lang ?? undefined}
|
||||
dir="auto"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderHtml -->
|
||||
{@html rendered}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/** A row of page tabs, rendered as links so they're navigable and shareable. */
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
/** Optional count shown in parentheses. */
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[]
|
||||
/** Route path of the active tab, compared against each `href`. */
|
||||
current: string
|
||||
label: string
|
||||
}
|
||||
|
||||
let { tabs, current, label }: Props = $props()
|
||||
</script>
|
||||
|
||||
<nav class="tab-bar" aria-label={label}>
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a
|
||||
class="tab"
|
||||
href={tab.href}
|
||||
aria-current={tab.href === current ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}{#if tab.count != null} ({tab.count}){/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
/** A `Feed<Account>` rendered as person rows, plus its pager. */
|
||||
import type { Account } from '$lib/api/types'
|
||||
import type { Feed } from '$lib/stores/feed.svelte'
|
||||
import PersonRow from './PersonRow.svelte'
|
||||
import Pager from '../common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
feed: Feed<Account>
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
let { feed, emptyText = 'Nobody here yet.' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="person-list">
|
||||
{#each feed.items as account (account.id)}
|
||||
<PersonRow {account} />
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Pager {feed} label="View More People" {emptyText} />
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
/** A person in a list: friends, search results, the browse directory. */
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
/** Extra controls on the right, e.g. Approve/Deny on friend requests. */
|
||||
actions?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { account, actions }: Props = $props()
|
||||
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
</script>
|
||||
|
||||
<li class="person-row" data-account={account.acct} data-bot={account.bot ? 'true' : 'false'}>
|
||||
<a href={profilePath(account)} class="person-row-photo-link">
|
||||
<img
|
||||
class="person-row-photo"
|
||||
src={account.avatar_static || account.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="person-row-body">
|
||||
<a class="person-row-name" href={profilePath(account)}>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
|
||||
{@html name}
|
||||
</a>
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
|
||||
{#if account.note}
|
||||
<RichText class="person-row-note" html={account.note} emojis={account.emojis} inline />
|
||||
{/if}
|
||||
|
||||
<p class="person-row-meta">
|
||||
{formatCount(account.statuses_count)} entries ·
|
||||
{formatCount(account.followers_count)} friends
|
||||
{#if account.last_status_at}
|
||||
· active {relativeTime(account.last_status_at)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
<div class="person-row-actions">{@render actions()}</div>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Contacting Tom" — the two-column action list.
|
||||
*
|
||||
* Follow state comes from `/api/v1/accounts/relationships`, which is only
|
||||
* available when signed in; logged out, the actions become sign-in prompts
|
||||
* rather than disappearing, so the box keeps its shape.
|
||||
*/
|
||||
import type { Account, Relationship } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
account: Account
|
||||
relationship: Relationship | null
|
||||
onrelationship?: (relationship: Relationship) => void
|
||||
}
|
||||
|
||||
let { account, relationship, onrelationship }: Props = $props()
|
||||
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const firstName = $derived(displayNameOf(account).split(/\s+/)[0])
|
||||
const isSelf = $derived(session.me?.id === account.id)
|
||||
const following = $derived(relationship?.following ?? false)
|
||||
const requested = $derived(relationship?.requested ?? false)
|
||||
const blocking = $derived(relationship?.blocking ?? false)
|
||||
|
||||
const followLabel = $derived(
|
||||
blocking
|
||||
? 'Unblock User'
|
||||
: following
|
||||
? 'Remove from Friends'
|
||||
: requested
|
||||
? 'Cancel Friend Request'
|
||||
: account.locked
|
||||
? 'Request to Add'
|
||||
: 'Add to Friends',
|
||||
)
|
||||
|
||||
async function run(action: () => Promise<Relationship>): Promise<void> {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
onrelationship?.(await action())
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFollow(): void {
|
||||
if (blocking) {
|
||||
void run(() => unblockAccount(session.api, account.id))
|
||||
} else if (following || requested) {
|
||||
void run(() => unfollowAccount(session.api, account.id))
|
||||
} else {
|
||||
void run(() => followAccount(session.api, account.id))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBlock(): void {
|
||||
void run(() =>
|
||||
blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Module title={`Contacting ${firstName}`}>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<ul class="action-list">
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">✉</span>
|
||||
{#if session.signedIn}
|
||||
<a class="action-list-label" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>Send Message</a>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Send Message</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">👤</span>
|
||||
{#if isSelf}
|
||||
<span class="action-list-label muted">This is you</span>
|
||||
{:else if session.signedIn}
|
||||
<button
|
||||
type="button"
|
||||
class="link-button action-list-label"
|
||||
disabled={busy}
|
||||
aria-pressed={following ? 'true' : 'false'}
|
||||
onclick={toggleFollow}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{:else}
|
||||
<a class="action-list-label" href="#/login">Add to Friends</a>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">★</span>
|
||||
<a class="action-list-label" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
View on {account.acct.includes('@') ? account.acct.split('@')[1] : session.host}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">➦</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/friends`}>Forward to Friend</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">💬</span>
|
||||
<a class="action-list-label" href={`#/@${account.acct}/blog`}>Instant Message</a>
|
||||
</li>
|
||||
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">⛔</span>
|
||||
{#if isSelf || !session.signedIn}
|
||||
<span class="action-list-label muted">Block User</span>
|
||||
{:else}
|
||||
<button type="button" class="link-button action-list-label" disabled={busy} onclick={toggleBlock}>
|
||||
{blocking ? 'Unblock User' : 'Block User'}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{#if relationship?.followed_by && !isSelf}
|
||||
<p class="contact-note muted">{firstName} has you on their friends list.</p>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Details" — profile fields that didn't map to an interests row.
|
||||
*
|
||||
* Mastodon's link verification is surfaced here: a field whose URL proved
|
||||
* ownership is highlighted, matching what every other client does, because
|
||||
* an unverified link that looks verified is a phishing surface.
|
||||
*/
|
||||
import type { ProfileField } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
fields: ProfileField[]
|
||||
}
|
||||
|
||||
let { title, fields }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if fields.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table details-table">
|
||||
<tbody>
|
||||
{#each fields as field (field.name)}
|
||||
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
||||
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
||||
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
||||
{#if field.verified}
|
||||
<span class="verified-mark" title="Ownership of this link is verified">✓</span>
|
||||
{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html field.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Friend Space" — the grid of tiny avatars with names above them.
|
||||
*
|
||||
* Mastodon lets an account hide its follower/following lists, and remote
|
||||
* accounts often return an empty list rather than an error, so the count and
|
||||
* the grid are allowed to disagree; the count is authoritative.
|
||||
*/
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** The subject, used in "Tom has 527 friends." */
|
||||
ownerName: string
|
||||
friends: Account[]
|
||||
total: number
|
||||
viewAllHref: string
|
||||
loading?: boolean
|
||||
/** The list is withheld by the account's privacy settings. */
|
||||
hidden?: boolean
|
||||
/** The count is withheld too, so `total` is not meaningful. */
|
||||
countHidden?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
ownerName,
|
||||
friends,
|
||||
total,
|
||||
viewAllHref,
|
||||
loading = false,
|
||||
hidden = false,
|
||||
countHidden = false,
|
||||
compact = false,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Module {title} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={viewAllHref}>[view all]</a>
|
||||
{/snippet}
|
||||
|
||||
<!-- Never render a withheld count as "0 friends" — that reports a privacy
|
||||
setting as a fact about the person. -->
|
||||
{#if countHidden}
|
||||
<p class="friend-count">{ownerName} keeps their friend count private.</p>
|
||||
{:else}
|
||||
<p class="friend-count">
|
||||
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
|
||||
friend{total === 1 ? '' : 's'}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<p class="empty-note">This friends list is private.</p>
|
||||
{:else if loading && friends.length === 0}
|
||||
<p class="loading-note">Loading friends…</p>
|
||||
{:else if friends.length === 0}
|
||||
<p class="empty-note">No friends to show yet.</p>
|
||||
{:else}
|
||||
<ul class="friend-grid" class:friend-grid--compact={compact}>
|
||||
{#each friends as friend (friend.id)}
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Tom's Interests" — the label/value table.
|
||||
*
|
||||
* Rows only appear when the account has a matching profile field, so a bare
|
||||
* Mastodon account gets a compact box rather than six empty rows.
|
||||
*/
|
||||
import type { InterestEntry } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
interests: InterestEntry[]
|
||||
}
|
||||
|
||||
let { title, interests }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if interests.length > 0}
|
||||
<Module {title} flush>
|
||||
<table class="data-table interests-table">
|
||||
<tbody>
|
||||
{#each interests as entry (entry.row)}
|
||||
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
||||
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
||||
<td class="data-table-value interests-value">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html entry.value}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
{/if}
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The photo-and-vitals block at the top of a profile.
|
||||
*
|
||||
* MySpace showed gender, age, location and last-login as bare lines beside
|
||||
* the photo. Mastodon publishes none of those, so the ones a user hasn't put
|
||||
* in a profile field are simply omitted — except "age", which falls back to
|
||||
* how long the account has existed, because a profile with no numbers at all
|
||||
* doesn't read as a profile.
|
||||
*/
|
||||
import type { ProfileView } from '$lib/util/profile'
|
||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView
|
||||
}
|
||||
|
||||
let { profile }: Props = $props()
|
||||
|
||||
const account = $derived(profile.account)
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
const handle = $derived(fullHandle(account, session.host))
|
||||
const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
|
||||
const photo = $derived(account.avatar || account.avatar_static)
|
||||
</script>
|
||||
|
||||
<div class="profile-identity">
|
||||
<div class="profile-photo-wrap">
|
||||
{#if photo}
|
||||
<a class="profile-photo-link" href={account.url} target="_blank" rel="noopener noreferrer">
|
||||
<img class="profile-photo" src={photo} alt={displayNameOf(account)} decoding="async" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="profile-photo profile-photo--empty" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<a class="profile-photo-caption" href={`#/@${account.acct}/pics`}>View more pics</a>
|
||||
</div>
|
||||
|
||||
<div class="profile-vitals-wrap">
|
||||
<p class="profile-headline">{profile.headline}</p>
|
||||
|
||||
<dl class="profile-vitals">
|
||||
{#if profile.gender}
|
||||
<dt>Gender</dt>
|
||||
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
|
||||
{/if}
|
||||
|
||||
{#if accountAge !== null}
|
||||
<dt>Age</dt>
|
||||
<dd class="profile-vital profile-vital--age">
|
||||
{accountAge} years old
|
||||
{#if profile.age === null}
|
||||
<span class="muted">(on this server)</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
|
||||
{#if profile.location}
|
||||
<dt>Location</dt>
|
||||
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>Last active</dt>
|
||||
<dd class="profile-vital profile-vital--active">
|
||||
Last active:<br />
|
||||
{account.last_status_at ? relativeTime(account.last_status_at) : 'unknown'}
|
||||
</dd>
|
||||
|
||||
<dt>Member since</dt>
|
||||
<dd class="profile-vital profile-vital--joined">
|
||||
Member since: {shortDate(account.created_at)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{#if profile.mood}
|
||||
<p class="profile-mood">
|
||||
Mood: <span class="profile-mood-value">{profile.mood}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="profile-viewlinks">
|
||||
<span class="profile-viewlinks-label">View my:</span>
|
||||
<a href={`#/@${account.acct}/blog`}>Blog</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/friends`}>Friends</a>
|
||||
|
|
||||
<a href={`#/@${account.acct}/pics`}>Pics</a>
|
||||
</p>
|
||||
|
||||
<p class="profile-handle-line">
|
||||
<span class="visually-hidden">Handle:</span>
|
||||
<code class="profile-handle">{handle}</code>
|
||||
{#if account.bot}
|
||||
<span class="profile-badge" data-badge="bot">bot</span>
|
||||
{/if}
|
||||
{#if account.locked}
|
||||
<span class="profile-badge" data-badge="locked">private</span>
|
||||
{/if}
|
||||
{#each account.roles ?? [] as role (role.id)}
|
||||
<span class="profile-badge" data-badge="role">{role.name}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}` : ''}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}`
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Browse — the profile directory.
|
||||
*
|
||||
* `/api/v1/directory` is opt-in per user and disabled entirely on some
|
||||
* servers, so an empty result is a normal outcome and says so rather than
|
||||
* looking broken.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { fetchDirectory, instanceDomain } from '$lib/api/endpoints'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PersonList from '$components/people/PersonList.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
|
||||
let order = $state<'active' | 'new'>('active')
|
||||
let localOnly = $state(true)
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
let feed = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
|
||||
|
||||
$effect(() => {
|
||||
const currentOrder = order
|
||||
const currentLocal = localOnly
|
||||
const host = session.host
|
||||
if (!host) return
|
||||
|
||||
untrack(() => {
|
||||
// The directory paginates by offset, not by cursor, so the loader keeps
|
||||
// its own running offset rather than using the Link header.
|
||||
let offset = 0
|
||||
feed = new Feed<Account>(async (cursor) => {
|
||||
if (!cursor.max_id) offset = 0
|
||||
const limit = cursor.limit ?? 20
|
||||
const items = await fetchDirectory(session.api, {
|
||||
offset,
|
||||
limit,
|
||||
order: currentOrder,
|
||||
local: currentLocal,
|
||||
})
|
||||
offset += items.length
|
||||
return {
|
||||
items,
|
||||
// Synthesise a cursor so `Feed` knows whether more may exist.
|
||||
links: items.length === limit ? { maxId: String(offset) } : {},
|
||||
}
|
||||
})
|
||||
void feed.reload()
|
||||
document.title = 'Browse | plspace'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page browse-page">
|
||||
<h1 class="page-title">Browse</h1>
|
||||
<p class="page-subtitle">People who've listed themselves in {domain}'s directory.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Find people" variant="band">
|
||||
<div class="row browse-controls">
|
||||
<label class="field-row">
|
||||
<span>Sort by</span>
|
||||
<select bind:value={order}>
|
||||
<option value="active">Recently active</option>
|
||||
<option value="new">Newest members</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field browse-scope">
|
||||
<input type="checkbox" bind:checked={localOnly} />
|
||||
<span>Only people on {domain}</span>
|
||||
</label>
|
||||
|
||||
<button type="button" class="button" onclick={() => router.go('#/search')}>
|
||||
Search instead
|
||||
</button>
|
||||
</div>
|
||||
</Module>
|
||||
|
||||
<Module title="Members" variant="band">
|
||||
<PersonList
|
||||
{feed}
|
||||
emptyText="Nobody is listed in this server's directory. Try the search page instead."
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
/** A full-page composer, reachable from the nav and from "Send Message". */
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Handle to address the entry to, from `?to=`. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
let { to }: Props = $props()
|
||||
|
||||
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
|
||||
</script>
|
||||
|
||||
<div class="page compose-page">
|
||||
<h1 class="page-title">{to ? 'Send a Message' : 'Post a Blog Entry'}</h1>
|
||||
{#if to}
|
||||
<p class="page-subtitle">
|
||||
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
|
||||
audience to <em>Mentioned people only</em> to keep it private.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title={to ? 'New message' : 'New entry'}>
|
||||
<Composer
|
||||
initialText={prefill}
|
||||
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
||||
submitLabel={to ? 'Send' : 'Post Entry'}
|
||||
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
||||
/>
|
||||
</Module>
|
||||
|
||||
{#if !session.signedIn}
|
||||
<p class="notice"><a href="#/login">Sign in</a> to post.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Hello, Tom!" — the logged-in dashboard, modelled on the 2007 home page:
|
||||
* a narrow left rail with the control panel, a main column of Friend Status
|
||||
* and Bulletin Space, and a right rail of server odds and ends.
|
||||
*
|
||||
* Logged out, the same page becomes a front door: what this server is, and
|
||||
* what its local timeline looks like right now.
|
||||
*/
|
||||
import type { Account, Notification, Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import {
|
||||
fetchFollowing,
|
||||
fetchNotifications,
|
||||
fetchTimeline,
|
||||
instanceDomain,
|
||||
instanceStats,
|
||||
instanceThumbnail,
|
||||
} from '$lib/api/endpoints'
|
||||
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
let friendStatus = $state<Status[]>([])
|
||||
let bulletins = $state<Status[]>([])
|
||||
let following = $state<Account[]>([])
|
||||
let notifications = $state<Notification[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
/**
|
||||
* Per-module failures. Several large servers (mastodon.social among them)
|
||||
* refuse timeline reads without a token, so "empty" and "not allowed" must
|
||||
* look different or the page reads as broken.
|
||||
*/
|
||||
let friendStatusError = $state<string | null>(null)
|
||||
let bulletinError = $state<string | null>(null)
|
||||
|
||||
const me = $derived(session.me)
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
const thumbnail = $derived(instanceThumbnail(session.instance))
|
||||
const stats = $derived(instanceStats(session.instance))
|
||||
|
||||
/** Notification counts by kind, for the "New Messages!" summary. */
|
||||
const counts = $derived.by(() => {
|
||||
const table: Record<string, number> = {}
|
||||
for (const notification of notifications) {
|
||||
table[notification.type] = (table[notification.type] ?? 0) + 1
|
||||
}
|
||||
return table
|
||||
})
|
||||
|
||||
const summary = $derived([
|
||||
{ label: 'New Messages!', count: counts.mention ?? 0, href: '#/mail/mentions', kind: 'mention' },
|
||||
{ label: 'New Friend Requests!', count: counts.follow_request ?? 0, href: '#/mail/requests', kind: 'follow_request' },
|
||||
{ label: 'New Friends!', count: counts.follow ?? 0, href: '#/mail/follows', kind: 'follow' },
|
||||
{ label: 'New Kudos!', count: counts.favourite ?? 0, href: '#/mail/kudos', kind: 'favourite' },
|
||||
{ label: 'New Reposts!', count: counts.reblog ?? 0, href: '#/mail/reposts', kind: 'reblog' },
|
||||
])
|
||||
|
||||
$effect(() => {
|
||||
const host = session.host
|
||||
const signedIn = session.signedIn
|
||||
if (!host) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
void load(signedIn)
|
||||
})
|
||||
|
||||
async function load(signedIn: boolean): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
friendStatusError = null
|
||||
bulletinError = null
|
||||
|
||||
try {
|
||||
// Everything here is independent; a failure in one shouldn't blank the page.
|
||||
const [statusPage, bulletinPage] = await Promise.all([
|
||||
signedIn
|
||||
? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
|
||||
friendStatusError = messageOf(cause)
|
||||
return { items: [], links: {} }
|
||||
})
|
||||
: Promise.resolve({ items: [], links: {} }),
|
||||
fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
|
||||
bulletinError = messageOf(cause)
|
||||
return { items: [], links: {} }
|
||||
}),
|
||||
])
|
||||
|
||||
friendStatus = statusPage.items
|
||||
bulletins = bulletinPage.items
|
||||
|
||||
if (signedIn && session.me) {
|
||||
void fetchFollowing(session.api, session.me.id, { limit: 12 })
|
||||
.then((page) => (following = page.items))
|
||||
.catch(() => (following = []))
|
||||
void fetchNotifications(session.api, { limit: 40 })
|
||||
.then((page) => (notifications = page.items))
|
||||
.catch(() => (notifications = []))
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function moodFor(status: Status): string {
|
||||
return fallbackMood(status.account.id)
|
||||
}
|
||||
|
||||
function messageOf(cause: unknown): string {
|
||||
return cause instanceof Error ? cause.message : 'Could not load that.'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page home-page">
|
||||
{#if !session.host}
|
||||
<h1 class="page-title">Welcome to plspace</h1>
|
||||
<p class="page-subtitle">It’s always Pleroma™.</p>
|
||||
<div class="layout--single">
|
||||
<Module title="Get started">
|
||||
<p>
|
||||
plspace is a web client for Pleroma. Point it at your server to begin.
|
||||
</p>
|
||||
<p><a class="button button--primary" href="#/login">Choose your server</a></p>
|
||||
</Module>
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="page-title">
|
||||
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if}
|
||||
</h1>
|
||||
{#if me}
|
||||
<p class="page-subtitle">
|
||||
My URL: <a href={profilePath(me)}>#/@{me.acct}</a>
|
||||
· Last login: {shortDate(me.last_status_at ?? new Date())}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="page-subtitle">
|
||||
Browsing as a guest. <a href="#/login">Sign in</a> to post, follow and read your own feed.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--dashboard">
|
||||
<!-- ------------------------------------------------ left: control panel -->
|
||||
<div class="layout-column layout-column--left">
|
||||
{#if me}
|
||||
<Module title="My Profile">
|
||||
<p class="center">
|
||||
<Avatar account={me} size="friend" />
|
||||
</p>
|
||||
<p class="center">
|
||||
<a href={profilePath(me)}>{displayNameOf(me)}</a>
|
||||
</p>
|
||||
<p class="center muted">
|
||||
Profile views: {formatCount(me.statuses_count)} entries
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Updates">
|
||||
<ul class="mail-summary">
|
||||
{#each summary as item (item.kind)}
|
||||
<li class="mail-summary-item" data-kind={item.kind} data-unread={item.count > 0 ? 'true' : 'false'}>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
{#if item.count > 0}<span class="mail-folder-count">({item.count})</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="mail-summary-buttons">
|
||||
<a class="button button--small" href="#/mail">inbox</a>
|
||||
<a class="button button--small" href="#/mail/requests">friend requests</a>
|
||||
<a class="button button--small" href="#/timeline/home">my feed</a>
|
||||
<a class="button button--small" href="#/compose">post bulletin</a>
|
||||
</div>
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title="Control Panel">
|
||||
<ul class="action-list action-list--single">
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">✎</span>
|
||||
<a class="action-list-label" href="#/compose">Compose</a>
|
||||
</li>
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">📥</span>
|
||||
<a class="action-list-label" href="#/mail">Inbox</a>
|
||||
</li>
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">🌐</span>
|
||||
<a class="action-list-label" href="#/timeline/public">The whole network</a>
|
||||
</li>
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">🔍</span>
|
||||
<a class="action-list-label" href="#/browse">Browse people</a>
|
||||
</li>
|
||||
<li class="action-list-item">
|
||||
<span class="action-list-icon" aria-hidden="true">🎨</span>
|
||||
<a class="action-list-label" href="#/settings">Layouts & settings</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Module>
|
||||
</div>
|
||||
|
||||
<!-- --------------------------------------------------- main: the feed -->
|
||||
<div class="layout-column layout-column--main">
|
||||
{#if session.signedIn}
|
||||
<Module title="Post a bulletin">
|
||||
<Composer placeholder="What are you up to?" submitLabel="Post" onposted={() => void load(true)} />
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title="Friend Status">
|
||||
{#snippet action()}
|
||||
<a href="#/timeline/home">view all</a>
|
||||
{/snippet}
|
||||
|
||||
{#if !session.signedIn}
|
||||
<p class="empty-note"><a href="#/login">Sign in</a> to see what your friends are up to.</p>
|
||||
{:else if friendStatusError}
|
||||
<p class="error-note" role="alert">{friendStatusError}</p>
|
||||
{:else if loading && friendStatus.length === 0}
|
||||
<p class="loading-note">Loading…</p>
|
||||
{:else if friendStatus.length === 0}
|
||||
<p class="empty-note">Nothing yet. Add some friends to fill this up.</p>
|
||||
{:else}
|
||||
<ul class="status-line-list">
|
||||
{#each friendStatus as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
<li class="status-line" data-account={entry.account.acct}>
|
||||
<Avatar account={entry.account} />
|
||||
<div class="status-line-body">
|
||||
<a class="status-line-author" href={profilePath(entry.account)}>
|
||||
{displayNameOf(entry.account)}
|
||||
</a>
|
||||
<RichText
|
||||
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
inline
|
||||
/>
|
||||
<a class="status-line-time" href={`#/blog/${entry.id}`}>
|
||||
{relativeTime(entry.created_at)}
|
||||
</a>
|
||||
<span class="status-line-mood">Mood: {moodFor(entry)}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Bulletin Space">
|
||||
{#snippet action()}
|
||||
<a href="#/timeline/local">view all</a>
|
||||
{/snippet}
|
||||
|
||||
{#if bulletinError}
|
||||
<p class="error-note" role="alert">
|
||||
{bulletinError}
|
||||
{#if !session.signedIn}
|
||||
<a href="#/login">Signing in</a> usually fixes this — some servers don't serve
|
||||
timelines to guests.
|
||||
{/if}
|
||||
</p>
|
||||
{:else if bulletins.length === 0}
|
||||
<p class="empty-note">{loading ? 'Loading…' : 'No bulletins right now.'}</p>
|
||||
{:else}
|
||||
<table class="bulletin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Bulletin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each bulletins as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
<tr>
|
||||
<td class="bulletin-from">
|
||||
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</a>
|
||||
</td>
|
||||
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
|
||||
<td class="bulletin-subject">
|
||||
<a href={`#/blog/${entry.id}`}>
|
||||
{toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
{#if session.signedIn}
|
||||
<Module title="Friend Space">
|
||||
{#snippet action()}
|
||||
<a href={me ? `#/@${me.acct}/friends` : '#/browse'}>view all</a>
|
||||
{/snippet}
|
||||
|
||||
{#if following.length === 0}
|
||||
<p class="empty-note">You haven't added any friends yet. <a href="#/browse">Find some.</a></p>
|
||||
{:else}
|
||||
<p class="friend-count">
|
||||
You have <span class="friend-count-value">{formatCount(me?.following_count ?? 0)}</span> friends.
|
||||
</p>
|
||||
<ul class="friend-grid friend-grid--compact">
|
||||
{#each following as friend (friend.id)}
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ------------------------------------------------- right: the server -->
|
||||
<div class="layout-column layout-column--right">
|
||||
<Module title={domain}>
|
||||
{#if thumbnail}
|
||||
<p class="center">
|
||||
<img class="instance-thumbnail" src={thumbnail} alt="" loading="lazy" />
|
||||
</p>
|
||||
{/if}
|
||||
<p class="instance-title"><strong>{session.instance?.title ?? domain}</strong></p>
|
||||
{#if session.instance?.short_description || session.instance?.description}
|
||||
<RichText
|
||||
html={session.instance.short_description || session.instance.description}
|
||||
class="instance-description"
|
||||
/>
|
||||
{/if}
|
||||
{#if stats.length > 0}
|
||||
<table class="data-table instance-stats">
|
||||
<tbody>
|
||||
{#each stats as stat (stat.label)}
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row">{stat.label}</th>
|
||||
<td class="data-table-value">{formatCount(stat.value)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="plspace Tip">
|
||||
<p>
|
||||
Name a profile field <code>Music</code>, <code>Movies</code>, <code>Books</code> or
|
||||
<code>Heroes</code> and it fills in the Interests table on your profile. Name one
|
||||
<code>Mood</code> and it shows beside your photo.
|
||||
</p>
|
||||
<p><a href="#/settings">Customise your layout →</a></p>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Sign in, or browse a server anonymously.
|
||||
*
|
||||
* Two paths on purpose: signing in registers an OAuth app on the target
|
||||
* server and redirects to its consent screen, while "just look around" only
|
||||
* needs the host and works on any server that allows anonymous API reads.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { normalizeHost } from '$lib/api/client'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
|
||||
let host = $state(session.host)
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
/**
|
||||
* Servers to try, all verified to serve `/api/v1/timelines/public` without a
|
||||
* token so that "Just look around" actually shows something.
|
||||
*
|
||||
* That is the bar for being on this list. Plenty of instances set Pleroma's
|
||||
* `restrict_unauthenticated` (or Mastodon's equivalent) and answer 401 to
|
||||
* anonymous timeline reads; suggesting one of those hands a first-time
|
||||
* visitor an empty page. Re-check before adding to this list.
|
||||
*/
|
||||
const SUGGESTIONS = ['pleroma.soykaf.com', 'lain.com', 'spinster.xyz']
|
||||
|
||||
async function signIn(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await session.login(host, '#/')
|
||||
// On success the browser has already navigated away.
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not start sign-in.'
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function browseOnly(): Promise<void> {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
await session.connect(host)
|
||||
router.go('#/')
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not reach that server.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page login-page">
|
||||
<h1 class="page-title">Sign in</h1>
|
||||
<p class="page-subtitle">plspace works with any Pleroma server.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
{#if session.error}
|
||||
<p class="notice">{session.error}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Module title="Your server">
|
||||
<form onsubmit={signIn}>
|
||||
<div class="field">
|
||||
<label class="field-label" for="login-host">Server address</label>
|
||||
<input
|
||||
id="login-host"
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={host}
|
||||
placeholder="pleroma.soykaf.com"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<span class="field-hint">
|
||||
The domain of the server your account lives on. You can paste your full
|
||||
<code>@you@server</code> handle instead.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<button class="button button--primary" type="submit" disabled={busy || !normalizeHost(host)}>
|
||||
{busy ? 'Redirecting…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
disabled={busy || !normalizeHost(host)}
|
||||
onclick={() => void browseOnly()}
|
||||
>
|
||||
Just look around
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Module>
|
||||
|
||||
<Module title="Not sure where to start?">
|
||||
<p>A few servers to try. All of these let you look around without signing in:</p>
|
||||
<ul class="server-suggestions">
|
||||
{#each SUGGESTIONS as suggestion (suggestion)}
|
||||
<li class="server-suggestion">
|
||||
<button type="button" class="link-button" onclick={() => (host = suggestion)}>
|
||||
{suggestion}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="What happens when you sign in">
|
||||
<p>
|
||||
plspace registers itself as an application on your server, then sends you there to approve
|
||||
it. Your password is never typed into plspace — you enter it on your own server, and
|
||||
plspace only ever receives an access token.
|
||||
</p>
|
||||
<p>
|
||||
That token is stored in this browser's local storage and used directly from your browser.
|
||||
There is no plspace backend; nothing you read or post passes through anyone else's server.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Forget this server
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The Mail Center — notifications as an inbox, plus the Friend Request
|
||||
* Manager with its Approve / Deny buttons.
|
||||
*
|
||||
* Folders map onto notification types. The "requests" folder is a different
|
||||
* endpoint (`/api/v1/follow_requests`) because pending requests aren't
|
||||
* notifications once they've been read.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Account, Notification } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import {
|
||||
authorizeFollowRequest,
|
||||
fetchFollowRequests,
|
||||
fetchNotifications,
|
||||
rejectFollowRequest,
|
||||
} from '$lib/api/endpoints'
|
||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
folder?: string
|
||||
}
|
||||
|
||||
let { folder = 'inbox' }: Props = $props()
|
||||
|
||||
interface Folder {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
/** Notification types this folder shows; empty means everything. */
|
||||
types: string[]
|
||||
}
|
||||
|
||||
const FOLDERS: Folder[] = [
|
||||
{ key: 'inbox', label: 'Inbox', icon: '📥', types: [] },
|
||||
{ key: 'mentions', label: 'Messages', icon: '✉', types: ['mention'] },
|
||||
{ key: 'requests', label: 'Friend Requests', icon: '➕', types: [] },
|
||||
{ key: 'follows', label: 'New Friends', icon: '👥', types: ['follow'] },
|
||||
{ key: 'kudos', label: 'Kudos', icon: '★', types: ['favourite'] },
|
||||
{ key: 'reposts', label: 'Reposts', icon: '↻', types: ['reblog'] },
|
||||
]
|
||||
|
||||
const active = $derived(FOLDERS.find((entry) => entry.key === folder) ?? FOLDERS[0])
|
||||
const isRequests = $derived(active.key === 'requests')
|
||||
|
||||
let notifications = $state<Feed<Notification>>(new Feed<Notification>(async () => ({ items: [], links: {} })))
|
||||
let requests = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
|
||||
let busyIds = $state<Record<string, boolean>>({})
|
||||
let actionError = $state<string | null>(null)
|
||||
|
||||
const VERB: Record<string, string> = {
|
||||
mention: 'sent you a message',
|
||||
follow: 'added you as a friend',
|
||||
follow_request: 'wants to be your friend',
|
||||
favourite: 'gave your entry kudos',
|
||||
reblog: 'reposted your entry',
|
||||
poll: 'closed a poll you voted in',
|
||||
status: 'posted a new entry',
|
||||
update: 'edited an entry you interacted with',
|
||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const key = active.key
|
||||
const types = active.types
|
||||
if (!session.signedIn) return
|
||||
|
||||
untrack(() => {
|
||||
if (key === 'requests') {
|
||||
requests = new Feed<Account>((cursor) => fetchFollowRequests(session.api, cursor))
|
||||
void requests.reload()
|
||||
} else {
|
||||
notifications = new Feed<Notification>((cursor) =>
|
||||
fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined),
|
||||
)
|
||||
void notifications.reload()
|
||||
}
|
||||
document.title = `${key === 'requests' ? 'Friend Requests' : 'Mail Center'} | plspace`
|
||||
})
|
||||
})
|
||||
|
||||
async function respond(account: Account, approve: boolean): Promise<void> {
|
||||
if (busyIds[account.id]) return
|
||||
busyIds = { ...busyIds, [account.id]: true }
|
||||
actionError = null
|
||||
try {
|
||||
await (approve ? authorizeFollowRequest : rejectFollowRequest)(session.api, account.id)
|
||||
requests.remove(account.id)
|
||||
} catch (cause) {
|
||||
actionError = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||
} finally {
|
||||
busyIds = { ...busyIds, [account.id]: false }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page mail-page" data-folder={active.key}>
|
||||
<h1 class="page-title">Mail Center</h1>
|
||||
<p class="page-subtitle">
|
||||
{isRequests ? 'Friend Request Manager' : active.label}
|
||||
</p>
|
||||
|
||||
{#if !session.signedIn}
|
||||
<p class="notice">
|
||||
<a href="#/login">Sign in</a> to read your mail.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="layout--split">
|
||||
<div class="layout-column layout-column--left">
|
||||
<Module title="Folders" flush>
|
||||
<ul class="mail-folders">
|
||||
{#each FOLDERS as entry (entry.key)}
|
||||
<li class="mail-folder">
|
||||
<a
|
||||
class="mail-folder-link"
|
||||
href={entry.key === 'inbox' ? '#/mail' : `#/mail/${entry.key}`}
|
||||
aria-current={entry.key === active.key ? 'page' : undefined}
|
||||
>
|
||||
<span class="action-list-icon" aria-hidden="true">{entry.icon}</span>
|
||||
<span class="mail-folder-label">{entry.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
</div>
|
||||
|
||||
<div class="layout-column layout-column--main">
|
||||
{#if actionError}
|
||||
<p class="error-note" role="alert">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
{#if isRequests}
|
||||
<Module title="Approve or Deny Your Friend Requests" variant="band">
|
||||
{#if requests.items.length > 0}
|
||||
<p class="mail-listing-count">
|
||||
Listing 1–{requests.items.length} of {requests.items.length}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<table class="mail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Confirmation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each requests.items as account (account.id)}
|
||||
<tr class="mail-row" data-kind="follow_request" data-account={account.acct}>
|
||||
<td class="mail-table-from">
|
||||
<a href={profilePath(account)}>
|
||||
<img src={account.avatar_static || account.avatar} alt="" loading="lazy" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<a href={profilePath(account)}>{displayNameOf(account)}</a>
|
||||
</strong>
|
||||
wants to be your friend!
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
<div class="mail-table-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
disabled={busyIds[account.id]}
|
||||
onclick={() => void respond(account, true)}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
disabled={busyIds[account.id]}
|
||||
onclick={() => void respond(account, false)}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<a class="button" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>
|
||||
Send Message
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pager feed={requests} label="View More Requests" emptyText="No pending friend requests." />
|
||||
</Module>
|
||||
{:else}
|
||||
<Module title={active.label} variant="band">
|
||||
<table class="mail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">From</th>
|
||||
<th scope="col">Subject</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each notifications.items as item (item.id)}
|
||||
<tr class="mail-row" data-kind={item.type} data-account={item.account.acct}>
|
||||
<td class="mail-table-date">{stampDate(item.created_at)}</td>
|
||||
<td class="mail-table-from">
|
||||
<a href={profilePath(item.account)}>
|
||||
<img src={item.account.avatar_static || item.account.avatar} alt="" loading="lazy" />
|
||||
</a>
|
||||
</td>
|
||||
<td class="mail-table-subject">
|
||||
<strong>
|
||||
<a href={profilePath(item.account)}>{displayNameOf(item.account)}</a>
|
||||
</strong>
|
||||
{VERB[item.type] ?? item.type}
|
||||
{#if item.status}
|
||||
<p class="mail-table-excerpt">
|
||||
<a href={`#/blog/${item.status.id}`}>
|
||||
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
'(no text)'}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pager feed={notifications} label="View More Mail" emptyText="Your inbox is empty." />
|
||||
</Module>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { router } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
</script>
|
||||
|
||||
<div class="page notfound-page">
|
||||
<h1 class="page-title">Page not found</h1>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Sorry!">
|
||||
<p>
|
||||
There's nothing at <code>{router.current.path}</code>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="#/">Go home</a> ·
|
||||
<a href="#/browse">Browse people</a> ·
|
||||
<a href="#/search">Search</a>
|
||||
</p>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The profile page — the whole point of the exercise.
|
||||
*
|
||||
* Left rail: photo, vitals, contacting box, URL, interests, details.
|
||||
* Main column: latest blog entries, blurbs, friend space.
|
||||
*
|
||||
* The account's own published CSS (a profile field named `css`) is applied
|
||||
* while this page is mounted and torn down on unmount, scoped to
|
||||
* `.profile-page` — see lib/stores/theme.svelte.ts.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Account, Relationship, Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { theme, profileCssFromFields } from '$lib/stores/theme.svelte'
|
||||
import {
|
||||
fetchAccountStatuses,
|
||||
fetchFollowers,
|
||||
fetchRelationship,
|
||||
lookupAccount,
|
||||
} from '$lib/api/endpoints'
|
||||
import {
|
||||
buildProfileView,
|
||||
displayNameOf,
|
||||
followerCountHidden,
|
||||
followersHidden,
|
||||
followingCountHidden,
|
||||
formatCount,
|
||||
} from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
|
||||
import ContactBox from '$components/profile/ContactBox.svelte'
|
||||
import InterestsTable from '$components/profile/InterestsTable.svelte'
|
||||
import DetailsTable from '$components/profile/DetailsTable.svelte'
|
||||
import FriendSpace from '$components/profile/FriendSpace.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
|
||||
interface Props {
|
||||
acct: string
|
||||
/** Which sub-page: the profile itself, or a full list. */
|
||||
view?: 'profile' | 'blog' | 'friends' | 'pics'
|
||||
}
|
||||
|
||||
let { acct, view = 'profile' }: Props = $props()
|
||||
|
||||
let account = $state<Account | null>(null)
|
||||
let relationship = $state<Relationship | null>(null)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let friends = $state<Account[]>([])
|
||||
let friendsLoading = $state(false)
|
||||
|
||||
// Recreated whenever the account changes, so the feed never shows one
|
||||
// person's entries under another's name.
|
||||
let entries = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
|
||||
|
||||
const profile = $derived(account ? buildProfileView(account) : null)
|
||||
const firstName = $derived(account ? displayNameOf(account).split(/\s+/)[0] : '')
|
||||
const listHidden = $derived(account ? followersHidden(account) : false)
|
||||
const countHidden = $derived(account ? followerCountHidden(account) : false)
|
||||
const followsCountHidden = $derived(account ? followingCountHidden(account) : false)
|
||||
/** Route prefix for this profile; snippets can't see the null-narrowing. */
|
||||
const base = $derived(account ? `#/@${account.acct}` : '#/')
|
||||
|
||||
/** How many friend tiles the compact grid shows before "[view all]". */
|
||||
const FRIEND_PREVIEW = 12
|
||||
|
||||
$effect(() => {
|
||||
// Track the inputs that change what's on screen; the load itself is
|
||||
// untracked so reading state inside it can't retrigger this effect.
|
||||
const handle = acct
|
||||
const currentView = view
|
||||
const host = session.host
|
||||
if (!host) return
|
||||
|
||||
untrack(() => void load(handle, currentView))
|
||||
})
|
||||
|
||||
async function load(handle: string, currentView: Props['view']): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
account = null
|
||||
relationship = null
|
||||
friends = []
|
||||
|
||||
try {
|
||||
const found = await lookupAccount(session.api, handle)
|
||||
// A newer navigation won the race.
|
||||
if (acct !== handle) return
|
||||
|
||||
account = found
|
||||
document.title = `${displayNameOf(found)} | plspace`
|
||||
|
||||
theme.applyProfileCss(profileCssFromFields(found.fields))
|
||||
|
||||
entries = new Feed<Status>(
|
||||
(cursor) =>
|
||||
fetchAccountStatuses(session.api, found.id, cursor, {
|
||||
// The profile page mirrors "Latest Blog Entries": top-level posts.
|
||||
exclude_replies: currentView !== 'blog',
|
||||
}),
|
||||
currentView === 'blog' ? 20 : 10,
|
||||
)
|
||||
void entries.reload()
|
||||
|
||||
void loadFriends(found, currentView)
|
||||
void loadRelationship(found)
|
||||
} catch (cause) {
|
||||
if (acct !== handle) return
|
||||
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
|
||||
} finally {
|
||||
if (acct === handle) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFriends(target: Account, currentView: Props['view']): Promise<void> {
|
||||
if (followersHidden(target)) return
|
||||
friendsLoading = true
|
||||
try {
|
||||
const page = await fetchFollowers(session.api, target.id, {
|
||||
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
|
||||
})
|
||||
if (account?.id === target.id) friends = page.items
|
||||
} catch {
|
||||
// Hidden or unavailable follower lists are normal; the count still shows.
|
||||
if (account?.id === target.id) friends = []
|
||||
} finally {
|
||||
friendsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationship(target: Account): Promise<void> {
|
||||
try {
|
||||
const found = await fetchRelationship(session.api, target.id)
|
||||
if (account?.id === target.id) relationship = found
|
||||
} catch {
|
||||
/* relationships need auth; absence is fine */
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => () => theme.clearProfileCss())
|
||||
|
||||
/** One line of an entry, for the headline list on the profile page. */
|
||||
function teaserFor(entry: Status): string {
|
||||
const text = toPlainText(entry.spoiler_text || entry.content)
|
||||
if (text) return text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text
|
||||
if (entry.media_attachments.length > 0) {
|
||||
const count = entry.media_attachments.length
|
||||
return `(${count} photo${count === 1 ? '' : 's'})`
|
||||
}
|
||||
return '(no text)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page profile-page" data-account={account?.acct ?? acct} data-view={view}>
|
||||
{#if loading}
|
||||
<p class="loading-note">Loading profile…</p>
|
||||
{:else if error}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Profile not found.</strong>
|
||||
{error}
|
||||
</p>
|
||||
{:else if account && profile}
|
||||
<h1 class="page-title profile-name">{displayNameOf(account)}</h1>
|
||||
|
||||
{#if account.moved}
|
||||
<p class="profile-moved">
|
||||
This account has moved to
|
||||
<a href={`#/@${account.moved.acct}`}>@{account.moved.acct}</a>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--split">
|
||||
<div class="layout-column layout-column--left">
|
||||
<ProfileIdentity {profile} />
|
||||
|
||||
<ContactBox
|
||||
{account}
|
||||
{relationship}
|
||||
onrelationship={(next) => (relationship = next)}
|
||||
/>
|
||||
|
||||
<Module title="plspace URL">
|
||||
<p class="profile-url">
|
||||
<a href={`#/@${account.acct}`}>{location.origin}{location.pathname}#/@{account.acct}</a>
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
|
||||
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
|
||||
|
||||
<Module title={`${firstName}'s Stats`} flush>
|
||||
<table class="data-table stats-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row">Blog entries</th>
|
||||
<td class="data-table-value">{formatCount(account.statuses_count)}</td>
|
||||
</tr>
|
||||
<!-- Pleroma zeroes these counts when the user hides them, so the
|
||||
flag has to be checked before the number is believed. -->
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row">Friends</th>
|
||||
<td class="data-table-value" data-private={countHidden ? 'true' : 'false'}>
|
||||
{#if countHidden}
|
||||
<span class="muted">private</span>
|
||||
{:else}
|
||||
<a href={`#/@${account.acct}/friends`}>{formatCount(account.followers_count)}</a>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row">Friend of</th>
|
||||
<td class="data-table-value" data-private={followsCountHidden ? 'true' : 'false'}>
|
||||
{#if followsCountHidden}
|
||||
<span class="muted">private</span>
|
||||
{:else}
|
||||
{formatCount(account.following_count)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row">Joined</th>
|
||||
<td class="data-table-value">{new Date(account.created_at).getFullYear()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Module>
|
||||
</div>
|
||||
|
||||
<div class="layout-column layout-column--main">
|
||||
{#if view === 'friends'}
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
{friends}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}`}
|
||||
loading={friendsLoading}
|
||||
hidden={listHidden}
|
||||
{countHidden}
|
||||
/>
|
||||
{:else if view === 'blog'}
|
||||
<Module title={`${firstName}'s Blog`} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
|
||||
<ul class="blog-list">
|
||||
{#each entries.items as status (status.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
{status}
|
||||
compact
|
||||
longFormDate
|
||||
onupdate={(next) => entries.update(status.id, () => next)}
|
||||
ondelete={(id) => entries.remove(id)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<Pager
|
||||
feed={entries}
|
||||
emptyText="There are no Blog Entries yet."
|
||||
endText="That’s the whole blog."
|
||||
/>
|
||||
</Module>
|
||||
{:else if view === 'pics'}
|
||||
<Module title={`${firstName}'s Pics`} variant="band">
|
||||
<p class="empty-note">
|
||||
Photos appear here as they're attached to blog entries.
|
||||
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context.
|
||||
</p>
|
||||
</Module>
|
||||
{:else}
|
||||
<!--
|
||||
The profile page lists entry headlines with "(view more)", exactly
|
||||
as the 2005 page did. Full entries live on /blog — otherwise ten
|
||||
posts of media push the Blurbs and Friend Space off the bottom,
|
||||
which is the wrong shape for a profile.
|
||||
-->
|
||||
<Module title={`${firstName}'s Latest Blog Entries`} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={`${base}/blog`}>[View Blog]</a>
|
||||
{/snippet}
|
||||
|
||||
{#if entries.loading && entries.items.length === 0}
|
||||
<p class="loading-note">Loading…</p>
|
||||
{:else if entries.items.length === 0}
|
||||
<p class="empty-note">There are no Blog Entries yet.</p>
|
||||
{:else}
|
||||
<ul class="entry-teaser-list">
|
||||
{#each entries.items.slice(0, 6) as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
<li class="entry-teaser" data-status-id={entry.id}>
|
||||
<span class="entry-teaser-text">{teaserFor(entry)}</span>
|
||||
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="entry-teaser-all">
|
||||
<a href={`${base}/blog`}>[View All Blog Entries]</a>
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`${firstName}'s Blurbs`} variant="band">
|
||||
<h3 class="section-heading">About me:</h3>
|
||||
{#if profile.about}
|
||||
<div class="rich-text blurb-body">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html profile.about}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-note">{firstName} hasn't written an About me yet.</p>
|
||||
{/if}
|
||||
|
||||
<h3 class="section-heading">Who I'd like to meet:</h3>
|
||||
{#if profile.wantsToMeet}
|
||||
<div class="rich-text blurb-body">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
|
||||
{@html profile.wantsToMeet}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="blurb-body">
|
||||
People who educate, inspire or entertain me. And you, apparently.
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
friends={friends.slice(0, FRIEND_PREVIEW)}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}/friends`}
|
||||
loading={friendsLoading}
|
||||
hidden={listHidden}
|
||||
{countHidden}
|
||||
compact
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Search across people, entries and hashtags.
|
||||
*
|
||||
* `resolve` is only sent when signed in — it makes the server fetch unknown
|
||||
* remote accounts, which anonymous callers aren't allowed to trigger.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { SearchResults } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { search } from '$lib/api/endpoints'
|
||||
import { router, routeTo } from '$lib/router.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PersonRow from '$components/people/PersonRow.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
q?: string
|
||||
}
|
||||
|
||||
let { q = '' }: Props = $props()
|
||||
|
||||
// Seeded from the route, then kept in sync by the effect below.
|
||||
let input = $state(untrack(() => q))
|
||||
let results = $state<SearchResults | null>(null)
|
||||
let loading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
const query = q
|
||||
input = query
|
||||
if (!query || !session.host) {
|
||||
results = null
|
||||
return
|
||||
}
|
||||
untrack(() => void run(query))
|
||||
})
|
||||
|
||||
async function run(query: string): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const found = await search(session.api, query, { limit: 20 })
|
||||
if (q === query) results = found
|
||||
} catch (cause) {
|
||||
if (q === query) error = cause instanceof Error ? cause.message : 'Search failed.'
|
||||
} finally {
|
||||
if (q === query) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function submit(event: SubmitEvent): void {
|
||||
event.preventDefault()
|
||||
const trimmed = input.trim()
|
||||
if (trimmed) router.go(routeTo('/search', { q: trimmed }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page search-page">
|
||||
<h1 class="page-title">Search</h1>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Search plspace">
|
||||
<form class="search-form" onsubmit={submit}>
|
||||
<div class="field-row">
|
||||
<label class="visually-hidden" for="search-input">Search terms</label>
|
||||
<input
|
||||
id="search-input"
|
||||
class="field-input search-input"
|
||||
type="search"
|
||||
bind:value={input}
|
||||
placeholder="A name, @user@server, #hashtag, or a link to a post"
|
||||
/>
|
||||
<button class="button button--primary" type="submit">Search</button>
|
||||
</div>
|
||||
<span class="field-hint">
|
||||
Paste a full <code>@user@server</code> handle or a post URL to pull it in from another server.
|
||||
</span>
|
||||
</form>
|
||||
</Module>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="loading-note">Searching…</p>
|
||||
{:else if results}
|
||||
<Module title={`People (${formatCount(results.accounts.length)})`} variant="band">
|
||||
{#if results.accounts.length === 0}
|
||||
<p class="empty-note">No people matched.</p>
|
||||
{:else}
|
||||
<ul class="person-list">
|
||||
{#each results.accounts as account (account.id)}
|
||||
<PersonRow {account} />
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`Hashtags (${formatCount(results.hashtags.length)})`} variant="band">
|
||||
{#if results.hashtags.length === 0}
|
||||
<p class="empty-note">No hashtags matched.</p>
|
||||
{:else}
|
||||
<ul class="tag-list">
|
||||
{#each results.hashtags as tag (tag.name)}
|
||||
<li class="tag-list-item">
|
||||
<a href={`#/tag/${encodeURIComponent(tag.name)}`}>#{tag.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title={`Blog Entries (${formatCount(results.statuses.length)})`} variant="band">
|
||||
{#if results.statuses.length === 0}
|
||||
<p class="empty-note">No entries matched. Many servers only search entries you wrote.</p>
|
||||
{:else}
|
||||
<ul class="blog-list">
|
||||
{#each results.statuses as status (status.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
{status}
|
||||
onupdate={(next) => {
|
||||
if (results) {
|
||||
results = {
|
||||
...results,
|
||||
statuses: results.statuses.map((item) => (item.id === next.id ? next : item)),
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
{:else}
|
||||
<p class="empty-note">Enter something to search for.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Settings — mostly the layout editor, which is the feature this whole app
|
||||
* exists to have.
|
||||
*
|
||||
* Two things are editable: the CSS applied to *your* view of plspace (stored
|
||||
* locally), and whether the CSS other people publish on their profiles is
|
||||
* honoured when you visit them.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { theme, CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte'
|
||||
import { PRESETS, EXAMPLE_CSS } from '$lib/themes'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
|
||||
let draft = $state(theme.viewerCss)
|
||||
let saved = $state(false)
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
/** Class hooks worth documenting, grouped the way pages are built. */
|
||||
const HOOKS: Array<{ group: string; entries: Array<[string, string]> }> = [
|
||||
{
|
||||
group: 'Page skeletons',
|
||||
entries: [
|
||||
['.page', 'Every page’s outer container'],
|
||||
['.profile-page', 'The profile page — also the scope for published profile CSS'],
|
||||
['.layout--split', 'Two-column pages (left rail + main)'],
|
||||
['.layout--dashboard', 'The three-column home page'],
|
||||
['.layout-column--left / --main / --right', 'The individual columns'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Boxes',
|
||||
entries: [
|
||||
['.module', 'A bordered box'],
|
||||
['.module-header', 'Its caption bar'],
|
||||
['.module-body', 'Its contents'],
|
||||
['.module--band', 'The peach-bar variant used in the main column'],
|
||||
['.section-heading', '“About me:” style orange headings'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Profile',
|
||||
entries: [
|
||||
['.profile-photo', 'The big photo'],
|
||||
['.profile-headline', 'The quoted line beside it'],
|
||||
['.profile-vitals', 'Gender / age / location / last active'],
|
||||
['.interests-table, .interests-label, .interests-value', 'The Interests table'],
|
||||
['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'],
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Blog entries',
|
||||
entries: [
|
||||
['.blog-entry', 'One entry'],
|
||||
['.blog-entry[data-mine="true"]', 'Entries you wrote'],
|
||||
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
|
||||
['.blog-entry[data-boosted="true"]', 'Reposts'],
|
||||
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons you’ve activated'],
|
||||
['.comment[data-depth="2"]', 'Comments by nesting depth'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function save(): void {
|
||||
theme.setViewerCss(draft)
|
||||
saved = true
|
||||
setTimeout(() => (saved = false), 2000)
|
||||
}
|
||||
|
||||
function applyPreset(css: string): void {
|
||||
draft = css
|
||||
theme.setViewerCss(css)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
draft = ''
|
||||
theme.setViewerCss('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page settings-page">
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<p class="page-subtitle">Everything here is stored in this browser only.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Your account">
|
||||
{#if session.signedIn && session.me}
|
||||
<p>
|
||||
Signed in as
|
||||
<a href={profilePath(session.me)}>{displayNameOf(session.me)}</a>
|
||||
on <strong>{domain}</strong>.
|
||||
</p>
|
||||
<p class="field-row">
|
||||
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Forget this server
|
||||
</button>
|
||||
</p>
|
||||
{:else if session.host}
|
||||
<p>Browsing <strong>{domain}</strong> as a guest.</p>
|
||||
<p class="field-row">
|
||||
<a class="button button--primary" href="#/login">Sign in</a>
|
||||
<button type="button" class="button" onclick={() => session.disconnect()}>
|
||||
Choose a different server
|
||||
</button>
|
||||
</p>
|
||||
{:else}
|
||||
<p>Not connected to a server. <a href="#/login">Choose one</a>.</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Pick a layout" variant="band">
|
||||
<ul class="preset-list">
|
||||
{#each PRESETS as preset (preset.id)}
|
||||
<li class="preset">
|
||||
<button type="button" class="button preset-button" onclick={() => applyPreset(preset.css)}>
|
||||
{preset.name}
|
||||
</button>
|
||||
<span class="preset-description muted">{preset.description}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="Your CSS" variant="band">
|
||||
<p>
|
||||
This is applied to every page you view in plspace. Overriding the custom properties in
|
||||
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go
|
||||
further.
|
||||
</p>
|
||||
|
||||
<label class="visually-hidden" for="viewer-css">Your CSS</label>
|
||||
<textarea
|
||||
id="viewer-css"
|
||||
class="css-editor"
|
||||
bind:value={draft}
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder={EXAMPLE_CSS}
|
||||
></textarea>
|
||||
|
||||
<div class="field-row">
|
||||
<button type="button" class="button button--primary" onclick={save}>Save CSS</button>
|
||||
<button type="button" class="button" onclick={reset}>Reset to default</button>
|
||||
<button type="button" class="button" onclick={() => (draft = EXAMPLE_CSS)}>
|
||||
Load the example
|
||||
</button>
|
||||
{#if saved}<span class="muted">Saved.</span>{/if}
|
||||
</div>
|
||||
|
||||
<p class="field-hint">
|
||||
<code>@import</code> and non-HTTPS <code>url()</code> values are stripped before your CSS is
|
||||
applied.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Other people's layouts" variant="band">
|
||||
<label class="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={theme.allowProfileCss}
|
||||
onchange={(event) => theme.setAllowProfileCss(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
Show profile layouts published by the people I visit
|
||||
</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
A profile can publish a stylesheet by putting CSS in a profile field named
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
|
||||
Their rules are rewritten to apply only inside <code>{PROFILE_SCOPE}</code>, so a profile
|
||||
can restyle its own page but not the rest of plspace.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Publish your own layout" variant="band">
|
||||
<p>
|
||||
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and
|
||||
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since
|
||||
it's an ordinary profile field, it survives elsewhere too — other clients just show it
|
||||
as text.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<a
|
||||
class="button"
|
||||
href={`https://${session.host}/settings/profile`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit your profile on {domain}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Class reference" variant="band">
|
||||
{#each HOOKS as section (section.group)}
|
||||
<h3 class="section-heading">{section.group}</h3>
|
||||
<table class="data-table hooks-table">
|
||||
<tbody>
|
||||
{#each section.entries as [selector, description] (selector)}
|
||||
<tr>
|
||||
<th class="data-table-label" scope="row"><code>{selector}</code></th>
|
||||
<td class="data-table-value">{description}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/each}
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A single blog entry with its comment thread.
|
||||
*
|
||||
* `/context` returns ancestors and descendants flat; the descendants are
|
||||
* re-nested here so replies-to-replies indent, capped at three levels because
|
||||
* a 2005 layout has nowhere to put the fourth.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { fetchContext, fetchStatus } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import { stampDate, isoDate } from '$lib/util/time'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
let { id }: Props = $props()
|
||||
|
||||
let status = $state<Status | null>(null)
|
||||
let ancestors = $state<Status[]>([])
|
||||
let descendants = $state<Status[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
interface ThreadedReply {
|
||||
status: Status
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** Flatten the descendant tree depth-first so it renders as one list. */
|
||||
const thread = $derived.by<ThreadedReply[]>(() => {
|
||||
if (!status) return []
|
||||
|
||||
const byParent = new Map<string, Status[]>()
|
||||
for (const reply of descendants) {
|
||||
const parent = reply.in_reply_to_id ?? status.id
|
||||
const bucket = byParent.get(parent) ?? []
|
||||
bucket.push(reply)
|
||||
byParent.set(parent, bucket)
|
||||
}
|
||||
|
||||
const out: ThreadedReply[] = []
|
||||
const walk = (parentId: string, depth: number): void => {
|
||||
for (const reply of byParent.get(parentId) ?? []) {
|
||||
out.push({ status: reply, depth: Math.min(depth, 3) })
|
||||
walk(reply.id, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(status.id, 0)
|
||||
|
||||
// Anything whose parent fell outside the context still deserves showing.
|
||||
const seen = new Set(out.map((entry) => entry.status.id))
|
||||
for (const reply of descendants) {
|
||||
if (!seen.has(reply.id)) out.push({ status: reply, depth: 0 })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** Prefill a comment with the mentions a reply conventionally carries. */
|
||||
const replyPrefill = $derived.by(() => {
|
||||
if (!status) return ''
|
||||
const handles = new Set<string>()
|
||||
if (status.account.id !== session.me?.id) handles.add(status.account.acct)
|
||||
for (const mention of status.mentions) {
|
||||
if (mention.id !== session.me?.id) handles.add(mention.acct)
|
||||
}
|
||||
return handles.size > 0 ? `${[...handles].map((acct) => `@${acct}`).join(' ')} ` : ''
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const currentId = id
|
||||
const host = session.host
|
||||
if (!host) return
|
||||
untrack(() => void load(currentId))
|
||||
})
|
||||
|
||||
async function load(currentId: string): Promise<void> {
|
||||
loading = true
|
||||
error = null
|
||||
try {
|
||||
const [entry, context] = await Promise.all([
|
||||
fetchStatus(session.api, currentId),
|
||||
fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })),
|
||||
])
|
||||
if (id !== currentId) return
|
||||
|
||||
status = entry
|
||||
ancestors = context.ancestors
|
||||
descendants = context.descendants
|
||||
document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace`
|
||||
} catch (cause) {
|
||||
if (id !== currentId) return
|
||||
error = cause instanceof Error ? cause.message : 'Could not load that entry.'
|
||||
} finally {
|
||||
if (id === currentId) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPosted(created: Status): void {
|
||||
descendants = [...descendants, created]
|
||||
if (status) status = { ...status, replies_count: status.replies_count + 1 }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page entry-page">
|
||||
{#if loading}
|
||||
<p class="loading-note">Loading entry…</p>
|
||||
{:else if error}
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Entry not available.</strong>
|
||||
{error}
|
||||
</p>
|
||||
{:else if status}
|
||||
<h1 class="page-title">
|
||||
<a href={profilePath(status.account)}>{displayNameOf(status.account)}</a>'s Blog
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
|
||||
</p>
|
||||
|
||||
<div class="layout--single">
|
||||
{#if ancestors.length > 0}
|
||||
<Module title="Earlier in this thread" variant="band">
|
||||
<ul class="blog-list">
|
||||
{#each ancestors as ancestor (ancestor.id)}
|
||||
<li class="blog-list-item">
|
||||
<BlogEntry
|
||||
status={ancestor}
|
||||
compact
|
||||
onupdate={(next) =>
|
||||
(ancestors = ancestors.map((item) => (item.id === next.id ? next : item)))}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title="Blog Entry" variant="band">
|
||||
<BlogEntry
|
||||
{status}
|
||||
longFormDate
|
||||
onupdate={(next) => (status = next)}
|
||||
ondelete={() => history.back()}
|
||||
/>
|
||||
</Module>
|
||||
|
||||
<Module title={`Comments (${status.replies_count})`} variant="band">
|
||||
{#if session.signedIn}
|
||||
<Composer
|
||||
inReplyTo={status}
|
||||
initialText={replyPrefill}
|
||||
placeholder="Leave a comment…"
|
||||
submitLabel="Post Comment"
|
||||
onposted={onPosted}
|
||||
/>
|
||||
{:else}
|
||||
<p class="empty-note"><a href="#/login">Sign in</a> to leave a comment.</p>
|
||||
{/if}
|
||||
|
||||
{#if thread.length === 0}
|
||||
<p class="empty-note">No comments yet. Be the first.</p>
|
||||
{:else}
|
||||
<ul class="comment-list">
|
||||
{#each thread as reply (reply.status.id)}
|
||||
<li class="comment" data-depth={reply.depth} data-account={reply.status.account.acct}>
|
||||
<div class="comment-avatar">
|
||||
<Avatar account={reply.status.account} />
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<a class="comment-author" href={profilePath(reply.status.account)}>
|
||||
{displayNameOf(reply.status.account)}
|
||||
</a>
|
||||
<a class="comment-date" href={`#/blog/${reply.status.id}`}>
|
||||
<time datetime={isoDate(reply.status.created_at)}>
|
||||
{stampDate(reply.status.created_at)}
|
||||
</time>
|
||||
</a>
|
||||
{#if reply.status.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
||||
<RichText
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
tags={reply.status.tags}
|
||||
/>
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
tags={reply.status.tags}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Module>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A timeline, presented as a blog: "My Blog" (home), "This Server" (local),
|
||||
* "The Whole Network" (federated), or a hashtag.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { Status } from '$lib/api/types'
|
||||
import type { TimelineKind } from '$lib/api/endpoints'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { fetchTimeline, instanceDomain } from '$lib/api/endpoints'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import TabBar from '$components/common/TabBar.svelte'
|
||||
import BlogList from '$components/blog/BlogList.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
interface Props {
|
||||
kind: TimelineKind
|
||||
tag?: string
|
||||
}
|
||||
|
||||
let { kind, tag }: Props = $props()
|
||||
|
||||
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||
|
||||
const title = $derived(
|
||||
kind === 'home'
|
||||
? 'My Blog'
|
||||
: kind === 'local'
|
||||
? `Blogs on ${domain}`
|
||||
: kind === 'tag'
|
||||
? `#${tag}`
|
||||
: 'The Whole Network',
|
||||
)
|
||||
|
||||
const tabs = $derived([
|
||||
...(session.signedIn ? [{ label: 'My Blog', href: '#/timeline/home' }] : []),
|
||||
{ label: 'This Server', href: '#/timeline/local' },
|
||||
{ label: 'Whole Network', href: '#/timeline/public' },
|
||||
])
|
||||
|
||||
const currentTab = $derived(kind === 'tag' ? '' : `#/timeline/${kind}`)
|
||||
|
||||
$effect(() => {
|
||||
const currentKind = kind
|
||||
const currentTag = tag
|
||||
const host = session.host
|
||||
const authed = session.signedIn
|
||||
if (!host) return
|
||||
|
||||
untrack(() => {
|
||||
// Home needs a token; fall back rather than showing a 401.
|
||||
const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind
|
||||
feed = new Feed<Status>((cursor) =>
|
||||
fetchTimeline(session.api, resolved, cursor, { tag: currentTag }),
|
||||
)
|
||||
void feed.reload()
|
||||
document.title = `${title} | plspace`
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||
<h1 class="page-title">{title}</h1>
|
||||
|
||||
{#if kind !== 'tag'}
|
||||
<TabBar {tabs} current={currentTab} label="Timelines" />
|
||||
{/if}
|
||||
|
||||
{#if kind === 'home' && !session.signedIn}
|
||||
<p class="notice">
|
||||
You're browsing as a guest, so this is <strong>{domain}</strong>'s local timeline.
|
||||
<a href="#/login">Sign in</a> to read your own.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="layout--single">
|
||||
{#if kind === 'home' && session.signedIn}
|
||||
<Module title="Post a new entry">
|
||||
<Composer onposted={(status) => feed.prepend(status)} />
|
||||
</Module>
|
||||
{/if}
|
||||
|
||||
<Module title={title} variant="band">
|
||||
<BlogList
|
||||
{feed}
|
||||
emptyText={kind === 'tag'
|
||||
? `Nobody has posted with #${tag} that this server knows about.`
|
||||
: 'There are no Blog Entries yet.'}
|
||||
longFormDate
|
||||
/>
|
||||
</Module>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user