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
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
/**
* "Contacting Tom" — the two-column action list.
*
* Follow state comes from `/api/v1/accounts/relationships`, which is only
* available when signed in; logged out, the actions become sign-in prompts
* rather than disappearing, so the box keeps its shape.
*/
import type { Account, Relationship } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
import { displayNameOf } from '$lib/util/profile'
import Module from '../common/Module.svelte'
interface Props {
account: Account
relationship: Relationship | null
onrelationship?: (relationship: Relationship) => void
}
let { account, relationship, onrelationship }: Props = $props()
let busy = $state(false)
let error = $state<string | null>(null)
const firstName = $derived(displayNameOf(account).split(/\s+/)[0])
const isSelf = $derived(session.me?.id === account.id)
const following = $derived(relationship?.following ?? false)
const requested = $derived(relationship?.requested ?? false)
const blocking = $derived(relationship?.blocking ?? false)
const followLabel = $derived(
blocking
? 'Unblock User'
: following
? 'Remove from Friends'
: requested
? 'Cancel Friend Request'
: account.locked
? 'Request to Add'
: 'Add to Friends',
)
async function run(action: () => Promise<Relationship>): Promise<void> {
if (busy) return
busy = true
error = null
try {
onrelationship?.(await action())
} catch (cause) {
error = cause instanceof Error ? cause.message : 'That didnt work.'
} finally {
busy = false
}
}
function toggleFollow(): void {
if (blocking) {
void run(() => unblockAccount(session.api, account.id))
} else if (following || requested) {
void run(() => unfollowAccount(session.api, account.id))
} else {
void run(() => followAccount(session.api, account.id))
}
}
function toggleBlock(): void {
void run(() =>
blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id),
)
}
</script>
<Module title={`Contacting ${firstName}`}>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<ul class="action-list">
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
{#if session.signedIn}
<a class="action-list-label" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>Send Message</a>
{:else}
<a class="action-list-label" href="#/login">Send Message</a>
{/if}
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">👤</span>
{#if isSelf}
<span class="action-list-label muted">This is you</span>
{:else if session.signedIn}
<button
type="button"
class="link-button action-list-label"
disabled={busy}
aria-pressed={following ? 'true' : 'false'}
onclick={toggleFollow}
>
{followLabel}
</button>
{:else}
<a class="action-list-label" href="#/login">Add to Friends</a>
{/if}
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
<a class="action-list-label" href={account.url} target="_blank" rel="noopener noreferrer">
View on {account.acct.includes('@') ? account.acct.split('@')[1] : session.host}
</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
<a class="action-list-label" href={`#/@${account.acct}/friends`}>Forward to Friend</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">💬</span>
<a class="action-list-label" href={`#/@${account.acct}/blog`}>Instant Message</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
{#if isSelf || !session.signedIn}
<span class="action-list-label muted">Block User</span>
{:else}
<button type="button" class="link-button action-list-label" disabled={busy} onclick={toggleBlock}>
{blocking ? 'Unblock User' : 'Block User'}
</button>
{/if}
</li>
</ul>
{#if relationship?.followed_by && !isSelf}
<p class="contact-note muted">{firstName} has you on their friends list.</p>
{/if}
</Module>
@@ -0,0 +1,39 @@
<script lang="ts">
/**
* "Tom's Details" — profile fields that didn't map to an interests row.
*
* Mastodon's link verification is surfaced here: a field whose URL proved
* ownership is highlighted, matching what every other client does, because
* an unverified link that looks verified is a phishing surface.
*/
import type { ProfileField } from '$lib/util/profile'
import Module from '../common/Module.svelte'
interface Props {
title: string
fields: ProfileField[]
}
let { title, fields }: Props = $props()
</script>
{#if fields.length > 0}
<Module {title} flush>
<table class="data-table details-table">
<tbody>
{#each fields as field (field.name)}
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
<th class="data-table-label details-label" scope="row">{field.name}</th>
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
{#if field.verified}
<span class="verified-mark" title="Ownership of this link is verified"></span>
{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html field.value}
</td>
</tr>
{/each}
</tbody>
</table>
</Module>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
/**
* "Tom's Friend Space" — the grid of tiny avatars with names above them.
*
* Mastodon lets an account hide its follower/following lists, and remote
* accounts often return an empty list rather than an error, so the count and
* the grid are allowed to disagree; the count is authoritative.
*/
import type { Account } from '$lib/api/types'
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
import Module from '../common/Module.svelte'
interface Props {
title: string
/** The subject, used in "Tom has 527 friends." */
ownerName: string
friends: Account[]
total: number
viewAllHref: string
loading?: boolean
/** The list is withheld by the account's privacy settings. */
hidden?: boolean
/** The count is withheld too, so `total` is not meaningful. */
countHidden?: boolean
compact?: boolean
}
let {
title,
ownerName,
friends,
total,
viewAllHref,
loading = false,
hidden = false,
countHidden = false,
compact = false,
}: Props = $props()
</script>
<Module {title} variant="band">
{#snippet action()}
<a href={viewAllHref}>[view all]</a>
{/snippet}
<!-- Never render a withheld count as "0 friends" — that reports a privacy
setting as a fact about the person. -->
{#if countHidden}
<p class="friend-count">{ownerName} keeps their friend count private.</p>
{:else}
<p class="friend-count">
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
friend{total === 1 ? '' : 's'}.
</p>
{/if}
{#if hidden}
<p class="empty-note">This friends list is private.</p>
{:else if loading && friends.length === 0}
<p class="loading-note">Loading friends&hellip;</p>
{:else if friends.length === 0}
<p class="empty-note">No friends to show yet.</p>
{:else}
<ul class="friend-grid" class:friend-grid--compact={compact}>
{#each friends as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}>
<span class="friend-card-name">{displayNameOf(friend)}</span>
<img
class="friend-card-photo"
src={friend.avatar_static || friend.avatar}
alt=""
loading="lazy"
decoding="async"
/>
</a>
</li>
{/each}
</ul>
{/if}
</Module>
@@ -0,0 +1,35 @@
<script lang="ts">
/**
* "Tom's Interests" — the label/value table.
*
* Rows only appear when the account has a matching profile field, so a bare
* Mastodon account gets a compact box rather than six empty rows.
*/
import type { InterestEntry } from '$lib/util/profile'
import Module from '../common/Module.svelte'
interface Props {
title: string
interests: InterestEntry[]
}
let { title, interests }: Props = $props()
</script>
{#if interests.length > 0}
<Module {title} flush>
<table class="data-table interests-table">
<tbody>
{#each interests as entry (entry.row)}
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
<td class="data-table-value interests-value">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html entry.value}
</td>
</tr>
{/each}
</tbody>
</table>
</Module>
{/if}
@@ -0,0 +1,107 @@
<script lang="ts">
/**
* The photo-and-vitals block at the top of a profile.
*
* MySpace showed gender, age, location and last-login as bare lines beside
* the photo. Mastodon publishes none of those, so the ones a user hasn't put
* in a profile field are simply omitted — except "age", which falls back to
* how long the account has existed, because a profile with no numbers at all
* doesn't read as a profile.
*/
import type { ProfileView } from '$lib/util/profile'
import { displayNameOf, fullHandle } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html'
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
import { session } from '$lib/stores/session.svelte'
interface Props {
profile: ProfileView
}
let { profile }: Props = $props()
const account = $derived(profile.account)
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
const handle = $derived(fullHandle(account, session.host))
const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
const photo = $derived(account.avatar || account.avatar_static)
</script>
<div class="profile-identity">
<div class="profile-photo-wrap">
{#if photo}
<a class="profile-photo-link" href={account.url} target="_blank" rel="noopener noreferrer">
<img class="profile-photo" src={photo} alt={displayNameOf(account)} decoding="async" />
</a>
{:else}
<span class="profile-photo profile-photo--empty" aria-hidden="true"></span>
{/if}
<a class="profile-photo-caption" href={`#/@${account.acct}/pics`}>View more pics</a>
</div>
<div class="profile-vitals-wrap">
<p class="profile-headline">{profile.headline}</p>
<dl class="profile-vitals">
{#if profile.gender}
<dt>Gender</dt>
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
{/if}
{#if accountAge !== null}
<dt>Age</dt>
<dd class="profile-vital profile-vital--age">
{accountAge} years old
{#if profile.age === null}
<span class="muted">(on this server)</span>
{/if}
</dd>
{/if}
{#if profile.location}
<dt>Location</dt>
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
{/if}
<dt>Last active</dt>
<dd class="profile-vital profile-vital--active">
Last active:<br />
{account.last_status_at ? relativeTime(account.last_status_at) : 'unknown'}
</dd>
<dt>Member since</dt>
<dd class="profile-vital profile-vital--joined">
Member since: {shortDate(account.created_at)}
</dd>
</dl>
{#if profile.mood}
<p class="profile-mood">
Mood: <span class="profile-mood-value">{profile.mood}</span>
</p>
{/if}
<p class="profile-viewlinks">
<span class="profile-viewlinks-label">View my:</span>
<a href={`#/@${account.acct}/blog`}>Blog</a>
|
<a href={`#/@${account.acct}/friends`}>Friends</a>
|
<a href={`#/@${account.acct}/pics`}>Pics</a>
</p>
<p class="profile-handle-line">
<span class="visually-hidden">Handle:</span>
<code class="profile-handle">{handle}</code>
{#if account.bot}
<span class="profile-badge" data-badge="bot">bot</span>
{/if}
{#if account.locked}
<span class="profile-badge" data-badge="locked">private</span>
{/if}
{#each account.roles ?? [] as role (role.id)}
<span class="profile-badge" data-badge="role">{role.name}</span>
{/each}
</p>
</div>
</div>