Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166<script lang="ts"> import { untrack } from "svelte"; import Link from "$icon/link"; import MapPin from "$icon/map-pin"; import Field from "#lib/components/ui/Field.svelte"; import Input from "#lib/components/ui/Input.svelte"; import Textarea from "#lib/components/ui/Textarea.svelte"; import { getAuth } from "#lib/auth.svelte.ts"; import { PROFILE_LIMITS, putProfile, uploadProfileAvatar } from "#lib/api/profile.ts"; import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import type { PinnedKey } from "#lib/api/pinning.ts"; import { LINK_SLOTS, normalizeProfileLink } from "#lib/api/profileSettings.ts"; import { bumpAvatar } from "#lib/avatar.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import type { ProfileRecord } from "#lib/api/records.ts";
interface Props { id: string; profile: ProfileRecord | null; pinned: PinnedKey[]; avatarFile?: File | null; avatarError?: string; error?: string; onsubmit: () => void; }
let { id, profile, pinned, avatarFile, avatarError, error, onsubmit }: Props = $props();
const auth = getAuth();
// seed once; the profile prop can't change while the form is open. const seed = untrack(() => profile); let description = $state(seed?.description ?? ""); let pronouns = $state(seed?.pronouns ?? ""); let location = $state(seed?.location ?? ""); let links = $state( Array.from({ length: LINK_SLOTS }, (_, index) => ({ value: (seed?.links?.[index] as string | undefined) ?? "", error: undefined as string | undefined })) ); const uid = $props.id();
const checkLinks = () => links.map((link) => { try { const normalized = normalizeProfileLink(link.value); link.error = undefined; return normalized; } catch (cause) { link.error = cause instanceof Error ? cause.message : String(cause); return undefined; } });
export const save = async (): Promise<ProfileRecord | undefined> => { const agent = auth.agent; if (!agent) return undefined; const checked = checkLinks(); if (links.some((link) => link.error)) return undefined; const cleanLinks = checked.filter((link) => link !== undefined); const avatar = avatarFile ? await uploadProfileAvatar(agent, avatarFile) : profile?.avatar; const record: ProfileRecord = { ...profile, $type: "org.tangled.actor.profile", avatar, description: description.trim() || undefined, pronouns: pronouns.trim() || undefined, location: location.trim() || undefined, links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined, pinnedRepositories: pinned.length > 0 ? pinned : undefined }; const written = await putProfile(agent, record); const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); await awaitIndexedOrSaved(ctx, written); if (avatarFile) bumpAvatar(agent.sub); return record; };</script>
<form {id} class="@container my-xs flex max-w-full flex-col gap-md typography-paragraph-small" onsubmit={(event) => { event.preventDefault(); onsubmit(); }}> {#if avatarError} <p class="typography-paragraph-small text-foreground-danger">{avatarError}</p> {:else if avatarFile} <p class="typography-paragraph-small text-foreground-muted"> {avatarFile.name} will be uploaded when you save. </p> {/if}
<div class="grid gap-md @min-[36rem]:grid-cols-2 @min-[36rem]:gap-xl"> <div class="flex flex-col gap-md"> <Field label="Bio" labelCase="normal"> {#snippet children({ id })} <Textarea {id} rows={3} resizeable placeholder="Write a bio" maxlength={PROFILE_LIMITS.description} bind:value={description} /> {/snippet} </Field>
<Field label="Pronouns" labelCase="normal"> {#snippet children({ id })} <Input {id} placeholder="they/them" maxlength={PROFILE_LIMITS.pronouns} bind:value={pronouns} /> {/snippet} </Field>
<Field label="Location" labelCase="normal"> {#snippet children({ id })} <Input {id} iconLeft={MapPin} maxlength={PROFILE_LIMITS.location} bind:value={location} /> {/snippet} </Field> </div>
<Field label="Social links" labelCase="normal" group> <div class="flex flex-col gap-xs"> {#each links as link, index (index)} <Input data-testid="socialLink" data-index={index} iconLeft={Link} placeholder={`Social link ${index + 1}`} label={`Social link ${index + 1}`} error={!!link.error} aria-describedby={link.error ? `${uid}-link-${index}-error` : undefined} oninput={() => (link.error = undefined)} bind:value={link.value} /> {#if link.error} <p id={`${uid}-link-${index}-error`} class="typography-paragraph-small text-foreground-danger" > {link.error} </p> {/if} {/each} </div> </Field> </div>
{#if error} <p class="typography-paragraph-small text-foreground-danger">{error}</p> {/if}</form>