initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
/**
* Media on a status.
*
* Sensitive media is blurred rather than hidden so the layout doesn't jump
* when it's revealed, and the reveal is per-attachment because a single post
* can mix flagged and unflagged media.
*/
import type { MediaAttachment } from '$lib/api/types'
interface Props {
attachments: MediaAttachment[]
sensitive?: boolean
}
let { attachments, sensitive = false }: Props = $props()
let revealed = $state<Record<string, boolean>>({})
function isRevealed(id: string): boolean {
return !sensitive || revealed[id] === true
}
function toggle(id: string): void {
revealed = { ...revealed, [id]: !revealed[id] }
}
</script>
{#if attachments.length > 0}
<ul class="attachment-list">
{#each attachments as media (media.id)}
<li
class="attachment"
data-type={media.type}
data-sensitive={sensitive ? 'true' : 'false'}
data-revealed={isRevealed(media.id) ? 'true' : 'false'}
>
<figure class="attachment-figure">
{#if media.type === 'video' || media.type === 'gifv'}
<video
class="attachment-media"
src={media.url}
poster={media.preview_url ?? undefined}
controls
playsinline
loop={media.type === 'gifv'}
preload="none"
>
<!-- Remote media carries no caption track; declared so the
requirement is explicit rather than merely unmet. -->
<track kind="captions" />
</video>
{:else if media.type === 'audio'}
<audio class="attachment-media attachment-media--audio" src={media.url} controls preload="none"
></audio>
{:else if media.type === 'image'}
<a href={media.url} target="_blank" rel="noopener noreferrer">
<img
class="attachment-media"
src={media.preview_url ?? media.url}
alt={media.description ?? ''}
loading="lazy"
decoding="async"
/>
</a>
{:else}
<a class="attachment-media attachment-media--file" href={media.url} target="_blank" rel="noopener noreferrer">
Attachment
</a>
{/if}
{#if media.description}
<figcaption class="attachment-caption">{media.description}</figcaption>
{/if}
</figure>
{#if sensitive}
<button type="button" class="button button--small attachment-reveal" onclick={() => toggle(media.id)}>
{isRevealed(media.id) ? 'Hide' : 'Show'} sensitive media
</button>
{/if}
</li>
{/each}
</ul>
{/if}
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
/**
* One status, dressed as a MySpace blog entry.
*
* Vocabulary mapping, applied consistently across the app:
* favourite -> Kudos (MySpace blogs really did call them that)
* reblog -> Repost
* reply -> Comment
*
* Toggling kudos/repost updates optimistically and rolls back on failure —
* a federated round trip is slow enough that waiting feels broken.
*/
import type { Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints'
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html'
import { isoDate, longDate, stampDate } from '$lib/util/time'
import Avatar from '../common/Avatar.svelte'
import RichText from '../common/RichText.svelte'
import Attachments from './Attachments.svelte'
import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte'
interface Props {
status: Status
/** Called with the updated status after an action succeeds. */
onupdate?: (status: Status) => void
/** Called after the viewer deletes their own entry. */
ondelete?: (id: string) => void
/** Drop the avatar gutter — used inside threads. */
compact?: boolean
/** Show the full date rather than a short stamp. */
longFormDate?: boolean
}
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
/** The status actually being displayed; a boost renders its target. */
const entry = $derived(status.reblog ?? status)
const booster = $derived(status.reblog ? status.account : null)
const author = $derived(entry.account)
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
const handle = $derived(fullHandle(author, session.host))
const permalink = $derived(`#/blog/${entry.id}`)
const isMine = $derived(session.me?.id === entry.account.id)
let busy = $state(false)
let actionError = $state<string | null>(null)
const VISIBILITY_ICON: Record<string, string> = {
public: '🌐',
unlisted: '🔓',
private: '🔒',
direct: '✉',
}
const VISIBILITY_LABEL: Record<string, string> = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Friends only',
direct: 'Private message',
}
async function toggleKudos(): Promise<void> {
if (!session.signedIn || busy) return
const next = !entry.favourited
busy = true
actionError = null
// Optimistic: reflect the new state before the request settles.
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
try {
const updated = await favouriteStatus(session.api, entry.id, next)
onupdate?.(rewrap(status, updated))
} catch (cause) {
onupdate?.(status)
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
} finally {
busy = false
}
}
async function toggleRepost(): Promise<void> {
if (!session.signedIn || busy) return
const next = !entry.reblogged
busy = true
actionError = null
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
try {
const updated = await reblogStatus(session.api, entry.id, next)
// Reblogging returns the *wrapper* status; unwrap to the original.
onupdate?.(rewrap(status, updated.reblog ?? updated))
} catch (cause) {
onupdate?.(status)
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
} finally {
busy = false
}
}
async function remove(): Promise<void> {
if (!isMine || busy) return
if (!confirm('Delete this entry? This cannot be undone.')) return
busy = true
try {
await deleteStatus(session.api, entry.id)
ondelete?.(status.id)
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
} finally {
busy = false
}
}
/** Patch the inner status, preserving the boost wrapper if there is one. */
function applyLocal(wrapper: Status, patch: Partial<Status>): Status {
if (wrapper.reblog) return { ...wrapper, reblog: { ...wrapper.reblog, ...patch } }
return { ...wrapper, ...patch }
}
function rewrap(wrapper: Status, fresh: Status): Status {
return wrapper.reblog ? { ...wrapper, reblog: fresh } : fresh
}
</script>
<article
class="blog-entry"
data-status-id={entry.id}
data-account={author.acct}
data-visibility={entry.visibility}
data-boosted={booster ? 'true' : 'false'}
data-reply={entry.in_reply_to_id ? 'true' : 'false'}
data-sensitive={entry.sensitive ? 'true' : 'false'}
data-compact={compact ? 'true' : 'false'}
data-mine={isMine ? 'true' : 'false'}
>
{#if booster}
<p class="blog-entry-attribution">
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
</p>
{/if}
<header class="blog-entry-header">
{#if !compact}
<div class="blog-entry-avatar">
<Avatar account={author} />
</div>
{/if}
<div class="blog-entry-byline">
<a class="blog-entry-author" href={profilePath(author)}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
{@html authorName}
</a>
<span class="blog-entry-handle">{handle}</span>
<span class="blog-entry-date">
<a href={permalink}>
<time datetime={isoDate(entry.created_at)}>
{longFormDate ? longDate(entry.created_at) : stampDate(entry.created_at)}
</time>
</a>
<span class="blog-entry-visibility" title={VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}>
{VISIBILITY_ICON[entry.visibility] ?? ''}
<span class="visually-hidden">{VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}</span>
</span>
{#if entry.edited_at}
<span class="blog-entry-edited">(edited)</span>
{/if}
</span>
</div>
</header>
<div class="blog-entry-body">
{#if entry.spoiler_text}
<details class="content-warning">
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
<RichText
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if}
</details>
{:else}
<RichText
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if}
{/if}
{#if entry.poll}
<PollView poll={entry.poll} />
{/if}
{#if entry.card && entry.media_attachments.length === 0}
<PreviewCardView card={entry.card} />
{/if}
{#if actionError}
<p class="error-note" role="alert">{actionError}</p>
{/if}
<footer class="blog-entry-actions">
<a class="blog-action blog-action--comment" href={permalink}>
Comment <span class="blog-action-count">({formatCount(entry.replies_count)})</span>
</a>
<button
type="button"
class="link-button blog-action blog-action--kudos"
aria-pressed={entry.favourited ? 'true' : 'false'}
disabled={!session.signedIn || busy}
title={session.signedIn ? 'Give kudos' : 'Sign in to give kudos'}
onclick={toggleKudos}
>
{entry.favourited ? 'Kudos given' : 'Kudos'}
<span class="blog-action-count">({formatCount(entry.favourites_count)})</span>
</button>
<button
type="button"
class="link-button blog-action blog-action--repost"
aria-pressed={entry.reblogged ? 'true' : 'false'}
disabled={!session.signedIn || busy || entry.visibility === 'direct' || entry.visibility === 'private'}
title={entry.visibility === 'private' || entry.visibility === 'direct'
? 'This entry cant be reposted'
: 'Repost to your friends'}
onclick={toggleRepost}
>
{entry.reblogged ? 'Reposted' : 'Repost'}
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
</button>
{#if entry.url}
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
Original
</a>
{/if}
{#if isMine}
<button type="button" class="link-button blog-action blog-action--delete" disabled={busy} onclick={remove}>
Delete
</button>
{/if}
</footer>
</div>
</article>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
/** A `Feed<Status>` rendered as a list of blog entries, plus its pager. */
import type { Status } from '$lib/api/types'
import type { Feed } from '$lib/stores/feed.svelte'
import BlogEntry from './BlogEntry.svelte'
import Pager from '../common/Pager.svelte'
interface Props {
feed: Feed<Status>
emptyText?: string
label?: string
longFormDate?: boolean
}
let {
feed,
emptyText = 'There are no Blog Entries yet.',
label = 'View More Entries',
longFormDate = false,
}: Props = $props()
</script>
<ul class="blog-list">
{#each feed.items as status (status.id)}
<li class="blog-list-item">
<BlogEntry
{status}
{longFormDate}
onupdate={(next) => feed.update(status.id, () => next)}
ondelete={(id) => feed.remove(id)}
/>
</li>
{/each}
</ul>
<Pager {feed} {emptyText} {label} />
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
/**
* Post a blog entry, or a comment on someone else's.
*
* Character limits vary per server (500 on Mastodon, often 5000 on Pleroma),
* so the counter reads `configuration.statuses.max_characters` from the
* instance and only falls back to 500 when the server doesn't say.
*/
import { untrack } from 'svelte'
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { postStatus, uploadMedia } from '$lib/api/endpoints'
interface Props {
/** Set to reply to an existing entry. */
inReplyTo?: Status | null
/** Prefilled body, e.g. the mentions of the entry being replied to. */
initialText?: string
placeholder?: string
submitLabel?: string
onposted?: (status: Status) => void
}
let {
inReplyTo = null,
initialText = '',
placeholder = 'What are you up to?',
submitLabel = 'Post Entry',
onposted,
}: Props = $props()
// Seeded once from the prop; afterwards the textarea owns the value.
let text = $state(untrack(() => initialText))
let warning = $state('')
let showWarning = $state(false)
let visibility = $state<StatusVisibility>('public')
let attachments = $state<MediaAttachment[]>([])
let busy = $state(false)
let uploading = $state(false)
let error = $state<string | null>(null)
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
const remaining = $derived(maxCharacters - text.length - warning.length)
const canPost = $derived(
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
)
// Default replies to the visibility of what they answer, so a private thread
// doesn't accidentally get a public reply.
$effect(() => {
if (inReplyTo) visibility = inReplyTo.visibility
})
async function onFiles(event: Event): Promise<void> {
const input = event.currentTarget as HTMLInputElement
const files = Array.from(input.files ?? [])
if (files.length === 0) return
uploading = true
error = null
try {
for (const file of files.slice(0, maxAttachments - attachments.length)) {
const media = await uploadMedia(session.api, file)
attachments = [...attachments, media]
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Upload failed.'
} finally {
uploading = false
input.value = ''
}
}
function removeAttachment(id: string): void {
attachments = attachments.filter((media) => media.id !== id)
}
async function submit(event: SubmitEvent): Promise<void> {
event.preventDefault()
if (!canPost) return
busy = true
error = null
try {
const created = await postStatus(session.api, {
status: text,
in_reply_to_id: inReplyTo?.id ?? null,
visibility,
spoiler_text: showWarning ? warning : undefined,
media_ids: attachments.map((media) => media.id),
})
text = ''
warning = ''
showWarning = false
attachments = []
onposted?.(created)
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not post that.'
} finally {
busy = false
}
}
</script>
{#if session.signedIn}
<form class="composer" onsubmit={submit}>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
{#if showWarning}
<div class="field">
<label class="field-label" for="composer-warning">Content warning</label>
<input
id="composer-warning"
class="field-input"
type="text"
bind:value={warning}
placeholder="What should readers know first?"
/>
</div>
{/if}
<label class="visually-hidden" for="composer-body">Entry text</label>
<textarea id="composer-body" class="composer-body" bind:value={text} {placeholder} rows="4"></textarea>
{#if attachments.length > 0}
<ul class="composer-attachments">
{#each attachments as media (media.id)}
<li class="composer-attachment">
<img src={media.preview_url ?? media.url} alt={media.description ?? ''} />
<button
type="button"
class="button button--small"
onclick={() => removeAttachment(media.id)}
>
Remove
</button>
</li>
{/each}
</ul>
{/if}
<div class="composer-toolbar">
<label class="button button--small composer-upload">
{uploading ? 'Uploading…' : 'Add photo'}
<input
class="visually-hidden"
type="file"
accept="image/*,video/*,audio/*"
multiple
disabled={uploading || attachments.length >= maxAttachments}
onchange={onFiles}
/>
</label>
<button
type="button"
class="button button--small"
aria-pressed={showWarning ? 'true' : 'false'}
onclick={() => (showWarning = !showWarning)}
>
Warning
</button>
<label class="visually-hidden" for="composer-visibility">Who can see this</label>
<select id="composer-visibility" bind:value={visibility}>
<option value="public">Everyone</option>
<option value="unlisted">Everyone (off the public timeline)</option>
<option value="private">Friends only</option>
<option value="direct">Mentioned people only</option>
</select>
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
<button class="button button--primary" type="submit" disabled={!canPost}>
{busy ? 'Posting…' : submitLabel}
</button>
</div>
</form>
{:else}
<p class="empty-note">
<a href="#/login">Sign in</a> to post.
</p>
{/if}
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
import type { Poll } from '$lib/api/types'
import { relativeTime } from '$lib/util/time'
import { formatCount } from '$lib/util/profile'
interface Props {
poll: Poll
}
let { poll }: Props = $props()
const total = $derived(poll.votes_count || 0)
function share(votes: number | null): number {
if (!total || votes === null) return 0
return Math.round((votes / total) * 100)
}
</script>
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
{#each poll.options as option, index (index)}
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
<div class="poll-option-label">
<span class="poll-option-title">{option.title}</span>
<span class="poll-option-share">{share(option.votes_count)}%</span>
</div>
<div class="poll-option-bar">
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
</div>
</div>
{/each}
<p class="poll-meta">
{formatCount(total)} vote{total === 1 ? '' : 's'}
{#if poll.expired}
&middot; closed
{:else if poll.expires_at}
&middot; closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
{/if}
</p>
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
/** The link preview a server attaches to a status. */
import type { PreviewCard } from '$lib/api/types'
interface Props {
card: PreviewCard
}
let { card }: Props = $props()
const host = $derived.by(() => {
try {
return new URL(card.url).hostname.replace(/^www\./, '')
} catch {
return card.provider_name ?? ''
}
})
</script>
<a class="preview-card" href={card.url} target="_blank" rel="noopener noreferrer" data-card-type={card.type}>
{#if card.image}
<img class="preview-card-image" src={card.image} alt="" loading="lazy" decoding="async" />
{/if}
<span class="preview-card-body">
<span class="preview-card-title">{card.title}</span>
{#if card.description}
<span class="preview-card-description">{card.description}</span>
{/if}
<span class="preview-card-host">{host}</span>
</span>
</a>