campfire/src/routes/[server]/[account]/+page.svelte

330 lines
9.2 KiB
Svelte

<script>
import Button from '@cf/ui/Button.svelte';
import HomeIcon from '@cf/icons/unlisted.svg';
import MoreIcon from '@cf/icons/more.svg';
import LockIcon from '@cf/icons/lock.svg';
import BotIcon from '@cf/icons/bot.svg';
import Lang from '$lib/lang';
import * as api from '$lib/api.js';
import { server, createServer } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { parseAccount } from '$lib/account.js';
import { parsePost } from '$lib/post.js';
import { account } from '$lib/stores/account';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths';
import Post from '../../../lib/ui/post/Post.svelte';
import { writable } from 'svelte/store';
export let data;
const lang = Lang();
let profile_pinned_posts = writable([]);
let profile_posts_max_id = null;
let profile_posts = writable([]);
let profile = fetchProfile(data.account_handle);
let error = false;
let previous_page = base;
let post_replies = false;
let post_boosts = true;
let post_media = false;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page;
profile = fetchProfile(data.account_handle);
})
async function getPosts(profile, max_id) {
const posts = await api.getUserPosts(
$server.host,
$app.token,
profile.id,
max_id,
post_replies,
post_boosts,
post_media
);
let parsed_posts = [];
for (let post of posts) {
parsed_posts.push(await parsePost(post, 1));
}
profile_posts.update(posts => {
posts.push(...parsed_posts);
return posts;
});
return parsed_posts.length > 0 ? parsed_posts[parsed_posts.length - 1].id : null;
}
async function fetchProfile(handle) {
let token = $app ? $app.token : null;
profile_posts.set([]);
profile_pinned_posts.set([]);
if (!$server || $server.host !== data.server_host) {
server.set(await createServer(data.server_host));
if (!$server) {
error = lang.string('error.connection_failed', data.server_host);
throw new Error(lang.string('logs.connection_failed', data.server_host));
}
}
let profile_data;
try {
profile_data = await api.lookupUser($server.host, token, handle);
} catch (error) {
throw error;
}
if (!profile_data || profile_data.error) {
error = lang.string('error.profile_fetch_failed_id', handle);
throw new Error(lang.string('logs.profile_fetch_failed_id', handle));
}
let profile = await parseAccount(profile_data, 0);
api.getUserPinnedPosts(
$server.host,
token,
profile.id,
).then(async posts => {
const parsed_posts = [];
for (let post of posts) {
parsed_posts.push(await parsePost(post, 1));
}
profile_pinned_posts.set(parsed_posts);
});
let post_lock = false; // `true` == "locked"
getPosts(profile, null).then(last_id => {
profile_posts_max_id = last_id;
post_lock = false;
});
document.addEventListener("scroll", () => {
if (window.innerHeight + window.scrollY < document.body.offsetHeight - 2048)
return;
if ($profile_posts.length == 0)
return;
if (profile_posts_max_id == null)
return;
if (profile_posts_max_id != $profile_posts[$profile_posts.length - 1].id)
return;
if (post_lock) return;
post_lock = true;
getPosts(profile, profile_posts_max_id).then(last_id => {
profile_posts_max_id = last_id;
post_lock = false;
});
});
return profile;
}
</script>
{#await profile}
<div class="loading throb">
<span>{lang.string('profile.loading')}</span>
</div>
{:then profile}
<header data-banner="{profile.banner_url}">
<img src="{profile.banner_url}" class="profile-banner" alt="">
<div class="profile-tag">
<!-- svelte-ignore a11y-img-redundant-alt -->
<img src="{profile.avatar_url}" alt="">
<div class="profile-tag-names">
<div class="profile-tag-display-name">
<h1>
{@html profile.rich_name}
{#if profile.locked}
<span title="{lang.string('profile.locked')}">
<LockIcon width="22px"/>
</span>
{/if}
{#if profile.bot}
<span title="{lang.string('profile.bot')}">
<BotIcon width="22px"/>
</span>
{/if}
</h1>
</div>
<p>{profile.fqn}</p>
</div>
</div>
</header>
<div class="profile-info">
<p class="profile-bio">{@html profile.rich_bio}</p>
<ul class="profile-counts">
<li><b>{lang.string('profile.followers')}</b> {profile.followers_count}</li>
<li><b>{lang.string('profile.following')}</b> {profile.following_count}</li>
<li><b>{lang.string('profile.posts')}</b> {profile.posts_count}</li>
</ul>
<div class="profile-actions">
{#if $account && profile.fqn !== $account.fqn}
<Button filled disabled label="{lang.string('profile.follow')} {profile.nickname}" class="profile-btn-follow">
{lang.string('profile.follow')}
</Button>
{/if}
<Button label="{lang.string('profile.home_instance')}" href="{profile.url}">
<HomeIcon width="24px"/>
</Button>
<Button>
<MoreIcon width="24px"/>
</Button>
</div>
</div>
<div class="profile-post-categories">
<Button active>
{lang.string('profile.posts')}
</Button>
<Button>
{lang.string('profile.replies')}
</Button>
<Button>
{lang.string('profile.media')}
</Button>
</div>
<div class="profile-pinned-posts">
{#if profile_pinned_posts}
{#each $profile_pinned_posts as post}
<Post post_data={post} pinned />
{/each}
<br/><hr/><br/>
{/if}
</div>
<div class="profile-posts">
{#each $profile_posts as post}
<Post post_data={post} />
{/each}
</div>
{:catch error}
<p class="error">{error}</p>
{/await}
<style>
header {
margin-top: 1rem;
width: 100%;
height: 215px;
position: relative;
}
.profile-banner {
width: 100%;
height: 100%;
object-fit: cover;
background-color: var(--bg-700);
}
.profile-tag {
position: absolute;
bottom: 16px;
left: 16px;
background: color-mix(in srgb, transparent, var(--bg-1000) 50%);
backdrop-filter: blur(8px);
width: fit-content;
display: flex;
height: 64px;
border-radius: 8px;
}
.profile-tag img {
aspect-ratio: 1;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.profile-tag-names {
padding: 8px 16px;
align-self: center;
}
.profile-tag-names * {
margin: 0;
}
.profile-tag-names h1 {
font-size: 1.15rem
}
.profile-tag-names h1 :global(svg) {
height: 1.2em;
width: 1.2em;
margin: -1em -.1em 0 -.1em;
transform: translateY(.2em);
}
.profile-info {
background-color: var(--bg-800);
padding: 16px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.profile-bio {
margin: 0;
/* !! may not be required in prod */
white-space: pre-line;
}
:global(.profile-bio p:first-of-type) {
margin: 0;
}
.profile-counts {
padding: 0;
}
.profile-counts li {
display: inline-block;
}
.profile-counts > *:not(.profile-counts:first-child) {
margin-right: 16px;
}
.profile-actions {
width: fit-content;
display: flex;
align-items: center;
gap: .5rem;
}
.profile-actions :global(button.profile-btn-follow) {
padding: 0 32px;
}
.profile-actions :global(a) {
width: fit-content;
height: 16px;
}
.profile-actions :global(button) {
width: fit-content;
height: 42px;
}
.profile-post-categories {
display: flex;
padding: 1rem 0;
gap: 1rem;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
.error {
padding: 4em 0;
width: 100%;
text-align: center;
}
</style>