johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<script lang="ts">
import type * as getOwnProfile from '#lib/lexicons/dev/knotless/getOwnProfile.ts';
import type * as getProfile from '#lib/lexicons/dev/knotless/getProfile.ts';
import type * as updateProfile from '#lib/lexicons/dev/knotless/updateProfile.ts';
import type * as uploadAvatar from '#lib/lexicons/dev/knotless/uploadAvatar.ts';
import type { UriString } from '@atproto/lex';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { call, upload } from '#lib/data.ts';
import { urlPath } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import LinkRows from '#lib/molecules/LinkRows.svelte';
/**
* Changing how the account shows on knotless: its knotless profile,
* `own`, each field of which stands in for its Bluesky profile's, as
* `shown` has the two together. Saved, the profile page shows it.
*/
let { own, shown }: { own: getOwnProfile.OwnProfile; shown: getProfile.ProfileView } = $props();
/** The most links a profile takes, and the most bytes of a picture sent to be made to fit. */
const LINKS = 10;
const PICTURE = 30_000_000;
const handle = $derived(shown.handle ?? shown.did);
// What the Bluesky profile shows for a field the knotless one leaves out;
// for one it sets, unknown here.
const bluesky = $derived({
name: own.displayName ? undefined : shown.displayName,
bio: own.description ? undefined : shown.description,
pronouns: own.pronouns ? undefined : shown.pronouns,
links: own.links?.length ? [] : (shown.links ?? []),
avatar: own.avatar ? undefined : shown.avatar
});
// The form starts from the profile as it is, and is the person's after.
let name = $state(untrack(() => own.displayName ?? ''));
let bio = $state(untrack(() => own.description ?? ''));
let pronouns = $state(untrack(() => own.pronouns ?? ''));
let links = $state(
untrack(() => (own.links ?? []).map((l) => ({ url: l.url as string, title: l.title ?? '' })))
);
let bot = $state(untrack(() => !!own.bot));
let file = $state<File | null>(null);
let removed = $state(false);
let busy = $state(false);
let problem = $state('');
let picked = $state<string | null>(null);
$effect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
picked = url;
return () => URL.revokeObjectURL(url);
});
const avatar = $derived(
file ? picked : removed ? bluesky.avatar : (own.avatar ?? bluesky.avatar)
);
const filled = $derived(links.filter((l) => l.url.trim()));
function choose(e: Event) {
const chosen = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
problem = '';
if (chosen && !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(chosen.type))
problem = 'A picture is a PNG, JPEG, WebP or GIF.';
else if (chosen && chosen.size > PICTURE)
problem = `That picture is larger than ${PICTURE.toLocaleString('en')} bytes.`;
else {
file = chosen;
removed = false;
}
}
function unpick() {
file = null;
removed = !!own.avatar;
}
async function save(e: SubmitEvent) {
e.preventDefault();
busy = true;
problem = '';
try {
if (file) await upload<typeof uploadAvatar.main>('dev.knotless.uploadAvatar', file);
const titled = filled.map((l) => ({
url: l.url.trim() as UriString,
title: l.title.trim() || undefined
}));
await call<typeof updateProfile.main>('dev.knotless.updateProfile', {
displayName: name,
description: bio,
pronouns,
links: titled,
removeAvatar: removed && !file ? true : undefined,
bot
});
await goto(`/${urlPath(handle)}`, { invalidateAll: true });
} catch (err) {
problem = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<div class="settings-section"><h2>Public profile</h2></div>
<div class="profile-form">
<form class="settings-form" onsubmit={save}>
<p class="mute">
Each field stands in for your Bluesky profile's: one left empty shows Bluesky's.
</p>
<div class="field">
<span class="field-label">Profile picture</span>
<div class="picture">
<Avatar name={handle} did={shown.did} size="xl" src={avatar} />
<div class="picture-actions">
<label class="btn"
>Upload a picture<input
class="file"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onchange={choose}
/></label
>
{#if file || (own.avatar && !removed)}<button class="btn" type="button" onclick={unpick}
>{file ? 'Keep the one there is' : 'Remove'}</button
>{/if}
<p class="hint">A PNG, JPEG, WebP or GIF. A large one is made smaller.</p>
</div>
</div>
</div>
<label class="field"
><span class="field-label">Name</span><input
name="name"
maxlength="64"
placeholder={bluesky.name ?? ''}
bind:value={name}
/></label
>
<label class="field"
><span class="field-label">Bio</span><textarea
name="bio"
rows="4"
maxlength="256"
placeholder={bluesky.bio ?? 'Tell us a little about yourself'}
bind:value={bio}></textarea></label
>
<label class="field"
><span class="field-label">Pronouns</span><input
name="pronouns"
maxlength="20"
placeholder={bluesky.pronouns ?? ''}
bind:value={pronouns}
/></label
>
<fieldset class="field">
<legend class="field-label">Links</legend>
<LinkRows bind:links max={LINKS} />
{#if !links.length && bluesky.links.length}<p class="hint">
Shows your Bluesky profile's links until you add one.
</p>{/if}
</fieldset>
<div class="field">
<label class="check"
><input type="checkbox" name="bot" bind:checked={bot} />This account is a bot</label
>
<p class="hint">What it says and pushes stays off the front page.</p>
</div>
{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
<div>
<button class="btn primary" type="submit" disabled={busy} aria-busy={busy}
>{busy ? 'Saving…' : 'Update profile'}</button
>
</div>
</form>
</div>